@gajae-code/ai 0.4.4 → 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 (41) hide show
  1. package/CHANGELOG.md +42 -0
  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/stream.d.ts +2 -2
  12. package/dist/types/types.d.ts +29 -3
  13. package/dist/types/utils/event-stream.d.ts +6 -1
  14. package/dist/types/utils/tool-choice-capability.d.ts +41 -0
  15. package/package.json +2 -2
  16. package/src/auth-storage.ts +5 -1
  17. package/src/index.ts +1 -0
  18. package/src/model-manager.ts +33 -1
  19. package/src/model-thinking.ts +9 -0
  20. package/src/models.json +92 -0
  21. package/src/models.ts +33 -7
  22. package/src/provider-models/openai-compat.ts +9 -1
  23. package/src/providers/amazon-bedrock.ts +145 -60
  24. package/src/providers/anthropic.ts +89 -10
  25. package/src/providers/azure-openai-responses.ts +44 -3
  26. package/src/providers/composer-discipline.ts +38 -0
  27. package/src/providers/cursor.ts +80 -4
  28. package/src/providers/google-gemini-cli.ts +69 -10
  29. package/src/providers/google-gemini-headers.ts +1 -1
  30. package/src/providers/google-shared.ts +61 -12
  31. package/src/providers/ollama.ts +60 -4
  32. package/src/providers/openai-codex-responses.ts +151 -2
  33. package/src/providers/openai-completions-compat.ts +9 -1
  34. package/src/providers/openai-completions.ts +46 -6
  35. package/src/providers/openai-request-transform.ts +1 -0
  36. package/src/providers/openai-responses.ts +54 -5
  37. package/src/providers/register-builtins.ts +5 -6
  38. package/src/stream.ts +24 -19
  39. package/src/types.ts +41 -3
  40. package/src/utils/event-stream.ts +35 -5
  41. package/src/utils/tool-choice-capability.ts +220 -0
@@ -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,
@@ -569,7 +570,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
569
570
  return stream;
570
571
  };
571
572
 
572
- type ToolCallState = ToolCall & { index: number; partialJson?: string; kind: "mcp" | "todo_write" };
573
+ type ToolCallState = ToolCall & { index: number; partialJson?: string; kind: "mcp" | "todo_write" | "native" };
573
574
 
574
575
  interface BlockState {
575
576
  currentTextBlock: (TextContent & { index: number }) | null;
@@ -1844,6 +1845,60 @@ function buildTodoWriteArgs(toolCall: CursorUpdateTodosToolCall): {
1844
1845
  };
1845
1846
  }
1846
1847
 
1848
+ // Map a cursor ToolCall oneof field name (e.g. "shellToolCall") to a display tool
1849
+ // name. Mirrors cli-jaw's cursorToolKindLabel (src/agent/events/cursor.ts) so the
1850
+ // two surfaces label cursor-native tools identically.
1851
+ const CURSOR_NATIVE_KIND_ALIASES: Record<string, string> = {
1852
+ shell: "bash",
1853
+ read: "read",
1854
+ write: "write",
1855
+ delete: "delete",
1856
+ edit: "edit",
1857
+ grep: "grep",
1858
+ glob: "glob",
1859
+ ls: "ls",
1860
+ semSearch: "codebase_search",
1861
+ webSearch: "web_search",
1862
+ fetch: "fetch",
1863
+ task: "task",
1864
+ createPlan: "create_plan",
1865
+ askQuestion: "ask_question",
1866
+ readLints: "read_lints",
1867
+ applyAgentDiff: "apply_diff",
1868
+ };
1869
+
1870
+ function cursorNativeToolName(kindKey: string): string {
1871
+ const base = kindKey.replace(/ToolCall$/i, "");
1872
+ if (!base) return "tool";
1873
+ return CURSOR_NATIVE_KIND_ALIASES[base] ?? base;
1874
+ }
1875
+
1876
+ // Cursor's model sometimes calls its own native IDE tools (shell/glob/grep/…)
1877
+ // instead of the advertised MCP tools. Those arrive as ToolCall oneof variants we
1878
+ // do not otherwise handle (everything except mcpToolCall / updateTodosToolCall), so
1879
+ // without this they are silently dropped and never render. Build a generic toolCall
1880
+ // block from whichever *ToolCall field is set so the call (and its result) is shown.
1881
+ function buildNativeToolCallBlock(
1882
+ toolCall: Record<string, unknown>,
1883
+ callId: string,
1884
+ index: number,
1885
+ ): ToolCallState | null {
1886
+ for (const [key, payload] of Object.entries(toolCall)) {
1887
+ if (!/ToolCall$/.test(key) || !payload || typeof payload !== "object") continue;
1888
+ if (key === "mcpToolCall" || key === "updateTodosToolCall") continue;
1889
+ const args = (payload as { args?: unknown }).args;
1890
+ return {
1891
+ type: "toolCall",
1892
+ id: callId,
1893
+ name: cursorNativeToolName(key),
1894
+ arguments: args && typeof args === "object" ? (args as Record<string, unknown>) : { raw: payload },
1895
+ index,
1896
+ kind: "native",
1897
+ };
1898
+ }
1899
+ return null;
1900
+ }
1901
+
1847
1902
  function buildMcpResultFromToolResult(_mcpCall: CursorMcpCall, toolResult: ToolResultMessage) {
1848
1903
  if (toolResult.isError) {
1849
1904
  return buildMcpErrorResult(toolResultToText(toolResult) || "MCP tool failed");
@@ -1987,6 +2042,21 @@ function processInteractionUpdate(
1987
2042
  output.content.push(block);
1988
2043
  state.setToolCall(block);
1989
2044
  stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
2045
+ return;
2046
+ }
2047
+
2048
+ // Fallback: cursor-native tool variants (shell/glob/grep/…) we don't model
2049
+ // explicitly. Render them so the call and its result are visible instead of
2050
+ // vanishing.
2051
+ const nativeBlock = buildNativeToolCallBlock(
2052
+ toolCall,
2053
+ update.message.value.callId || crypto.randomUUID(),
2054
+ output.content.length,
2055
+ );
2056
+ if (nativeBlock) {
2057
+ output.content.push(nativeBlock);
2058
+ state.setToolCall(nativeBlock);
2059
+ stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
1990
2060
  }
1991
2061
  }
1992
2062
  } else if (updateCase === "toolCallDelta" || updateCase === "partialToolCall") {
@@ -2215,12 +2285,18 @@ function findLastUserMessageIndex(messages: Message[]): number {
2215
2285
  * When no system prompts are provided, returns a single default greeting so we never emit
2216
2286
  * an empty `rootPromptMessagesJson` head.
2217
2287
  */
2218
- export function buildCursorSystemPromptJsons(systemPrompt: readonly string[] | undefined): string[] {
2288
+ export function buildCursorSystemPromptJsons(systemPrompt: readonly string[] | undefined, modelId?: string): string[] {
2219
2289
  const systemPrompts = normalizeSystemPrompts(systemPrompt);
2220
2290
  if (systemPrompts.length === 0) {
2221
2291
  return [JSON.stringify({ role: "system", content: "You are a helpful assistant." })];
2222
2292
  }
2223
- return systemPrompts.map(content => JSON.stringify({ role: "system", content }));
2293
+ const jsons = systemPrompts.map(content => JSON.stringify({ role: "system", content }));
2294
+ // Composer-harness models need anchor/edit discipline pinned ahead of the
2295
+ // host prompt (see composer-discipline.ts for the observed failure modes).
2296
+ if (modelId !== undefined && isComposerHarnessModel(modelId)) {
2297
+ jsons.unshift(JSON.stringify({ role: "system", content: COMPOSER_EDIT_DISCIPLINE_PROMPT }));
2298
+ }
2299
+ return jsons;
2224
2300
  }
2225
2301
 
2226
2302
  function buildRootPromptMessagesJson(
@@ -2432,7 +2508,7 @@ function buildGrpcRequest(
2432
2508
  } {
2433
2509
  const blobStore = state.blobStore;
2434
2510
 
2435
- const systemPromptIds = buildCursorSystemPromptJsons(context.systemPrompt).map(json =>
2511
+ const systemPromptIds = buildCursorSystemPromptJsons(context.systemPrompt, model.id).map(json =>
2436
2512
  storeCursorBlob(blobStore, new TextEncoder().encode(json)),
2437
2513
  );
2438
2514
 
@@ -25,6 +25,11 @@ import { resolveRetryBudget } from "../utils/retry-budget";
25
25
  // Refresh is the sole responsibility of AuthStorage (broker-aware, single-flighted);
26
26
  // the stream provider trusts the access token threaded through `options.apiKey`.
27
27
  import { normalizeSchemaForCCA } from "../utils/schema";
28
+ import {
29
+ isForcedToolChoiceUnsupportedError,
30
+ markToolChoiceIncapability,
31
+ resolveToolChoice,
32
+ } from "../utils/tool-choice-capability";
28
33
  import { ANTIGRAVITY_SYSTEM_INSTRUCTION, getAntigravityUserAgent, getGeminiCliHeaders } from "./google-gemini-headers";
29
34
  import type { Content, FunctionCallingConfigMode, ThinkingConfig } from "./google-shared";
30
35
  import {
@@ -48,7 +53,7 @@ import {
48
53
  export type { GoogleThinkingLevel };
49
54
 
50
55
  export interface GoogleGeminiCliOptions extends StreamOptions {
51
- toolChoice?: "auto" | "none" | "any";
56
+ toolChoice?: "auto" | "none" | "any" | "required";
52
57
  /**
53
58
  * Thinking/reasoning configuration.
54
59
  * - Gemini 2.x models: use `budgetTokens` to set the thinking budget
@@ -324,7 +329,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
324
329
  ...(needsClaudeThinkingBetaHeader(model) ? { "anthropic-beta": CLAUDE_THINKING_BETA_HEADER } : {}),
325
330
  ...(options?.headers ?? {}),
326
331
  };
327
- const requestBodyJson = JSON.stringify(requestBody);
332
+ let requestBodyJson = JSON.stringify(requestBody);
328
333
  rawRequestDump = {
329
334
  provider: model.provider,
330
335
  api: output.api,
@@ -334,7 +339,13 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
334
339
  headers: requestHeaders,
335
340
  };
336
341
 
337
- const response = await fetchWithRetry(
342
+ const sentForcedToolChoice =
343
+ (requestBody.request.toolConfig?.functionCallingConfig?.mode === "ANY" ||
344
+ requestBody.request.toolConfig?.functionCallingConfig?.mode === "VALIDATED") &&
345
+ options?.toolChoice !== undefined &&
346
+ options.toolChoice !== "auto" &&
347
+ options.toolChoice !== "none";
348
+ let response = await fetchWithRetry(
338
349
  attempt => `${endpoints[Math.min(attempt, endpoints.length - 1)]}/v1internal:streamGenerateContent?alt=sse`,
339
350
  {
340
351
  method: "POST",
@@ -347,6 +358,49 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
347
358
  fetch: options?.fetch,
348
359
  },
349
360
  );
361
+ if (!response.ok && sentForcedToolChoice) {
362
+ const errorText = await response.text();
363
+ const error = withHttpStatus(
364
+ new Error(`Cloud Code Assist API error (${response.status}): ${extractErrorMessage(errorText)}`),
365
+ response.status,
366
+ );
367
+ if (firstTokenTime === undefined && isForcedToolChoiceUnsupportedError(error, true)) {
368
+ const beforeMark = resolveToolChoice(model, options?.toolChoice);
369
+ markToolChoiceIncapability(model, "auto", error.message);
370
+ stream.push({
371
+ type: "toolChoiceIncapability",
372
+ api: model.api,
373
+ provider: model.provider,
374
+ model: model.id,
375
+ requestedLevel: beforeMark.requestedLevel,
376
+ resolvedLevel: "auto",
377
+ reason: error.message,
378
+ registryKey: beforeMark.registryKey,
379
+ });
380
+ requestBody = {
381
+ ...requestBody,
382
+ request: { ...requestBody.request, toolConfig: undefined },
383
+ };
384
+ requestBodyJson = JSON.stringify(requestBody);
385
+ rawRequestDump = { ...rawRequestDump, body: requestBody };
386
+ response = await fetchWithRetry(
387
+ attempt =>
388
+ `${endpoints[Math.min(attempt, endpoints.length - 1)]}/v1internal:streamGenerateContent?alt=sse`,
389
+ {
390
+ method: "POST",
391
+ headers: requestHeaders,
392
+ body: requestBodyJson,
393
+ signal: options?.signal,
394
+ maxAttempts: 1,
395
+ defaultDelayMs: attempt => BASE_DELAY_MS * 2 ** attempt,
396
+ maxDelayMs: options?.maxRetryDelayMs ?? RATE_LIMIT_BUDGET_MS,
397
+ fetch: options?.fetch,
398
+ },
399
+ );
400
+ } else {
401
+ throw error;
402
+ }
403
+ }
350
404
  if (!response.ok) {
351
405
  const errorText = await response.text();
352
406
  throw withHttpStatus(
@@ -743,13 +797,15 @@ export function buildRequest(
743
797
  request.generationConfig = generationConfig;
744
798
  }
745
799
 
800
+ const resolvedToolChoice = resolveToolChoice(model, options.toolChoice);
746
801
  if (context.tools && context.tools.length > 0) {
747
802
  const convertedTools = convertTools(context.tools, model);
748
803
  request.tools = isAntigravity ? normalizeAntigravityTools(convertedTools) : convertedTools;
749
- if (options.toolChoice) {
804
+ const resolved = resolvedToolChoice;
805
+ if (resolved.resolvedChoice) {
750
806
  request.toolConfig = {
751
807
  functionCallingConfig: {
752
- mode: mapToolChoice(options.toolChoice),
808
+ mode: mapToolChoice(resolved.resolvedChoice as string),
753
809
  },
754
810
  };
755
811
  }
@@ -763,11 +819,14 @@ export function buildRequest(
763
819
  }
764
820
 
765
821
  if (isAntigravity && isClaudeModel(model.id)) {
766
- request.toolConfig = {
767
- functionCallingConfig: {
768
- mode: "VALIDATED" as FunctionCallingConfigMode,
769
- },
770
- };
822
+ const resolvedLevel = resolvedToolChoice.resolvedLevel;
823
+ if (resolvedLevel === "named" || resolvedLevel === "required") {
824
+ request.toolConfig = {
825
+ functionCallingConfig: {
826
+ mode: "VALIDATED" as FunctionCallingConfigMode,
827
+ },
828
+ };
829
+ }
771
830
  }
772
831
 
773
832
  if (isAntigravity && shouldInjectAntigravitySystemInstruction(model.id)) {
@@ -4,7 +4,7 @@
4
4
  * GeminiCLI/VERSION/MODEL (PLATFORM; ARCH; SURFACE)
5
5
  */
6
6
  export function getGeminiCliUserAgent(modelId = "gemini-3.1-pro-preview"): string {
7
- const version = process.env.PI_AI_GEMINI_CLI_VERSION || "0.45.2";
7
+ const version = process.env.PI_AI_GEMINI_CLI_VERSION || "0.46.0";
8
8
  const platform = process.platform === "win32" ? "win32" : process.platform;
9
9
  const arch = process.arch === "x64" ? "x64" : process.arch;
10
10
  return `GeminiCLI/${version}/${modelId} (${platform}; ${arch}; terminal)`;
@@ -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
  }