@mcoda/codali 0.1.133 → 0.1.135-codali.2

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.
Files changed (232) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/ORCHESTRATION.md +387 -0
  3. package/README.md +103 -1
  4. package/config/alternate-local.example.json +49 -0
  5. package/config/docdex-runtime.json +21 -0
  6. package/config/orchestration.schema.json +548 -0
  7. package/config/raijin-suku.example.json +172 -0
  8. package/config/terminal-long-scenarios.v1.json +70 -0
  9. package/dist/agents/RoleResolution.d.ts.map +1 -1
  10. package/dist/agents/RoleResolution.js +4 -4
  11. package/dist/api/CodaliApi.d.ts +16 -0
  12. package/dist/api/CodaliApi.d.ts.map +1 -1
  13. package/dist/api/CodaliApi.js +39 -10
  14. package/dist/cli/AskCommand.d.ts +3 -0
  15. package/dist/cli/AskCommand.d.ts.map +1 -1
  16. package/dist/cli/AskCommand.js +76 -13
  17. package/dist/cli/RunCommand.d.ts +4 -0
  18. package/dist/cli/RunCommand.d.ts.map +1 -1
  19. package/dist/cli/RunCommand.js +110 -24
  20. package/dist/cli/TerminalCommand.d.ts +35 -0
  21. package/dist/cli/TerminalCommand.d.ts.map +1 -0
  22. package/dist/cli/TerminalCommand.js +616 -0
  23. package/dist/cli/TerminalCredentials.d.ts +3 -0
  24. package/dist/cli/TerminalCredentials.d.ts.map +1 -0
  25. package/dist/cli/TerminalCredentials.js +89 -0
  26. package/dist/cli/TerminalInput.d.ts +24 -0
  27. package/dist/cli/TerminalInput.d.ts.map +1 -0
  28. package/dist/cli/TerminalInput.js +98 -0
  29. package/dist/cli/TerminalText.d.ts +10 -0
  30. package/dist/cli/TerminalText.d.ts.map +1 -0
  31. package/dist/cli/TerminalText.js +53 -0
  32. package/dist/cli.d.ts.map +1 -1
  33. package/dist/cli.js +33 -4
  34. package/dist/cognitive/ConfiguredPipeline.d.ts +34 -0
  35. package/dist/cognitive/ConfiguredPipeline.d.ts.map +1 -0
  36. package/dist/cognitive/ConfiguredPipeline.js +50 -0
  37. package/dist/cognitive/ValidationRunner.d.ts +4 -0
  38. package/dist/cognitive/ValidationRunner.d.ts.map +1 -1
  39. package/dist/cognitive/ValidationRunner.js +18 -41
  40. package/dist/config/NodeOrchestration.d.ts +21 -0
  41. package/dist/config/NodeOrchestration.d.ts.map +1 -0
  42. package/dist/config/NodeOrchestration.js +54 -0
  43. package/dist/config/OrchestrationConfig.d.ts +100 -0
  44. package/dist/config/OrchestrationConfig.d.ts.map +1 -0
  45. package/dist/config/OrchestrationConfig.js +245 -0
  46. package/dist/connectors/mcp/McpClient.d.ts +3 -1
  47. package/dist/connectors/mcp/McpClient.d.ts.map +1 -1
  48. package/dist/connectors/mcp/McpClient.js +3 -2
  49. package/dist/connectors/mcp/McpServerRegistry.d.ts +2 -2
  50. package/dist/connectors/mcp/McpServerRegistry.d.ts.map +1 -1
  51. package/dist/connectors/mcp/McpServerRegistry.js +39 -16
  52. package/dist/connectors/mcp/McpToolAdapter.js +2 -2
  53. package/dist/connectors/media/ConfiguredMediaTools.d.ts +6 -0
  54. package/dist/connectors/media/ConfiguredMediaTools.d.ts.map +1 -0
  55. package/dist/connectors/media/ConfiguredMediaTools.js +41 -0
  56. package/dist/connectors/media/ImageGenerationTool.js +3 -3
  57. package/dist/connectors/media/MediaArtifactReceipt.d.ts +5 -0
  58. package/dist/connectors/media/MediaArtifactReceipt.d.ts.map +1 -0
  59. package/dist/connectors/media/MediaArtifactReceipt.js +65 -0
  60. package/dist/connectors/media/MediaJobStore.d.ts +68 -0
  61. package/dist/connectors/media/MediaJobStore.d.ts.map +1 -0
  62. package/dist/connectors/media/MediaJobStore.js +199 -0
  63. package/dist/connectors/media/MediaJobWorker.d.ts +2 -0
  64. package/dist/connectors/media/MediaJobWorker.d.ts.map +1 -0
  65. package/dist/connectors/media/MediaJobWorker.js +65 -0
  66. package/dist/connectors/media/RemoteMediaJob.d.ts +6 -0
  67. package/dist/connectors/media/RemoteMediaJob.d.ts.map +1 -0
  68. package/dist/connectors/media/RemoteMediaJob.js +80 -0
  69. package/dist/docdex/DocdexClient.d.ts +18 -1
  70. package/dist/docdex/DocdexClient.d.ts.map +1 -1
  71. package/dist/docdex/DocdexClient.js +135 -19
  72. package/dist/docdex/ManagedDocdex.d.ts +29 -0
  73. package/dist/docdex/ManagedDocdex.d.ts.map +1 -0
  74. package/dist/docdex/ManagedDocdex.js +215 -0
  75. package/dist/docdex/ManagedRuntime.d.ts +28 -0
  76. package/dist/docdex/ManagedRuntime.d.ts.map +1 -0
  77. package/dist/docdex/ManagedRuntime.js +212 -0
  78. package/dist/eval/EvaluationProcess.d.ts +13 -0
  79. package/dist/eval/EvaluationProcess.d.ts.map +1 -0
  80. package/dist/eval/EvaluationProcess.js +76 -0
  81. package/dist/eval/LongWorkflowJourney.d.ts +34 -0
  82. package/dist/eval/LongWorkflowJourney.d.ts.map +1 -0
  83. package/dist/eval/LongWorkflowJourney.js +243 -0
  84. package/dist/eval/PlanningCorpus.d.ts +45 -0
  85. package/dist/eval/PlanningCorpus.d.ts.map +1 -0
  86. package/dist/eval/PlanningCorpus.js +189 -0
  87. package/dist/eval/PlanningCorpusRunner.d.ts +13 -0
  88. package/dist/eval/PlanningCorpusRunner.d.ts.map +1 -0
  89. package/dist/eval/PlanningCorpusRunner.js +89 -0
  90. package/dist/eval/TerminalCorpusRunner.d.ts +36 -0
  91. package/dist/eval/TerminalCorpusRunner.d.ts.map +1 -0
  92. package/dist/eval/TerminalCorpusRunner.js +222 -0
  93. package/dist/eval/TerminalTaskCorpus.d.ts +26 -0
  94. package/dist/eval/TerminalTaskCorpus.d.ts.map +1 -0
  95. package/dist/eval/TerminalTaskCorpus.js +105 -0
  96. package/dist/eval/TerminalWorkflowCorpus.d.ts +11 -0
  97. package/dist/eval/TerminalWorkflowCorpus.d.ts.map +1 -0
  98. package/dist/eval/TerminalWorkflowCorpus.js +116 -0
  99. package/dist/eval/fixtures/TerminalRecoveryJourney.d.ts +4 -0
  100. package/dist/eval/fixtures/TerminalRecoveryJourney.d.ts.map +1 -0
  101. package/dist/eval/fixtures/TerminalRecoveryJourney.js +132 -0
  102. package/dist/gateway/CodaliGateway.d.ts +2 -0
  103. package/dist/gateway/CodaliGateway.d.ts.map +1 -1
  104. package/dist/gateway/CodaliGateway.js +16 -4
  105. package/dist/gateway/ConfiguredGateway.d.ts +23 -0
  106. package/dist/gateway/ConfiguredGateway.d.ts.map +1 -0
  107. package/dist/gateway/ConfiguredGateway.js +44 -0
  108. package/dist/gateway/GatewayTracer.d.ts +1 -1
  109. package/dist/gateway/GatewayTracer.d.ts.map +1 -1
  110. package/dist/gateway/GatewayTracer.js +2 -27
  111. package/dist/gateway/LocalGatewayTaskRunner.d.ts.map +1 -1
  112. package/dist/gateway/LocalGatewayTaskRunner.js +13 -1
  113. package/dist/index.d.ts +23 -0
  114. package/dist/index.d.ts.map +1 -1
  115. package/dist/index.js +12 -0
  116. package/dist/permissions/PermissionGate.d.ts +44 -0
  117. package/dist/permissions/PermissionGate.d.ts.map +1 -0
  118. package/dist/permissions/PermissionGate.js +97 -0
  119. package/dist/providers/ConfiguredEmbeddings.d.ts +43 -0
  120. package/dist/providers/ConfiguredEmbeddings.d.ts.map +1 -0
  121. package/dist/providers/ConfiguredEmbeddings.js +87 -0
  122. package/dist/providers/ConfiguredModelScope.d.ts +69 -0
  123. package/dist/providers/ConfiguredModelScope.d.ts.map +1 -0
  124. package/dist/providers/ConfiguredModelScope.js +113 -0
  125. package/dist/providers/ConfiguredProvider.d.ts +79 -0
  126. package/dist/providers/ConfiguredProvider.d.ts.map +1 -0
  127. package/dist/providers/ConfiguredProvider.js +238 -0
  128. package/dist/providers/ModelServiceError.d.ts +10 -0
  129. package/dist/providers/ModelServiceError.d.ts.map +1 -0
  130. package/dist/providers/ModelServiceError.js +45 -0
  131. package/dist/providers/MswarmWorkerProvider.d.ts.map +1 -1
  132. package/dist/providers/MswarmWorkerProvider.js +4 -0
  133. package/dist/providers/OpenAiCompatibleProvider.d.ts.map +1 -1
  134. package/dist/providers/OpenAiCompatibleProvider.js +134 -52
  135. package/dist/providers/ProviderTypes.d.ts +14 -0
  136. package/dist/providers/ProviderTypes.d.ts.map +1 -1
  137. package/dist/runtime/CodaliJobRuntime.d.ts +6 -0
  138. package/dist/runtime/CodaliJobRuntime.d.ts.map +1 -1
  139. package/dist/runtime/CodaliJobRuntime.js +36 -1
  140. package/dist/runtime/CodaliRuntime.d.ts +30 -0
  141. package/dist/runtime/CodaliRuntime.d.ts.map +1 -1
  142. package/dist/runtime/CodaliRuntime.js +71 -1
  143. package/dist/runtime/CompletionReport.d.ts +5 -0
  144. package/dist/runtime/CompletionReport.d.ts.map +1 -0
  145. package/dist/runtime/CompletionReport.js +12 -0
  146. package/dist/runtime/ProgressTracker.d.ts +17 -0
  147. package/dist/runtime/ProgressTracker.d.ts.map +1 -0
  148. package/dist/runtime/ProgressTracker.js +27 -0
  149. package/dist/runtime/RequestedTestEvidence.d.ts +12 -0
  150. package/dist/runtime/RequestedTestEvidence.d.ts.map +1 -0
  151. package/dist/runtime/RequestedTestEvidence.js +16 -0
  152. package/dist/runtime/RequiredRetrieval.d.ts +13 -0
  153. package/dist/runtime/RequiredRetrieval.d.ts.map +1 -0
  154. package/dist/runtime/RequiredRetrieval.js +25 -0
  155. package/dist/runtime/ReviewSource.d.ts +6 -0
  156. package/dist/runtime/ReviewSource.d.ts.map +1 -0
  157. package/dist/runtime/ReviewSource.js +38 -0
  158. package/dist/runtime/Runner.d.ts +13 -0
  159. package/dist/runtime/Runner.d.ts.map +1 -1
  160. package/dist/runtime/Runner.js +220 -158
  161. package/dist/runtime/SecretRedaction.d.ts +11 -0
  162. package/dist/runtime/SecretRedaction.d.ts.map +1 -0
  163. package/dist/runtime/SecretRedaction.js +85 -0
  164. package/dist/runtime/SessionController.d.ts +148 -0
  165. package/dist/runtime/SessionController.d.ts.map +1 -0
  166. package/dist/runtime/SessionController.js +1356 -0
  167. package/dist/runtime/StructuralEvidence.d.ts +22 -0
  168. package/dist/runtime/StructuralEvidence.d.ts.map +1 -0
  169. package/dist/runtime/StructuralEvidence.js +87 -0
  170. package/dist/runtime/TaskReviewContext.d.ts +34 -0
  171. package/dist/runtime/TaskReviewContext.d.ts.map +1 -0
  172. package/dist/runtime/TaskReviewContext.js +34 -0
  173. package/dist/runtime/ValidationOutput.d.ts +5 -0
  174. package/dist/runtime/ValidationOutput.d.ts.map +1 -0
  175. package/dist/runtime/ValidationOutput.js +32 -0
  176. package/dist/runtime/VerificationCommand.d.ts +3 -0
  177. package/dist/runtime/VerificationCommand.d.ts.map +1 -0
  178. package/dist/runtime/VerificationCommand.js +48 -0
  179. package/dist/runtime/WorkStatusPage.d.ts +15 -0
  180. package/dist/runtime/WorkStatusPage.d.ts.map +1 -0
  181. package/dist/runtime/WorkStatusPage.js +58 -0
  182. package/dist/runtime/WorkspaceLedger.d.ts +13 -0
  183. package/dist/runtime/WorkspaceLedger.d.ts.map +1 -0
  184. package/dist/runtime/WorkspaceLedger.js +145 -0
  185. package/dist/runtime/WorkspaceLock.d.ts.map +1 -1
  186. package/dist/runtime/WorkspaceLock.js +20 -17
  187. package/dist/runtime/WorkspaceSnapshot.d.ts +13 -0
  188. package/dist/runtime/WorkspaceSnapshot.d.ts.map +1 -0
  189. package/dist/runtime/WorkspaceSnapshot.js +88 -0
  190. package/dist/session/AtomicState.d.ts +13 -0
  191. package/dist/session/AtomicState.d.ts.map +1 -0
  192. package/dist/session/AtomicState.js +109 -0
  193. package/dist/session/SessionStoragePath.d.ts +15 -0
  194. package/dist/session/SessionStoragePath.d.ts.map +1 -0
  195. package/dist/session/SessionStoragePath.js +89 -0
  196. package/dist/session/SessionStore.d.ts +9 -0
  197. package/dist/session/SessionStore.d.ts.map +1 -1
  198. package/dist/session/SessionStore.js +181 -47
  199. package/dist/session/WorkState.d.ts +23 -0
  200. package/dist/session/WorkState.d.ts.map +1 -0
  201. package/dist/session/WorkState.js +367 -0
  202. package/dist/session/WorkTypes.d.ts +94 -0
  203. package/dist/session/WorkTypes.d.ts.map +1 -0
  204. package/dist/session/WorkTypes.js +1 -0
  205. package/dist/tools/ToolRegistry.d.ts +2 -0
  206. package/dist/tools/ToolRegistry.d.ts.map +1 -1
  207. package/dist/tools/ToolRegistry.js +12 -2
  208. package/dist/tools/ToolTypes.d.ts +16 -0
  209. package/dist/tools/ToolTypes.d.ts.map +1 -1
  210. package/dist/tools/docdex/DocdexTools.d.ts +2 -0
  211. package/dist/tools/docdex/DocdexTools.d.ts.map +1 -1
  212. package/dist/tools/docdex/DocdexTools.js +17 -2
  213. package/dist/tools/filesystem/FileTools.d.ts.map +1 -1
  214. package/dist/tools/filesystem/FileTools.js +30 -10
  215. package/dist/tools/shell/AsyncProcess.d.ts +16 -0
  216. package/dist/tools/shell/AsyncProcess.d.ts.map +1 -0
  217. package/dist/tools/shell/AsyncProcess.js +94 -0
  218. package/dist/tools/shell/DarwinRuntimeLibraries.d.ts +13 -0
  219. package/dist/tools/shell/DarwinRuntimeLibraries.d.ts.map +1 -0
  220. package/dist/tools/shell/DarwinRuntimeLibraries.js +130 -0
  221. package/dist/tools/shell/HostToolchain.d.ts +19 -0
  222. package/dist/tools/shell/HostToolchain.d.ts.map +1 -0
  223. package/dist/tools/shell/HostToolchain.js +264 -0
  224. package/dist/tools/shell/IsolatedExecutor.d.ts +15 -0
  225. package/dist/tools/shell/IsolatedExecutor.d.ts.map +1 -0
  226. package/dist/tools/shell/IsolatedExecutor.js +134 -0
  227. package/dist/tools/shell/ShellTool.d.ts.map +1 -1
  228. package/dist/tools/shell/ShellTool.js +9 -29
  229. package/dist/tools/shell/ShellWorkingDirectory.d.ts +3 -0
  230. package/dist/tools/shell/ShellWorkingDirectory.d.ts.map +1 -0
  231. package/dist/tools/shell/ShellWorkingDirectory.js +30 -0
  232. package/package.json +27 -24
@@ -0,0 +1,238 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { CryptoHelper, PathHelper, mswarmClientProductHeaders } from "@mcoda/shared";
4
+ import { resolveCredential } from "../runcontext/CredentialFile.js";
5
+ import { resolveBinding } from "../config/OrchestrationConfig.js";
6
+ import { OpenAiCompatibleProvider } from "./OpenAiCompatibleProvider.js";
7
+ import { ModelServiceError } from "./ModelServiceError.js";
8
+ import { registerSecretValue } from "../runtime/SecretRedaction.js";
9
+ export const normalizeModelCaller = (caller = {}) => {
10
+ const clientIdentity = caller.clientIdentity?.trim();
11
+ const clientProduct = caller.clientProduct?.trim().toLowerCase() ?? "codali";
12
+ if (clientIdentity !== undefined && !/^[A-Za-z0-9._:-]{1,128}$/.test(clientIdentity))
13
+ throw new Error("Invalid model client identity");
14
+ if (!/^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/.test(clientProduct))
15
+ throw new Error("Invalid model client product");
16
+ return { ...(clientIdentity ? { clientIdentity } : {}), clientProduct };
17
+ };
18
+ const modelCallerHeaders = (caller) => ({
19
+ ...mswarmClientProductHeaders(caller.clientProduct),
20
+ ...(caller.clientIdentity ? { "x-mswarm-client-identity": caller.clientIdentity, "x-mswarm-client": caller.clientIdentity } : {}),
21
+ });
22
+ const record = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
23
+ /** Discovery failed before inference; a caller may release its local token reservation. */
24
+ export class NativeReasoningTransportError extends Error {
25
+ constructor(message) { super(message); this.name = "NativeReasoningTransportError"; }
26
+ }
27
+ export const providerCredential = async (provider) => {
28
+ if (!provider.credentialRef)
29
+ return undefined;
30
+ const name = provider.credentialRef.slice(4);
31
+ const direct = resolveCredential(name);
32
+ if (direct) {
33
+ registerSecretValue(direct);
34
+ return direct;
35
+ }
36
+ if (provider.adapter === "mswarm" && resolveCredential("CODALI_MSWARM_LOGGED_OUT") === "1")
37
+ throw new Error("Logged out; use codali login");
38
+ // Migrate by reading the existing user store; never read repository credentials.
39
+ if (provider.adapter === "mswarm" && name === "MCODA_MSWARM_API_KEY") {
40
+ try {
41
+ const config = JSON.parse(await readFile(PathHelper.getGlobalConfigPath(), "utf8"));
42
+ const existing = config.mswarm;
43
+ if (existing?.encryptedApiKey && new URL(existing.baseUrl ?? "https://api.mswarm.org").origin === new URL(provider.baseUrl).origin) {
44
+ const key = await CryptoHelper.decryptSecret(existing.encryptedApiKey);
45
+ registerSecretValue(key);
46
+ return key;
47
+ }
48
+ }
49
+ catch (error) {
50
+ if (error.code !== "ENOENT")
51
+ throw new Error("Stored mSwarm credentials could not be loaded; use codali login");
52
+ }
53
+ }
54
+ throw new Error(`Missing credential ${name}; use codali login or set the environment variable`);
55
+ };
56
+ export class ModelServiceClient {
57
+ constructor(provider, credentialOverride, caller) {
58
+ this.provider = provider;
59
+ this.credentialOverride = credentialOverride;
60
+ this.caller = normalizeModelCaller(caller);
61
+ if (credentialOverride)
62
+ registerSecretValue(credentialOverride);
63
+ }
64
+ credential() { return this.credentialOverride === undefined ? providerCredential(this.provider) : Promise.resolve(this.credentialOverride); }
65
+ async json(url, options = {}) {
66
+ if (new URL(url).origin !== new URL(this.provider.baseUrl).origin)
67
+ throw new Error("Refusing credentials to an unconfigured origin");
68
+ const key = await this.credential();
69
+ const controller = new AbortController();
70
+ const abort = () => controller.abort(options.signal?.reason);
71
+ options.signal?.throwIfAborted();
72
+ options.signal?.addEventListener("abort", abort, { once: true });
73
+ const timer = setTimeout(() => controller.abort(new Error("Service request timed out")), options.timeoutMs ?? 30000);
74
+ try {
75
+ const response = await fetch(url, {
76
+ redirect: "error", method: options.method ?? "GET", signal: controller.signal,
77
+ headers: { accept: "application/json", ...(key ? this.provider.adapter === "mswarm" ? { "x-api-key": key } : { authorization: `Bearer ${key}` } : {}), ...(this.provider.adapter === "mswarm" ? modelCallerHeaders(this.caller) : {}), ...(options.body ? { "content-type": "application/json" } : {}) },
78
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
79
+ });
80
+ if (!response.ok)
81
+ throw await ModelServiceError.fromResponse(response);
82
+ if (!response.body)
83
+ throw new Error("Empty service response");
84
+ const reader = response.body.getReader();
85
+ const chunks = [];
86
+ let size = 0;
87
+ try {
88
+ while (true) {
89
+ const next = await reader.read();
90
+ if (next.done)
91
+ break;
92
+ size += next.value.byteLength;
93
+ if (size > 96 * 1024 * 1024) {
94
+ await reader.cancel();
95
+ throw new Error("Service response exceeds size limit");
96
+ }
97
+ chunks.push(next.value);
98
+ }
99
+ }
100
+ finally {
101
+ reader.releaseLock();
102
+ }
103
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
104
+ }
105
+ finally {
106
+ clearTimeout(timer);
107
+ options.signal?.removeEventListener("abort", abort);
108
+ }
109
+ }
110
+ async catalog(signal, refresh = false) {
111
+ const credential = await this.credential();
112
+ const key = createHash("sha256").update(credential ?? "anonymous").digest("hex");
113
+ if (!refresh && this.cache?.key === key && this.cache.expires > Date.now())
114
+ return this.cache.entries;
115
+ const base = new URL(this.provider.baseUrl);
116
+ const url = this.provider.catalogUrl ?? (this.provider.adapter === "mswarm" ? new URL("/v1/swarm/self-hosted/agents", base).toString() : `${this.provider.baseUrl.replace(/\/$/, "")}/models`);
117
+ const payload = record(await this.json(url, { signal }));
118
+ const items = payload.agents ?? payload.data;
119
+ if (!Array.isArray(items))
120
+ throw new Error("Invalid model catalog response");
121
+ const entries = items.map(item => {
122
+ const raw = record(item);
123
+ const aliases = [raw.slug, raw.public_model_id, raw.default_model, raw.id].filter((v) => typeof v === "string" && !!v);
124
+ return { id: aliases[0] ?? "", aliases, backend: raw.adapter === "strata-local" ? "strata" : typeof raw.backend === "string" ? raw.backend : typeof raw.adapter === "string" ? raw.adapter : undefined,
125
+ health: String(raw.health_status ?? "unknown"), supportsTools: raw.supports_tools === true,
126
+ contextWindow: typeof raw.context_window === "number" ? raw.context_window : undefined,
127
+ operations: Array.isArray(raw.operations) ? raw.operations.map(v => typeof v === "string" ? v : String(record(v).type ?? "")) : [], raw };
128
+ }).filter(x => x.id);
129
+ this.cache = { key, expires: Date.now() + 30000, entries };
130
+ return entries;
131
+ }
132
+ async usage(signal) {
133
+ if (this.provider.adapter !== "mswarm" && !this.provider.usageUrl)
134
+ return { available: false };
135
+ return this.json(this.provider.usageUrl ?? new URL("/v1/swarm/runtime/usage-limits", this.provider.baseUrl).toString(), { signal });
136
+ }
137
+ /** The proxy and selected node must both advertise this transport contract.
138
+ * A model's supports_reasoning flag alone says nothing about request forwarding. */
139
+ async requireNativeReasoning(modelId, parameters, signal) {
140
+ if (this.provider.adapter !== "mswarm" || parameters.length === 0)
141
+ return;
142
+ const endpoint = new URL(this.provider.baseUrl);
143
+ endpoint.pathname = endpoint.pathname.replace(/\/$/, "");
144
+ endpoint.pathname = ["", "/v1"].includes(endpoint.pathname)
145
+ ? "/v1/swarm/self-hosted/openai/models" : `${endpoint.pathname}/models`;
146
+ const payload = record(await this.json(endpoint.href, { signal }).catch(error => {
147
+ throw new NativeReasoningTransportError(`Native reasoning preflight failed before inference: ${error instanceof Error ? error.message : String(error)}`);
148
+ }));
149
+ const models = Array.isArray(payload.data) ? payload.data.map(record) : [];
150
+ const model = models.find(item => [item.id, item.agent_slug, item.remote_slug].includes(modelId));
151
+ const contract = record(model?.native_reasoning);
152
+ const supported = Array.isArray(contract.parameters) ? contract.parameters : [];
153
+ if (contract.transport_version !== 1 || parameters.some(parameter => !supported.includes(parameter))) {
154
+ throw new NativeReasoningTransportError(`Native reasoning transport is unavailable for ${modelId}; upgrade the mSwarm proxy and selected node, or remove unsupported native reasoning settings from the configuration`);
155
+ }
156
+ }
157
+ }
158
+ export class ConfiguredProvider {
159
+ constructor(resolved, binding = "lead", options = {}) {
160
+ this.resolved = resolved;
161
+ this.binding = binding;
162
+ this.options = options;
163
+ this.name = "configured";
164
+ this.supportsToolCalls = true;
165
+ this.clients = new Map();
166
+ this.caller = normalizeModelCaller(options.caller);
167
+ this.credentials = Object.freeze(Object.assign(Object.create(null), options.credentials));
168
+ for (const [id, credential] of Object.entries(this.credentials)) {
169
+ if (!Object.prototype.hasOwnProperty.call(resolved.config.providers, id) || typeof credential !== "string" || !credential.trim())
170
+ throw new Error("Invalid host provider credential mapping");
171
+ registerSecretValue(credential);
172
+ }
173
+ }
174
+ client(providerId) {
175
+ const provider = this.resolved.config.providers[providerId];
176
+ if (!provider)
177
+ throw new Error(`Unknown configured provider: ${providerId}`);
178
+ if (!this.clients.has(providerId))
179
+ this.clients.set(providerId, new ModelServiceClient(provider, this.credentials[providerId], this.caller));
180
+ return this.clients.get(providerId);
181
+ }
182
+ async select(operation, signal, requireTools = false) {
183
+ const candidates = resolveBinding(this.resolved.config, this.binding, operation);
184
+ const reasons = [];
185
+ for (const candidate of candidates) {
186
+ const { id, model } = candidate;
187
+ const catalog = await this.client(model.provider).catalog(signal);
188
+ const actual = catalog.find(entry => entry.aliases.includes(model.catalogId));
189
+ if (!actual) {
190
+ reasons.push(`${id}: not entitled/advertised`);
191
+ continue;
192
+ }
193
+ if (!["healthy", "unknown", "ready"].includes(actual.health)) {
194
+ reasons.push(`${id}: ${actual.health}`);
195
+ continue;
196
+ }
197
+ if (model.backend && actual.backend !== model.backend) {
198
+ reasons.push(`${id}: backend identity not verified`);
199
+ continue;
200
+ }
201
+ if (actual.operations.length && !actual.operations.includes(operation)) {
202
+ reasons.push(`${id}: unsupported operation`);
203
+ continue;
204
+ }
205
+ if (requireTools && (!model.supportsTools || this.resolved.config.providers[model.provider].adapter === "mswarm" && !actual.supportsTools)) {
206
+ reasons.push(`${id}: tools unavailable`);
207
+ continue;
208
+ }
209
+ return { id, model, actual, provider: this.resolved.config.providers[model.provider] };
210
+ }
211
+ throw new Error(`No eligible configured ${this.binding} route: ${reasons.join("; ")}`);
212
+ }
213
+ async generate(request) {
214
+ request.signal?.throwIfAborted();
215
+ const { id, model, provider, actual } = await this.select("chat.completions", request.signal, !!request.tools?.length);
216
+ const invocation = { id: randomUUID(), binding: this.binding, configHash: this.resolved.hash, modelId: id, catalogId: model.catalogId, backend: actual.backend, host: model.host };
217
+ const nativeReasoning = this.options.effort ? model.reasoning?.[this.options.effort] : undefined;
218
+ if (nativeReasoning)
219
+ invocation.nativeReasoningRequest = { ...nativeReasoning };
220
+ if (nativeReasoning)
221
+ await this.client(model.provider).requireNativeReasoning(model.catalogId, Object.keys(nativeReasoning), request.signal);
222
+ const maxTokens = Math.min(request.maxTokens ?? model.maxOutputTokens, model.maxOutputTokens);
223
+ const inputEstimate = Math.ceil(JSON.stringify([request.messages, request.tools]).length / 3);
224
+ if (inputEstimate + maxTokens > Math.min(model.contextWindow, actual.contextWindow ?? model.contextWindow))
225
+ throw new Error("Context budget exceeded; checkpoint and compact before continuing");
226
+ const bounded = { ...request, maxTokens, stream: request.stream === true && model.supportsStreaming === true };
227
+ await this.options.beforeRequest?.(invocation, bounded);
228
+ const adapter = new OpenAiCompatibleProvider({
229
+ model: model.catalogId, baseUrl: provider.baseUrl, apiKey: this.credentials[model.provider] ?? await providerCredential(provider), timeoutMs: 300000,
230
+ supportsStreaming: model.supportsStreaming ?? true, supportsTools: model.supportsTools,
231
+ headers: provider.adapter === "mswarm" ? modelCallerHeaders(this.caller) : undefined, retryTransient: false,
232
+ extraBody: nativeReasoning,
233
+ });
234
+ const result = await adapter.generate(bounded);
235
+ await this.options.afterRequest?.(invocation, result);
236
+ return { ...result, route: invocation };
237
+ }
238
+ }
@@ -0,0 +1,10 @@
1
+ export type ModelServiceErrorCode = "authentication_failed" | "entitlement_missing" | "quota_exhausted" | "rate_limited" | "service_unavailable" | "model_unavailable" | "invalid_request";
2
+ /** Transport diagnostics contain stable classifications, never an upstream body. */
3
+ export declare class ModelServiceError extends Error {
4
+ readonly code: ModelServiceErrorCode;
5
+ readonly status: number;
6
+ readonly retryAfterSeconds?: number | undefined;
7
+ constructor(code: ModelServiceErrorCode, status: number, retryAfterSeconds?: number | undefined);
8
+ static fromResponse(response: Response): Promise<ModelServiceError>;
9
+ }
10
+ //# sourceMappingURL=ModelServiceError.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ModelServiceError.d.ts","sourceRoot":"","sources":["../../src/providers/ModelServiceError.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,qBAAqB,GAAG,uBAAuB,GAAG,qBAAqB,GAAG,iBAAiB,GAAG,cAAc,GAAG,qBAAqB,GAAG,mBAAmB,GAAG,iBAAiB,CAAC;AAE3L,oFAAoF;AACpF,qBAAa,iBAAkB,SAAQ,KAAK;IAC9B,QAAQ,CAAC,IAAI,EAAE,qBAAqB;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM;IAAE,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM;gBAAzF,IAAI,EAAE,qBAAqB,EAAW,MAAM,EAAE,MAAM,EAAW,iBAAiB,CAAC,EAAE,MAAM,YAAA;WAKjG,YAAY,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,iBAAiB,CAAC;CAoB1E"}
@@ -0,0 +1,45 @@
1
+ /** Transport diagnostics contain stable classifications, never an upstream body. */
2
+ export class ModelServiceError extends Error {
3
+ constructor(code, status, retryAfterSeconds) {
4
+ super(`Model service HTTP ${status}: ${code}${code === "authentication_failed" ? "; authenticate with codali login" : ""}`);
5
+ this.code = code;
6
+ this.status = status;
7
+ this.retryAfterSeconds = retryAfterSeconds;
8
+ this.name = "ModelServiceError";
9
+ }
10
+ static async fromResponse(response) {
11
+ let serverCode = "";
12
+ if (response.body) {
13
+ const reader = response.body.getReader();
14
+ const chunks = [];
15
+ let bytes = 0;
16
+ try {
17
+ while (true) {
18
+ const part = await reader.read();
19
+ if (part.done)
20
+ break;
21
+ bytes += part.value.byteLength;
22
+ if (bytes > 8192)
23
+ break;
24
+ chunks.push(part.value);
25
+ }
26
+ const payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
27
+ if (typeof (payload.error?.code ?? payload.code) === "string")
28
+ serverCode = String(payload.error?.code ?? payload.code);
29
+ }
30
+ catch { /* The status still supplies a safe classification. */ }
31
+ finally {
32
+ await reader.cancel().catch(() => undefined);
33
+ reader.releaseLock();
34
+ }
35
+ }
36
+ const code = response.status === 401 ? "authentication_failed"
37
+ : response.status === 403 ? "entitlement_missing"
38
+ : ["quota_exceeded", "quota_exhausted", "insufficient_quota", "credits_exhausted", "insufficient_credits"].includes(serverCode) || response.status === 402 ? "quota_exhausted"
39
+ : response.status === 429 ? "rate_limited"
40
+ : response.status === 404 ? "model_unavailable"
41
+ : response.status >= 500 ? "service_unavailable" : "invalid_request";
42
+ const retry = Number(response.headers.get("retry-after"));
43
+ return new ModelServiceError(code, response.status, response.headers.has("retry-after") && Number.isFinite(retry) && retry >= 0 ? Math.min(retry, 3600) : undefined);
44
+ }
45
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"MswarmWorkerProvider.d.ts","sourceRoot":"","sources":["../../src/providers/MswarmWorkerProvider.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EAER,eAAe,EACf,gBAAgB,EAEjB,MAAM,oBAAoB,CAAC;AA8C5B,qBAAa,oBAAqB,YAAW,QAAQ;IAQvC,OAAO,CAAC,QAAQ,CAAC,MAAM;IAPnC,+EAA+E;IAC/E,QAAQ,CAAC,iBAAiB,SAAS;IAEnC,QAAQ,CAAC,IAAI,mBAAmB;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;gBAEH,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE;IAWvG,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC;CA+DpE"}
1
+ {"version":3,"file":"MswarmWorkerProvider.d.ts","sourceRoot":"","sources":["../../src/providers/MswarmWorkerProvider.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EAER,eAAe,EACf,gBAAgB,EAEjB,MAAM,oBAAoB,CAAC;AA8C5B,qBAAa,oBAAqB,YAAW,QAAQ;IAQvC,OAAO,CAAC,QAAQ,CAAC,MAAM;IAPnC,+EAA+E;IAC/E,QAAQ,CAAC,iBAAiB,SAAS;IAEnC,QAAQ,CAAC,IAAI,mBAAmB;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;gBAEH,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE;IAWvG,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC;CAmEpE"}
@@ -52,7 +52,10 @@ export class MswarmWorkerProvider {
52
52
  this.apiKey = config.apiKey;
53
53
  }
54
54
  async generate(request) {
55
+ request.signal?.throwIfAborted();
55
56
  const controller = new AbortController();
57
+ const onAbort = () => controller.abort(request.signal?.reason);
58
+ request.signal?.addEventListener("abort", onAbort, { once: true });
56
59
  const timeout = this.config.timeoutMs && this.config.timeoutMs > 0
57
60
  ? setTimeout(() => controller.abort(), this.config.timeoutMs)
58
61
  : undefined;
@@ -106,6 +109,7 @@ export class MswarmWorkerProvider {
106
109
  };
107
110
  }
108
111
  finally {
112
+ request.signal?.removeEventListener("abort", onAbort);
109
113
  if (timeout) {
110
114
  clearTimeout(timeout);
111
115
  }
@@ -1 +1 @@
1
- {"version":3,"file":"OpenAiCompatibleProvider.d.ts","sourceRoot":"","sources":["../../src/providers/OpenAiCompatibleProvider.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,QAAQ,EACR,cAAc,EAEd,eAAe,EACf,gBAAgB,EAGjB,MAAM,oBAAoB,CAAC;AAgJ5B,eAAO,MAAM,uBAAuB,GAClC,KAAK,MAAM,EACX,MAAM,WAAW,EACjB,aAAa,eAAe,KAC3B,OAAO,CAAC,QAAQ,CAclB,CAAC;AAEF,qBAAa,wBAAyB,YAAW,QAAQ;IAS3C,OAAO,CAAC,MAAM;IAR1B,+EAA+E;IAC/E,QAAQ,CAAC,iBAAiB,QAAQ;IAElC,IAAI,SAAuB;IAC3B,OAAO,CAAC,WAAW,CAAoC;IACvD,OAAO,CAAC,QAAQ,CAAsB;IACtC,OAAO,CAAC,sBAAsB,CAAoC;gBAE9C,MAAM,EAAE,cAAc;IAWpC,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAiNnE,OAAO,CAAC,WAAW;CA0BpB"}
1
+ {"version":3,"file":"OpenAiCompatibleProvider.d.ts","sourceRoot":"","sources":["../../src/providers/OpenAiCompatibleProvider.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EACV,QAAQ,EACR,cAAc,EAEd,eAAe,EACf,gBAAgB,EAGjB,MAAM,oBAAoB,CAAC;AAkK5B,eAAO,MAAM,uBAAuB,GAClC,KAAK,MAAM,EACX,MAAM,WAAW,EACjB,aAAa,eAAe,KAC3B,OAAO,CAAC,QAAQ,CAclB,CAAC;AAEF,qBAAa,wBAAyB,YAAW,QAAQ;IAS3C,OAAO,CAAC,MAAM;IAR1B,+EAA+E;IAC/E,QAAQ,CAAC,iBAAiB,QAAQ;IAElC,IAAI,SAAuB;IAC3B,OAAO,CAAC,WAAW,CAAoC;IACvD,OAAO,CAAC,QAAQ,CAAsB;IACtC,OAAO,CAAC,sBAAsB,CAAoC;gBAE9C,MAAM,EAAE,cAAc;IAWpC,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAoPnE,OAAO,CAAC,WAAW;CA0BpB"}
@@ -1,4 +1,5 @@
1
1
  import { normalizeLocalOpenAiCompatibleRunnerConfig, } from "@mcoda/shared";
2
+ import { ModelServiceError } from "./ModelServiceError.js";
2
3
  const parseToolArgs = (raw) => {
3
4
  try {
4
5
  return JSON.parse(raw);
@@ -7,6 +8,11 @@ const parseToolArgs = (raw) => {
7
8
  return raw;
8
9
  }
9
10
  };
11
+ // JSON permits lone UTF-16 surrogates, but native tokenizers often require
12
+ // Unicode scalar values. Keep their visible source spelling in model context
13
+ // instead of losing them to replacement characters. With /u, valid pairs are
14
+ // matched as one astral code point and are therefore outside this range.
15
+ const modelContextText = (value) => value.replace(/[\uD800-\uDFFF]/gu, (unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`);
10
16
  const normalizeBaseUrl = (baseUrl) => {
11
17
  const root = baseUrl ?? "https://api.openai.com/v1";
12
18
  return root.endsWith("/") ? root : `${root}/`;
@@ -45,7 +51,16 @@ const toResponseFormat = (format, strategy = "openai") => {
45
51
  return { type: "json_object" };
46
52
  }
47
53
  if (format.type === "json_schema") {
48
- return { type: "json_schema", json_schema: format.schema ?? {} };
54
+ const schema = format.schema ?? {};
55
+ // ProviderResponseFormat carries a JSON Schema; OpenAI-compatible servers
56
+ // require it inside a named json_schema envelope. Preserve callers that
57
+ // already supply that envelope.
58
+ const wrapped = typeof schema.name === "string" &&
59
+ schema.schema !== null && typeof schema.schema === "object";
60
+ return {
61
+ type: "json_schema",
62
+ json_schema: wrapped ? schema : { name: "codali_response", schema },
63
+ };
49
64
  }
50
65
  if (format.type === "text") {
51
66
  return { type: "text" };
@@ -139,6 +154,7 @@ export class OpenAiCompatibleProvider {
139
154
  this.responseFormatStrategy = normalizedLocal.config.responseFormatStrategy ?? "openai";
140
155
  }
141
156
  async generate(request) {
157
+ request.signal?.throwIfAborted();
142
158
  const baseUrl = normalizeBaseUrl(this.localRunner.baseUrl ?? this.config.baseUrl);
143
159
  const url = new URL("chat/completions", baseUrl).toString();
144
160
  const auth = this.resolveAuth();
@@ -159,12 +175,29 @@ export class OpenAiCompatibleProvider {
159
175
  : undefined;
160
176
  const messages = request.messages.map((message) => ({
161
177
  role: message.role,
162
- content: message.content,
178
+ content: modelContextText(message.content),
163
179
  name: message.name,
164
180
  tool_call_id: message.toolCallId,
181
+ tool_calls: message.toolCalls?.map((call) => ({
182
+ id: call.id,
183
+ type: "function",
184
+ function: {
185
+ name: call.name,
186
+ arguments: typeof call.args === "string" ? modelContextText(call.args) : JSON.stringify(call.args ?? {}),
187
+ },
188
+ })),
165
189
  }));
166
190
  if (promptOnlyInstruction) {
167
- messages.unshift({ role: "system", content: promptOnlyInstruction });
191
+ // Local chat templates may accept only one initial system message.
192
+ if (messages[0]?.role === "system") {
193
+ messages[0] = {
194
+ ...messages[0],
195
+ content: `${messages[0].content}\n\n${promptOnlyInstruction}`,
196
+ };
197
+ }
198
+ else {
199
+ messages.unshift({ role: "system", content: promptOnlyInstruction });
200
+ }
168
201
  }
169
202
  const body = {
170
203
  ...(this.localRunner.requireModelInRequest === false ? {} : { model: this.config.model }),
@@ -173,7 +206,7 @@ export class OpenAiCompatibleProvider {
173
206
  type: "function",
174
207
  function: {
175
208
  name: tool.name,
176
- description: tool.description,
209
+ description: tool.description === undefined ? undefined : modelContextText(tool.description),
177
210
  parameters: tool.inputSchema ?? {},
178
211
  },
179
212
  })),
@@ -202,20 +235,23 @@ export class OpenAiCompatibleProvider {
202
235
  }
203
236
  }
204
237
  const controller = new AbortController();
238
+ const onAbort = () => controller.abort(request.signal?.reason);
239
+ request.signal?.addEventListener("abort", onAbort, { once: true });
205
240
  const timeoutMs = this.config.timeoutMs ?? 60000;
206
241
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
207
242
  try {
208
- const response = await fetchWithTransientRetry(url, {
243
+ const requestFetch = this.config.retryTransient === false ? (url, init) => fetch(url, init) : fetchWithTransientRetry;
244
+ const response = await requestFetch(url, {
209
245
  method: "POST",
210
246
  headers,
211
247
  body: JSON.stringify(body),
212
248
  signal: controller.signal,
249
+ redirect: "error",
213
250
  }, controller);
214
251
  if (!response.ok) {
215
- const errorBody = await response.text();
216
- throw new Error(`OpenAI-compatible error ${response.status}: ${errorBody}`);
252
+ throw await ModelServiceError.fromResponse(response);
217
253
  }
218
- if (request.stream) {
254
+ if (request.stream && !response.headers.get("content-type")?.includes("application/json")) {
219
255
  if (!response.body) {
220
256
  throw new Error("OpenAI-compatible streaming response missing body");
221
257
  }
@@ -223,55 +259,85 @@ export class OpenAiCompatibleProvider {
223
259
  const decoder = new TextDecoder();
224
260
  let buffer = "";
225
261
  let content = "";
262
+ let ended = false;
263
+ let finishReason;
226
264
  const toolCallMap = new Map();
227
265
  let usage;
228
- while (true) {
229
- const { value, done } = await reader.read();
230
- if (done)
231
- break;
232
- buffer += decoder.decode(value, { stream: true });
233
- const lines = buffer.split("\n");
234
- buffer = lines.pop() ?? "";
235
- for (const line of lines) {
236
- const trimmed = line.trim();
237
- if (!trimmed.startsWith("data:"))
238
- continue;
239
- const data = trimmed.slice(5).trim();
240
- if (!data || data === "[DONE]")
241
- continue;
242
- try {
243
- const chunk = JSON.parse(data);
244
- const delta = chunk.choices?.[0]?.delta;
245
- if (delta?.content) {
246
- content += delta.content;
247
- emitToken(delta.content);
248
- }
249
- if (Array.isArray(delta?.tool_calls)) {
250
- for (const call of delta.tool_calls) {
251
- const index = call.index ?? 0;
252
- const existing = toolCallMap.get(index) ?? {
253
- id: call.id ?? `call_${index + 1}`,
254
- name: "",
255
- args: "",
256
- };
257
- if (call.function?.name) {
258
- existing.name = call.function.name;
259
- }
260
- if (typeof call.function?.arguments === "string") {
261
- existing.args += call.function.arguments;
262
- }
263
- toolCallMap.set(index, existing);
264
- }
265
- }
266
- if (chunk.usage) {
267
- usage = chunk.usage;
268
- }
266
+ const consume = (line) => {
267
+ const trimmed = line.trim();
268
+ if (!trimmed.startsWith("data:"))
269
+ return;
270
+ const data = trimmed.slice(5).trim();
271
+ if (!data)
272
+ return;
273
+ if (data === "[DONE]") {
274
+ ended = true;
275
+ return;
276
+ }
277
+ const chunk = JSON.parse(data);
278
+ if (chunk.error) {
279
+ const detail = typeof chunk.error === "string" ? chunk.error : JSON.stringify(chunk.error);
280
+ throw new Error(`OpenAI-compatible stream error: ${this.config.apiKey ? detail.split(this.config.apiKey).join("[redacted]").slice(0, 600) : detail.slice(0, 600)}`);
281
+ }
282
+ const delta = chunk.choices?.[0]?.delta;
283
+ if (typeof chunk.choices?.[0]?.finish_reason === "string")
284
+ finishReason = chunk.choices[0].finish_reason;
285
+ if (typeof delta?.content === "string") {
286
+ content += delta.content;
287
+ if (content.length > 4000000)
288
+ throw new Error("Model stream exceeds output limit");
289
+ emitToken(delta.content);
290
+ }
291
+ if (Array.isArray(delta?.tool_calls))
292
+ for (const call of delta.tool_calls) {
293
+ const index = call.index ?? 0;
294
+ if (!Number.isInteger(index) || index < 0 || index > 128)
295
+ throw new Error("Invalid streamed tool index");
296
+ const existing = toolCallMap.get(index) ?? { id: call.id ?? `call_${index + 1}`, name: "", args: "" };
297
+ if (typeof call.id === "string")
298
+ existing.id = call.id;
299
+ if (typeof call.function?.name === "string")
300
+ existing.name = call.function.name;
301
+ if (typeof call.function?.arguments === "string")
302
+ existing.args += call.function.arguments;
303
+ if (existing.args.length > 2000000)
304
+ throw new Error("Tool arguments exceed stream limit");
305
+ toolCallMap.set(index, existing);
269
306
  }
270
- catch {
271
- // ignore malformed chunks
307
+ if (chunk.usage)
308
+ usage = chunk.usage;
309
+ };
310
+ try {
311
+ while (true) {
312
+ const { value, done } = await reader.read();
313
+ if (done) {
314
+ buffer += decoder.decode();
315
+ if (buffer.trim())
316
+ consume(buffer);
317
+ break;
272
318
  }
319
+ buffer += decoder.decode(value, { stream: true });
320
+ if (buffer.length > 2000000)
321
+ throw new Error("Model stream frame exceeds limit");
322
+ const lines = buffer.split("\n");
323
+ buffer = lines.pop() ?? "";
324
+ for (const line of lines)
325
+ consume(line);
273
326
  }
274
327
  }
328
+ finally {
329
+ await reader.cancel().catch(() => undefined);
330
+ reader.releaseLock();
331
+ }
332
+ if ((!ended && !finishReason) || finishReason === "length")
333
+ throw new Error("Model stream ended before a complete response");
334
+ if (!content && !toolCallMap.size)
335
+ throw new Error("Model stream returned no content or tool calls");
336
+ for (const call of toolCallMap.values()) {
337
+ if (!call.name || !call.id)
338
+ throw new Error("Incomplete streamed tool call");
339
+ JSON.parse(call.args);
340
+ }
275
341
  const toolCalls = toolCallMap.size > 0
276
342
  ? Array.from(toolCallMap.entries()).map(([index, call]) => ({
277
343
  id: call.id ?? `call_${index + 1}`,
@@ -293,7 +359,22 @@ export class OpenAiCompatibleProvider {
293
359
  };
294
360
  }
295
361
  const raw = (await response.json());
296
- const choice = raw.choices[0]?.message;
362
+ // A relay can send HTTP 200 keepalives before inference fails. Inspect
363
+ // the final envelope instead of treating those headers as success.
364
+ if (raw?.error) {
365
+ const error = raw.error;
366
+ const record = typeof error === "object" && error !== null
367
+ ? error
368
+ : {};
369
+ const detail = typeof error === "string" ? error
370
+ : typeof record.message === "string" ? record.message : "Model gateway failed";
371
+ const code = typeof record.code === "string" ? record.code : "upstream_error";
372
+ const message = `OpenAI-compatible error ${code}: ${detail}`;
373
+ const redacted = this.config.apiKey
374
+ ? message.split(this.config.apiKey).join("[redacted]") : message;
375
+ throw new Error(redacted.slice(0, 600));
376
+ }
377
+ const choice = raw?.choices?.[0]?.message;
297
378
  if (!choice) {
298
379
  throw new Error("OpenAI-compatible response missing choices");
299
380
  }
@@ -319,6 +400,7 @@ export class OpenAiCompatibleProvider {
319
400
  };
320
401
  }
321
402
  finally {
403
+ request.signal?.removeEventListener("abort", onAbort);
322
404
  clearTimeout(timeout);
323
405
  }
324
406
  }
@@ -43,6 +43,8 @@ export interface ProviderMessage {
43
43
  content: string;
44
44
  name?: string;
45
45
  toolCallId?: string;
46
+ /** Assistant calls retained alongside the matching tool result messages. */
47
+ toolCalls?: ProviderToolCall[];
46
48
  }
47
49
  export interface ProviderUsage {
48
50
  inputTokens?: number;
@@ -50,6 +52,7 @@ export interface ProviderUsage {
50
52
  totalTokens?: number;
51
53
  }
52
54
  export interface ProviderRequest {
55
+ signal?: AbortSignal;
53
56
  messages: ProviderMessage[];
54
57
  tools?: ProviderToolDefinition[];
55
58
  toolChoice?: "auto" | "none" | {
@@ -68,8 +71,19 @@ export interface ProviderResponse {
68
71
  toolCalls?: ProviderToolCall[];
69
72
  usage?: ProviderUsage;
70
73
  raw?: unknown;
74
+ /** Selected configured route; host labels are configuration metadata, not hardware attestation. */
75
+ route?: {
76
+ id: string;
77
+ binding: string;
78
+ configHash: string;
79
+ modelId: string;
80
+ catalogId: string;
81
+ backend?: string;
82
+ host?: string;
83
+ };
71
84
  }
72
85
  export interface ProviderConfig {
86
+ retryTransient?: boolean;
73
87
  model: string;
74
88
  /** Agent this provider serves, so failures can name it. */
75
89
  agentSlug?: string;