clawmatrix 0.1.21 → 0.1.23
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/package.json +1 -1
- package/src/connection.ts +2 -0
- package/src/handoff.ts +1 -1
- package/src/index.ts +96 -17
- package/src/knowledge-sync.ts +68 -20
- package/src/local-tools.ts +9 -2
- package/src/model-proxy.ts +217 -74
- package/src/peer-manager.ts +9 -6
- package/src/types.ts +2 -0
package/package.json
CHANGED
package/src/connection.ts
CHANGED
|
@@ -278,6 +278,8 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
278
278
|
const interval = HEARTBEAT_BASE + Math.random() * HEARTBEAT_JITTER;
|
|
279
279
|
this.heartbeatTimer = setTimeout(() => {
|
|
280
280
|
if (this.closed) return;
|
|
281
|
+
// Increment before checking: this ping is about to be sent and
|
|
282
|
+
// counts as outstanding until a pong arrives.
|
|
281
283
|
this.missedPongs++;
|
|
282
284
|
if (this.missedPongs >= HEARTBEAT_TIMEOUT_COUNT) {
|
|
283
285
|
this.close(4002, "heartbeat timeout");
|
package/src/handoff.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { OpenClawPluginApi, GatewayRequestHandlerOptions } from "openclaw/plugin-sdk";
|
|
2
|
-
import { ClawMatrixConfigSchema, parseConfig } from "./config.ts";
|
|
1
|
+
import type { OpenClawPluginApi, OpenClawConfig, GatewayRequestHandlerOptions } from "openclaw/plugin-sdk";
|
|
2
|
+
import { ClawMatrixConfigSchema, parseConfig, type ClawMatrixConfig } from "./config.ts";
|
|
3
3
|
import { createClusterService, getClusterRuntime } from "./cluster-service.ts";
|
|
4
4
|
import { createClusterHandoffTool } from "./tools/cluster-handoff.ts";
|
|
5
5
|
import { createClusterHandoffReplyTool } from "./tools/cluster-handoff-reply.ts";
|
|
@@ -12,6 +12,59 @@ import { createClusterToolTool } from "./tools/cluster-tool.ts";
|
|
|
12
12
|
import { createClusterEventsTool } from "./tools/cluster-events.ts";
|
|
13
13
|
import { registerClusterCli } from "./cli.ts";
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Auto-discover models from OpenClaw's models.providers config.
|
|
17
|
+
* Iterates all configured providers and their models, building the
|
|
18
|
+
* full model list with endpoint info (baseUrl, apiKey, api type, etc.).
|
|
19
|
+
* Excludes clawmatrix proxy providers (remote models from peer nodes).
|
|
20
|
+
*/
|
|
21
|
+
type ProviderEntry = { baseUrl?: string; apiKey?: string; api?: string; models?: Array<Record<string, unknown>> };
|
|
22
|
+
|
|
23
|
+
function discoverModels(
|
|
24
|
+
openclawConfig: OpenClawConfig,
|
|
25
|
+
config: ClawMatrixConfig,
|
|
26
|
+
): ClawMatrixConfig["models"] {
|
|
27
|
+
const cfg = openclawConfig as Record<string, unknown>;
|
|
28
|
+
const providers = (cfg.models as { providers?: Record<string, ProviderEntry> } | undefined)?.providers;
|
|
29
|
+
if (!providers || typeof providers !== "object") return [];
|
|
30
|
+
|
|
31
|
+
// Collect proxyModel node IDs to exclude clawmatrix-registered providers
|
|
32
|
+
const proxyNodeIds = new Set(config.proxyModels.map((m) => m.nodeId));
|
|
33
|
+
|
|
34
|
+
const result: ClawMatrixConfig["models"] = [];
|
|
35
|
+
for (const [providerId, providerConfig] of Object.entries(providers)) {
|
|
36
|
+
// Skip clawmatrix proxy providers (remote models from peer nodes)
|
|
37
|
+
if (proxyNodeIds.has(providerId)) continue;
|
|
38
|
+
|
|
39
|
+
const models = providerConfig?.models;
|
|
40
|
+
if (!Array.isArray(models)) continue;
|
|
41
|
+
|
|
42
|
+
for (const m of models) {
|
|
43
|
+
if (!m.id || typeof m.id !== "string") continue;
|
|
44
|
+
result.push({
|
|
45
|
+
id: m.id,
|
|
46
|
+
provider: providerId,
|
|
47
|
+
description: m.name as string | undefined,
|
|
48
|
+
baseUrl: providerConfig?.baseUrl,
|
|
49
|
+
apiKey: typeof providerConfig?.apiKey === "string" ? providerConfig.apiKey : undefined,
|
|
50
|
+
api: (m.api ?? providerConfig?.api) as ClawMatrixConfig["models"][0]["api"],
|
|
51
|
+
contextWindow: m.contextWindow as number | undefined,
|
|
52
|
+
maxTokens: m.maxTokens as number | undefined,
|
|
53
|
+
reasoning: m.reasoning as boolean | undefined,
|
|
54
|
+
input: m.input as ("text" | "image")[] | undefined,
|
|
55
|
+
cost: m.cost as { input: number; output: number; cacheRead: number; cacheWrite: number } | undefined,
|
|
56
|
+
compat: m.compat as ClawMatrixConfig["models"][0]["compat"],
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (result.length > 0) {
|
|
62
|
+
console.debug(`[clawmatrix] Auto-discovered ${result.length} model(s) from models.providers: ${result.map((m) => `${m.provider}/${m.id}`).join(", ")}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
15
68
|
const plugin = {
|
|
16
69
|
id: "clawmatrix",
|
|
17
70
|
name: "ClawMatrix",
|
|
@@ -36,6 +89,11 @@ const plugin = {
|
|
|
36
89
|
return;
|
|
37
90
|
}
|
|
38
91
|
|
|
92
|
+
// Auto-discover models from agents.defaults.models if no explicit models configured
|
|
93
|
+
if (config.models.length === 0) {
|
|
94
|
+
config = { ...config, models: discoverModels(api.config, config) };
|
|
95
|
+
}
|
|
96
|
+
|
|
39
97
|
// Background service: manages mesh connections, WS listener, heartbeat
|
|
40
98
|
api.registerService(createClusterService(config, api.config, api.runtime.version));
|
|
41
99
|
|
|
@@ -60,26 +118,42 @@ const plugin = {
|
|
|
60
118
|
const models = ((cfg).models ??= {}) as Record<string, unknown>;
|
|
61
119
|
const providers = (models.providers ??= {}) as Record<string, unknown>;
|
|
62
120
|
for (const [nodeId, nodeModels] of Object.entries(modelsByNode)) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
121
|
+
const apiType = nodeApiType[nodeId] ?? "openai-completions";
|
|
122
|
+
const existing = providers[nodeId] as Record<string, unknown> | undefined;
|
|
123
|
+
if (existing) {
|
|
124
|
+
// Provider already exists (e.g. from models.json or config reload) —
|
|
125
|
+
// ensure the dummy apiKey is always present so auth resolution succeeds.
|
|
126
|
+
if (!existing.apiKey) existing.apiKey = "sk-clawmatrix-proxy";
|
|
127
|
+
if (!existing.baseUrl) existing.baseUrl = baseUrl;
|
|
128
|
+
} else {
|
|
129
|
+
providers[nodeId] = { baseUrl, apiKey: "sk-clawmatrix-proxy", api: apiType, models: nodeModels };
|
|
66
130
|
}
|
|
67
131
|
}
|
|
68
132
|
};
|
|
69
133
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
134
|
+
// Patch all known config objects.
|
|
135
|
+
// activateSecretsRuntimeSnapshot clones config on startup & hot-reload,
|
|
136
|
+
// so our injected providers get lost. There is no plugin-facing "config_reload"
|
|
137
|
+
// event, so we re-patch periodically and skip when the reference hasn't changed.
|
|
138
|
+
let lastSnapshotRef: unknown = null;
|
|
139
|
+
const patchAllConfigs = () => {
|
|
140
|
+
patchProviders(api.config as Record<string, unknown>);
|
|
141
|
+
try {
|
|
142
|
+
const snapshot = api.runtime.config.loadConfig();
|
|
143
|
+
if (snapshot && snapshot !== lastSnapshotRef) {
|
|
144
|
+
lastSnapshotRef = snapshot;
|
|
145
|
+
patchProviders(snapshot as Record<string, unknown>);
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// Best-effort
|
|
79
149
|
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
patchAllConfigs();
|
|
153
|
+
|
|
154
|
+
const repatchTimer = setInterval(patchAllConfigs, 10_000);
|
|
155
|
+
repatchTimer.unref?.();
|
|
156
|
+
api.on("dispose", () => clearInterval(repatchTimer));
|
|
83
157
|
|
|
84
158
|
for (const [nodeId, models] of Object.entries(modelsByNode)) {
|
|
85
159
|
api.registerProvider({
|
|
@@ -165,6 +239,11 @@ const plugin = {
|
|
|
165
239
|
},
|
|
166
240
|
);
|
|
167
241
|
|
|
242
|
+
// Log model selection on each LLM call (fire-and-forget)
|
|
243
|
+
api.on("llm_input", (event) => {
|
|
244
|
+
api.logger.debug(`[clawmatrix] llm_input: provider=${event.provider} model=${event.model}`);
|
|
245
|
+
});
|
|
246
|
+
|
|
168
247
|
// CLI subcommand
|
|
169
248
|
api.registerCli(registerClusterCli, { commands: ["clawmatrix"] });
|
|
170
249
|
|
package/src/knowledge-sync.ts
CHANGED
|
@@ -31,13 +31,27 @@ export interface KnowledgeSyncOptions {
|
|
|
31
31
|
|
|
32
32
|
const TAG = "knowledge";
|
|
33
33
|
|
|
34
|
+
async function streamToString(stream: ReadableStream | null): Promise<string> {
|
|
35
|
+
if (!stream) return "";
|
|
36
|
+
const reader = stream.getReader();
|
|
37
|
+
const chunks: Uint8Array[] = [];
|
|
38
|
+
for (;;) {
|
|
39
|
+
const { done, value } = await reader.read();
|
|
40
|
+
if (done) break;
|
|
41
|
+
chunks.push(value);
|
|
42
|
+
}
|
|
43
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
export class KnowledgeSync {
|
|
35
47
|
private doc: Automerge.Doc<KnowledgeDoc>;
|
|
36
48
|
private syncStates = new Map<string, Automerge.SyncState>();
|
|
37
49
|
private watcher: FSWatcher | null = null;
|
|
38
50
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
39
|
-
/** Paths
|
|
40
|
-
|
|
51
|
+
/** Paths recently written by exportToFs with their expected content — used to
|
|
52
|
+
* suppress watcher-triggered syncs for our own writes. Entries are cleared
|
|
53
|
+
* once handleLocalChanges confirms the file content matches. */
|
|
54
|
+
private writtenByExport = new Map<string, string>();
|
|
41
55
|
private opts: KnowledgeSyncOptions;
|
|
42
56
|
private ig: Ignore = ignore();
|
|
43
57
|
|
|
@@ -78,8 +92,6 @@ export class KnowledgeSync {
|
|
|
78
92
|
// Start watching for file changes
|
|
79
93
|
this.watcher = watch(this.opts.workspacePath, { recursive: true }, (_event, filename) => {
|
|
80
94
|
if (!filename) return;
|
|
81
|
-
// Ignore files currently being written by export
|
|
82
|
-
if (this.writingPaths.has(filename)) return;
|
|
83
95
|
// Ignore hidden files
|
|
84
96
|
if (filename.startsWith(".")) return;
|
|
85
97
|
// Ignore gitignored files
|
|
@@ -184,12 +196,22 @@ export class KnowledgeSync {
|
|
|
184
196
|
const currentFiles = await this.readWorkspaceFiles();
|
|
185
197
|
const docFiles = this.doc.files ?? {};
|
|
186
198
|
|
|
199
|
+
// Clear export markers whose content matches the current file (our write landed).
|
|
200
|
+
// If the content differs, a real local edit happened after export — treat it as modified.
|
|
201
|
+
for (const [relPath, expectedContent] of this.writtenByExport) {
|
|
202
|
+
if (currentFiles[relPath] === expectedContent) {
|
|
203
|
+
this.writtenByExport.delete(relPath);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
187
207
|
// Collect changed files for logging
|
|
188
208
|
const added: string[] = [];
|
|
189
209
|
const modified: string[] = [];
|
|
190
210
|
const deleted: string[] = [];
|
|
191
211
|
|
|
192
212
|
for (const [relPath, content] of Object.entries(currentFiles)) {
|
|
213
|
+
// Skip files that were just written by exportToFs and haven't been edited since
|
|
214
|
+
if (this.writtenByExport.has(relPath)) continue;
|
|
193
215
|
if (!(relPath in docFiles)) {
|
|
194
216
|
added.push(relPath);
|
|
195
217
|
} else if (docFiles[relPath] !== content) {
|
|
@@ -308,28 +330,21 @@ export class KnowledgeSync {
|
|
|
308
330
|
// Don't export files that would be gitignored
|
|
309
331
|
if (this.isIgnored(relPath)) continue;
|
|
310
332
|
if (currentFiles[relPath] !== content) {
|
|
311
|
-
this.
|
|
333
|
+
this.writtenByExport.set(relPath, content);
|
|
312
334
|
const absPath = path.join(this.opts.workspacePath, relPath);
|
|
313
335
|
await mkdir(path.dirname(absPath), { recursive: true });
|
|
314
336
|
await writeFile(absPath, content, "utf-8");
|
|
315
|
-
setTimeout(() => this.writingPaths.delete(relPath), 500);
|
|
316
337
|
written++;
|
|
317
338
|
}
|
|
318
339
|
}
|
|
319
340
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const absPath = path.join(this.opts.workspacePath, relPath);
|
|
325
|
-
await unlink(absPath).catch(() => {});
|
|
326
|
-
setTimeout(() => this.writingPaths.delete(relPath), 500);
|
|
327
|
-
removed++;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
341
|
+
// Note: we intentionally do NOT delete local files that are absent from
|
|
342
|
+
// the doc. A locally created file that hasn't been synced yet would be
|
|
343
|
+
// lost if we deleted it here. Deletions propagate through the doc via
|
|
344
|
+
// handleLocalChanges() → Automerge change → broadcastSync() instead.
|
|
330
345
|
|
|
331
|
-
if (written > 0
|
|
332
|
-
debug(TAG, `exported to filesystem: ${written} written
|
|
346
|
+
if (written > 0) {
|
|
347
|
+
debug(TAG, `exported to filesystem: ${written} written`);
|
|
333
348
|
}
|
|
334
349
|
}
|
|
335
350
|
|
|
@@ -356,6 +371,34 @@ export class KnowledgeSync {
|
|
|
356
371
|
} else {
|
|
357
372
|
debug(TAG, "git repo already initialized");
|
|
358
373
|
}
|
|
374
|
+
|
|
375
|
+
// Ensure local git user config exists (so commits don't fail on unconfigured machines)
|
|
376
|
+
const nameCheck = spawnProcess(["git", "config", "user.name"], {
|
|
377
|
+
cwd: this.opts.workspacePath,
|
|
378
|
+
stdout: "pipe",
|
|
379
|
+
stderr: "pipe",
|
|
380
|
+
});
|
|
381
|
+
if ((await nameCheck.exited) !== 0) {
|
|
382
|
+
const setName = spawnProcess(["git", "config", "user.name", "clawmatrix"], {
|
|
383
|
+
cwd: this.opts.workspacePath,
|
|
384
|
+
stdout: "pipe",
|
|
385
|
+
stderr: "pipe",
|
|
386
|
+
});
|
|
387
|
+
await setName.exited;
|
|
388
|
+
}
|
|
389
|
+
const emailCheck = spawnProcess(["git", "config", "user.email"], {
|
|
390
|
+
cwd: this.opts.workspacePath,
|
|
391
|
+
stdout: "pipe",
|
|
392
|
+
stderr: "pipe",
|
|
393
|
+
});
|
|
394
|
+
if ((await emailCheck.exited) !== 0) {
|
|
395
|
+
const setEmail = spawnProcess(["git", "config", "user.email", "clawmatrix@local"], {
|
|
396
|
+
cwd: this.opts.workspacePath,
|
|
397
|
+
stdout: "pipe",
|
|
398
|
+
stderr: "pipe",
|
|
399
|
+
});
|
|
400
|
+
await setEmail.exited;
|
|
401
|
+
}
|
|
359
402
|
} catch {
|
|
360
403
|
debug(TAG, "git not available, skipping git integration");
|
|
361
404
|
}
|
|
@@ -389,8 +432,13 @@ export class KnowledgeSync {
|
|
|
389
432
|
stderr: "pipe",
|
|
390
433
|
},
|
|
391
434
|
);
|
|
392
|
-
await commit.exited;
|
|
393
|
-
|
|
435
|
+
const commitCode = await commit.exited;
|
|
436
|
+
if (commitCode !== 0) {
|
|
437
|
+
const stderr = await streamToString(commit.stderr);
|
|
438
|
+
debug(TAG, `git commit failed (exit ${commitCode}): ${stderr}`);
|
|
439
|
+
} else {
|
|
440
|
+
debug(TAG, `git commit: ${message}`);
|
|
441
|
+
}
|
|
394
442
|
} catch (err) {
|
|
395
443
|
debug(TAG, `git commit failed: ${err}`);
|
|
396
444
|
}
|
package/src/local-tools.ts
CHANGED
|
@@ -126,13 +126,20 @@ async function executeExec(params: ExecParams): Promise<ToolResult> {
|
|
|
126
126
|
|
|
127
127
|
// ── read/write/edit: reuse pi-coding-agent factories ───────────────
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
/** Cache key includes cwd so tools are recreated if the working directory changes. */
|
|
130
|
+
let piToolCache = new Map<string, { execute: Function }>();
|
|
131
|
+
let piToolCwd = "";
|
|
130
132
|
|
|
131
133
|
function getPiTool(name: string): { execute: Function } {
|
|
134
|
+
const cwd = process.cwd();
|
|
135
|
+
if (cwd !== piToolCwd) {
|
|
136
|
+
piToolCache = new Map();
|
|
137
|
+
piToolCwd = cwd;
|
|
138
|
+
}
|
|
139
|
+
|
|
132
140
|
let tool = piToolCache.get(name);
|
|
133
141
|
if (tool) return tool;
|
|
134
142
|
|
|
135
|
-
const cwd = process.cwd();
|
|
136
143
|
switch (name) {
|
|
137
144
|
case "read":
|
|
138
145
|
tool = createReadTool(cwd);
|
package/src/model-proxy.ts
CHANGED
|
@@ -12,6 +12,15 @@ import { debug } from "./debug.ts";
|
|
|
12
12
|
import { readBody } from "./http-utils.ts";
|
|
13
13
|
|
|
14
14
|
const MODEL_TIMEOUT = 120_000; // 2 minutes
|
|
15
|
+
|
|
16
|
+
/** Normalize usage from OpenAI-compatible APIs (supports both field naming conventions). */
|
|
17
|
+
function parseUsage(usage: Record<string, number> | undefined): { inputTokens: number; outputTokens: number } | undefined {
|
|
18
|
+
if (!usage) return undefined;
|
|
19
|
+
return {
|
|
20
|
+
inputTokens: usage.input_tokens ?? usage.prompt_tokens ?? 0,
|
|
21
|
+
outputTokens: usage.output_tokens ?? usage.completion_tokens ?? 0,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
15
24
|
const MAX_STREAM_BUFFER = 1_048_576; // 1MB — guard against upstream not sending newlines
|
|
16
25
|
|
|
17
26
|
type ResponseFormat = "chat" | "responses";
|
|
@@ -54,6 +63,11 @@ export class ModelProxy {
|
|
|
54
63
|
private gatewayInfo: GatewayInfo;
|
|
55
64
|
private openclawConfig: OpenClawConfig;
|
|
56
65
|
|
|
66
|
+
/** Cache of models that need a different API format than configured (detected at runtime).
|
|
67
|
+
* Entries expire after 10 minutes so upstream upgrades are eventually detected. */
|
|
68
|
+
private modelApiCache = new Map<string, { api: string; ts: number }>();
|
|
69
|
+
private static readonly MODEL_API_CACHE_TTL = 600_000; // 10 minutes
|
|
70
|
+
|
|
57
71
|
constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo, openclawConfig: OpenClawConfig) {
|
|
58
72
|
this.config = config;
|
|
59
73
|
this.peerManager = peerManager;
|
|
@@ -227,9 +241,11 @@ export class ModelProxy {
|
|
|
227
241
|
}
|
|
228
242
|
|
|
229
243
|
// 2. Read from OpenClaw's models.providers[provider]
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
244
|
+
const modelsConfig = (this.openclawConfig as Record<string, unknown>).models;
|
|
245
|
+
const providers = (modelsConfig && typeof modelsConfig === "object")
|
|
246
|
+
? (modelsConfig as { providers?: Record<string, { baseUrl?: string; apiKey?: string; api?: string }> }).providers
|
|
247
|
+
: undefined;
|
|
248
|
+
const providerConfig = providers?.[model.provider];
|
|
233
249
|
if (providerConfig?.baseUrl) {
|
|
234
250
|
return {
|
|
235
251
|
baseUrl: providerConfig.baseUrl.replace(/\/$/, ""),
|
|
@@ -303,8 +319,17 @@ export class ModelProxy {
|
|
|
303
319
|
this.pending.clear();
|
|
304
320
|
this.streamText.clear();
|
|
305
321
|
this.streamSetupSent.clear();
|
|
322
|
+
this.modelApiCache.clear();
|
|
306
323
|
}
|
|
307
324
|
|
|
325
|
+
/** Clean up all tracking state for a request (pending, streamText, streamSetupSent). */
|
|
326
|
+
private cleanupRequest(id: string, stableStreamId?: string) {
|
|
327
|
+
const pending = this.pending.get(id);
|
|
328
|
+
if (pending) clearTimeout(pending.timer);
|
|
329
|
+
this.pending.delete(id);
|
|
330
|
+
this.streamText.delete(id);
|
|
331
|
+
if (stableStreamId) this.streamSetupSent.delete(stableStreamId);
|
|
332
|
+
}
|
|
308
333
|
|
|
309
334
|
private sendResponse(res: import("node:http").ServerResponse, response: ProxyResponse) {
|
|
310
335
|
res.writeHead(response.status, response.headers);
|
|
@@ -320,8 +345,12 @@ export class ModelProxy {
|
|
|
320
345
|
res.end();
|
|
321
346
|
return;
|
|
322
347
|
}
|
|
323
|
-
res.write(value);
|
|
324
|
-
|
|
348
|
+
const ok = res.write(value);
|
|
349
|
+
if (ok) {
|
|
350
|
+
pump();
|
|
351
|
+
} else {
|
|
352
|
+
res.once("drain", pump);
|
|
353
|
+
}
|
|
325
354
|
}).catch(() => {
|
|
326
355
|
reader.releaseLock();
|
|
327
356
|
res.end();
|
|
@@ -377,7 +406,7 @@ export class ModelProxy {
|
|
|
377
406
|
}
|
|
378
407
|
|
|
379
408
|
private async handleChatCompletion(rawBody: string, _api: string): Promise<ProxyResponse> {
|
|
380
|
-
let body: { model: string; messages: unknown[]; stream?: boolean; temperature?: number; max_tokens?: number };
|
|
409
|
+
let body: { model: string; messages: unknown[]; stream?: boolean; temperature?: number; max_tokens?: number; tools?: unknown[]; tool_choice?: unknown };
|
|
381
410
|
try {
|
|
382
411
|
body = JSON.parse(rawBody);
|
|
383
412
|
} catch {
|
|
@@ -410,7 +439,7 @@ export class ModelProxy {
|
|
|
410
439
|
}
|
|
411
440
|
return {
|
|
412
441
|
type: "model_req", id, from: this.config.nodeId, to: candidate.routeNodeId, timestamp: Date.now(),
|
|
413
|
-
payload: { model: modelId, provider: candidate.proxyModel?.provider, api: candidate.proxyModel?.api, messages, temperature: body.temperature, maxTokens: body.max_tokens, stream },
|
|
442
|
+
payload: { model: modelId, provider: candidate.proxyModel?.provider, api: candidate.proxyModel?.api, messages, tools: body.tools, toolChoice: body.tool_choice, temperature: body.temperature, maxTokens: body.max_tokens, stream },
|
|
414
443
|
};
|
|
415
444
|
};
|
|
416
445
|
const frame = buildFrame(first, requestId);
|
|
@@ -423,7 +452,7 @@ export class ModelProxy {
|
|
|
423
452
|
}
|
|
424
453
|
|
|
425
454
|
private async handleResponses(rawBody: string): Promise<ProxyResponse> {
|
|
426
|
-
let body: { model: string; input: unknown; stream?: boolean; temperature?: number; max_output_tokens?: number; instructions?: string };
|
|
455
|
+
let body: { model: string; input: unknown; stream?: boolean; temperature?: number; max_output_tokens?: number; instructions?: string; tools?: unknown[]; tool_choice?: unknown };
|
|
427
456
|
try {
|
|
428
457
|
body = JSON.parse(rawBody);
|
|
429
458
|
} catch {
|
|
@@ -467,7 +496,7 @@ export class ModelProxy {
|
|
|
467
496
|
}
|
|
468
497
|
return {
|
|
469
498
|
type: "model_req", id, from: this.config.nodeId, to: candidate.routeNodeId, timestamp: Date.now(),
|
|
470
|
-
payload: { model: modelId, provider: candidate.proxyModel?.provider, api: candidate.proxyModel?.api, messages: inputItems, inputFormat: "responses", temperature: body.temperature, maxTokens: body.max_output_tokens, stream },
|
|
499
|
+
payload: { model: modelId, provider: candidate.proxyModel?.provider, api: candidate.proxyModel?.api, messages: inputItems, inputFormat: "responses", tools: body.tools, toolChoice: body.tool_choice, temperature: body.temperature, maxTokens: body.max_output_tokens, stream },
|
|
471
500
|
};
|
|
472
501
|
};
|
|
473
502
|
const frame = buildFrame(first, requestId);
|
|
@@ -524,8 +553,7 @@ export class ModelProxy {
|
|
|
524
553
|
const stableId = streamId ?? requestId;
|
|
525
554
|
|
|
526
555
|
const timer = setTimeout(() => {
|
|
527
|
-
this.
|
|
528
|
-
this.streamText.delete(requestId);
|
|
556
|
+
this.cleanupRequest(requestId);
|
|
529
557
|
this.peerManager.router.markFailed(requestId);
|
|
530
558
|
this.tryStreamFailover(stableId, responseFormat, controller, encoder, model, failoverCandidates, buildFrame, `model request to "${targetNodeId}" timed out`);
|
|
531
559
|
}, MODEL_TIMEOUT);
|
|
@@ -542,14 +570,14 @@ export class ModelProxy {
|
|
|
542
570
|
|
|
543
571
|
// Emit setup events for responses API (only once per stream, keyed by stableId)
|
|
544
572
|
if (responseFormat === "responses" && !this.streamSetupSent.has(stableId)) {
|
|
545
|
-
|
|
573
|
+
const hasTools = Array.isArray(frame.payload.tools) && frame.payload.tools.length > 0;
|
|
574
|
+
this.enqueueResponsesStreamSetup(controller, encoder, stableId, model, hasTools);
|
|
546
575
|
this.streamSetupSent.add(stableId);
|
|
547
576
|
}
|
|
548
577
|
|
|
549
578
|
const sent = this.peerManager.sendTo(targetNodeId, frame);
|
|
550
579
|
if (!sent) {
|
|
551
|
-
this.
|
|
552
|
-
clearTimeout(timer);
|
|
580
|
+
this.cleanupRequest(requestId);
|
|
553
581
|
this.tryStreamFailover(stableId, responseFormat, controller, encoder, model, failoverCandidates, buildFrame, `cannot reach node "${targetNodeId}"`);
|
|
554
582
|
}
|
|
555
583
|
}
|
|
@@ -593,18 +621,23 @@ export class ModelProxy {
|
|
|
593
621
|
}
|
|
594
622
|
|
|
595
623
|
/** Emit responses API stream setup events (response.created → content_part.added). */
|
|
596
|
-
private enqueueResponsesStreamSetup(controller: ReadableStreamDefaultController, encoder: TextEncoder, id: string, model: string) {
|
|
624
|
+
private enqueueResponsesStreamSetup(controller: ReadableStreamDefaultController, encoder: TextEncoder, id: string, model: string, hasTools = false) {
|
|
597
625
|
const respId = `resp_${id}`;
|
|
598
|
-
const msgId = `msg_${id}`;
|
|
599
626
|
const now = Math.floor(Date.now() / 1000);
|
|
600
627
|
const baseResp = { id: respId, object: "response", created_at: now, status: "in_progress", model, output: [] };
|
|
601
|
-
const msgItem = { type: "message", id: msgId, role: "assistant", content: [], status: "in_progress" };
|
|
602
|
-
const textPart = { type: "output_text", text: "" };
|
|
603
628
|
|
|
604
629
|
controller.enqueue(encoder.encode(`event: response.created\ndata: ${JSON.stringify({ type: "response.created", response: baseResp })}\n\n`));
|
|
605
630
|
controller.enqueue(encoder.encode(`event: response.in_progress\ndata: ${JSON.stringify({ type: "response.in_progress", response: baseResp })}\n\n`));
|
|
606
|
-
|
|
607
|
-
|
|
631
|
+
|
|
632
|
+
// When tools are present, skip pre-fabricated output_item/content_part events —
|
|
633
|
+
// the real events (including function_call items) will be forwarded from the remote.
|
|
634
|
+
if (!hasTools) {
|
|
635
|
+
const msgId = `msg_${id}`;
|
|
636
|
+
const msgItem = { type: "message", id: msgId, role: "assistant", content: [], status: "in_progress" };
|
|
637
|
+
const textPart = { type: "output_text", text: "" };
|
|
638
|
+
controller.enqueue(encoder.encode(`event: response.output_item.added\ndata: ${JSON.stringify({ type: "response.output_item.added", output_index: 0, item: msgItem })}\n\n`));
|
|
639
|
+
controller.enqueue(encoder.encode(`event: response.content_part.added\ndata: ${JSON.stringify({ type: "response.content_part.added", item_id: msgId, output_index: 0, content_index: 0, part: textPart })}\n\n`));
|
|
640
|
+
}
|
|
608
641
|
}
|
|
609
642
|
|
|
610
643
|
/** Emit responses API stream completion events (output_text.done → response.completed). */
|
|
@@ -820,18 +853,17 @@ export class ModelProxy {
|
|
|
820
853
|
// process the request and sent model_res instead of model_stream).
|
|
821
854
|
if (pending.stream) {
|
|
822
855
|
if (!frame.payload.success && pending.controller && pending.encoder) {
|
|
823
|
-
|
|
824
|
-
this.pending.delete(frame.id);
|
|
825
|
-
this.streamText.delete(frame.id);
|
|
856
|
+
const stableId = pending.stableStreamId ?? frame.id;
|
|
826
857
|
// Try failover if no content has been sent yet
|
|
827
858
|
if (!pending.hasContent && pending.failoverCandidates?.length && pending.buildFrame) {
|
|
859
|
+
this.cleanupRequest(frame.id);
|
|
828
860
|
this.tryStreamFailover(
|
|
829
|
-
|
|
861
|
+
stableId, pending.responseFormat, pending.controller, pending.encoder,
|
|
830
862
|
pending.model ?? "", pending.failoverCandidates, pending.buildFrame,
|
|
831
863
|
`remote error: ${frame.payload.error}`,
|
|
832
864
|
);
|
|
833
865
|
} else {
|
|
834
|
-
|
|
866
|
+
this.cleanupRequest(frame.id, stableId);
|
|
835
867
|
try {
|
|
836
868
|
const errMsg = `[ClawMatrix] Remote error: ${frame.payload.error}`;
|
|
837
869
|
if (pending.responseFormat === "responses") {
|
|
@@ -843,14 +875,12 @@ export class ModelProxy {
|
|
|
843
875
|
}
|
|
844
876
|
pending.controller.close();
|
|
845
877
|
} catch { /* controller may already be closed */ }
|
|
846
|
-
this.streamSetupSent.delete(stableId);
|
|
847
878
|
}
|
|
848
879
|
}
|
|
849
880
|
return;
|
|
850
881
|
}
|
|
851
882
|
|
|
852
|
-
|
|
853
|
-
this.pending.delete(frame.id);
|
|
883
|
+
this.cleanupRequest(frame.id);
|
|
854
884
|
pending.resolve(frame.payload);
|
|
855
885
|
}
|
|
856
886
|
|
|
@@ -870,9 +900,7 @@ export class ModelProxy {
|
|
|
870
900
|
this.handleModelStreamChat(frame, pending);
|
|
871
901
|
}
|
|
872
902
|
} catch {
|
|
873
|
-
|
|
874
|
-
this.pending.delete(frame.id);
|
|
875
|
-
this.streamText.delete(frame.id);
|
|
903
|
+
this.cleanupRequest(frame.id, pending.stableStreamId);
|
|
876
904
|
}
|
|
877
905
|
}
|
|
878
906
|
|
|
@@ -889,9 +917,7 @@ export class ModelProxy {
|
|
|
889
917
|
pending.controller!.enqueue(pending.encoder!.encode(`data: ${JSON.stringify(finalChunk)}\n\n`));
|
|
890
918
|
pending.controller!.enqueue(pending.encoder!.encode("data: [DONE]\n\n"));
|
|
891
919
|
pending.controller!.close();
|
|
892
|
-
|
|
893
|
-
this.pending.delete(frame.id);
|
|
894
|
-
this.streamSetupSent.delete(stableId);
|
|
920
|
+
this.cleanupRequest(frame.id, stableId);
|
|
895
921
|
} else {
|
|
896
922
|
// Use full deltaObj when available (carries tool_calls etc.), otherwise simple text delta
|
|
897
923
|
const delta = frame.payload.deltaObj ?? { content: frame.payload.delta };
|
|
@@ -907,13 +933,27 @@ export class ModelProxy {
|
|
|
907
933
|
const stableId = pending.stableStreamId ?? frame.id;
|
|
908
934
|
const fullText = this.streamText.get(frame.id) ?? "";
|
|
909
935
|
this.streamText.delete(frame.id);
|
|
910
|
-
|
|
936
|
+
|
|
937
|
+
// If the remote forwarded the full response.completed event, emit it directly
|
|
938
|
+
const doneObj = frame.payload.deltaObj as { event?: string; data?: unknown } | undefined;
|
|
939
|
+
if (doneObj?.event === "response.completed" && doneObj.data) {
|
|
940
|
+
pending.controller!.enqueue(pending.encoder!.encode(`event: response.completed\ndata: ${JSON.stringify(doneObj.data)}\n\n`));
|
|
941
|
+
} else {
|
|
942
|
+
// Fallback: reconstruct text-only completion
|
|
943
|
+
this.enqueueResponsesStreamDone(pending.controller!, pending.encoder!, stableId, pending.model ?? "", fullText, frame.payload.usage);
|
|
944
|
+
}
|
|
911
945
|
pending.controller!.close();
|
|
912
|
-
|
|
913
|
-
this.pending.delete(frame.id);
|
|
914
|
-
this.streamSetupSent.delete(stableId);
|
|
946
|
+
this.cleanupRequest(frame.id, stableId);
|
|
915
947
|
} else {
|
|
916
|
-
//
|
|
948
|
+
// Forward structured Responses API events (function_call, output_item, etc.)
|
|
949
|
+
const obj = frame.payload.deltaObj as { event?: string; data?: unknown } | undefined;
|
|
950
|
+
if (obj?.event && obj.data) {
|
|
951
|
+
pending.controller!.enqueue(pending.encoder!.encode(`event: ${obj.event}\ndata: ${JSON.stringify(obj.data)}\n\n`));
|
|
952
|
+
pending.hasContent = true;
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Text delta
|
|
917
957
|
this.streamText.set(frame.id, (this.streamText.get(frame.id) ?? "") + (frame.payload.delta ?? ""));
|
|
918
958
|
const respStableId = pending.stableStreamId ?? frame.id;
|
|
919
959
|
const evt = { type: "response.output_text.delta", item_id: `msg_${respStableId}`, output_index: 0, content_index: 0, delta: frame.payload.delta };
|
|
@@ -933,14 +973,14 @@ export class ModelProxy {
|
|
|
933
973
|
} satisfies ModelStreamChunk);
|
|
934
974
|
}
|
|
935
975
|
|
|
936
|
-
private sendStreamDone(to: string, id: string, usage?: { inputTokens: number; outputTokens: number }) {
|
|
976
|
+
private sendStreamDone(to: string, id: string, usage?: { inputTokens: number; outputTokens: number }, deltaObj?: unknown) {
|
|
937
977
|
this.peerManager.sendTo(to, {
|
|
938
978
|
type: "model_stream",
|
|
939
979
|
id,
|
|
940
980
|
from: this.config.nodeId,
|
|
941
981
|
to,
|
|
942
982
|
timestamp: Date.now(),
|
|
943
|
-
payload: { delta: "", done: true, usage },
|
|
983
|
+
payload: { delta: "", done: true, usage, ...(deltaObj !== undefined && { deltaObj }) },
|
|
944
984
|
} satisfies ModelStreamChunk);
|
|
945
985
|
}
|
|
946
986
|
|
|
@@ -967,7 +1007,12 @@ export class ModelProxy {
|
|
|
967
1007
|
|
|
968
1008
|
try {
|
|
969
1009
|
const endpoint = this.resolveModelEndpoint(model);
|
|
970
|
-
|
|
1010
|
+
// Use payload.api override from requesting side, or cached API from previous auto-detection
|
|
1011
|
+
const cached = this.modelApiCache.get(model.id);
|
|
1012
|
+
const cachedApi = (cached && Date.now() - cached.ts < ModelProxy.MODEL_API_CACHE_TTL) ? cached.api : undefined;
|
|
1013
|
+
if (cached && !cachedApi) this.modelApiCache.delete(model.id); // expired
|
|
1014
|
+
const effectiveApi = payload.api ?? cachedApi ?? endpoint.api;
|
|
1015
|
+
const isResponsesApi = effectiveApi === "openai-responses" || effectiveApi === "openai-codex-responses";
|
|
971
1016
|
const path = isResponsesApi ? "/responses" : "/chat/completions";
|
|
972
1017
|
const url = `${endpoint.baseUrl}${path}`;
|
|
973
1018
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
@@ -996,6 +1041,8 @@ export class ModelProxy {
|
|
|
996
1041
|
stream: payload.stream,
|
|
997
1042
|
temperature: payload.temperature,
|
|
998
1043
|
max_output_tokens: payload.maxTokens,
|
|
1044
|
+
...(payload.tools && { tools: payload.tools }),
|
|
1045
|
+
...(payload.toolChoice !== undefined && { tool_choice: payload.toolChoice }),
|
|
999
1046
|
};
|
|
1000
1047
|
} else {
|
|
1001
1048
|
const messages = srcFormat === "chat"
|
|
@@ -1008,6 +1055,8 @@ export class ModelProxy {
|
|
|
1008
1055
|
max_tokens: payload.maxTokens,
|
|
1009
1056
|
stream: payload.stream,
|
|
1010
1057
|
...(payload.stream ? { stream_options: { include_usage: true } } : {}),
|
|
1058
|
+
...(payload.tools && { tools: payload.tools }),
|
|
1059
|
+
...(payload.toolChoice !== undefined && { tool_choice: payload.toolChoice }),
|
|
1011
1060
|
};
|
|
1012
1061
|
}
|
|
1013
1062
|
|
|
@@ -1031,6 +1080,8 @@ export class ModelProxy {
|
|
|
1031
1080
|
let buffer = "";
|
|
1032
1081
|
let lastUsage: { inputTokens: number; outputTokens: number } | undefined;
|
|
1033
1082
|
let streamDone = false;
|
|
1083
|
+
let contentSent = false;
|
|
1084
|
+
let completedEvent: unknown = undefined;
|
|
1034
1085
|
|
|
1035
1086
|
while (!streamDone) {
|
|
1036
1087
|
const { done, value } = await reader.read();
|
|
@@ -1053,7 +1104,6 @@ export class ModelProxy {
|
|
|
1053
1104
|
if (!line.startsWith("data: ")) continue;
|
|
1054
1105
|
const data = line.slice(6).trim();
|
|
1055
1106
|
if (data === "[DONE]") {
|
|
1056
|
-
this.sendStreamDone(from, id, lastUsage);
|
|
1057
1107
|
streamDone = true;
|
|
1058
1108
|
break;
|
|
1059
1109
|
}
|
|
@@ -1067,33 +1117,37 @@ export class ModelProxy {
|
|
|
1067
1117
|
const delta = parsed.delta || "";
|
|
1068
1118
|
if (delta) {
|
|
1069
1119
|
this.sendStreamDelta(from, id, delta);
|
|
1120
|
+
contentSent = true;
|
|
1070
1121
|
}
|
|
1122
|
+
} else if (
|
|
1123
|
+
evtType === "response.output_item.added" ||
|
|
1124
|
+
evtType === "response.output_item.done" ||
|
|
1125
|
+
evtType === "response.content_part.added" ||
|
|
1126
|
+
evtType === "response.content_part.done" ||
|
|
1127
|
+
evtType === "response.output_text.done" ||
|
|
1128
|
+
evtType === "response.function_call_arguments.delta" ||
|
|
1129
|
+
evtType === "response.function_call_arguments.done"
|
|
1130
|
+
) {
|
|
1131
|
+
// Forward structured Responses API events via deltaObj
|
|
1132
|
+
this.sendStreamDelta(from, id, "", { event: evtType, data: parsed });
|
|
1133
|
+
contentSent = true;
|
|
1071
1134
|
} else if (evtType === "response.completed") {
|
|
1072
1135
|
const usage = parsed.response?.usage;
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
inputTokens: usage.input_tokens ?? usage.prompt_tokens ?? 0,
|
|
1076
|
-
outputTokens: usage.output_tokens ?? usage.completion_tokens ?? 0,
|
|
1077
|
-
};
|
|
1078
|
-
}
|
|
1079
|
-
this.sendStreamDone(from, id, lastUsage);
|
|
1136
|
+
lastUsage = parseUsage(usage) ?? lastUsage;
|
|
1137
|
+
completedEvent = { event: evtType, data: parsed };
|
|
1080
1138
|
streamDone = true;
|
|
1081
1139
|
break;
|
|
1082
1140
|
}
|
|
1083
1141
|
} else {
|
|
1084
1142
|
// Chat completions format
|
|
1085
|
-
|
|
1086
|
-
lastUsage = {
|
|
1087
|
-
inputTokens: parsed.usage.prompt_tokens,
|
|
1088
|
-
outputTokens: parsed.usage.completion_tokens,
|
|
1089
|
-
};
|
|
1090
|
-
}
|
|
1143
|
+
lastUsage = parseUsage(parsed.usage) ?? lastUsage;
|
|
1091
1144
|
const d = parsed.choices?.[0]?.delta;
|
|
1092
1145
|
const delta = d?.content || d?.reasoning_content || "";
|
|
1093
1146
|
// Pass full delta object when it contains tool_calls or other structured data
|
|
1094
1147
|
const hasStructured = d?.tool_calls || d?.refusal != null;
|
|
1095
1148
|
if (delta || hasStructured) {
|
|
1096
1149
|
this.sendStreamDelta(from, id, delta, hasStructured ? d : undefined);
|
|
1150
|
+
contentSent = true;
|
|
1097
1151
|
}
|
|
1098
1152
|
}
|
|
1099
1153
|
} catch {
|
|
@@ -1102,9 +1156,30 @@ export class ModelProxy {
|
|
|
1102
1156
|
currentEvent = "";
|
|
1103
1157
|
}
|
|
1104
1158
|
}
|
|
1105
|
-
|
|
1106
|
-
//
|
|
1107
|
-
if (!
|
|
1159
|
+
|
|
1160
|
+
// Responses API stream produced no content — fall back to chat completions
|
|
1161
|
+
if (isResponsesApi && !contentSent && !cachedApi) {
|
|
1162
|
+
debug("model_req", `responses API stream produced no content for "${model.id}", retrying with chat completions`);
|
|
1163
|
+
const chatResult = await this.retryWithChatCompletions(endpoint, modelField, payload, headers);
|
|
1164
|
+
if (chatResult) {
|
|
1165
|
+
this.modelApiCache.set(model.id, { api: "openai-completions", ts: Date.now() });
|
|
1166
|
+
debug("model_req", `cached "${model.id}" as openai-completions (stream fallback)`);
|
|
1167
|
+
if (chatResult.content) {
|
|
1168
|
+
this.sendStreamDelta(from, id, chatResult.content);
|
|
1169
|
+
}
|
|
1170
|
+
this.sendStreamDone(from, id, chatResult.usage);
|
|
1171
|
+
} else if (completedEvent) {
|
|
1172
|
+
this.sendStreamDone(from, id, lastUsage, completedEvent);
|
|
1173
|
+
} else {
|
|
1174
|
+
this.sendStreamDone(from, id, lastUsage);
|
|
1175
|
+
}
|
|
1176
|
+
} else if (completedEvent) {
|
|
1177
|
+
this.sendStreamDone(from, id, lastUsage, completedEvent);
|
|
1178
|
+
} else if (!streamDone) {
|
|
1179
|
+
// Upstream closed without sending [DONE] or response.completed
|
|
1180
|
+
this.sendStreamDone(from, id, lastUsage);
|
|
1181
|
+
} else {
|
|
1182
|
+
// Chat completions [DONE] received
|
|
1108
1183
|
this.sendStreamDone(from, id, lastUsage);
|
|
1109
1184
|
}
|
|
1110
1185
|
} finally {
|
|
@@ -1112,12 +1187,31 @@ export class ModelProxy {
|
|
|
1112
1187
|
}
|
|
1113
1188
|
} else {
|
|
1114
1189
|
// Non-streaming response
|
|
1115
|
-
const
|
|
1190
|
+
const responseText = await response.text();
|
|
1191
|
+
let result: Record<string, unknown>;
|
|
1192
|
+
let chatFallbackResult: Awaited<ReturnType<ModelProxy["retryWithChatCompletions"]>> = null;
|
|
1193
|
+
try {
|
|
1194
|
+
result = JSON.parse(responseText);
|
|
1195
|
+
} catch {
|
|
1196
|
+
// Upstream returned non-JSON (e.g. SSE in non-stream mode) — try chat completions fallback
|
|
1197
|
+
if (!cachedApi && isResponsesApi) {
|
|
1198
|
+
debug("model_req", `responses API returned non-JSON for "${model.id}", retrying with chat completions`);
|
|
1199
|
+
chatFallbackResult = await this.retryWithChatCompletions(endpoint, modelField, payload, headers);
|
|
1200
|
+
if (chatFallbackResult) {
|
|
1201
|
+
this.modelApiCache.set(model.id, { api: "openai-completions", ts: Date.now() });
|
|
1202
|
+
debug("model_req", `cached "${model.id}" as openai-completions (non-JSON fallback)`);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
if (!chatFallbackResult) throw new Error(`Upstream returned non-JSON: ${responseText.slice(0, 100)}`);
|
|
1206
|
+
result = {}; // unused — chatFallbackResult takes precedence
|
|
1207
|
+
}
|
|
1116
1208
|
let content: string;
|
|
1117
1209
|
let message: unknown | undefined;
|
|
1118
1210
|
let usage: { inputTokens: number; outputTokens: number } | undefined;
|
|
1119
1211
|
|
|
1120
|
-
if (
|
|
1212
|
+
if (chatFallbackResult) {
|
|
1213
|
+
({ content, message, usage } = chatFallbackResult);
|
|
1214
|
+
} else if (isResponsesApi) {
|
|
1121
1215
|
// Responses API: extract text from output[].content[].text
|
|
1122
1216
|
content = "";
|
|
1123
1217
|
const output = result.output as { type?: string; content?: { type?: string; text?: string }[] }[] | undefined;
|
|
@@ -1130,13 +1224,27 @@ export class ModelProxy {
|
|
|
1130
1224
|
}
|
|
1131
1225
|
}
|
|
1132
1226
|
}
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1227
|
+
|
|
1228
|
+
// Auto-detect: if Responses API returned empty output but produced tokens,
|
|
1229
|
+
// the upstream adapter likely doesn't support Responses API properly.
|
|
1230
|
+
// Retry with chat completions and cache the result.
|
|
1231
|
+
const parsedUsage = parseUsage(result.usage as Record<string, number> | undefined);
|
|
1232
|
+
const hasMessage = Array.isArray(output) && output.some((o: { type?: string }) => o.type === "message");
|
|
1233
|
+
if (!hasMessage && (parsedUsage?.outputTokens ?? 0) > 0 && !cachedApi) {
|
|
1234
|
+
debug("model_req", `responses API returned empty output for "${model.id}" (output_tokens=${parsedUsage!.outputTokens}), retrying with chat completions`);
|
|
1235
|
+
const chatResult = await this.retryWithChatCompletions(endpoint, modelField, payload, headers);
|
|
1236
|
+
if (chatResult) {
|
|
1237
|
+
this.modelApiCache.set(model.id, { api: "openai-completions", ts: Date.now() });
|
|
1238
|
+
debug("model_req", `cached "${model.id}" as openai-completions`);
|
|
1239
|
+
({ content, message, usage } = chatResult);
|
|
1240
|
+
} else {
|
|
1241
|
+
message = result.output;
|
|
1242
|
+
usage = parsedUsage;
|
|
1243
|
+
}
|
|
1244
|
+
} else {
|
|
1245
|
+
// Carry full output array for structured data (function_call items, etc.)
|
|
1246
|
+
message = result.output;
|
|
1247
|
+
usage = parsedUsage;
|
|
1140
1248
|
}
|
|
1141
1249
|
} else {
|
|
1142
1250
|
// Chat completions format
|
|
@@ -1146,12 +1254,7 @@ export class ModelProxy {
|
|
|
1146
1254
|
if (msg?.tool_calls || msg?.refusal != null || msg?.function_call) {
|
|
1147
1255
|
message = msg;
|
|
1148
1256
|
}
|
|
1149
|
-
|
|
1150
|
-
usage = {
|
|
1151
|
-
inputTokens: result.usage.prompt_tokens,
|
|
1152
|
-
outputTokens: result.usage.completion_tokens,
|
|
1153
|
-
};
|
|
1154
|
-
}
|
|
1257
|
+
usage = parseUsage(result.usage);
|
|
1155
1258
|
}
|
|
1156
1259
|
|
|
1157
1260
|
this.peerManager.sendTo(from, {
|
|
@@ -1182,4 +1285,44 @@ export class ModelProxy {
|
|
|
1182
1285
|
} satisfies ModelResponse);
|
|
1183
1286
|
}
|
|
1184
1287
|
}
|
|
1288
|
+
|
|
1289
|
+
/** Retry a model request using chat completions format (fallback from Responses API). */
|
|
1290
|
+
private async retryWithChatCompletions(
|
|
1291
|
+
endpoint: { baseUrl: string; apiKey?: string; direct: boolean; api: string },
|
|
1292
|
+
modelField: string,
|
|
1293
|
+
payload: ModelRequest["payload"],
|
|
1294
|
+
headers: Record<string, string>,
|
|
1295
|
+
): Promise<{ content: string; message?: unknown; usage?: { inputTokens: number; outputTokens: number } } | null> {
|
|
1296
|
+
try {
|
|
1297
|
+
const srcFormat = payload.inputFormat ?? "chat";
|
|
1298
|
+
const messages = srcFormat === "chat"
|
|
1299
|
+
? payload.messages
|
|
1300
|
+
: ModelProxy.normalizeResponsesInput(payload.messages);
|
|
1301
|
+
const chatBody: Record<string, unknown> = {
|
|
1302
|
+
model: modelField,
|
|
1303
|
+
messages,
|
|
1304
|
+
temperature: payload.temperature,
|
|
1305
|
+
max_tokens: payload.maxTokens,
|
|
1306
|
+
stream: false,
|
|
1307
|
+
...(payload.tools && { tools: payload.tools }),
|
|
1308
|
+
...(payload.toolChoice !== undefined && { tool_choice: payload.toolChoice }),
|
|
1309
|
+
};
|
|
1310
|
+
const chatUrl = `${endpoint.baseUrl}/chat/completions`;
|
|
1311
|
+
const chatResp = await fetch(chatUrl, {
|
|
1312
|
+
method: "POST",
|
|
1313
|
+
headers,
|
|
1314
|
+
body: JSON.stringify(chatBody),
|
|
1315
|
+
});
|
|
1316
|
+
if (!chatResp.ok) return null;
|
|
1317
|
+
const chatResult = await chatResp.json();
|
|
1318
|
+
const msg = chatResult.choices?.[0]?.message;
|
|
1319
|
+
const content = msg?.content || msg?.reasoning_content || "";
|
|
1320
|
+
const message = (msg?.tool_calls || msg?.refusal != null || msg?.function_call) ? msg : undefined;
|
|
1321
|
+
const usage = parseUsage(chatResult.usage);
|
|
1322
|
+
return { content, message, usage };
|
|
1323
|
+
} catch (err) {
|
|
1324
|
+
debug("model_req", `retryWithChatCompletions failed for "${modelField}": ${err instanceof Error ? err.message : String(err)}`);
|
|
1325
|
+
return null;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1185
1328
|
}
|
package/src/peer-manager.ts
CHANGED
|
@@ -213,13 +213,16 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
213
213
|
});
|
|
214
214
|
});
|
|
215
215
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
216
|
+
let reconnectScheduled = false;
|
|
217
|
+
const tryReconnect = () => {
|
|
218
|
+
if (!reconnectScheduled) {
|
|
219
|
+
reconnectScheduled = true;
|
|
220
|
+
this.scheduleReconnect(peer);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
219
223
|
|
|
220
|
-
ws.addEventListener("
|
|
221
|
-
|
|
222
|
-
});
|
|
224
|
+
ws.addEventListener("error", tryReconnect);
|
|
225
|
+
ws.addEventListener("close", tryReconnect);
|
|
223
226
|
}
|
|
224
227
|
|
|
225
228
|
private scheduleReconnect(peer: PeerConfig) {
|
package/src/types.ts
CHANGED
|
@@ -101,6 +101,8 @@ export interface ModelRequest extends ClusterFrame {
|
|
|
101
101
|
/** Format of `messages`: "chat" = OpenAI chat completions, "responses" = OpenAI Responses API input items.
|
|
102
102
|
* Defaults to "chat" for backward compatibility. */
|
|
103
103
|
inputFormat?: "chat" | "responses";
|
|
104
|
+
tools?: unknown[];
|
|
105
|
+
toolChoice?: unknown;
|
|
104
106
|
temperature?: number;
|
|
105
107
|
maxTokens?: number;
|
|
106
108
|
stream: boolean;
|