@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.
- package/CHANGELOG.md +25 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/providers/amazon-bedrock.d.ts +29 -5
- package/dist/types/providers/composer-discipline.d.ts +27 -0
- package/dist/types/providers/cursor.d.ts +1 -1
- package/dist/types/providers/google-gemini-cli.d.ts +1 -1
- package/dist/types/providers/google-shared.d.ts +11 -1
- package/dist/types/providers/ollama.d.ts +36 -1
- package/dist/types/providers/openai-completions-compat.d.ts +3 -1
- package/dist/types/providers/register-builtins.d.ts +3 -3
- package/dist/types/types.d.ts +25 -3
- package/dist/types/utils/event-stream.d.ts +6 -1
- package/dist/types/utils/tool-choice-capability.d.ts +41 -0
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/model-thinking.ts +9 -0
- package/src/models.json +92 -0
- package/src/models.ts +33 -7
- package/src/provider-models/openai-compat.ts +9 -1
- package/src/providers/amazon-bedrock.ts +145 -60
- package/src/providers/anthropic.ts +85 -32
- package/src/providers/azure-openai-responses.ts +44 -3
- package/src/providers/composer-discipline.ts +38 -0
- package/src/providers/cursor.ts +10 -3
- package/src/providers/google-gemini-cli.ts +69 -10
- package/src/providers/google-shared.ts +61 -12
- package/src/providers/ollama.ts +60 -4
- package/src/providers/openai-codex-responses.ts +151 -2
- package/src/providers/openai-completions-compat.ts +9 -1
- package/src/providers/openai-completions.ts +46 -6
- package/src/providers/openai-request-transform.ts +1 -0
- package/src/providers/openai-responses.ts +54 -5
- package/src/providers/register-builtins.ts +5 -6
- package/src/types.ts +37 -3
- package/src/utils/event-stream.ts +35 -5
- 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
|
-
|
|
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
|
|
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
|
-
|
|
804
|
+
const resolved = resolvedToolChoice;
|
|
805
|
+
if (resolved.resolvedChoice) {
|
|
750
806
|
request.toolConfig = {
|
|
751
807
|
functionCallingConfig: {
|
|
752
|
-
mode: mapToolChoice(
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/providers/ollama.ts
CHANGED
|
@@ -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
|
|
259
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1170
|
-
|
|
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";
|