@gajae-code/ai 0.4.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/dist/types/index.d.ts +1 -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/utils/event-stream.d.ts +6 -1
  13. package/dist/types/utils/tool-choice-capability.d.ts +41 -0
  14. package/package.json +2 -2
  15. package/src/index.ts +1 -0
  16. package/src/model-thinking.ts +9 -0
  17. package/src/models.json +92 -0
  18. package/src/models.ts +33 -7
  19. package/src/provider-models/openai-compat.ts +9 -1
  20. package/src/providers/amazon-bedrock.ts +145 -60
  21. package/src/providers/anthropic.ts +85 -32
  22. package/src/providers/azure-openai-responses.ts +44 -3
  23. package/src/providers/composer-discipline.ts +38 -0
  24. package/src/providers/cursor.ts +10 -3
  25. package/src/providers/google-gemini-cli.ts +69 -10
  26. package/src/providers/google-shared.ts +61 -12
  27. package/src/providers/ollama.ts +60 -4
  28. package/src/providers/openai-codex-responses.ts +151 -2
  29. package/src/providers/openai-completions-compat.ts +9 -1
  30. package/src/providers/openai-completions.ts +46 -6
  31. package/src/providers/openai-request-transform.ts +1 -0
  32. package/src/providers/openai-responses.ts +54 -5
  33. package/src/providers/register-builtins.ts +5 -6
  34. package/src/types.ts +37 -3
  35. package/src/utils/event-stream.ts +35 -5
  36. package/src/utils/tool-choice-capability.ts +220 -0
@@ -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:
@@ -1,4 +1,4 @@
1
- import { $env, $inheritedEnv, extractHttpStatusFromError } from "@gajae-code/utils";
1
+ import { $credentialEnv, $env, $inheritedEnv, extractHttpStatusFromError, logger } from "@gajae-code/utils";
2
2
  import OpenAI from "openai";
3
3
  import type {
4
4
  ChatCompletionAssistantMessageParam,
@@ -61,6 +61,12 @@ import { adaptSchemaForStrict, NO_STRICT, toolWireSchema } from "../utils/schema
61
61
  import { wrapFetchForSseDebug } from "../utils/sse-debug";
62
62
  import { type HealedToolCall, modelMayLeakKimiToolCalls, ToolCallHealer } from "../utils/tool-call-healing";
63
63
  import { isForcedToolChoice, mapToOpenAICompletionsToolChoice } from "../utils/tool-choice";
64
+ import {
65
+ isForcedToolChoiceUnsupportedError,
66
+ markToolChoiceIncapability,
67
+ resolveToolChoice,
68
+ } from "../utils/tool-choice-capability";
69
+ import { COMPOSER_EDIT_DISCIPLINE_PROMPT, isComposerHarnessModel } from "./composer-discipline";
64
70
  import {
65
71
  buildCopilotDynamicHeaders,
66
72
  hasCopilotVisionInput,
@@ -493,7 +499,25 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
493
499
  });
494
500
  } catch (error) {
495
501
  const capturedErrorResponse = getCapturedErrorResponse();
496
- if (
502
+ const sentForcedToolChoice = isForcedToolChoice(
503
+ (rawRequestDump?.body as { tool_choice?: unknown } | undefined)?.tool_choice,
504
+ );
505
+ if (firstTokenTime === undefined && isForcedToolChoiceUnsupportedError(error, sentForcedToolChoice)) {
506
+ const reason = await finalizeErrorMessage(error, rawRequestDump, capturedErrorResponse);
507
+ markToolChoiceIncapability(model, "auto", reason);
508
+ const resolvedToolChoice = resolveToolChoice(model, options?.toolChoice);
509
+ stream.push({
510
+ type: "toolChoiceIncapability",
511
+ api: model.api,
512
+ provider: model.provider,
513
+ model: model.id,
514
+ requestedLevel: resolvedToolChoice.requestedLevel,
515
+ resolvedLevel: "auto",
516
+ reason,
517
+ registryKey: resolvedToolChoice.registryKey,
518
+ });
519
+ openaiStream = await createCompletionsStream();
520
+ } else if (
497
521
  isOpenRouterAnthropicModel(model) &&
498
522
  !disableStrictTools &&
499
523
  isCompiledGrammarTooLargeStrictError(error, capturedErrorResponse)
@@ -928,12 +952,12 @@ async function createClient(
928
952
  clearCapturedErrorResponse: () => void;
929
953
  }> {
930
954
  if (!apiKey) {
931
- if (!$env.OPENAI_API_KEY) {
955
+ apiKey = $credentialEnv("OPENAI_API_KEY");
956
+ if (!apiKey) {
932
957
  throw new Error(
933
958
  "OpenAI API key is required. Set OPENAI_API_KEY environment variable or pass it as an argument.",
934
959
  );
935
960
  }
936
- apiKey = $env.OPENAI_API_KEY;
937
961
  }
938
962
  const rawApiKey = apiKey;
939
963
 
@@ -1166,8 +1190,19 @@ function buildParams(
1166
1190
  params.tools = [];
1167
1191
  }
1168
1192
 
1169
- if (options?.toolChoice && compat.supportsToolChoice) {
1170
- params.tool_choice = mapToOpenAICompletionsToolChoice(options.toolChoice);
1193
+ if (options?.toolChoice) {
1194
+ const toolChoice = resolveToolChoice(model, options.toolChoice, compat);
1195
+ if (toolChoice.degraded && toolChoice.supportSource === "runtime") {
1196
+ logger.debug("openai-completions: degraded tool_choice after runtime capability discovery", {
1197
+ model: model.id,
1198
+ requestedLevel: toolChoice.requestedLevel,
1199
+ resolvedLevel: toolChoice.resolvedLevel,
1200
+ reason: toolChoice.reason,
1201
+ });
1202
+ }
1203
+ if (toolChoice.resolvedChoice !== undefined) {
1204
+ params.tool_choice = mapToOpenAICompletionsToolChoice(toolChoice.resolvedChoice);
1205
+ }
1171
1206
  }
1172
1207
 
1173
1208
  if (params.tool_choice === "none" && (!Array.isArray(params.tools) || params.tools.length === 0)) {
@@ -1430,6 +1465,11 @@ export function convertMessages(
1430
1465
  };
1431
1466
 
1432
1467
  const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
1468
+ // Composer-harness models need anchor/edit discipline pinned ahead of the
1469
+ // host prompt (see composer-discipline.ts for the observed failure modes).
1470
+ if (systemPrompts.length > 0 && isComposerHarnessModel(model.id)) {
1471
+ systemPrompts.unshift(COMPOSER_EDIT_DISCIPLINE_PROMPT);
1472
+ }
1433
1473
  if (systemPrompts.length > 0) {
1434
1474
  const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole;
1435
1475
  const role = useDeveloperRole ? "developer" : "system";