@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.
- package/CHANGELOG.md +31 -1
- package/dist/types/index.d.ts +2 -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/usage/grok-cli.d.ts +10 -0
- package/dist/types/utils/event-stream.d.ts +6 -1
- package/dist/types/utils/oauth/xai.d.ts +10 -3
- package/dist/types/utils/tool-choice-capability.d.ts +41 -0
- package/package.json +2 -2
- package/src/auth-storage.ts +3 -0
- package/src/index.ts +2 -0
- package/src/model-thinking.ts +9 -0
- package/src/models.json +116 -0
- package/src/models.ts +33 -7
- package/src/provider-models/descriptors.ts +1 -1
- 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/rate-limit-utils.ts +11 -2
- package/src/types.ts +37 -3
- package/src/usage/grok-cli.ts +163 -0
- package/src/utils/event-stream.ts +35 -5
- package/src/utils/oauth/xai.ts +49 -13
- 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.`;
|
package/src/providers/cursor.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|