@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830

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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DPl4nBMA.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DNdRKXK9.js +0 -112
  155. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
@@ -0,0 +1,1131 @@
1
+ import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base";
2
+ import { randomUUID } from "node:crypto";
3
+ import type {
4
+ AdapterEvent,
5
+ OcxAssistantMessage,
6
+ OcxContentPart,
7
+ OcxMessage,
8
+ OcxParsedRequest,
9
+ OcxProviderConfig,
10
+ OcxThinkingContent,
11
+ OcxToolCall,
12
+ OcxUsage,
13
+ } from "../types";
14
+ import {
15
+ isAllowedToolChoice,
16
+ modelInList,
17
+ namespacedToolName,
18
+ toolChoiceToolPredicate,
19
+ } from "../types";
20
+ import { configuredReasoningEfforts, isReasoningEffortOmitted, mapReasoningEffort, modelRecordValue, reasoningEffortMapFor } from "../reasoning-effort";
21
+ import {
22
+ readBoundedResponseBytes,
23
+ } from "../lib/bounded-body";
24
+ import { debugProviderDiagnostic } from "../lib/debug";
25
+ import {
26
+ isTranslatorBudgetExceededError,
27
+ retainTranslatedEventBatch,
28
+ TRANSLATOR_MAX_SSE_EVENT_BYTES,
29
+ TranslatorBudgetExceededError,
30
+ type TranslatorBudget,
31
+ } from "../lib/translator-budget";
32
+ import { redactSecretString, SENSITIVE_KEY_PATTERN } from "../lib/redact";
33
+ import { parseDataUrl } from "./image";
34
+ import {
35
+ ollamaNativeChatUrl,
36
+ ollamaNativeEndpointKind,
37
+ type OllamaNativeEndpointKind,
38
+ } from "./ollama-native-url";
39
+
40
+ /** Native `/api/chat` message shape used by this adapter. */
41
+ export interface OllamaNativeMessage {
42
+ role: "system" | "user" | "assistant" | "tool";
43
+ content: string;
44
+ thinking?: string;
45
+ images?: string[];
46
+ tool_call_id?: string;
47
+ tool_name?: string;
48
+ tool_calls?: Array<{
49
+ type: "function";
50
+ function: {
51
+ index?: number;
52
+ name: string;
53
+ arguments: Record<string, unknown>;
54
+ };
55
+ id?: string;
56
+ }>;
57
+ }
58
+
59
+ interface OllamaNativeTool {
60
+ type: "function";
61
+ function: {
62
+ name: string;
63
+ description?: string;
64
+ parameters: Record<string, unknown>;
65
+ };
66
+ }
67
+
68
+ interface PendingToolCall {
69
+ id: string;
70
+ name: string;
71
+ namespace?: string;
72
+ wireName: string;
73
+ order: number;
74
+ result?: OcxMessage & { role: "toolResult" };
75
+ }
76
+
77
+ interface PendingToolBatch {
78
+ calls: PendingToolCall[];
79
+ byId: Map<string, PendingToolCall>;
80
+ }
81
+
82
+ interface NativeStreamToolCall {
83
+ key: string;
84
+ budgetKey: string;
85
+ order: number;
86
+ name: string;
87
+ nativeId?: string;
88
+ nativeIndex?: number;
89
+ arguments: Record<string, unknown>;
90
+ argumentBytes: number;
91
+ }
92
+
93
+ interface NativeStreamState {
94
+ toolCalls: Map<string, NativeStreamToolCall>;
95
+ nextToolOrder: number;
96
+ usage?: OcxUsage;
97
+ stopReason?: string;
98
+ sawMessage: boolean;
99
+ terminal: boolean;
100
+ terminalError: boolean;
101
+ allowParallelToolCalls: boolean;
102
+ }
103
+
104
+ type JsonRecord = Record<string, unknown>;
105
+ type NativeReadResult = { done: false; value: Uint8Array } | { done: true; value?: undefined };
106
+
107
+ const NATIVE_THINK_VALUES = new Set(["low", "medium", "high", "max"]);
108
+ const NATIVE_TOOL_ID_MAX_LENGTH = 256;
109
+ const NATIVE_TOOL_ID_CONTROL = /[\u0000-\u001f\u007f]/u;
110
+
111
+ function isRecord(value: unknown): value is JsonRecord {
112
+ return value !== null && typeof value === "object" && !Array.isArray(value);
113
+ }
114
+
115
+ function isFiniteNonNegativeInteger(value: unknown): value is number {
116
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
117
+ }
118
+
119
+ /**
120
+ * Provider-owned call ids are carried into the client-visible Responses call_id field, so never
121
+ * expose malformed or unbounded strings. A duplicate native id is treated like an unusable id:
122
+ * Ollama is allowed to omit ids or repeat them across requests, while the OCX history contract
123
+ * requires one stable, globally unique pairing key.
124
+ */
125
+ function validNativeToolCallId(value: unknown): string | undefined {
126
+ if (
127
+ typeof value !== "string"
128
+ || value.length === 0
129
+ || value.length > NATIVE_TOOL_ID_MAX_LENGTH
130
+ || value !== value.trim()
131
+ || NATIVE_TOOL_ID_CONTROL.test(value)
132
+ ) return undefined;
133
+ return value;
134
+ }
135
+
136
+ function mintNativeToolCallId(nativeIndex: number | undefined, issuedIds: Set<string>): string {
137
+ let id = "";
138
+ do {
139
+ id = "ollama_call_" + randomUUID() + "_" + (nativeIndex ?? "na");
140
+ } while (issuedIds.has(id));
141
+ issuedIds.add(id);
142
+ return id;
143
+ }
144
+
145
+ function allocateNativeToolCallId(
146
+ nativeId: unknown,
147
+ nativeIndex: number | undefined,
148
+ issuedIds: Set<string>,
149
+ ): string {
150
+ const valid = validNativeToolCallId(nativeId);
151
+ if (valid && !issuedIds.has(valid)) {
152
+ issuedIds.add(valid);
153
+ return valid;
154
+ }
155
+ return mintNativeToolCallId(nativeIndex, issuedIds);
156
+ }
157
+
158
+ function safeNativeString(value: unknown, fallback: string): string {
159
+ if (typeof value !== "string") return fallback;
160
+ const redacted = redactSecretString(value.trim());
161
+ return redacted.length > 400 ? `${redacted.slice(0, 400)}…` : redacted;
162
+ }
163
+
164
+ function errorDetail(value: unknown): string | undefined {
165
+ if (typeof value === "string") return value.trim() || undefined;
166
+ if (!isRecord(value)) return undefined;
167
+ if (typeof value.error === "string" && value.error.trim()) return value.error.trim();
168
+ if (isRecord(value.error) && typeof value.error.message === "string" && value.error.message.trim()) {
169
+ return value.error.message.trim();
170
+ }
171
+ if (typeof value.detail === "string" && value.detail.trim()) return value.detail.trim();
172
+ if (typeof value.message === "string" && value.message.trim()) return value.message.trim();
173
+ return undefined;
174
+ }
175
+
176
+ function nativeErrorEvent(
177
+ detail: unknown,
178
+ usage?: OcxUsage,
179
+ status = 502,
180
+ ): Extract<AdapterEvent, { type: "error" }> {
181
+ return {
182
+ type: "error",
183
+ status,
184
+ errorType: "upstream_error",
185
+ code: "ollama_native_error",
186
+ message: safeNativeString(errorDetail(detail), "Ollama native upstream error"),
187
+ ...(usage ? { usage } : {}),
188
+ };
189
+ }
190
+
191
+ function malformedNativeEvent(message: string, usage?: OcxUsage): Extract<AdapterEvent, { type: "error" }> {
192
+ return {
193
+ type: "error",
194
+ status: 502,
195
+ errorType: "upstream_error",
196
+ code: "invalid_ollama_native_payload",
197
+ message,
198
+ ...(usage ? { usage } : {}),
199
+ };
200
+ }
201
+
202
+ function translationBudgetEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "error" }> {
203
+ return {
204
+ type: "error",
205
+ status: 502,
206
+ errorType: "upstream_error",
207
+ code: "translation_buffer_limit",
208
+ message: "upstream translation buffer exceeded the safe limit",
209
+ ...(usage ? { usage } : {}),
210
+ };
211
+ }
212
+
213
+ function wireModelId(provider: OcxProviderConfig, modelId: string): string {
214
+ if (!provider.modelSuffixBracketStrip) return modelId;
215
+ const end = modelId.trimEnd();
216
+ if (!end.endsWith("]")) return modelId;
217
+ const start = end.lastIndexOf("[");
218
+ return start > 0 ? end.slice(0, start) : modelId;
219
+ }
220
+
221
+ function assertObjectArguments(value: unknown, label: string): Record<string, unknown> {
222
+ if (!isRecord(value)) throw new Error(`ollama-native ${label} arguments must be a JSON object`);
223
+ return value;
224
+ }
225
+
226
+ function normalizedBase64(value: string, label: string): string {
227
+ const base64 = value.replace(/\s+/g, "");
228
+ if (
229
+ base64.length === 0
230
+ || !/^[A-Za-z0-9+/]*={0,2}$/.test(base64)
231
+ || base64.length % 4 === 1
232
+ ) {
233
+ throw new Error(`ollama-native ${label} image is not valid base64`);
234
+ }
235
+ return base64;
236
+ }
237
+
238
+ function imageToBase64(imageUrl: string, label: string): string {
239
+ const data = parseDataUrl(imageUrl);
240
+ if (data) {
241
+ if (!data.mediaType.toLowerCase().startsWith("image/")) {
242
+ throw new Error(`ollama-native ${label} image data URL is not an image`);
243
+ }
244
+ return normalizedBase64(data.base64, label);
245
+ }
246
+ if (/^https?:\/\//i.test(imageUrl)) {
247
+ throw new Error(`ollama-native does not fetch remote ${label} image URLs; provide a data URL/base64 image`);
248
+ }
249
+ return normalizedBase64(imageUrl, label);
250
+ }
251
+
252
+ function contentToNative(
253
+ content: string | OcxContentPart[],
254
+ label: string,
255
+ allowImages = true,
256
+ ): { content: string; images?: string[] } {
257
+ if (typeof content === "string") return { content };
258
+ let text = "";
259
+ const images: string[] = [];
260
+ for (const part of content) {
261
+ if (part.type === "text") {
262
+ text += part.text;
263
+ continue;
264
+ }
265
+ // Ollama's native /api/chat message shape carries `images: string[]` and has no video
266
+ // counterpart, so a video part is refused rather than silently dropped or mis-sent as an image.
267
+ if (part.type === "video") throw new Error(`ollama-native cannot send video content in ${label}`);
268
+ if (!allowImages) throw new Error(`ollama-native cannot preserve images in ${label} developer content`);
269
+ images.push(imageToBase64(part.imageUrl, label));
270
+ }
271
+ return images.length > 0 ? { content: text, images } : { content: text };
272
+ }
273
+
274
+ function assistantTextThinkingAndCalls(message: OcxAssistantMessage): {
275
+ content: string;
276
+ thinking?: string;
277
+ calls: OcxToolCall[];
278
+ } {
279
+ let content = "";
280
+ let thinking = "";
281
+ const calls: OcxToolCall[] = [];
282
+ for (const part of message.content) {
283
+ if (part.type === "text") content += part.text;
284
+ else if (part.type === "thinking") thinking += (part as OcxThinkingContent).thinking;
285
+ else if (part.type === "toolCall") calls.push(part);
286
+ }
287
+ return {
288
+ content,
289
+ ...(thinking ? { thinking } : {}),
290
+ calls,
291
+ };
292
+ }
293
+
294
+ function buildNativeMessages(
295
+ parsed: OcxParsedRequest,
296
+ reservedToolCallIds: Set<string>,
297
+ ): OllamaNativeMessage[] {
298
+ const messages: OllamaNativeMessage[] = [];
299
+ for (const system of parsed.context.systemPrompt ?? []) {
300
+ messages.push({ role: "system", content: system });
301
+ }
302
+
303
+ // A request-boundary adapter is a fresh object in production. Reserve every id already
304
+ // present in the parsed history before the next provider response is translated, so a provider
305
+ // id reused on a later request cannot become a duplicate OCX call_id. The set is deliberately
306
+ // owned by this adapter/request lifecycle rather than process-global state.
307
+ reservedToolCallIds.clear();
308
+ let pending: PendingToolBatch | undefined;
309
+
310
+ const flushPending = (): void => {
311
+ if (!pending) return;
312
+ for (const call of pending.calls) {
313
+ if (!call.result) {
314
+ throw new Error(`ollama-native tool call ${call.id} is missing its tool result; refusing interrupted replay`);
315
+ }
316
+ }
317
+ for (const call of pending.calls) {
318
+ const result = call.result!;
319
+ const translated = contentToNative(result.content, "tool result");
320
+ messages.push({
321
+ role: "tool",
322
+ tool_call_id: call.id,
323
+ tool_name: call.wireName,
324
+ content: translated.content,
325
+ ...(translated.images ? { images: translated.images } : {}),
326
+ });
327
+ }
328
+ pending = undefined;
329
+ };
330
+
331
+ for (const message of parsed.context.messages) {
332
+ if (message.role === "toolResult") {
333
+ if (!pending) {
334
+ throw new Error(`ollama-native orphan tool result ${message.toolCallId || "<missing-id>"}`);
335
+ }
336
+ const call = pending.byId.get(message.toolCallId);
337
+ if (!call) {
338
+ throw new Error(`ollama-native tool result ${message.toolCallId || "<missing-id>"} has no originating call`);
339
+ }
340
+ if (call.result) {
341
+ throw new Error(`ollama-native duplicate tool result for ${message.toolCallId}`);
342
+ }
343
+ if (call.name !== message.toolName || call.namespace !== message.toolNamespace) {
344
+ throw new Error(`ollama-native tool result ${message.toolCallId} names the wrong originating tool`);
345
+ }
346
+ call.result = message;
347
+ continue;
348
+ }
349
+
350
+ // Native Ollama requires the whole assistant tool-call turn followed by its tool results. A
351
+ // new conversational message is a hard boundary; unresolved calls are never fabricated.
352
+ if (pending) flushPending();
353
+
354
+ switch (message.role) {
355
+ case "user": {
356
+ const translated = contentToNative(message.content, "user");
357
+ messages.push({ role: "user", content: translated.content, ...(translated.images ? { images: translated.images } : {}) });
358
+ break;
359
+ }
360
+ case "developer": {
361
+ const translated = contentToNative(message.content, "developer", false);
362
+ messages.push({ role: "system", content: translated.content });
363
+ break;
364
+ }
365
+ case "assistant": {
366
+ const extracted = assistantTextThinkingAndCalls(message);
367
+ const wireCalls: PendingToolCall[] = [];
368
+ const nativeCalls = extracted.calls.map((call, index) => {
369
+ if (!call.id || reservedToolCallIds.has(call.id)) {
370
+ throw new Error(`ollama-native assistant tool call id is missing or duplicated: ${call.id || "<missing-id>"}`);
371
+ }
372
+ reservedToolCallIds.add(call.id);
373
+ const args = assertObjectArguments(call.arguments, `assistant tool call ${call.id}`);
374
+ // `customWireName` belongs to the prior caller/provider wire. It must not override
375
+ // this adapter's deterministic namespace flattening during replay: a native turn is
376
+ // paired by the OCX name/namespace, then lowered to the native wire name here.
377
+ const wireName = namespacedToolName(call.namespace, call.name);
378
+ if (!wireName) throw new Error(`ollama-native assistant tool call ${call.id} has no name`);
379
+ const pendingCall: PendingToolCall = {
380
+ id: call.id,
381
+ name: call.name,
382
+ namespace: call.namespace,
383
+ wireName,
384
+ order: index,
385
+ };
386
+ wireCalls.push(pendingCall);
387
+ return {
388
+ type: "function" as const,
389
+ id: call.id,
390
+ function: { index, name: wireName, arguments: args },
391
+ };
392
+ });
393
+ const native: OllamaNativeMessage = {
394
+ role: "assistant",
395
+ content: extracted.content,
396
+ ...(extracted.thinking ? { thinking: extracted.thinking } : {}),
397
+ ...(nativeCalls.length > 0 ? { tool_calls: nativeCalls } : {}),
398
+ };
399
+ messages.push(native);
400
+ if (wireCalls.length > 0) {
401
+ pending = { calls: wireCalls, byId: new Map(wireCalls.map(call => [call.id, call])) };
402
+ }
403
+ break;
404
+ }
405
+ }
406
+ }
407
+ if (pending) flushPending();
408
+ return messages;
409
+ }
410
+
411
+ function buildNativeTools(parsed: OcxParsedRequest): OllamaNativeTool[] | undefined {
412
+ const declared = parsed.context.tools;
413
+ if (!declared || declared.length === 0 || parsed.options.toolChoice === "none") return undefined;
414
+
415
+ const choice = parsed.options.toolChoice;
416
+ if (
417
+ choice === "required"
418
+ || (isAllowedToolChoice(choice) && choice.mode === "required")
419
+ || (choice && typeof choice === "object" && !isAllowedToolChoice(choice) && "name" in choice)
420
+ ) {
421
+ throw new Error("ollama-native does not support required or exact named tool_choice");
422
+ }
423
+ const predicate = toolChoiceToolPredicate(choice, declared);
424
+ const seenNames = new Set<string>();
425
+ const tools: OllamaNativeTool[] = [];
426
+ for (const tool of declared) {
427
+ if (!predicate(tool)) continue;
428
+ const name = namespacedToolName(tool.namespace, tool.name);
429
+ if (!name || seenNames.has(name)) throw new Error(`ollama-native duplicate flattened tool name: ${name || "<missing>"}`);
430
+ if (!isRecord(tool.parameters)) throw new Error(`ollama-native tool ${name} has no JSON schema object`);
431
+ seenNames.add(name);
432
+ tools.push({
433
+ type: "function",
434
+ function: {
435
+ name,
436
+ ...(tool.description ? { description: tool.description } : {}),
437
+ // Native Ollama accepts the schema directly. In particular, do not copy OpenAI's
438
+ // function.strict flag: `/api/chat` has no documented strict field.
439
+ parameters: tool.parameters,
440
+ },
441
+ });
442
+ }
443
+ return tools.length > 0 ? tools : undefined;
444
+ }
445
+
446
+ function nativeThink(
447
+ provider: OcxProviderConfig,
448
+ parsed: OcxParsedRequest,
449
+ ): false | true | "low" | "medium" | "high" | "max" | undefined {
450
+ const requested = parsed.options.reasoning;
451
+ // The Responses parser leaves reasoning undefined when the caller made no reasoning decision.
452
+ // Ollama distinguishes an omitted think field from think:false; preserve that distinction.
453
+ if (requested === undefined) return undefined;
454
+ // An explicit `__omit__` wire mapping (issue #2356) is an intentional decision to send NO
455
+ // reasoning field. mapReasoningEffort() collapses the sentinel to `undefined`, and the
456
+ // `?? requested` fallback below would then re-emit the requested label — defeating the
457
+ // sentinel. Upstream consults only the BOUNDARY spelling (`ultra` → `max`) and states raw
458
+ // ultra must never influence the provider wire, so only wireMap[boundary] can authorize an
459
+ // omission. The explicit mapping is checked before the native `none`/noReasoning fallbacks so
460
+ // it stays authoritative over them.
461
+ const wireMap = reasoningEffortMapFor(provider, parsed.modelId);
462
+ if (wireMap) {
463
+ const boundary = requested === "ultra" ? "max" : requested;
464
+ if (isReasoningEffortOmitted(wireMap[boundary])) return undefined;
465
+ }
466
+ if (requested === "none" || modelInList(provider.noReasoningModels, parsed.modelId)) return false;
467
+ // Upstream intentionally advertises synthetic top rungs on routed rows so Codex/subagent effort
468
+ // overrides validate against catalog membership; the wire stays honest because the native
469
+ // adapter clamps the requested effort onto the provider's real supported ladder
470
+ // (clampToSupportedCodexEffort: max/ultra on a [low,medium,high] model serializes "high").
471
+ const mapped = mapReasoningEffort(provider, parsed.modelId, requested);
472
+ if (mapped !== undefined) {
473
+ let value = mapped;
474
+ if (value === "minimal") value = "low";
475
+ if (value === "xhigh" || value === "ultra") value = "max";
476
+ if (value === "enabled" || value === "adaptive" || value === "true") return true;
477
+ if (value === "disabled" || value === "false") return false;
478
+ if (NATIVE_THINK_VALUES.has(value)) return value as "low" | "medium" | "high" | "max";
479
+ throw new Error(`ollama-native does not support reasoning level "${redactSecretString(value)}"`);
480
+ }
481
+ // mapReasoningEffort() returned undefined. For an ordinary Codex label against a declared
482
+ // non-empty ladder this can ONLY be an authoritative post-clamp `__omit__` sentinel (the
483
+ // clamp resolved the requested effort onto a rung whose wire mapping is the sentinel) —
484
+ // honour it; never resurrect the raw requested label. Native boolean aliases have no
485
+ // mapping at all, so their raw passthrough stays isolated here.
486
+ const supported = configuredReasoningEfforts(provider, parsed.modelId);
487
+ const ordinaryLabel = requested === "minimal" || requested === "low" || requested === "medium"
488
+ || requested === "high" || requested === "xhigh" || requested === "ultra"
489
+ || requested === "max";
490
+ if (supported !== undefined && supported.length > 0 && ordinaryLabel) return undefined;
491
+ let value = requested;
492
+ if (value === "minimal") value = "low";
493
+ if (value === "enabled" || value === "adaptive" || value === "true") return true;
494
+ if (value === "disabled" || value === "false") return false;
495
+ if (NATIVE_THINK_VALUES.has(value)) return value as "low" | "medium" | "high" | "max";
496
+ throw new Error(`ollama-native does not support reasoning level "${redactSecretString(value)}"`);
497
+ }
498
+
499
+ function nativeFormat(
500
+ parsed: OcxParsedRequest,
501
+ endpointKind: OllamaNativeEndpointKind,
502
+ ): "json" | Record<string, unknown> | undefined {
503
+ const format = parsed.options.textFormat;
504
+ if (!format) return undefined;
505
+ // Ollama's own documentation states "Ollama's Cloud currently does not support structured
506
+ // outputs" (docs/capabilities/structured-outputs.mdx). Cloud does not reject `format`: it
507
+ // returns 200 and ignores the constraint, so sending it would turn an output-shape contract
508
+ // into unconstrained prose the caller believes is schema-valid. Refuse the contract instead,
509
+ // the same call Kiro makes for a wire that cannot enforce it. Local and custom self-hosted
510
+ // Ollama keep the native `format` mapping, which their contract does honour.
511
+ if (endpointKind === "cloud") {
512
+ throw new Error("ollama-native does not support structured output on Ollama Cloud");
513
+ }
514
+ if (format.type === "json_object") return "json";
515
+ if (!format.schema || !isRecord(format.schema)) {
516
+ throw new Error("ollama-native json_schema output requires a JSON schema object");
517
+ }
518
+ // Ollama's native contract takes the schema itself, unlike OpenAI's response_format wrapper.
519
+ return format.schema;
520
+ }
521
+
522
+ function usageFromNative(value: JsonRecord | undefined): OcxUsage | undefined {
523
+ if (!value) return undefined;
524
+ const input = isFiniteNonNegativeInteger(value.prompt_eval_count) ? value.prompt_eval_count : undefined;
525
+ const output = isFiniteNonNegativeInteger(value.eval_count) ? value.eval_count : undefined;
526
+ if (input === undefined && output === undefined) return undefined;
527
+ return { inputTokens: input ?? 0, outputTokens: output ?? 0 };
528
+ }
529
+
530
+ function stopReasonFromNative(value: unknown): string | undefined {
531
+ if (typeof value !== "string" || !value.trim()) return undefined;
532
+ if (value === "length") return "max_tokens";
533
+ return value;
534
+ }
535
+
536
+ function nativeMessageEvents(message: JsonRecord, state: NativeStreamState, budget: TranslatorBudget): AdapterEvent[] {
537
+ const events: AdapterEvent[] = [];
538
+ if (message.role !== undefined && message.role !== "assistant") {
539
+ throw new Error("ollama-native response message role was not assistant");
540
+ }
541
+ // Deltas are forwarded as they arrive; the parser keeps no second complete copy of the
542
+ // response. In-flight memory is bounded by the per-line reservation in the stream reader and
543
+ // the bounded buffered read, matching how the openai-chat adapter accounts deltas.
544
+ if (message.thinking !== undefined) {
545
+ if (typeof message.thinking !== "string") throw new Error("ollama-native response thinking was not text");
546
+ if (message.thinking) events.push({ type: "reasoning_raw_delta", text: message.thinking });
547
+ }
548
+ if (message.content !== undefined) {
549
+ if (typeof message.content !== "string") throw new Error("ollama-native response content was not text");
550
+ if (message.content) events.push({ type: "text_delta", text: message.content });
551
+ }
552
+ if (message.tool_calls !== undefined) {
553
+ if (!Array.isArray(message.tool_calls)) throw new Error("ollama-native response tool_calls was not an array");
554
+ for (let position = 0; position < message.tool_calls.length; position++) {
555
+ const rawCall = message.tool_calls[position];
556
+ if (!isRecord(rawCall) || !isRecord(rawCall.function)) {
557
+ throw new Error("ollama-native response tool call was malformed");
558
+ }
559
+ const fn = rawCall.function;
560
+ if (typeof fn.name !== "string" || !fn.name.trim()) throw new Error("ollama-native response tool call had no name");
561
+ const args = assertObjectArguments(fn.arguments, "response tool call");
562
+ const index = isFiniteNonNegativeInteger(fn.index) ? fn.index : undefined;
563
+ const nativeId = validNativeToolCallId(rawCall.id);
564
+ const key = index === undefined ? `position:${position}` : `index:${index}`;
565
+ // Tool-call identity is explicitly keyed by the provider index when supplied: a later
566
+ // frame for the same index updates that call's arguments, while a distinct index creates a
567
+ // second call. This narrow tool-call compatibility rule is independent from text/thinking
568
+ // semantics, where every non-empty native field is an appended partial delta.
569
+ const existing = state.toolCalls.get(key);
570
+ if (!existing && !state.allowParallelToolCalls && state.toolCalls.size > 0) {
571
+ throw new Error("ollama-native provider emitted parallel tool calls while parallelToolCalls:false was requested");
572
+ }
573
+ if (existing) {
574
+ if (existing.name !== fn.name) throw new Error("ollama-native response reused a tool-call index for another function");
575
+ if (nativeId && existing.nativeId && nativeId !== existing.nativeId) {
576
+ throw new Error("ollama-native response changed a tool-call id for an existing index");
577
+ }
578
+ if (!existing.nativeId && nativeId) existing.nativeId = nativeId;
579
+ replaceNativeToolArguments(existing, args, budget);
580
+ } else {
581
+ const call: NativeStreamToolCall = {
582
+ key,
583
+ budgetKey: `ollama-native:${key}`,
584
+ order: state.nextToolOrder++,
585
+ name: fn.name,
586
+ ...(nativeId ? { nativeId } : {}),
587
+ ...(index !== undefined ? { nativeIndex: index } : {}),
588
+ arguments: args,
589
+ argumentBytes: 0,
590
+ };
591
+ budget.openCall(call.budgetKey);
592
+ try {
593
+ replaceNativeToolArguments(call, args, budget);
594
+ state.toolCalls.set(key, call);
595
+ } catch (error) {
596
+ budget.closeCall(call.budgetKey);
597
+ throw error;
598
+ }
599
+ }
600
+ events.push({ type: "heartbeat" });
601
+ }
602
+ }
603
+ state.sawMessage = true;
604
+ return events;
605
+ }
606
+
607
+ function flushNativeStreamToolCalls(
608
+ state: NativeStreamState,
609
+ issuedToolCallIds: Set<string>,
610
+ ): AdapterEvent[] {
611
+ const events: AdapterEvent[] = [];
612
+ const ordered = [...state.toolCalls.values()].sort((a, b) => a.order - b.order);
613
+ for (const call of ordered) {
614
+ const id = allocateNativeToolCallId(call.nativeId, call.nativeIndex, issuedToolCallIds);
615
+ events.push({ type: "tool_call_start", id, name: call.name });
616
+ events.push({ type: "tool_call_delta", arguments: JSON.stringify(call.arguments) });
617
+ events.push({ type: "tool_call_end" });
618
+ }
619
+ return events;
620
+ }
621
+
622
+ function replaceNativeToolArguments(
623
+ call: NativeStreamToolCall,
624
+ args: Record<string, unknown>,
625
+ budget: TranslatorBudget,
626
+ ): void {
627
+ const nextBytes = new TextEncoder().encode(JSON.stringify(args)).byteLength;
628
+ if (nextBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) {
629
+ throw new TranslatorBudgetExceededError("tool_args", TRANSLATOR_MAX_SSE_EVENT_BYTES);
630
+ }
631
+ if (nextBytes === 0) {
632
+ if (call.argumentBytes > 0) budget.releaseRetained(call.argumentBytes, { kind: "tool_args", callId: call.budgetKey });
633
+ call.arguments = args;
634
+ call.argumentBytes = 0;
635
+ return;
636
+ }
637
+ const reservation = budget.reserveTransient(nextBytes, { kind: "tool_args", callId: call.budgetKey });
638
+ try {
639
+ reservation.commitRetained();
640
+ if (call.argumentBytes > 0) budget.releaseRetained(call.argumentBytes, { kind: "tool_args", callId: call.budgetKey });
641
+ call.arguments = args;
642
+ call.argumentBytes = nextBytes;
643
+ } catch (error) {
644
+ reservation.release();
645
+ throw error;
646
+ }
647
+ }
648
+
649
+ function releaseNativeStateBuffers(state: NativeStreamState, budget: TranslatorBudget): void {
650
+ for (const call of state.toolCalls.values()) budget.closeCall(call.budgetKey);
651
+ }
652
+
653
+ function nativeBodyMessage(value: unknown): JsonRecord {
654
+ if (!isRecord(value)) throw new Error("ollama-native response message was missing or malformed");
655
+ return value;
656
+ }
657
+
658
+ function nativeEventsFromResponsePayload(
659
+ payload: unknown,
660
+ budget: TranslatorBudget,
661
+ issuedToolCallIds: Set<string>,
662
+ allowParallelToolCalls = true,
663
+ ): AdapterEvent[] {
664
+ if (!isRecord(payload)) return [malformedNativeEvent("Ollama native response was not a JSON object")];
665
+ if (payload.error !== undefined && payload.error !== null) return [nativeErrorEvent(payload.error)];
666
+
667
+ const state: NativeStreamState = {
668
+ toolCalls: new Map(),
669
+ nextToolOrder: 0,
670
+ usage: usageFromNative(payload),
671
+ stopReason: stopReasonFromNative(payload.done_reason),
672
+ sawMessage: false,
673
+ terminal: false,
674
+ terminalError: false,
675
+ allowParallelToolCalls,
676
+ };
677
+ // Same terminal contract as the NDJSON path — enforced BEFORE any actionable emission. The
678
+ // complete payload is already in memory and known invalid, so partial text and tool calls from
679
+ // it are suppressed along with the terminal: a truncated upstream reply must never be
680
+ // mistaken for a finished turn, and tool calls parsed out of one must never execute.
681
+ if (payload.done !== true) {
682
+ state.terminalError = true;
683
+ const reason = payload.done === undefined
684
+ ? "Ollama native response did not include done:true"
685
+ : payload.done === false
686
+ ? "Ollama native response reported done:false"
687
+ : "Ollama native response done flag was not boolean";
688
+ return [malformedNativeEvent(reason, state.usage)];
689
+ }
690
+ try {
691
+ const events = nativeMessageEvents(nativeBodyMessage(payload.message), state, budget);
692
+ events.push(...flushNativeStreamToolCalls(state, issuedToolCallIds));
693
+ events.push({ type: "done", ...(state.usage ? { usage: state.usage } : {}), ...(state.stopReason ? { stopReason: state.stopReason } : {}) });
694
+ state.terminal = true;
695
+ return events;
696
+ } catch (error) {
697
+ const events = isTranslatorBudgetExceededError(error)
698
+ ? [translationBudgetEvent(state.usage)]
699
+ : [malformedNativeEvent(error instanceof Error ? error.message : "Malformed Ollama native response", state.usage)];
700
+ state.terminalError = true;
701
+ return events;
702
+ } finally {
703
+ releaseNativeStateBuffers(state, budget);
704
+ }
705
+ }
706
+
707
+ function formatNativeErrorBody(status: number, _headers: Headers, payloadText: string): string {
708
+ let parsed: unknown;
709
+ try {
710
+ parsed = JSON.parse(payloadText);
711
+ } catch {
712
+ return status === 401 || status === 403
713
+ ? "Ollama authentication failed"
714
+ : status === 404
715
+ ? "Ollama native endpoint or model was not found"
716
+ : status === 429
717
+ ? "Ollama rate limit was exceeded"
718
+ : status >= 500
719
+ ? "Ollama native upstream failed"
720
+ : "";
721
+ }
722
+ const detail = errorDetail(parsed);
723
+ if (detail) return redactSecretString(detail).slice(0, 400);
724
+ return status === 401 || status === 403
725
+ ? "Ollama authentication failed"
726
+ : status === 404
727
+ ? "Ollama native endpoint or model was not found"
728
+ : status === 429
729
+ ? "Ollama rate limit was exceeded"
730
+ : status >= 500
731
+ ? "Ollama native upstream failed"
732
+ : "";
733
+ }
734
+
735
+ function buildHeaders(
736
+ provider: OcxProviderConfig,
737
+ endpointKind: OllamaNativeEndpointKind,
738
+ ): { headers: Record<string, string>; hasCredential: boolean } {
739
+ if (provider.authMode === "forward") {
740
+ throw new Error("ollama-native does not support forwarded caller credentials");
741
+ }
742
+ const hasApiKey = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0;
743
+ const local = endpointKind === "local" || provider.authMode === "local";
744
+ const plaintextRemote = endpointKind === "custom" && new URL(provider.baseUrl).protocol === "http:";
745
+ // A copied provider row can carry credential headers even when apiKey is empty, and a
746
+ // key-optional custom row would otherwise ship them to a plaintext remote. Detect them with
747
+ // the shared credential-bearing name authority instead of a narrower local list.
748
+ const credentialHeaders = Object.keys(provider.headers ?? {}).filter(key =>
749
+ SENSITIVE_KEY_PATTERN.test(key.trim()),
750
+ );
751
+ if (plaintextRemote && (hasApiKey || credentialHeaders.length > 0)) {
752
+ throw new Error(
753
+ "ollama-native refuses to send credentials over plaintext non-loopback HTTP"
754
+ + (hasApiKey ? "" : ` (credential headers: ${credentialHeaders.join(", ")})`),
755
+ );
756
+ }
757
+ const hasCredential = hasApiKey;
758
+ const requiresCredential = !local && (provider.authMode === undefined || provider.authMode === "key" || provider.authMode === "oauth");
759
+ if (requiresCredential && !hasCredential && !provider.keyOptional) {
760
+ throw new Error("ollama-native cloud/custom endpoint requires a non-empty API credential");
761
+ }
762
+
763
+ // Same precedence as openAIChatTransport(): the generated Bearer is laid down FIRST and
764
+ // provider.headers are applied LAST, so an explicitly configured Authorization wins. Collision
765
+ // handling is case-insensitive and leaves exactly ONE effective credential spelling on the wire.
766
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
767
+ if (!local && hasCredential) headers.Authorization = `Bearer ${provider.apiKey!.trim()}`;
768
+ for (const [key, value] of Object.entries(provider.headers ?? {})) {
769
+ // Loopback/local targets get no credentials at all — not even ones the row already carried —
770
+ // so a shared provider object cannot leak a shared credential to a local endpoint.
771
+ if (local && SENSITIVE_KEY_PATTERN.test(key.trim())) continue;
772
+ const lower = key.toLowerCase();
773
+ for (const existing of Object.keys(headers)) {
774
+ if (existing.toLowerCase() === lower && existing !== key) delete headers[existing];
775
+ }
776
+ headers[key] = value;
777
+ }
778
+ // Diagnostics carry the FACT that a credential is attached, never any header value.
779
+ return {
780
+ headers,
781
+ hasCredential: !local && (hasCredential
782
+ || Object.keys(provider.headers ?? {}).some(k => SENSITIVE_KEY_PATTERN.test(k.trim()))),
783
+ };
784
+ }
785
+
786
+ function replaceLiveBuffer(
787
+ budget: TranslatorBudget,
788
+ previousBytes: number,
789
+ nextBytes: number,
790
+ ): void {
791
+ // Release BEFORE reserving: the retained bound tracks what is actually in memory, so growing
792
+ // the residual never transiently charges old + new together.
793
+ if (previousBytes > 0) budget.releaseRetained(previousBytes, { kind: "live_transient" });
794
+ if (nextBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) {
795
+ throw new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_SSE_EVENT_BYTES);
796
+ }
797
+ if (nextBytes > 0) {
798
+ const reservation = budget.reserveTransient(nextBytes, { kind: "live_transient" });
799
+ reservation.commitRetained();
800
+ }
801
+ }
802
+
803
+ async function readWithAbort(
804
+ reader: ReadableStreamDefaultReader<Uint8Array>,
805
+ signal: AbortSignal | undefined,
806
+ ): Promise<NativeReadResult> {
807
+ if (!signal) return await reader.read() as NativeReadResult;
808
+ if (signal.aborted) throw signal.reason;
809
+ const read = reader.read();
810
+ void read.catch(() => undefined);
811
+ let rejectAbort: ((reason: unknown) => void) | undefined;
812
+ const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject; });
813
+ const onAbort = () => rejectAbort?.(signal.reason);
814
+ signal.addEventListener("abort", onAbort, { once: true });
815
+ try {
816
+ const result = await Promise.race([read, aborted]);
817
+ if (signal.aborted) throw signal.reason;
818
+ return result as NativeReadResult;
819
+ } finally {
820
+ signal.removeEventListener("abort", onAbort);
821
+ }
822
+ }
823
+
824
+ function streamState(allowParallelToolCalls = true): NativeStreamState {
825
+ return {
826
+ toolCalls: new Map(),
827
+ nextToolOrder: 0,
828
+ sawMessage: false,
829
+ terminal: false,
830
+ terminalError: false,
831
+ allowParallelToolCalls,
832
+ };
833
+ }
834
+
835
+ function processNativeLine(
836
+ line: string,
837
+ state: NativeStreamState,
838
+ budget: TranslatorBudget,
839
+ issuedToolCallIds: Set<string>,
840
+ ): AdapterEvent[] {
841
+ const trimmed = line.trim();
842
+ if (!trimmed) return [];
843
+ let parsed: unknown;
844
+ try {
845
+ parsed = JSON.parse(trimmed);
846
+ } catch {
847
+ state.terminal = true;
848
+ state.terminalError = true;
849
+ return [malformedNativeEvent("Ollama native stream contained malformed NDJSON")];
850
+ }
851
+ if (!isRecord(parsed)) {
852
+ state.terminal = true;
853
+ state.terminalError = true;
854
+ return [malformedNativeEvent("Ollama native stream line was not a JSON object")];
855
+ }
856
+ if (state.terminal) {
857
+ state.terminalError = true;
858
+ return [malformedNativeEvent("Ollama native stream emitted data after its terminal record")];
859
+ }
860
+ if (parsed.error !== undefined && parsed.error !== null) {
861
+ state.terminal = true;
862
+ state.terminalError = true;
863
+ return [nativeErrorEvent(parsed.error, state.usage)];
864
+ }
865
+ if (parsed.prompt_eval_count !== undefined || parsed.eval_count !== undefined) {
866
+ state.usage = usageFromNative(parsed) ?? state.usage;
867
+ }
868
+ if (parsed.done_reason !== undefined) state.stopReason = stopReasonFromNative(parsed.done_reason);
869
+
870
+ const events: AdapterEvent[] = [];
871
+ if (parsed.message !== undefined) {
872
+ try {
873
+ events.push(...nativeMessageEvents(nativeBodyMessage(parsed.message), state, budget));
874
+ } catch (error) {
875
+ if (isTranslatorBudgetExceededError(error)) throw error;
876
+ state.terminal = true;
877
+ state.terminalError = true;
878
+ return [malformedNativeEvent(error instanceof Error ? error.message : "Malformed Ollama native stream message", state.usage)];
879
+ }
880
+ }
881
+ if (parsed.done !== undefined && typeof parsed.done !== "boolean") {
882
+ state.terminal = true;
883
+ state.terminalError = true;
884
+ return [malformedNativeEvent("Ollama native stream done flag was not boolean", state.usage)];
885
+ }
886
+ if (parsed.done === true) {
887
+ state.terminal = true;
888
+ events.push(...flushNativeStreamToolCalls(state, issuedToolCallIds));
889
+ events.push({ type: "done", ...(state.usage ? { usage: state.usage } : {}), ...(state.stopReason ? { stopReason: state.stopReason } : {}) });
890
+ }
891
+ return events;
892
+ }
893
+
894
+ async function* parseOllamaNativeStream(
895
+ response: Response,
896
+ budget: TranslatorBudget,
897
+ signal?: AbortSignal,
898
+ issuedToolCallIds?: Set<string>,
899
+ allowParallelToolCalls = true,
900
+ ): AsyncGenerator<AdapterEvent> {
901
+ if (!response.body) {
902
+ yield malformedNativeEvent("Ollama native response had no body");
903
+ return;
904
+ }
905
+ const reader = response.body.getReader();
906
+ const decoder = new TextDecoder("utf-8", { fatal: true });
907
+ const encoder = new TextEncoder();
908
+ const state = streamState(allowParallelToolCalls);
909
+ const issuedIds = issuedToolCallIds ?? new Set<string>();
910
+ let buffer = "";
911
+ let bufferBytes = 0;
912
+ let mustCancel = false;
913
+
914
+ const ingest = function* (text: string): Generator<AdapterEvent> {
915
+ if (!text) return;
916
+ buffer += text;
917
+ const lines = buffer.split("\n");
918
+ buffer = lines.pop() ?? "";
919
+ // The safety bound applies to the genuinely retained residual — the incomplete NDJSON record
920
+ // still being assembled — and to each complete record below. It must never depend on the
921
+ // transport read size (one read may carry many valid records) nor transiently charge
922
+ // old + replacement together. On a ceiling violation the old reservation is left in place so
923
+ // the generator's finally releases exactly what is held.
924
+ const residualBytes = encoder.encode(buffer).byteLength;
925
+ if (residualBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) {
926
+ throw new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_SSE_EVENT_BYTES);
927
+ }
928
+ replaceLiveBuffer(budget, bufferBytes, residualBytes);
929
+ bufferBytes = residualBytes;
930
+
931
+ for (const rawLine of lines) {
932
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
933
+ const lineBytes = encoder.encode(line).byteLength;
934
+ if (lineBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) {
935
+ throw new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_SSE_EVENT_BYTES);
936
+ }
937
+ if (lineBytes > 0) {
938
+ const reservation = budget.reserveTransient(lineBytes, { kind: "live_transient" });
939
+ reservation.commitRetained();
940
+ try {
941
+ // The Responses terminal guard may stop consuming immediately after it sees `done`.
942
+ const events = processNativeLine(line, state, budget, issuedIds);
943
+ yield* events;
944
+ } finally {
945
+ budget.releaseRetained(lineBytes, { kind: "live_transient" });
946
+ }
947
+ }
948
+ if (state.terminal) {
949
+ return;
950
+ }
951
+ }
952
+ };
953
+
954
+ try {
955
+ while (true) {
956
+ const read = await readWithAbort(reader, signal);
957
+ if (read.done) break;
958
+ if (!read.value || read.value.byteLength === 0) continue;
959
+ yield* ingest(decoder.decode(read.value, { stream: true }));
960
+ if (state.terminal) {
961
+ mustCancel = true;
962
+ return;
963
+ }
964
+ }
965
+ yield* ingest(decoder.decode());
966
+ if (!state.terminal && buffer.length > 0) {
967
+ const rawLine = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
968
+ const lineBytes = encoder.encode(rawLine).byteLength;
969
+ if (lineBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) throw new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_SSE_EVENT_BYTES);
970
+ try {
971
+ // The EOF record keeps its residual charge while it is parsed and consumed — releasing it
972
+ // first would drop the accounting before the record is translated (and let a near-limit
973
+ // record's tool arguments slip past the turn cap that the newline-terminated path pays).
974
+ const events = processNativeLine(rawLine, state, budget, issuedIds);
975
+ yield* events;
976
+ } finally {
977
+ replaceLiveBuffer(budget, bufferBytes, 0);
978
+ bufferBytes = 0;
979
+ }
980
+ }
981
+ if (!state.terminal) {
982
+ mustCancel = true;
983
+ const event = malformedNativeEvent(
984
+ state.sawMessage || state.toolCalls.size > 0
985
+ ? "Ollama native stream ended before done:true"
986
+ : "Ollama native stream ended without a terminal record",
987
+ state.usage,
988
+ );
989
+ state.terminalError = true;
990
+ yield event;
991
+ } else {
992
+ mustCancel = true;
993
+ }
994
+ } catch (error) {
995
+ mustCancel = true;
996
+ let event: AdapterEvent;
997
+ if (isTranslatorBudgetExceededError(error)) {
998
+ event = translationBudgetEvent(state.usage);
999
+ } else if (signal?.aborted) {
1000
+ event = { type: "error", status: 499, message: "client closed request while reading Ollama native stream" };
1001
+ } else {
1002
+ event = malformedNativeEvent("Ollama native stream could not be decoded", state.usage);
1003
+ }
1004
+ state.terminalError = true;
1005
+ yield event;
1006
+ } finally {
1007
+ if (bufferBytes > 0) budget.releaseRetained(bufferBytes, { kind: "live_transient" });
1008
+ releaseNativeStateBuffers(state, budget);
1009
+ if (mustCancel) {
1010
+ try { await reader.cancel(); } catch { /* the upstream body may already be closed */ }
1011
+ }
1012
+ try { reader.releaseLock(); } catch { /* already released */ }
1013
+ }
1014
+ }
1015
+
1016
+ async function parseOllamaNativeResponse(
1017
+ response: Response,
1018
+ budget: TranslatorBudget,
1019
+ issuedToolCallIds: Set<string>,
1020
+ allowParallelToolCalls = true,
1021
+ ): Promise<AdapterEvent[]> {
1022
+ const bounded = await readBoundedResponseBytes(response, { maxBytes: TRANSLATOR_MAX_SSE_EVENT_BYTES });
1023
+ if (bounded.oversized) return [malformedNativeEvent("Ollama native response exceeded the safe body limit")];
1024
+ let payload: unknown;
1025
+ try {
1026
+ payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bounded.bytes));
1027
+ } catch {
1028
+ return [malformedNativeEvent("Ollama native response was not valid JSON")];
1029
+ }
1030
+ const retainedBytes = bounded.bytes.byteLength;
1031
+ if (retainedBytes > 0) budget.chargeRetained(retainedBytes, { kind: "retained_collectors" });
1032
+ try {
1033
+ const events = nativeEventsFromResponsePayload(payload, budget, issuedToolCallIds, allowParallelToolCalls);
1034
+ try {
1035
+ retainTranslatedEventBatch(events, budget);
1036
+ } catch (error) {
1037
+ if (isTranslatorBudgetExceededError(error)) return [translationBudgetEvent()];
1038
+ throw error;
1039
+ }
1040
+ return events;
1041
+ } finally {
1042
+ if (retainedBytes > 0) budget.releaseRetained(retainedBytes, { kind: "retained_collectors" });
1043
+ }
1044
+ }
1045
+
1046
+ export function createOllamaNativeAdapter(provider: OcxProviderConfig): ProviderAdapter {
1047
+ let requestAbortSignal: AbortSignal | undefined;
1048
+ let requestAllowsParallelToolCalls = true;
1049
+ const issuedToolCallIds = new Set<string>();
1050
+ return {
1051
+ name: "ollama-native",
1052
+ formatErrorBody: formatNativeErrorBody,
1053
+
1054
+ buildRequest(parsed: OcxParsedRequest, incoming?: IncomingMeta): AdapterRequest {
1055
+ requestAbortSignal = incoming?.abortSignal;
1056
+ requestAllowsParallelToolCalls = parsed.options.parallelToolCalls !== false;
1057
+ const url = ollamaNativeChatUrl(provider.baseUrl);
1058
+ const endpointKind = ollamaNativeEndpointKind(provider.baseUrl);
1059
+ const { headers, hasCredential } = buildHeaders(provider, endpointKind);
1060
+ const messages = buildNativeMessages(parsed, issuedToolCallIds);
1061
+ const tools = buildNativeTools(parsed);
1062
+ const format = nativeFormat(parsed, endpointKind);
1063
+ const options: Record<string, unknown> = {};
1064
+ const maxOutputTokens = parsed.options.maxOutputTokens
1065
+ ?? modelRecordValue(provider.modelMaxOutputTokens, parsed.modelId)
1066
+ ?? provider.defaultMaxOutputTokens;
1067
+ // Same gate semantics as the openai-chat adapter (modelInList on the provider lists).
1068
+ // Ollama's native Options carries every one of these under its own spelling.
1069
+ if (maxOutputTokens !== undefined) options.num_predict = maxOutputTokens;
1070
+ if (parsed.options.temperature !== undefined
1071
+ && !modelInList(provider.noTemperatureModels, parsed.modelId)) {
1072
+ options.temperature = parsed.options.temperature;
1073
+ }
1074
+ if (parsed.options.topP !== undefined
1075
+ && !modelInList(provider.noTopPModels, parsed.modelId)) {
1076
+ options.top_p = parsed.options.topP;
1077
+ }
1078
+ if (parsed.options.stopSequences !== undefined) options.stop = parsed.options.stopSequences;
1079
+ if (parsed.options.presencePenalty !== undefined
1080
+ && !modelInList(provider.noPenaltyModels, parsed.modelId)) {
1081
+ options.presence_penalty = parsed.options.presencePenalty;
1082
+ }
1083
+ if (parsed.options.frequencyPenalty !== undefined
1084
+ && !modelInList(provider.noPenaltyModels, parsed.modelId)) {
1085
+ options.frequency_penalty = parsed.options.frequencyPenalty;
1086
+ }
1087
+
1088
+ const think = nativeThink(provider, parsed);
1089
+ const body: Record<string, unknown> = {
1090
+ model: wireModelId(provider, parsed.modelId),
1091
+ messages,
1092
+ stream: parsed.stream,
1093
+ ...(think !== undefined ? { think } : {}),
1094
+ ...(tools ? { tools } : {}),
1095
+ ...(format !== undefined ? { format } : {}),
1096
+ ...(Object.keys(options).length > 0 ? { options } : {}),
1097
+ };
1098
+ const bodyJson = JSON.stringify(body);
1099
+ debugProviderDiagnostic("ollama-native", "request", {
1100
+ host: (() => { try { return new URL(url).host; } catch { return "upstream"; } })(),
1101
+ model: body.model,
1102
+ stream: parsed.stream,
1103
+ messageCount: messages.length,
1104
+ toolCount: tools?.length ?? 0,
1105
+ hasCredential,
1106
+ bodyBytes: new TextEncoder().encode(bodyJson).byteLength,
1107
+ thinkingRequested: parsed.options.reasoning !== undefined,
1108
+ });
1109
+ return { url, method: "POST", headers, body: bodyJson };
1110
+ },
1111
+
1112
+ parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
1113
+ return parseOllamaNativeStream(
1114
+ response,
1115
+ budget,
1116
+ requestAbortSignal,
1117
+ issuedToolCallIds,
1118
+ requestAllowsParallelToolCalls,
1119
+ );
1120
+ },
1121
+
1122
+ async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
1123
+ return await parseOllamaNativeResponse(
1124
+ response,
1125
+ budget,
1126
+ issuedToolCallIds,
1127
+ requestAllowsParallelToolCalls,
1128
+ );
1129
+ },
1130
+ };
1131
+ }