File size: 5,958 Bytes
a2c232b
f250aec
c7d34c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2c232b
c7d34c1
 
 
a2c232b
 
 
 
 
 
 
 
c7d34c1
a2c232b
 
c7d34c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a2c232b
c7d34c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
import { fetchOpenAIUpstream } from '../openai-image-transport';
import type { ActualCostDetails, ActualCostResolver, ResolveActualCostInput } from './types';

export const NEW_API_QUOTA_PER_UNIT = 500_000;

const LOG_TOKEN_PATH = '/api/log/token';
const LOG_MATCH_WINDOW_BEFORE_MS = 5_000;
const LOG_MATCH_WINDOW_AFTER_MS = 15_000;
const POLL_ATTEMPTS = 3;
const POLL_DELAY_MS = 600;
const FETCH_TIMEOUT_MS = 1_500;

type NewApiLogEntry = {
    id?: unknown;
    created_at?: unknown;
    type?: unknown;
    model_name?: unknown;
    quota?: unknown;
    request_id?: unknown;
};

type NewApiLogResponse = {
    success?: unknown;
    data?: unknown;
};

function unavailable(reason: string): ActualCostDetails {
    return {
        currency: 'usd-equivalent',
        source: 'unavailable',
        confidence: 'none',
        upstreamProvider: 'new-api',
        reason
    };
}

function sleep(ms: number): Promise<void> {
    return new Promise((resolve) => {
        setTimeout(resolve, ms);
    });
}

export function quotaToUsdEquivalent(quota: number): number {
    return Math.round((quota / NEW_API_QUOTA_PER_UNIT) * 1_000_000) / 1_000_000;
}

export function buildNewApiLogTokenUrl(apiBaseUrl: string): URL | undefined {
    try {
        const parsed = new URL(apiBaseUrl);
        return new URL(LOG_TOKEN_PATH, `${parsed.protocol}//${parsed.host}`);
    } catch {
        return undefined;
    }
}

function readNumber(value: unknown): number | undefined {
    return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}

function isConsumeLog(entry: NewApiLogEntry): boolean {
    const type = readNumber(entry.type);
    return type === 2;
}

export function matchNewApiCostLog(input: {
    logs: NewApiLogEntry[];
    model: string;
    startedAtMs: number;
    finishedAtMs: number;
}): ActualCostDetails {
    const windowStart = Math.floor((input.startedAtMs - LOG_MATCH_WINDOW_BEFORE_MS) / 1000);
    const windowEnd = Math.ceil((input.finishedAtMs + LOG_MATCH_WINDOW_AFTER_MS) / 1000);
    const matches = input.logs.filter((entry) => {
        const createdAt = readNumber(entry.created_at);
        const quota = readNumber(entry.quota);
        return (
            isConsumeLog(entry) &&
            entry.model_name === input.model &&
            createdAt !== undefined &&
            createdAt >= windowStart &&
            createdAt <= windowEnd &&
            quota !== undefined &&
            quota > 0
        );
    });

    if (matches.length === 0) {
        return unavailable('未匹配到当前请求时间窗口内的 new-api 扣费日志。');
    }
    if (matches.length > 1) {
        return {
            currency: 'usd-equivalent',
            source: 'unavailable',
            confidence: 'low',
            upstreamProvider: 'new-api',
            reason: `匹配到 ${matches.length} 条候选扣费日志,无法唯一确认。`
        };
    }

    const match = matches[0];
    const quota = readNumber(match.quota) as number;

    const id = readNumber(match.id);
    const requestId = typeof match.request_id === 'string' && match.request_id ? match.request_id : undefined;
    return {
        actualAmount: quotaToUsdEquivalent(quota),
        actualQuota: quota,
        currency: 'usd-equivalent',
        source: 'new-api-log-token',
        confidence: 'high',
        upstreamProvider: 'new-api',
        ...(id === undefined ? {} : { matchedLogId: id }),
        ...(requestId ? { matchedRequestId: requestId } : {})
    };
}

async function fetchLogs(url: URL, apiKey: string, upstreamProxyUrl?: string): Promise<NewApiLogEntry[] | undefined> {
    const abortController = new AbortController();
    const timeout = setTimeout(() => abortController.abort(), FETCH_TIMEOUT_MS);
    try {
        const response = await fetchOpenAIUpstream(
            url,
            {
                headers: {
                    Authorization: `Bearer ${apiKey}`,
                    Accept: 'application/json'
                },
                signal: abortController.signal
            },
            upstreamProxyUrl
        );
        if (!response.ok) return undefined;

        const body = (await response.json()) as NewApiLogResponse;
        if (body.success !== true || !Array.isArray(body.data)) return undefined;
        return body.data as NewApiLogEntry[];
    } finally {
        clearTimeout(timeout);
    }
}

export class NewApiCostResolver implements ActualCostResolver {
    readonly provider = 'new-api' as const;

    async resolve(input: ResolveActualCostInput): Promise<ActualCostDetails> {
        if (!input.apiBaseUrl || !input.apiKey) {
            return unavailable('缺少上游 API URL 或 API key,无法查询 new-api 扣费日志。');
        }

        const url = buildNewApiLogTokenUrl(input.apiBaseUrl);
        if (!url) {
            return unavailable('上游 API URL 无效,无法查询 new-api 扣费日志。');
        }

        try {
            for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
                const logs = await fetchLogs(url, input.apiKey, input.upstreamProxyUrl);
                if (logs) {
                    const result = matchNewApiCostLog({
                        logs,
                        model: input.model,
                        startedAtMs: input.startedAtMs,
                        finishedAtMs: input.finishedAtMs
                    });
                    if (result.source === 'new-api-log-token' || attempt === POLL_ATTEMPTS - 1) {
                        return result;
                    }
                }
                if (attempt < POLL_ATTEMPTS - 1) {
                    await sleep(POLL_DELAY_MS);
                }
            }
            return unavailable('new-api 扣费日志接口没有返回可解析日志。');
        } catch (error) {
            return unavailable(error instanceof Error ? error.message : String(error));
        }
    }
}