@starlink-awaken/agentmesh 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli.js +1 -1
- package/dist/src/model-gateway/metrics.js +3 -3
- package/dist/src/model-gateway/providers.d.ts +0 -1
- package/dist/src/model-gateway/providers.js +217 -34
- package/dist/src/model-gateway/router.js +24 -4
- package/dist/src/model-gateway/routes.js +29 -27
- package/dist/src/model-gateway/types.d.ts +1 -0
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
7
7
|
import { resolve, dirname, join } from 'node:path';
|
|
8
8
|
import { initLogger } from './core/logger.js';
|
|
9
9
|
const PROJECT_ROOT = resolve(dirname(import.meta.dir), '..');
|
|
10
|
-
const VERSION = '1.
|
|
10
|
+
const VERSION = '1.4.0';
|
|
11
11
|
const BANNER = `
|
|
12
12
|
█████╗ ██████╗ ███████╗███╗ ██╗████████╗
|
|
13
13
|
██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝
|
|
@@ -21,9 +21,9 @@ export function recordRequest(log) {
|
|
|
21
21
|
m.lastError = log.error;
|
|
22
22
|
m.lastErrorTime = log.timestamp;
|
|
23
23
|
}
|
|
24
|
-
recentRequests.
|
|
24
|
+
recentRequests.push(log);
|
|
25
25
|
if (recentRequests.length > MAX_RECENT)
|
|
26
|
-
recentRequests.
|
|
26
|
+
recentRequests.shift();
|
|
27
27
|
}
|
|
28
28
|
export function getMetrics() {
|
|
29
29
|
const providers = {};
|
|
@@ -46,7 +46,7 @@ export function getMetrics() {
|
|
|
46
46
|
total_requests: totalRequests,
|
|
47
47
|
total_failures: totalFailures,
|
|
48
48
|
providers,
|
|
49
|
-
recent: recentRequests.slice(
|
|
49
|
+
recent: recentRequests.slice(-20).reverse().map(r => ({
|
|
50
50
|
time: new Date(r.timestamp).toISOString(),
|
|
51
51
|
model: r.model,
|
|
52
52
|
provider: r.provider,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
1
|
import type { ChatCompletionRequest, ResolvedProvider } from './types.js';
|
|
2
2
|
export declare function callChatCompletions(provider: ResolvedProvider, request: ChatCompletionRequest): Promise<Response>;
|
|
3
3
|
export declare function callResponsesApi(provider: ResolvedProvider, body: Record<string, any>): Promise<Response>;
|
|
4
|
-
export declare function buildStreamingResponse(upstreamResp: Response): Response;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { circuitBreakerRegistry } from './circuit-breaker.js';
|
|
2
2
|
import { withRetry, isRetryable } from './retry.js';
|
|
3
|
-
// 所有目标 Provider 都兼容 OpenAI API 格式,统一客户端即可
|
|
4
3
|
export async function callChatCompletions(provider, request) {
|
|
5
4
|
const { base_url, api_key, name: providerName } = provider;
|
|
6
5
|
const { model, messages, stream, temperature, max_tokens, tools, tool_choice } = request;
|
|
@@ -19,22 +18,18 @@ export async function callChatCompletions(provider, request) {
|
|
|
19
18
|
'Content-Type': 'application/json',
|
|
20
19
|
Authorization: `Bearer ${api_key}`,
|
|
21
20
|
};
|
|
22
|
-
// OpenRouter 需要额外的头部
|
|
23
21
|
if (providerName === 'openrouter') {
|
|
24
22
|
headers['HTTP-Referer'] = 'http://127.0.0.1:3000';
|
|
25
23
|
headers['X-Title'] = 'Agent Mesh Gateway';
|
|
26
24
|
}
|
|
27
25
|
const url = `${base_url.replace(/\/$/, '')}/chat/completions`;
|
|
28
|
-
// 熔断器检查
|
|
29
26
|
if (!circuitBreakerRegistry.canRequest(providerName)) {
|
|
30
27
|
throw new Error(`Circuit breaker open for ${providerName}`);
|
|
31
28
|
}
|
|
32
29
|
try {
|
|
33
30
|
const resp = await withRetry(providerName, async () => {
|
|
34
31
|
const r = await fetch(url, {
|
|
35
|
-
method: 'POST',
|
|
36
|
-
headers,
|
|
37
|
-
body: JSON.stringify(body),
|
|
32
|
+
method: 'POST', headers, body: JSON.stringify(body),
|
|
38
33
|
signal: AbortSignal.timeout(120_000),
|
|
39
34
|
});
|
|
40
35
|
return r;
|
|
@@ -42,7 +37,6 @@ export async function callChatCompletions(provider, request) {
|
|
|
42
37
|
console.warn(`[Retry] ${providerName} attempt ${attempt} after ${status} — retrying in ${delayMs}ms`);
|
|
43
38
|
});
|
|
44
39
|
if (!resp.ok && isRetryable(resp.status)) {
|
|
45
|
-
// Retry logic already handled in withRetry, but if we get here after max retries:
|
|
46
40
|
circuitBreakerRegistry.recordFailure(providerName);
|
|
47
41
|
}
|
|
48
42
|
else if (resp.ok) {
|
|
@@ -59,37 +53,236 @@ export async function callChatCompletions(provider, request) {
|
|
|
59
53
|
}
|
|
60
54
|
}
|
|
61
55
|
export async function callResponsesApi(provider, body) {
|
|
62
|
-
|
|
63
|
-
const messages = convertResponsesInputToMessages(body.input || []);
|
|
56
|
+
const messages = convertInputToMessages(body.input || []);
|
|
64
57
|
if (body.instructions) {
|
|
65
58
|
messages.unshift({ role: 'system', content: body.instructions });
|
|
66
59
|
}
|
|
67
|
-
|
|
60
|
+
// 转换 tools 定义(Codex 的 tool schema → OpenAI format)
|
|
61
|
+
const tools = convertToolSchemas(body.tools);
|
|
62
|
+
const chatResp = await callChatCompletions(provider, {
|
|
68
63
|
model: body.model,
|
|
69
64
|
messages,
|
|
70
65
|
stream: body.stream,
|
|
71
|
-
tools
|
|
66
|
+
tools,
|
|
67
|
+
tool_choice: body.tool_choice,
|
|
68
|
+
});
|
|
69
|
+
// 非流式:直接转换响应
|
|
70
|
+
if (!body.stream) {
|
|
71
|
+
const ccData = (await chatResp.json());
|
|
72
|
+
return new Response(JSON.stringify(convertChatToResponses(ccData)), {
|
|
73
|
+
status: 200,
|
|
74
|
+
headers: { 'Content-Type': 'application/json' },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// 流式:解析 SSE → 转换 → 重新打包 SSE
|
|
78
|
+
const transformed = transformSSEStream(chatResp.body);
|
|
79
|
+
return new Response(transformed, {
|
|
80
|
+
status: 200,
|
|
81
|
+
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
|
|
72
82
|
});
|
|
73
83
|
}
|
|
74
|
-
function
|
|
84
|
+
function convertInputToMessages(input) {
|
|
75
85
|
const messages = [];
|
|
76
86
|
for (const item of input) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
switch (item.type || item.role) {
|
|
88
|
+
// 标准消息类型
|
|
89
|
+
case 'message':
|
|
90
|
+
messages.push({ role: item.role || 'user', content: extractTextContent(item.content) });
|
|
91
|
+
break;
|
|
92
|
+
case 'function_call': {
|
|
93
|
+
messages.push({
|
|
94
|
+
role: 'assistant',
|
|
95
|
+
content: null,
|
|
96
|
+
tool_calls: [{
|
|
97
|
+
id: item.call_id,
|
|
98
|
+
type: 'function',
|
|
99
|
+
function: {
|
|
100
|
+
name: item.name,
|
|
101
|
+
arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments),
|
|
102
|
+
},
|
|
103
|
+
}],
|
|
104
|
+
});
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case 'function_call_output':
|
|
108
|
+
messages.push({
|
|
109
|
+
role: 'tool',
|
|
110
|
+
tool_call_id: item.call_id,
|
|
111
|
+
content: typeof item.output === 'string' ? item.output : JSON.stringify(item.output),
|
|
112
|
+
});
|
|
113
|
+
break;
|
|
114
|
+
case 'system':
|
|
115
|
+
messages.push({ role: 'system', content: extractTextContent(item.content) });
|
|
116
|
+
break;
|
|
117
|
+
case 'user':
|
|
118
|
+
messages.push({ role: 'user', content: extractTextContent(item.content) });
|
|
119
|
+
break;
|
|
120
|
+
case 'assistant':
|
|
121
|
+
messages.push({ role: 'assistant', content: extractTextContent(item.content) });
|
|
122
|
+
break;
|
|
123
|
+
default:
|
|
124
|
+
if (item.role) {
|
|
125
|
+
messages.push({ role: item.role, content: extractTextContent(item.content) });
|
|
126
|
+
}
|
|
89
127
|
}
|
|
90
128
|
}
|
|
91
129
|
return messages;
|
|
92
130
|
}
|
|
131
|
+
function convertChatToResponses(ccData) {
|
|
132
|
+
const choice = ccData.choices?.[0];
|
|
133
|
+
if (!choice) {
|
|
134
|
+
return { id: ccData.id, object: 'response', model: ccData.model, output: [], usage: ccData.usage };
|
|
135
|
+
}
|
|
136
|
+
const output = [];
|
|
137
|
+
if (choice.message?.content) {
|
|
138
|
+
output.push({
|
|
139
|
+
type: 'message',
|
|
140
|
+
role: 'assistant',
|
|
141
|
+
content: [{ type: 'output_text', text: choice.message.content }],
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (choice.message?.tool_calls) {
|
|
145
|
+
for (const tc of choice.message.tool_calls) {
|
|
146
|
+
output.push({
|
|
147
|
+
type: 'function_call',
|
|
148
|
+
call_id: tc.id,
|
|
149
|
+
name: tc.function?.name,
|
|
150
|
+
arguments: tc.function?.arguments,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// finish_reason = 'tool_calls' 表示等待 tool 结果(Codex 需要此信息)
|
|
155
|
+
const status = choice.finish_reason === 'tool_calls' ? 'requires_action' : 'completed';
|
|
156
|
+
return {
|
|
157
|
+
id: ccData.id,
|
|
158
|
+
object: 'response',
|
|
159
|
+
model: ccData.model,
|
|
160
|
+
status,
|
|
161
|
+
output,
|
|
162
|
+
usage: ccData.usage,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const EVENT_DELTA = 'response.output_text.delta';
|
|
166
|
+
const EVENT_TOOL_DELTA = 'response.function_call_arguments.delta';
|
|
167
|
+
const EVENT_COMPLETED = 'response.completed';
|
|
168
|
+
const sseEncoder = new TextEncoder();
|
|
169
|
+
function transformSSEStream(upstreamBody) {
|
|
170
|
+
let buffer = '';
|
|
171
|
+
let responseId = '';
|
|
172
|
+
let modelName = '';
|
|
173
|
+
let contentBuffer = '';
|
|
174
|
+
const toolCallAccum = {};
|
|
175
|
+
return new ReadableStream({
|
|
176
|
+
async start(controller) {
|
|
177
|
+
const reader = upstreamBody.getReader();
|
|
178
|
+
const decoder = new TextDecoder();
|
|
179
|
+
try {
|
|
180
|
+
while (true) {
|
|
181
|
+
const { done, value } = await reader.read();
|
|
182
|
+
if (done)
|
|
183
|
+
break;
|
|
184
|
+
buffer += decoder.decode(value, { stream: true });
|
|
185
|
+
let newlineIdx;
|
|
186
|
+
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
|
|
187
|
+
const line = buffer.slice(0, newlineIdx);
|
|
188
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
189
|
+
if (!line.startsWith('data: '))
|
|
190
|
+
continue;
|
|
191
|
+
const data = line.slice(6).trim();
|
|
192
|
+
if (data === '[DONE]') {
|
|
193
|
+
const finalEvt = buildResponseEvent(responseId, modelName, contentBuffer, toolCallAccum, true);
|
|
194
|
+
controller.enqueue(sseEncoder.encode(finalEvt));
|
|
195
|
+
controller.enqueue(sseEncoder.encode('data: [DONE]\n\n'));
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const chunk = JSON.parse(data);
|
|
200
|
+
responseId = chunk.id || responseId;
|
|
201
|
+
modelName = chunk.model || modelName;
|
|
202
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
203
|
+
if (!delta)
|
|
204
|
+
continue;
|
|
205
|
+
if (delta.content) {
|
|
206
|
+
contentBuffer += delta.content;
|
|
207
|
+
const evt = `data: ${JSON.stringify({
|
|
208
|
+
type: EVENT_DELTA,
|
|
209
|
+
delta: delta.content,
|
|
210
|
+
})}\n\n`;
|
|
211
|
+
controller.enqueue(sseEncoder.encode(evt));
|
|
212
|
+
}
|
|
213
|
+
if (delta.tool_calls) {
|
|
214
|
+
for (const tc of delta.tool_calls) {
|
|
215
|
+
if (!toolCallAccum[tc.index]) {
|
|
216
|
+
toolCallAccum[tc.index] = {
|
|
217
|
+
id: tc.id || '',
|
|
218
|
+
type: 'function',
|
|
219
|
+
function: { name: tc.function?.name || '', arguments: '' },
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (tc.function?.arguments) {
|
|
223
|
+
toolCallAccum[tc.index].function.arguments += tc.function.arguments;
|
|
224
|
+
}
|
|
225
|
+
if (tc.id)
|
|
226
|
+
toolCallAccum[tc.index].id = tc.id;
|
|
227
|
+
if (tc.function?.name)
|
|
228
|
+
toolCallAccum[tc.index].function.name = tc.function.name;
|
|
229
|
+
}
|
|
230
|
+
// 发送 tool_call delta 事件
|
|
231
|
+
const tcEvt = `data: ${JSON.stringify({
|
|
232
|
+
type: EVENT_TOOL_DELTA,
|
|
233
|
+
tool_calls: Object.values(toolCallAccum),
|
|
234
|
+
})}\n\n`;
|
|
235
|
+
controller.enqueue(sseEncoder.encode(tcEvt));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// 非 JSON 行直接透传
|
|
240
|
+
controller.enqueue(sseEncoder.encode(line + '\n'));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
controller.error(err);
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
reader.releaseLock();
|
|
250
|
+
controller.close();
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
function buildResponseEvent(id, model, content, toolCalls, isFinal) {
|
|
256
|
+
const output = [];
|
|
257
|
+
if (content) {
|
|
258
|
+
output.push({ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: content }] });
|
|
259
|
+
}
|
|
260
|
+
for (const tc of Object.values(toolCalls)) {
|
|
261
|
+
output.push({
|
|
262
|
+
type: 'function_call',
|
|
263
|
+
call_id: tc.id,
|
|
264
|
+
name: tc.function.name,
|
|
265
|
+
arguments: tc.function.arguments,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
return `data: ${JSON.stringify({
|
|
269
|
+
type: isFinal ? EVENT_COMPLETED : EVENT_DELTA,
|
|
270
|
+
response: isFinal ? { id, model, object: 'response', status: 'completed', output } : undefined,
|
|
271
|
+
delta: isFinal ? undefined : content,
|
|
272
|
+
})}\n\n`;
|
|
273
|
+
}
|
|
274
|
+
function convertToolSchemas(tools) {
|
|
275
|
+
if (!tools || !Array.isArray(tools))
|
|
276
|
+
return undefined;
|
|
277
|
+
return tools.map(t => ({
|
|
278
|
+
type: 'function',
|
|
279
|
+
function: {
|
|
280
|
+
name: t.name,
|
|
281
|
+
description: t.description,
|
|
282
|
+
parameters: t.parameters || t.input_schema,
|
|
283
|
+
},
|
|
284
|
+
}));
|
|
285
|
+
}
|
|
93
286
|
function extractTextContent(content) {
|
|
94
287
|
if (typeof content === 'string')
|
|
95
288
|
return content;
|
|
@@ -101,13 +294,3 @@ function extractTextContent(content) {
|
|
|
101
294
|
}
|
|
102
295
|
return String(content || '');
|
|
103
296
|
}
|
|
104
|
-
export function buildStreamingResponse(upstreamResp) {
|
|
105
|
-
return new Response(upstreamResp.body, {
|
|
106
|
-
status: upstreamResp.status,
|
|
107
|
-
headers: {
|
|
108
|
-
'Content-Type': 'text/event-stream',
|
|
109
|
-
'Cache-Control': 'no-cache',
|
|
110
|
-
Connection: 'keep-alive',
|
|
111
|
-
},
|
|
112
|
-
});
|
|
113
|
-
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isProviderAvailable } from './quota.js';
|
|
2
2
|
import { circuitBreakerRegistry } from './circuit-breaker.js';
|
|
3
|
-
// 模型名重映射:对外模型名 → 实际 Provider
|
|
4
|
-
|
|
3
|
+
// 模型名重映射:对外模型名 → 实际 Provider 的模型名(可从 config 覆盖)
|
|
4
|
+
let modelAliases = {
|
|
5
5
|
deepseek: {
|
|
6
6
|
'gpt-5.3-codex': 'deepseek-v4-pro',
|
|
7
7
|
'gpt-5.4': 'deepseek-v4-pro',
|
|
@@ -13,6 +13,26 @@ const MODEL_ALIASES = {
|
|
|
13
13
|
let config;
|
|
14
14
|
export function initModelRouter(cfg) {
|
|
15
15
|
config = cfg;
|
|
16
|
+
// 从 config 加载模型别名(覆盖默认)
|
|
17
|
+
if (cfg.model_aliases) {
|
|
18
|
+
// cfg.model_aliases: { "gpt-x": "deepseek-v4-pro", "claude-y": "claude-opus" }
|
|
19
|
+
// 按目标模型名反查对应的 provider → 写入对应 provider 的别名表
|
|
20
|
+
for (const [aliasKey, realModel] of Object.entries(cfg.model_aliases)) {
|
|
21
|
+
for (const [providerName, providerCfg] of Object.entries(config.providers)) {
|
|
22
|
+
const providerModels = providerCfg.models || [];
|
|
23
|
+
if (providerModels.includes(realModel)) {
|
|
24
|
+
modelAliases[providerName] = modelAliases[providerName] || {};
|
|
25
|
+
modelAliases[providerName][aliasKey] = realModel;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// 兜底:如果没匹配到任何 provider,放入 deepseek
|
|
30
|
+
modelAliases.deepseek = modelAliases.deepseek || {};
|
|
31
|
+
if (!modelAliases.deepseek[aliasKey]) {
|
|
32
|
+
modelAliases.deepseek[aliasKey] = realModel;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
16
36
|
}
|
|
17
37
|
export function getConfig() {
|
|
18
38
|
return config;
|
|
@@ -24,7 +44,7 @@ export function resolveProvider(model) {
|
|
|
24
44
|
// 1. 按 model_routing 配置查找
|
|
25
45
|
const routingEntries = Object.entries(config.model_routing);
|
|
26
46
|
for (const [pattern, providers] of routingEntries) {
|
|
27
|
-
if (model.
|
|
47
|
+
if (model.startsWith(pattern)) {
|
|
28
48
|
for (const providerName of providers) {
|
|
29
49
|
const providerCfg = config.providers[providerName];
|
|
30
50
|
if (!providerCfg)
|
|
@@ -79,7 +99,7 @@ export function resolveProvider(model) {
|
|
|
79
99
|
return null;
|
|
80
100
|
}
|
|
81
101
|
export function remapModel(model, providerName) {
|
|
82
|
-
return
|
|
102
|
+
return modelAliases[providerName]?.[model] || model;
|
|
83
103
|
}
|
|
84
104
|
function resolveApiKey(_name, providerCfg) {
|
|
85
105
|
if (providerCfg.api_key && providerCfg.api_key !== '') {
|
|
@@ -4,6 +4,14 @@ import { getQuotaSummary, probeQuota } from './quota.js';
|
|
|
4
4
|
import { circuitBreakerRegistry } from './circuit-breaker.js';
|
|
5
5
|
import { checkAllProviders } from './health.js';
|
|
6
6
|
import { getMetrics, recordRequest } from './metrics.js';
|
|
7
|
+
import { checkRateLimit } from './rate-limit.js';
|
|
8
|
+
function logReq(originalModel, providerName, actualModel, reqStart, status, streaming, error) {
|
|
9
|
+
recordRequest({
|
|
10
|
+
timestamp: Date.now(), model: originalModel,
|
|
11
|
+
provider: providerName, actualModel, latencyMs: Date.now() - reqStart,
|
|
12
|
+
status, streaming, error,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
7
15
|
export async function modelGatewayRoutes(fastify) {
|
|
8
16
|
// 健康检查 + 配额总览
|
|
9
17
|
fastify.get('/model-gateway/health', async (_req, _reply) => {
|
|
@@ -41,6 +49,11 @@ export async function modelGatewayRoutes(fastify) {
|
|
|
41
49
|
});
|
|
42
50
|
// POST /v1/chat/completions — 标准 OpenAI 兼容端点
|
|
43
51
|
fastify.post('/v1/chat/completions', async (request, reply) => {
|
|
52
|
+
const ip = request.ip || '127.0.0.1';
|
|
53
|
+
const rl = checkRateLimit('/v1/chat/completions', ip);
|
|
54
|
+
if (!rl.allowed) {
|
|
55
|
+
return reply.code(429).header('Retry-After', String(rl.resetSeconds)).header('X-RateLimit-Limit', rl.limit).header('X-RateLimit-Remaining', '0').send({ error: { message: 'Rate limit exceeded' } });
|
|
56
|
+
}
|
|
44
57
|
const body = request.body;
|
|
45
58
|
if (!body || !body.messages) {
|
|
46
59
|
return reply.code(400).send({ error: { message: 'messages is required' } });
|
|
@@ -65,16 +78,16 @@ export async function modelGatewayRoutes(fastify) {
|
|
|
65
78
|
tools: body.tools,
|
|
66
79
|
tool_choice: body.tool_choice,
|
|
67
80
|
});
|
|
68
|
-
if (!upstreamResp.ok
|
|
81
|
+
if (!upstreamResp.ok) {
|
|
69
82
|
const errText = await upstreamResp.text();
|
|
70
|
-
|
|
83
|
+
logReq(originalModel, provider.name, model, reqStart, upstreamResp.status, !!body.stream, errText.slice(0, 200));
|
|
71
84
|
console.error(`[ModelGW] ${provider.name} error ${upstreamResp.status}: ${errText.slice(0, 200)}`);
|
|
72
85
|
return reply.code(upstreamResp.status).send({
|
|
73
86
|
error: { message: `${provider.name}: ${errText.slice(0, 500)}` },
|
|
74
87
|
});
|
|
75
88
|
}
|
|
76
89
|
if (body.stream) {
|
|
77
|
-
|
|
90
|
+
logReq(originalModel, provider.name, model, reqStart, 200, true);
|
|
78
91
|
return reply.headers({
|
|
79
92
|
'Content-Type': 'text/event-stream',
|
|
80
93
|
'Cache-Control': 'no-cache',
|
|
@@ -82,11 +95,11 @@ export async function modelGatewayRoutes(fastify) {
|
|
|
82
95
|
}).send(upstreamResp.body);
|
|
83
96
|
}
|
|
84
97
|
const data = await upstreamResp.json();
|
|
85
|
-
|
|
98
|
+
logReq(originalModel, provider.name, model, reqStart, 200, false);
|
|
86
99
|
reply.send(data);
|
|
87
100
|
}
|
|
88
101
|
catch (err) {
|
|
89
|
-
|
|
102
|
+
logReq(originalModel, provider.name, model, reqStart, 502, !!body.stream, err.message);
|
|
90
103
|
console.error(`[ModelGW] Error calling ${provider.name}:`, err.message);
|
|
91
104
|
reply.code(502).send({
|
|
92
105
|
error: { message: `Provider error: ${err.message}` },
|
|
@@ -95,6 +108,11 @@ export async function modelGatewayRoutes(fastify) {
|
|
|
95
108
|
});
|
|
96
109
|
// POST /v1/responses — Codex Desktop Responses API 适配
|
|
97
110
|
fastify.post('/v1/responses', async (request, reply) => {
|
|
111
|
+
const ip = request.ip || '127.0.0.1';
|
|
112
|
+
const rl = checkRateLimit('/v1/responses', ip);
|
|
113
|
+
if (!rl.allowed) {
|
|
114
|
+
return reply.code(429).header('Retry-After', String(rl.resetSeconds)).send({ error: { message: 'Rate limit exceeded' } });
|
|
115
|
+
}
|
|
98
116
|
const body = request.body;
|
|
99
117
|
if (!body || !body.input) {
|
|
100
118
|
return reply.code(400).send({ error: { message: 'input is required' } });
|
|
@@ -109,44 +127,28 @@ export async function modelGatewayRoutes(fastify) {
|
|
|
109
127
|
const model = remapModel(originalModel, provider.name);
|
|
110
128
|
body.model = model;
|
|
111
129
|
console.log(`[ModelGW:Responses] ${originalModel} → ${provider.name}/${model}`);
|
|
130
|
+
const reqStart = Date.now();
|
|
112
131
|
try {
|
|
113
132
|
const upstreamResp = await callResponsesApi(provider, body);
|
|
114
133
|
if (!upstreamResp.ok) {
|
|
115
134
|
const errText = await upstreamResp.text();
|
|
135
|
+
logReq(originalModel, provider.name, model, reqStart, upstreamResp.status, !!body.stream, errText.slice(0, 200));
|
|
116
136
|
return reply.code(upstreamResp.status).send({
|
|
117
137
|
error: { message: `${provider.name}: ${errText.slice(0, 500)}` },
|
|
118
138
|
});
|
|
119
139
|
}
|
|
140
|
+
logReq(originalModel, provider.name, model, reqStart, 200, !!body.stream);
|
|
120
141
|
if (body.stream) {
|
|
121
142
|
return reply.headers({
|
|
122
143
|
'Content-Type': 'text/event-stream',
|
|
123
144
|
'Cache-Control': 'no-cache',
|
|
124
145
|
}).send(upstreamResp.body);
|
|
125
146
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const choice = ccData.choices?.[0];
|
|
129
|
-
const responsesData = {
|
|
130
|
-
id: ccData.id,
|
|
131
|
-
object: 'response',
|
|
132
|
-
model: ccData.model,
|
|
133
|
-
output: [
|
|
134
|
-
{
|
|
135
|
-
type: 'message',
|
|
136
|
-
role: 'assistant',
|
|
137
|
-
content: [
|
|
138
|
-
{
|
|
139
|
-
type: 'output_text',
|
|
140
|
-
text: choice?.message?.content || '',
|
|
141
|
-
},
|
|
142
|
-
],
|
|
143
|
-
},
|
|
144
|
-
],
|
|
145
|
-
usage: ccData.usage,
|
|
146
|
-
};
|
|
147
|
-
reply.send(responsesData);
|
|
147
|
+
const data = await upstreamResp.json();
|
|
148
|
+
reply.send(data);
|
|
148
149
|
}
|
|
149
150
|
catch (err) {
|
|
151
|
+
logReq(originalModel, provider.name, model, reqStart, 502, !!body.stream, err.message);
|
|
150
152
|
console.error(`[ModelGW:Responses] Error:`, err.message);
|
|
151
153
|
reply.code(502).send({
|
|
152
154
|
error: { message: `Provider error: ${err.message}` },
|