@gajae-code/ai 0.4.5 → 0.5.1

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 (43) hide show
  1. package/CHANGELOG.md +31 -1
  2. package/dist/types/index.d.ts +2 -0
  3. package/dist/types/providers/amazon-bedrock.d.ts +29 -5
  4. package/dist/types/providers/composer-discipline.d.ts +27 -0
  5. package/dist/types/providers/cursor.d.ts +1 -1
  6. package/dist/types/providers/google-gemini-cli.d.ts +1 -1
  7. package/dist/types/providers/google-shared.d.ts +11 -1
  8. package/dist/types/providers/ollama.d.ts +36 -1
  9. package/dist/types/providers/openai-completions-compat.d.ts +3 -1
  10. package/dist/types/providers/register-builtins.d.ts +3 -3
  11. package/dist/types/types.d.ts +25 -3
  12. package/dist/types/usage/grok-cli.d.ts +10 -0
  13. package/dist/types/utils/event-stream.d.ts +6 -1
  14. package/dist/types/utils/oauth/xai.d.ts +10 -3
  15. package/dist/types/utils/tool-choice-capability.d.ts +41 -0
  16. package/package.json +2 -2
  17. package/src/auth-storage.ts +3 -0
  18. package/src/index.ts +2 -0
  19. package/src/model-thinking.ts +9 -0
  20. package/src/models.json +116 -0
  21. package/src/models.ts +33 -7
  22. package/src/provider-models/descriptors.ts +1 -1
  23. package/src/provider-models/openai-compat.ts +9 -1
  24. package/src/providers/amazon-bedrock.ts +145 -60
  25. package/src/providers/anthropic.ts +85 -32
  26. package/src/providers/azure-openai-responses.ts +44 -3
  27. package/src/providers/composer-discipline.ts +38 -0
  28. package/src/providers/cursor.ts +10 -3
  29. package/src/providers/google-gemini-cli.ts +69 -10
  30. package/src/providers/google-shared.ts +61 -12
  31. package/src/providers/ollama.ts +60 -4
  32. package/src/providers/openai-codex-responses.ts +151 -2
  33. package/src/providers/openai-completions-compat.ts +9 -1
  34. package/src/providers/openai-completions.ts +46 -6
  35. package/src/providers/openai-request-transform.ts +1 -0
  36. package/src/providers/openai-responses.ts +54 -5
  37. package/src/providers/register-builtins.ts +5 -6
  38. package/src/rate-limit-utils.ts +11 -2
  39. package/src/types.ts +37 -3
  40. package/src/usage/grok-cli.ts +163 -0
  41. package/src/utils/event-stream.ts +35 -5
  42. package/src/utils/oauth/xai.ts +49 -13
  43. package/src/utils/tool-choice-capability.ts +220 -0
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Anchor/edit discipline for composer-harness models (xai grok-composer-*,
3
+ * cursor composer-*).
4
+ *
5
+ * Composer models are trained on a proprietary coding-agent harness
6
+ * (Cursor / Grok Build) and carry habits that break this agent's hashline
7
+ * edit workflow when driven through a generic provider. Observed in live
8
+ * sessions with grok-composer-2.5-fast:
9
+ *
10
+ * - they print files with shell commands (`sed -n`, `cat`, `grep -n`) or
11
+ * python heredocs whose output carries NO line anchors, then FABRICATE the
12
+ * 2-char anchor hash the edit tool requires (e.g. guessed "617hp" where
13
+ * the file had "617ca" → "Edit rejected: N anchors do not match");
14
+ * - they mutate files out-of-band via python heredocs (pathlib write_text /
15
+ * str.replace), which invalidates every previously seen anchor and defeats
16
+ * the read-cache snapshot that powers stale-anchor recovery;
17
+ * - they arithmetically renumber anchors after their own edits instead of
18
+ * copying them from the latest tool output;
19
+ * - they leak reasoning prose into heredoc bodies, producing shell/python
20
+ * syntax errors.
21
+ *
22
+ * This prompt is the per-request countermeasure, pinned ahead of the host
23
+ * system prompt on both the openai-completions path and the cursor RPC path.
24
+ */
25
+
26
+ /** Matches composer-harness model ids on any provider (xai grok-composer-*, cursor composer-*). */
27
+ export function isComposerHarnessModel(modelId: string): boolean {
28
+ return modelId.toLowerCase().includes("composer");
29
+ }
30
+
31
+ export const COMPOSER_EDIT_DISCIPLINE_PROMPT = `File-editing discipline for this harness (this OVERRIDES contrary habits from your training):
32
+
33
+ - Read file contents ONLY with the provided read/search tools. NEVER print files through shell commands (sed, cat, awk, head, grep) or scripts — that output carries no line anchors, and the edit tool accepts ONLY anchors.
34
+ - Modify files ONLY with the provided edit/write tools. NEVER mutate files through shell redirection, sed -i, or inline python scripts — out-of-band writes invalidate every known anchor and break edit recovery.
35
+ - A line anchor (e.g. "42sr") is a line number plus a 2-char content hash. You CANNOT compute the hash yourself: copy anchors verbatim from the MOST RECENT read/search/edit output of that exact file. NEVER guess, renumber, or arithmetically shift an anchor.
36
+ - After ANY edit to a file (including your own), anchors you saw earlier are stale. Re-read the edited region, or copy the fresh anchors printed in the edit result, before issuing the next edit.
37
+ - If an edit is rejected with "anchors do not match", the rejection message prints the current lines WITH fresh anchors. Retry using exactly those printed anchors.
38
+ - A shell command string must contain only the command itself. NEVER interleave reasoning or commentary into command strings or heredocs.`;
@@ -30,6 +30,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
30
30
  import { parseStreamingJson } from "../utils/json-parse";
31
31
  import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
32
32
  import { toolWireSchema } from "../utils/schema/wire";
33
+ import { COMPOSER_EDIT_DISCIPLINE_PROMPT, isComposerHarnessModel } from "./composer-discipline";
33
34
  import type { McpToolDefinition } from "./cursor/gen/agent_pb";
34
35
  import {
35
36
  AgentClientMessageSchema,
@@ -2284,12 +2285,18 @@ function findLastUserMessageIndex(messages: Message[]): number {
2284
2285
  * When no system prompts are provided, returns a single default greeting so we never emit
2285
2286
  * an empty `rootPromptMessagesJson` head.
2286
2287
  */
2287
- export function buildCursorSystemPromptJsons(systemPrompt: readonly string[] | undefined): string[] {
2288
+ export function buildCursorSystemPromptJsons(systemPrompt: readonly string[] | undefined, modelId?: string): string[] {
2288
2289
  const systemPrompts = normalizeSystemPrompts(systemPrompt);
2289
2290
  if (systemPrompts.length === 0) {
2290
2291
  return [JSON.stringify({ role: "system", content: "You are a helpful assistant." })];
2291
2292
  }
2292
- return systemPrompts.map(content => JSON.stringify({ role: "system", content }));
2293
+ const jsons = systemPrompts.map(content => JSON.stringify({ role: "system", content }));
2294
+ // Composer-harness models need anchor/edit discipline pinned ahead of the
2295
+ // host prompt (see composer-discipline.ts for the observed failure modes).
2296
+ if (modelId !== undefined && isComposerHarnessModel(modelId)) {
2297
+ jsons.unshift(JSON.stringify({ role: "system", content: COMPOSER_EDIT_DISCIPLINE_PROMPT }));
2298
+ }
2299
+ return jsons;
2293
2300
  }
2294
2301
 
2295
2302
  function buildRootPromptMessagesJson(
@@ -2501,7 +2508,7 @@ function buildGrpcRequest(
2501
2508
  } {
2502
2509
  const blobStore = state.blobStore;
2503
2510
 
2504
- const systemPromptIds = buildCursorSystemPromptJsons(context.systemPrompt).map(json =>
2511
+ const systemPromptIds = buildCursorSystemPromptJsons(context.systemPrompt, model.id).map(json =>
2505
2512
  storeCursorBlob(blobStore, new TextEncoder().encode(json)),
2506
2513
  );
2507
2514
 
@@ -25,6 +25,11 @@ import { resolveRetryBudget } from "../utils/retry-budget";
25
25
  // Refresh is the sole responsibility of AuthStorage (broker-aware, single-flighted);
26
26
  // the stream provider trusts the access token threaded through `options.apiKey`.
27
27
  import { normalizeSchemaForCCA } from "../utils/schema";
28
+ import {
29
+ isForcedToolChoiceUnsupportedError,
30
+ markToolChoiceIncapability,
31
+ resolveToolChoice,
32
+ } from "../utils/tool-choice-capability";
28
33
  import { ANTIGRAVITY_SYSTEM_INSTRUCTION, getAntigravityUserAgent, getGeminiCliHeaders } from "./google-gemini-headers";
29
34
  import type { Content, FunctionCallingConfigMode, ThinkingConfig } from "./google-shared";
30
35
  import {
@@ -48,7 +53,7 @@ import {
48
53
  export type { GoogleThinkingLevel };
49
54
 
50
55
  export interface GoogleGeminiCliOptions extends StreamOptions {
51
- toolChoice?: "auto" | "none" | "any";
56
+ toolChoice?: "auto" | "none" | "any" | "required";
52
57
  /**
53
58
  * Thinking/reasoning configuration.
54
59
  * - Gemini 2.x models: use `budgetTokens` to set the thinking budget
@@ -324,7 +329,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
324
329
  ...(needsClaudeThinkingBetaHeader(model) ? { "anthropic-beta": CLAUDE_THINKING_BETA_HEADER } : {}),
325
330
  ...(options?.headers ?? {}),
326
331
  };
327
- const requestBodyJson = JSON.stringify(requestBody);
332
+ let requestBodyJson = JSON.stringify(requestBody);
328
333
  rawRequestDump = {
329
334
  provider: model.provider,
330
335
  api: output.api,
@@ -334,7 +339,13 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
334
339
  headers: requestHeaders,
335
340
  };
336
341
 
337
- const response = await fetchWithRetry(
342
+ const sentForcedToolChoice =
343
+ (requestBody.request.toolConfig?.functionCallingConfig?.mode === "ANY" ||
344
+ requestBody.request.toolConfig?.functionCallingConfig?.mode === "VALIDATED") &&
345
+ options?.toolChoice !== undefined &&
346
+ options.toolChoice !== "auto" &&
347
+ options.toolChoice !== "none";
348
+ let response = await fetchWithRetry(
338
349
  attempt => `${endpoints[Math.min(attempt, endpoints.length - 1)]}/v1internal:streamGenerateContent?alt=sse`,
339
350
  {
340
351
  method: "POST",
@@ -347,6 +358,49 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
347
358
  fetch: options?.fetch,
348
359
  },
349
360
  );
361
+ if (!response.ok && sentForcedToolChoice) {
362
+ const errorText = await response.text();
363
+ const error = withHttpStatus(
364
+ new Error(`Cloud Code Assist API error (${response.status}): ${extractErrorMessage(errorText)}`),
365
+ response.status,
366
+ );
367
+ if (firstTokenTime === undefined && isForcedToolChoiceUnsupportedError(error, true)) {
368
+ const beforeMark = resolveToolChoice(model, options?.toolChoice);
369
+ markToolChoiceIncapability(model, "auto", error.message);
370
+ stream.push({
371
+ type: "toolChoiceIncapability",
372
+ api: model.api,
373
+ provider: model.provider,
374
+ model: model.id,
375
+ requestedLevel: beforeMark.requestedLevel,
376
+ resolvedLevel: "auto",
377
+ reason: error.message,
378
+ registryKey: beforeMark.registryKey,
379
+ });
380
+ requestBody = {
381
+ ...requestBody,
382
+ request: { ...requestBody.request, toolConfig: undefined },
383
+ };
384
+ requestBodyJson = JSON.stringify(requestBody);
385
+ rawRequestDump = { ...rawRequestDump, body: requestBody };
386
+ response = await fetchWithRetry(
387
+ attempt =>
388
+ `${endpoints[Math.min(attempt, endpoints.length - 1)]}/v1internal:streamGenerateContent?alt=sse`,
389
+ {
390
+ method: "POST",
391
+ headers: requestHeaders,
392
+ body: requestBodyJson,
393
+ signal: options?.signal,
394
+ maxAttempts: 1,
395
+ defaultDelayMs: attempt => BASE_DELAY_MS * 2 ** attempt,
396
+ maxDelayMs: options?.maxRetryDelayMs ?? RATE_LIMIT_BUDGET_MS,
397
+ fetch: options?.fetch,
398
+ },
399
+ );
400
+ } else {
401
+ throw error;
402
+ }
403
+ }
350
404
  if (!response.ok) {
351
405
  const errorText = await response.text();
352
406
  throw withHttpStatus(
@@ -743,13 +797,15 @@ export function buildRequest(
743
797
  request.generationConfig = generationConfig;
744
798
  }
745
799
 
800
+ const resolvedToolChoice = resolveToolChoice(model, options.toolChoice);
746
801
  if (context.tools && context.tools.length > 0) {
747
802
  const convertedTools = convertTools(context.tools, model);
748
803
  request.tools = isAntigravity ? normalizeAntigravityTools(convertedTools) : convertedTools;
749
- if (options.toolChoice) {
804
+ const resolved = resolvedToolChoice;
805
+ if (resolved.resolvedChoice) {
750
806
  request.toolConfig = {
751
807
  functionCallingConfig: {
752
- mode: mapToolChoice(options.toolChoice),
808
+ mode: mapToolChoice(resolved.resolvedChoice as string),
753
809
  },
754
810
  };
755
811
  }
@@ -763,11 +819,14 @@ export function buildRequest(
763
819
  }
764
820
 
765
821
  if (isAntigravity && isClaudeModel(model.id)) {
766
- request.toolConfig = {
767
- functionCallingConfig: {
768
- mode: "VALIDATED" as FunctionCallingConfigMode,
769
- },
770
- };
822
+ const resolvedLevel = resolvedToolChoice.resolvedLevel;
823
+ if (resolvedLevel === "named" || resolvedLevel === "required") {
824
+ request.toolConfig = {
825
+ functionCallingConfig: {
826
+ mode: "VALIDATED" as FunctionCallingConfigMode,
827
+ },
828
+ };
829
+ }
771
830
  }
772
831
 
773
832
  if (isAntigravity && shouldInjectAntigravitySystemInstruction(model.id)) {
@@ -22,6 +22,11 @@ import { normalizeSystemPrompts } from "../utils";
22
22
  import { AssistantMessageEventStream } from "../utils/event-stream";
23
23
  import { finalizeErrorMessage, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector";
24
24
  import { normalizeSchemaForCCA, normalizeSchemaForGoogle, toolWireSchema } from "../utils/schema";
25
+ import {
26
+ isForcedToolChoiceUnsupportedError,
27
+ markToolChoiceIncapability,
28
+ resolveToolChoice,
29
+ } from "../utils/tool-choice-capability";
25
30
  import type {
26
31
  Content,
27
32
  FinishReason,
@@ -59,7 +64,7 @@ export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LO
59
64
  * `google-gemini-cli` uses a different transport and request shape — do not extend this for it.
60
65
  */
61
66
  export interface GoogleSharedStreamOptions extends StreamOptions {
62
- toolChoice?: "auto" | "none" | "any";
67
+ toolChoice?: "auto" | "none" | "any" | "required";
63
68
  thinking?: {
64
69
  enabled: boolean;
65
70
  budgetTokens?: number;
@@ -366,6 +371,7 @@ export function mapToolChoice(choice: string): FunctionCallingConfigMode {
366
371
  case "none":
367
372
  return "NONE";
368
373
  case "any":
374
+ case "required":
369
375
  return "ANY";
370
376
  default:
371
377
  return "AUTO";
@@ -689,14 +695,15 @@ export function buildGoogleGenerateContentParams<T extends "google-generative-ai
689
695
  ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools, model) }),
690
696
  };
691
697
 
692
- if (context.tools && context.tools.length > 0 && options.toolChoice) {
693
- config.toolConfig = {
694
- functionCallingConfig: {
695
- mode: mapToolChoice(options.toolChoice),
696
- },
697
- };
698
- } else {
699
- config.toolConfig = undefined;
698
+ if (context.tools && context.tools.length > 0) {
699
+ const resolved = resolveToolChoice(model, options.toolChoice);
700
+ if (resolved.resolvedChoice) {
701
+ config.toolConfig = {
702
+ functionCallingConfig: {
703
+ mode: mapToolChoice(resolved.resolvedChoice as string),
704
+ },
705
+ };
706
+ }
700
707
  }
701
708
 
702
709
  if (options.thinking?.enabled && model.reasoning) {
@@ -789,14 +796,56 @@ export function streamGoogleGenAI<T extends "google-generative-ai" | "google-ver
789
796
  headers: plan.headers,
790
797
  };
791
798
 
792
- const wireBody = paramsToWireBody(params);
799
+ let wireBody = paramsToWireBody(params);
800
+ const sentForcedToolChoice =
801
+ (params.config?.toolConfig?.functionCallingConfig?.mode === "ANY" ||
802
+ params.config?.toolConfig?.functionCallingConfig?.mode === "VALIDATED") &&
803
+ options?.toolChoice !== undefined &&
804
+ options.toolChoice !== "auto" &&
805
+ options.toolChoice !== "none";
793
806
  const fetchImpl = plan.fetch ?? options?.fetch ?? (globalThis.fetch.bind(globalThis) as FetchImpl);
794
- const response = await fetchImpl(plan.url, {
807
+ let response = await fetchImpl(plan.url, {
795
808
  method: "POST",
796
809
  headers: { ...plan.headers, "Content-Type": "application/json", Accept: "text/event-stream" },
797
810
  body: JSON.stringify(wireBody),
798
811
  signal: options?.signal,
799
812
  });
813
+ if (!response.ok && sentForcedToolChoice) {
814
+ const errorText = await response.text().catch(() => "");
815
+ const error = withHttpStatus(
816
+ new Error(`Google API error (${response.status}): ${extractGoogleErrorMessage(errorText)}`),
817
+ response.status,
818
+ );
819
+ if (firstTokenTime === undefined && isForcedToolChoiceUnsupportedError(error, true)) {
820
+ const beforeMark = resolveToolChoice(model, options?.toolChoice);
821
+ markToolChoiceIncapability(model, "auto", error.message);
822
+ stream.push({
823
+ type: "toolChoiceIncapability",
824
+ api: model.api,
825
+ provider: model.provider,
826
+ model: model.id,
827
+ requestedLevel: beforeMark.requestedLevel,
828
+ resolvedLevel: "auto",
829
+ reason: error.message,
830
+ registryKey: beforeMark.registryKey,
831
+ });
832
+ const retryParams = {
833
+ ...params,
834
+ config: params.config ? { ...params.config, toolConfig: undefined } : params.config,
835
+ };
836
+ params = retryParams;
837
+ rawRequestDump = { ...rawRequestDump, body: params };
838
+ wireBody = paramsToWireBody(params);
839
+ response = await fetchImpl(plan.url, {
840
+ method: "POST",
841
+ headers: { ...plan.headers, "Content-Type": "application/json", Accept: "text/event-stream" },
842
+ body: JSON.stringify(wireBody),
843
+ signal: options?.signal,
844
+ });
845
+ } else {
846
+ throw error;
847
+ }
848
+ }
800
849
  if (!response.ok) {
801
850
  const errorText = await response.text().catch(() => "");
802
851
  throw withHttpStatus(
@@ -857,7 +906,7 @@ export function streamGoogleGenAI<T extends "google-generative-ai" | "google-ver
857
906
  * `abortSignal` is intentionally dropped — the SDK propagates it via `fetch.signal`,
858
907
  * which our caller already wires up through `options.signal`.
859
908
  */
860
- function paramsToWireBody(params: GenerateContentParameters): Record<string, unknown> {
909
+ export function paramsToWireBody(params: GenerateContentParameters): Record<string, unknown> {
861
910
  const body: Record<string, unknown> = { contents: params.contents };
862
911
  const config = params.config;
863
912
  if (!config) return body;
@@ -20,6 +20,11 @@ import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-ins
20
20
  import { parseStreamingJson } from "../utils/json-parse";
21
21
  import { resolveRetryBudget } from "../utils/retry-budget";
22
22
  import { toolWireSchema } from "../utils/schema/wire";
23
+ import {
24
+ isForcedToolChoiceUnsupportedError,
25
+ markToolChoiceIncapability,
26
+ resolveToolChoice,
27
+ } from "../utils/tool-choice-capability";
23
28
  import { transformMessages } from "./transform-messages";
24
29
 
25
30
  export interface OllamaChatOptions extends StreamOptions {
@@ -253,10 +258,22 @@ function convertTools(tools: Tool[] | undefined): OllamaFunctionTool[] | undefin
253
258
  }));
254
259
  }
255
260
 
256
- function createChatBody(model: Model<"ollama-chat">, context: Context, options: OllamaChatOptions | undefined) {
261
+ export function createChatBody(model: Model<"ollama-chat">, context: Context, options: OllamaChatOptions | undefined) {
257
262
  const think = mapReasoning(options?.reasoning);
258
- const toolChoice = mapToolChoice(options?.toolChoice);
259
- const selectedTools = selectToolsForToolChoice(context.tools, options?.toolChoice);
263
+ const resolved = resolveToolChoice(model, options?.toolChoice);
264
+ const toolChoice = mapToolChoice(resolved.resolvedChoice);
265
+
266
+ // Ollama's wire protocol has no named forcing — a named request rides
267
+ // `tool_choice: "required"` with the tool list narrowed to the target
268
+ // (issue #1236). Narrow whenever the resolved choice still forces a tool.
269
+ const shouldNarrowToNamedTool =
270
+ resolved.requestedLevel === "named" &&
271
+ (resolved.resolvedLevel === "named" || resolved.resolvedLevel === "required") &&
272
+ resolved.targetToolName !== undefined;
273
+ const selectedTools =
274
+ shouldNarrowToNamedTool && resolved.targetToolName
275
+ ? selectToolsForToolChoice(context.tools, { type: "tool", name: resolved.targetToolName })
276
+ : context.tools;
260
277
  const tools = convertTools(selectedTools);
261
278
  return {
262
279
  model: model.id,
@@ -382,6 +399,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
382
399
  }
383
400
  const baseUrl = normalizeBaseUrl(model.baseUrl);
384
401
  let body = createChatBody(model, context, options);
402
+ const sentForcedToolChoice = body.tool_choice === "required";
385
403
  const replacementPayload = await options.onPayload?.(body, model);
386
404
  if (replacementPayload !== undefined) {
387
405
  body = replacementPayload as typeof body;
@@ -394,7 +412,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
394
412
  url: `${baseUrl}/api/chat`,
395
413
  body,
396
414
  };
397
- const response = await fetchWithRetry(`${baseUrl}/api/chat`, {
415
+ let response = await fetchWithRetry(`${baseUrl}/api/chat`, {
398
416
  method: "POST",
399
417
  headers: {
400
418
  ...model.headers,
@@ -408,6 +426,44 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
408
426
  defaultDelayMs: OLLAMA_RETRY_DELAYS_MS,
409
427
  fetch: options.fetch,
410
428
  });
429
+ if (!response.ok && sentForcedToolChoice) {
430
+ const error = new Error(
431
+ `HTTP ${response.status} from ${baseUrl}/api/chat: ${await response.text().catch(() => "")}`,
432
+ );
433
+ (error as Error & { status?: number }).status = response.status;
434
+ if (firstTokenTime === undefined && isForcedToolChoiceUnsupportedError(error, true)) {
435
+ markToolChoiceIncapability(model, "auto", error.message);
436
+ stream.push({
437
+ type: "toolChoiceIncapability",
438
+ api: model.api,
439
+ provider: model.provider,
440
+ model: model.id,
441
+ requestedLevel: "required",
442
+ resolvedLevel: "auto",
443
+ reason: error.message,
444
+ registryKey: resolveToolChoice(model, options?.toolChoice).registryKey,
445
+ });
446
+ body = { ...body };
447
+ delete (body as { tool_choice?: unknown }).tool_choice;
448
+ rawRequestDump = { ...rawRequestDump, body };
449
+ response = await fetchWithRetry(`${baseUrl}/api/chat`, {
450
+ method: "POST",
451
+ headers: {
452
+ ...model.headers,
453
+ ...options.headers,
454
+ Authorization: `Bearer ${apiKey}`,
455
+ "Content-Type": "application/json",
456
+ },
457
+ body: JSON.stringify(body),
458
+ signal: options.signal,
459
+ maxAttempts: 1,
460
+ defaultDelayMs: OLLAMA_RETRY_DELAYS_MS,
461
+ fetch: options.fetch,
462
+ });
463
+ } else {
464
+ throw error;
465
+ }
466
+ }
411
467
  if (!response.ok) {
412
468
  throw new Error(`HTTP ${response.status} from ${baseUrl}/api/chat`);
413
469
  }
@@ -51,6 +51,11 @@ import { getOpenAIStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/i
51
51
  import { parseStreamingJson } from "../utils/json-parse";
52
52
  import { resolveRetryBudget } from "../utils/retry-budget";
53
53
  import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
54
+ import {
55
+ isForcedToolChoiceUnsupportedError,
56
+ markToolChoiceIncapability,
57
+ resolveToolChoice,
58
+ } from "../utils/tool-choice-capability";
54
59
  import { compactGrammarDefinition } from "./grammar";
55
60
  import { CODEX_BASE_URL, getCodexAccountId, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "./openai-codex/constants";
56
61
  import {
@@ -175,6 +180,47 @@ interface CodexRequestContext {
175
180
  rawRequestDump: RawHttpRequestDump;
176
181
  }
177
182
 
183
+ async function retryCodexInitialTransportWithoutToolChoice(
184
+ model: Model<"openai-codex-responses">,
185
+ options: OpenAICodexResponsesOptions | undefined,
186
+ requestSetup: CodexRequestSetup,
187
+ requestContext: CodexRequestContext,
188
+ stream: AssistantMessageEventStream,
189
+ error: unknown,
190
+ ): Promise<{
191
+ eventStream: AsyncGenerator<Record<string, unknown>>;
192
+ requestBodyForState: RequestBody;
193
+ transport: CodexTransport;
194
+ }> {
195
+ if (
196
+ !isForcedToolChoiceUnsupportedError(error, isForcedCodexToolChoice(requestContext.transformedBody.tool_choice))
197
+ ) {
198
+ throw error;
199
+ }
200
+ const reason = await finalizeErrorMessage(error, requestContext.rawRequestDump);
201
+ markToolChoiceIncapability(model, "auto", reason);
202
+ const resolvedToolChoice = resolveToolChoice(model, options?.toolChoice);
203
+ stream.push({
204
+ type: "toolChoiceIncapability",
205
+ api: model.api,
206
+ provider: model.provider,
207
+ model: model.id,
208
+ requestedLevel: resolvedToolChoice.requestedLevel,
209
+ resolvedLevel: "auto",
210
+ reason,
211
+ registryKey: resolvedToolChoice.registryKey,
212
+ });
213
+ const next = await openCodexSseTransportWithoutToolChoice(
214
+ model,
215
+ requestContext,
216
+ requestSetup,
217
+ options,
218
+ requestContext.websocketState,
219
+ );
220
+ requestContext.rawRequestDump = { ...requestContext.rawRequestDump, body: next.requestBodyForState };
221
+ return next;
222
+ }
223
+
178
224
  interface CodexRequestSetup {
179
225
  requestSignal: AbortSignal;
180
226
  wrapCodexSseStream: (source: AsyncGenerator<Record<string, unknown>>) => AsyncGenerator<Record<string, unknown>>;
@@ -601,7 +647,16 @@ async function buildTransformedCodexRequestBody(
601
647
  if (context.tools && context.tools.length > 0) {
602
648
  params.tools = convertOpenAICodexResponsesTools(context.tools, model);
603
649
  if (options?.toolChoice) {
604
- const toolChoice = normalizeCodexToolChoice(options.toolChoice, context.tools, model);
650
+ const resolvedToolChoice = resolveToolChoice(model, options.toolChoice);
651
+ if (resolvedToolChoice.degraded && resolvedToolChoice.supportSource === "runtime") {
652
+ logCodexDebug("codex degraded tool_choice after runtime capability discovery", {
653
+ model: model.id,
654
+ requestedLevel: resolvedToolChoice.requestedLevel,
655
+ resolvedLevel: resolvedToolChoice.resolvedLevel,
656
+ reason: resolvedToolChoice.reason,
657
+ });
658
+ }
659
+ const toolChoice = normalizeCodexToolChoice(resolvedToolChoice.resolvedChoice, context.tools, model);
605
660
  if (toolChoice) {
606
661
  params.tool_choice = toolChoice;
607
662
  }
@@ -754,6 +809,22 @@ async function openCodexSseTransport(
754
809
  return { eventStream, requestBodyForState: structuredCloneJSON(body), transport: "sse" };
755
810
  }
756
811
 
812
+ async function openCodexSseTransportWithoutToolChoice(
813
+ model: Model<"openai-codex-responses">,
814
+ requestContext: CodexRequestContext,
815
+ requestSetup: CodexRequestSetup,
816
+ options: OpenAICodexResponsesOptions | undefined,
817
+ state: CodexWebSocketSessionState | undefined,
818
+ ): Promise<{
819
+ eventStream: AsyncGenerator<Record<string, unknown>>;
820
+ requestBodyForState: RequestBody;
821
+ transport: CodexTransport;
822
+ }> {
823
+ const body = structuredCloneJSON(requestContext.transformedBody);
824
+ delete body.tool_choice;
825
+ return openCodexSseTransport(model, requestContext, requestSetup, options, state, body);
826
+ }
827
+
757
828
  async function reopenCodexWebSocketRuntimeStream(
758
829
  context: CodexStreamProcessingContext,
759
830
  runtime: CodexStreamRuntime,
@@ -1273,6 +1344,9 @@ async function recoverCodexStreamError(
1273
1344
  runtime: CodexStreamRuntime,
1274
1345
  error: unknown,
1275
1346
  ): Promise<boolean> {
1347
+ if (await tryRetryWithoutForcedToolChoice(context, runtime, error)) {
1348
+ return true;
1349
+ }
1276
1350
  if (await tryReconnectCodexWebSocketOnConnectionLimit(context, runtime, error)) {
1277
1351
  return true;
1278
1352
  }
@@ -1288,6 +1362,69 @@ async function recoverCodexStreamError(
1288
1362
  return false;
1289
1363
  }
1290
1364
 
1365
+ async function tryRetryWithoutForcedToolChoice(
1366
+ context: CodexStreamProcessingContext,
1367
+ runtime: CodexStreamRuntime,
1368
+ error: unknown,
1369
+ ): Promise<boolean> {
1370
+ if (
1371
+ runtime.providerRetryAttempt > 0 ||
1372
+ context.output.content.length > 0 ||
1373
+ context.firstTokenTime !== undefined ||
1374
+ context.options?.signal?.aborted ||
1375
+ !isForcedToolChoiceUnsupportedError(error, isForcedCodexToolChoice(runtime.requestBodyForState.tool_choice))
1376
+ ) {
1377
+ return false;
1378
+ }
1379
+
1380
+ const reason = await finalizeErrorMessage(error, context.requestContext.rawRequestDump);
1381
+ markToolChoiceIncapability(context.model, "auto", reason);
1382
+ const resolvedToolChoice = resolveToolChoice(context.model, context.options?.toolChoice);
1383
+ context.stream.push({
1384
+ type: "toolChoiceIncapability",
1385
+ api: context.model.api,
1386
+ provider: context.model.provider,
1387
+ model: context.model.id,
1388
+ requestedLevel: resolvedToolChoice.requestedLevel,
1389
+ resolvedLevel: "auto",
1390
+ reason,
1391
+ registryKey: resolvedToolChoice.registryKey,
1392
+ });
1393
+
1394
+ runtime.providerRetryAttempt += 1;
1395
+ runtime.currentItem = null;
1396
+ runtime.currentBlock = null;
1397
+ runtime.sawTerminalEvent = false;
1398
+ runtime.nativeOutputItems.length = 0;
1399
+ resetOutputState(context.output);
1400
+ context.firstTokenTime = undefined;
1401
+
1402
+ const websocketState = context.requestContext.websocketState;
1403
+ if (websocketState) {
1404
+ resetCodexWebSocketAppendState(websocketState);
1405
+ resetCodexSessionMetadata(websocketState);
1406
+ }
1407
+ const next = await openCodexSseTransportWithoutToolChoice(
1408
+ context.model,
1409
+ context.requestContext,
1410
+ context.requestSetup,
1411
+ context.options,
1412
+ websocketState,
1413
+ );
1414
+ runtime.eventStream = next.eventStream;
1415
+ runtime.requestBodyForState = next.requestBodyForState;
1416
+ runtime.transport = next.transport;
1417
+ if (websocketState) {
1418
+ websocketState.lastTransport = next.transport;
1419
+ }
1420
+ context.requestContext.rawRequestDump = { ...context.requestContext.rawRequestDump, body: next.requestBodyForState };
1421
+ return true;
1422
+ }
1423
+
1424
+ function isForcedCodexToolChoice(choice: RequestBody["tool_choice"]): boolean {
1425
+ return !!choice && choice !== "none" && choice !== "auto";
1426
+ }
1427
+
1291
1428
  /**
1292
1429
  * Handles `websocket_connection_limit_reached` errors by closing the stale connection
1293
1430
  * and opening a fresh websocket. If content has already been emitted to the caller,
@@ -1546,7 +1683,19 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
1546
1683
 
1547
1684
  try {
1548
1685
  const requestContext = await buildCodexRequestContext(model, context, options, output);
1549
- const initialTransport = await openInitialCodexEventStream(model, options, requestSetup, requestContext);
1686
+ let initialTransport: Awaited<ReturnType<typeof openInitialCodexEventStream>>;
1687
+ try {
1688
+ initialTransport = await openInitialCodexEventStream(model, options, requestSetup, requestContext);
1689
+ } catch (error) {
1690
+ initialTransport = await retryCodexInitialTransportWithoutToolChoice(
1691
+ model,
1692
+ options,
1693
+ requestSetup,
1694
+ requestContext,
1695
+ stream,
1696
+ error,
1697
+ );
1698
+ }
1550
1699
  const runtime = createCodexStreamRuntime({
1551
1700
  ...initialTransport,
1552
1701
  websocketState: requestContext.websocketState,
@@ -4,12 +4,17 @@ type OpenAIReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "
4
4
  type ResolvedToolStrictMode = NonNullable<OpenAICompat["toolStrictMode"]> | "mixed";
5
5
 
6
6
  export type ResolvedOpenAICompat = Required<
7
- Omit<OpenAICompat, "openRouterRouting" | "vercelGatewayRouting" | "extraBody" | "toolStrictMode">
7
+ Omit<
8
+ OpenAICompat,
9
+ "openRouterRouting" | "vercelGatewayRouting" | "extraBody" | "toolStrictMode" | "toolChoiceSupport"
10
+ >
8
11
  > & {
9
12
  openRouterRouting?: OpenAICompat["openRouterRouting"];
10
13
  vercelGatewayRouting?: OpenAICompat["vercelGatewayRouting"];
11
14
  extraBody?: OpenAICompat["extraBody"];
12
15
  toolStrictMode: ResolvedToolStrictMode;
16
+ /** Optional explicit capability override; resolved via deriveToolChoiceSupport. */
17
+ toolChoiceSupport?: OpenAICompat["toolChoiceSupport"];
13
18
  };
14
19
 
15
20
  function detectStrictModeSupport(provider: string, baseUrl: string): boolean {
@@ -191,6 +196,7 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
191
196
  disableReasoningOnForcedToolChoice: isKimiModel || isAnthropicModel,
192
197
  disableReasoningOnToolChoice: isDeepseekFamily && Boolean(model.reasoning) && !isOpenRouter,
193
198
  supportsToolChoice: !isDirectDeepseekReasoning,
199
+ supportsForcedToolChoice: true,
194
200
  maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens",
195
201
  requiresToolResultName: isMistral,
196
202
  requiresAssistantAfterToolResult: false,
@@ -254,6 +260,8 @@ export function resolveOpenAICompat(
254
260
  reasoningEffortMap: { ...detected.reasoningEffortMap, ...(model.compat.reasoningEffortMap ?? {}) },
255
261
  supportsUsageInStreaming: model.compat.supportsUsageInStreaming ?? detected.supportsUsageInStreaming,
256
262
  supportsToolChoice: model.compat.supportsToolChoice ?? detected.supportsToolChoice,
263
+ supportsForcedToolChoice: model.compat.supportsForcedToolChoice ?? detected.supportsForcedToolChoice,
264
+ toolChoiceSupport: model.compat.toolChoiceSupport ?? detected.toolChoiceSupport,
257
265
  maxTokensField: model.compat.maxTokensField ?? detected.maxTokensField,
258
266
  requiresToolResultName: model.compat.requiresToolResultName ?? detected.requiresToolResultName,
259
267
  requiresAssistantAfterToolResult: