@oh-my-pi/pi-ai 17.3.8 → 17.4.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 CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.4.0] - 2026-08-20
6
+
7
+ ### Added
8
+
9
+ - Added model metadata fields (`context_length`, `max_output_tokens`, `input_modalities`, etc.) to auth gateway model listing responses
10
+
11
+ ### Fixed
12
+
13
+ - Fixed tool-argument repair applying lossy transformations (such as stringifying objects or stripping unrecognized keys) when validating union schemas (`anyOf`/`oneOf`), preventing corrupted tool call and subagent payloads
14
+ - Fixed 400 errors when communicating with local OpenAI-compatible inference servers that reject `chat_template_kwargs.reasoning_effort` by improving reasoning effort parameter fallback and compatibility handling
15
+ - Fixed DeepSeek-family models on hosts like Fireworks losing reasoning whenever tools were offered: a redundant `tool_choice: "auto"` is now omitted so the provider keeps thinking enabled; forced and `"none"` selectors still take priority ([#1207](https://github.com/can1357/oh-my-pi/issues/1207))
16
+
5
17
  ## [17.3.8] - 2026-08-19
6
18
 
7
19
  ### Changed
@@ -9,7 +9,7 @@
9
9
  * `ExecClientMessage` carrying a result with an unset oneof is a fake success
10
10
  * the server reads as "the tool ran and produced nothing".
11
11
  */
12
- import { type ExecuteHookRequest, type ExecuteHookResult, type McpStateExecResult, type McpToolDefinition, type PiBashExecResult, type PiEditExecResult, type PiFindExecResult, type PiGrepExecResult, type PiLsExecResult, type PiReadExecResult, type PiTruncation, type PiWriteExecResult } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
12
+ import { type ExecuteHookRequest, type ExecuteHookResult, type McpStateExecResult, type McpToolDefinition, type PiBashExecResult, type PiEditExecResult, type PiFindExecResult, type PiGrepExecResult, type PiLsExecResult, type PiReadExecResult, type PiTruncation, type PiWriteExecResult } from "@oh-my-pi/pi-catalog/discovery/cursor-proto";
13
13
  import type { ToolResultMessage } from "../../types.js";
14
14
  /**
15
15
  * The pure arg translation lives in `../cursor-pi-args` so the legacy pi shim
@@ -1,5 +1,5 @@
1
1
  import type http2 from "node:http2";
2
- import { type InteractionQuery } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
2
+ import { type InteractionQuery } from "@oh-my-pi/pi-catalog/discovery/cursor-proto";
3
3
  /**
4
4
  * Answer a Cursor `interaction_query` so the Run RPC can continue.
5
5
  *
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * Kept apart from `cursor/exec-modern.ts` on purpose: these are pure
11
11
  * string/path functions with no protobuf coupling, while that module pulls in
12
- * `@bufbuild/protobuf` and the generated `agent_pb` graph. The legacy shim is
12
+ * the generated cursor protobuf graph. The legacy shim is
13
13
  * compiled into the bundled virtual module registry, so importing it from a
14
14
  * nested path would drag the whole exec implementation in with it — and
15
15
  * `./providers/*` is a single-segment wildcard export that cannot serve a
@@ -1,7 +1,7 @@
1
1
  import http2 from "node:http2";
2
- import { type JsonValue } from "@bufbuild/protobuf";
3
- import type { McpToolDefinition } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
4
- import { type AgentServerMessage, type ConversationStateStructure } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
2
+ import type { McpToolDefinition } from "@oh-my-pi/pi-catalog/discovery/cursor-proto";
3
+ import { type AgentServerMessage, type ConversationStateStructure } from "@oh-my-pi/pi-catalog/discovery/cursor-proto";
4
+ import { type JsonValue } from "@oh-my-pi/pi-catalog/discovery/protobuf";
5
5
  import type { AssistantMessage, Context, CursorExecHandlerResult, CursorExecHandlers, CursorExecPairing, CursorTodoSyncHandler, CursorToolResultHandler, Message, Model, StreamFunction, StreamOptions, TextContent, ThinkingContent, Tool, ToolCall, ToolResultMessage } from "../types.js";
6
6
  import { kCursorExecResolved, kStreamingBlockIndex, kStreamingBlockKind, kStreamingEnvelopeId, kStreamingLastParseLen, kStreamingPartialJson } from "../utils/block-symbols.js";
7
7
  import { AssistantMessageEventStream } from "../utils/event-stream.js";
@@ -1,6 +1,14 @@
1
1
  import type { CapturedHttpErrorResponse } from "../utils/http-inspector.js";
2
- /** @internal */
3
- export type OpenAIReasoningEffortFallback = string | null;
2
+ /**
3
+ * Fallback marker: the server rejected the `chat_template_kwargs.reasoning_effort`
4
+ * spelling itself (strict kwargs whitelists — Ninfer-style servers), not the
5
+ * effort value. Apply strips the kwarg and hoists the value onto the top-level
6
+ * `reasoning_effort` field when that spelling is absent.
7
+ * @internal
8
+ */
9
+ export declare const STRIP_TEMPLATE_KWARG_REASONING_EFFORT: unique symbol;
10
+ /** @internal */
11
+ export type OpenAIReasoningEffortFallback = string | null | typeof STRIP_TEMPLATE_KWARG_REASONING_EFFORT;
4
12
  /** @internal */
5
13
  export interface OpenAIReasoningEffortFallbackState {
6
14
  reasoningEffortFallbacks: Map<string, OpenAIReasoningEffortFallback>;
@@ -315,6 +315,20 @@ export interface OpenAICompatPolicy {
315
315
  export declare function mapOpenAIReasoningEffort(model: Pick<Model, "thinking">, compat: {
316
316
  reasoningEffortMap?: Partial<Record<Effort, string>>;
317
317
  } | undefined, effort: string): string;
318
+ /**
319
+ * Whether a redundant `tool_choice: "auto"` should be dropped to keep
320
+ * reasoning alive. Hosts with `disableReasoningOnToolChoice` (DeepSeek family
321
+ * on e.g. Fireworks) silently turn reasoning off whenever any `tool_choice`
322
+ * is present. "auto" is already the provider default, so omitting it is
323
+ * wire-neutral for tool selection; forced and "none" choices are semantic and
324
+ * still win over reasoning (#1207).
325
+ */
326
+ export declare function shouldDropAutoToolChoiceForReasoning(model: Pick<Model, "reasoning">, compat: {
327
+ disableReasoningOnToolChoice: boolean;
328
+ }, toolChoice: unknown, options: {
329
+ reasoning?: string;
330
+ disableReasoning?: boolean;
331
+ } | undefined): boolean;
318
332
  export declare function resolveOpenAICompatPolicy<TApi extends Api>(model: Model<TApi>, options: ResolveOpenAICompatPolicyOptions): OpenAICompatPolicy;
319
333
  export declare function applyChatCompletionsCompatPolicy(params: OpenAICompletionsParams, policy: OpenAICompatPolicy): void;
320
334
  export declare function applyChatCompletionsReasoningParams(params: OpenAICompletionsParams, model: Model<"openai-completions">, compat: ResolvedOpenAICompat, options: (ChatCompletionsReasoningOptions & {
@@ -1,7 +1,7 @@
1
1
  export * from "@oh-my-pi/pi-catalog/effort";
2
2
  export * from "@oh-my-pi/pi-catalog/types";
3
3
  import type { Type } from "@oh-my-pi/omptype";
4
- import type { DeleteArgs, DeleteResult, DiagnosticsArgs, DiagnosticsResult, GrepArgs, GrepResult, LsArgs, LsResult, McpResult, PiBashExecArgs, PiBashExecResult, PiEditExecArgs, PiEditExecResult, PiFindExecArgs, PiFindExecResult, PiGrepExecArgs, PiGrepExecResult, PiLsExecArgs, PiLsExecResult, PiReadExecArgs, PiReadExecResult, PiWriteExecArgs, PiWriteExecResult, ReadArgs, ReadResult, ShellArgs, ShellResult, WriteArgs, WriteResult } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
4
+ import type { DeleteArgs, DeleteResult, DiagnosticsArgs, DiagnosticsResult, GrepArgs, GrepResult, LsArgs, LsResult, McpResult, PiBashExecArgs, PiBashExecResult, PiEditExecArgs, PiEditExecResult, PiFindExecArgs, PiFindExecResult, PiGrepExecArgs, PiGrepExecResult, PiLsExecArgs, PiLsExecResult, PiReadExecArgs, PiReadExecResult, PiWriteExecArgs, PiWriteExecResult, ReadArgs, ReadResult, ShellArgs, ShellResult, WriteArgs, WriteResult } from "@oh-my-pi/pi-catalog/discovery/cursor-proto";
5
5
  import type { Effort } from "@oh-my-pi/pi-catalog/effort";
6
6
  import type { Api, FetchImpl, Model, Provider, ThinkingBudgets, Usage } from "@oh-my-pi/pi-catalog/types";
7
7
  import type { ApiKey } from "./auth-retry.js";
@@ -4,11 +4,11 @@ export interface JsonSchemaValidationIssue {
4
4
  expectedTypes?: string[];
5
5
  keyword?: string;
6
6
  /**
7
- * Marks issues that originate inside a failed `anyOf` / `oneOf` branch.
8
- * Consumers such as the tool-argument coercion layer use this to avoid
9
- * applying type repairs (e.g. singleton-array wrapping) that would be
10
- * authoritative outside of a combinator but are only one candidate
11
- * branch's expectation here.
7
+ * Marks issues surfaced from a failed `anyOf` / `oneOf` branch (at any
8
+ * depth). Such a diagnosis is one candidate branch's guess, not
9
+ * authoritative: the tool-argument coercion layer keeps lossy repairs
10
+ * (container stringification, unrecognized-key deletion, singleton-array
11
+ * wrapping) off for these while still applying lossless ones.
12
12
  */
13
13
  fromUnionBranch?: boolean;
14
14
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "17.3.8",
4
+ "version": "17.4.0",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,14 +37,12 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@bufbuild/protobuf": "^2.12.1",
41
- "@oh-my-pi/omptype": "17.3.8",
42
- "@oh-my-pi/pi-catalog": "17.3.8",
43
- "@oh-my-pi/pi-utils": "17.3.8",
44
- "@oh-my-pi/pi-wire": "17.3.8"
40
+ "@oh-my-pi/omptype": "17.4.0",
41
+ "@oh-my-pi/pi-catalog": "17.4.0",
42
+ "@oh-my-pi/pi-utils": "17.4.0",
43
+ "@oh-my-pi/pi-wire": "17.4.0"
45
44
  },
46
45
  "devDependencies": {
47
- "@bufbuild/protoc-gen-es": "^2.12.1",
48
46
  "@types/bun": "^1.3.14"
49
47
  },
50
48
  "engines": {
@@ -730,19 +730,45 @@ async function handleCredentialsCheck(storage: AuthStorage, signal: AbortSignal)
730
730
  return json(200, { generatedAt: Date.now(), credentials });
731
731
  }
732
732
 
733
+ /**
734
+ * Row shape for `GET /v1/models`. Beyond the OpenAI-standard `id`/`object`/
735
+ * `owned_by`, rows advertise the catalog metadata OpenAI-compatible clients
736
+ * (omp's own proxy discovery, Zed's openai_compatible provider, ...) read to
737
+ * size and capability-gate discovered models: `context_length`,
738
+ * `max_output_tokens`, `input_modalities`, and `supports_tools` (only emitted
739
+ * when the catalog explicitly reports `false`; absent means usable).
740
+ */
741
+ interface ModelListRow {
742
+ id: string;
743
+ object: "model";
744
+ owned_by: string;
745
+ api: Api;
746
+ display_name: string;
747
+ context_length?: number;
748
+ max_output_tokens?: number;
749
+ input_modalities: ("text" | "image")[];
750
+ supports_tools?: boolean;
751
+ }
752
+
733
753
  function handleModelsList(opts: AuthGatewayBootOptions): Response {
734
754
  const seen = new Set<string>();
735
- const data: Array<{ id: string; object: "model"; owned_by: string; api: Api }> = [];
755
+ const data: ModelListRow[] = [];
736
756
  for (const model of opts.listModels?.() ?? []) {
737
757
  const id = `${model.provider}/${model.id}`;
738
758
  if (seen.has(id)) continue;
739
759
  seen.add(id);
740
- data.push({
760
+ const row: ModelListRow = {
741
761
  id,
742
762
  object: "model",
743
763
  owned_by: model.provider,
744
764
  api: model.api,
745
- });
765
+ display_name: model.name,
766
+ input_modalities: model.input,
767
+ };
768
+ if (model.contextWindow != null) row.context_length = model.contextWindow;
769
+ if (model.maxTokens != null) row.max_output_tokens = model.maxTokens;
770
+ if (model.supportsTools === false) row.supports_tools = false;
771
+ data.push(row);
746
772
  }
747
773
  return json(200, { object: "list", data });
748
774
  }
@@ -10,7 +10,6 @@
10
10
  * the server reads as "the tool ran and produced nothing".
11
11
  */
12
12
 
13
- import { create } from "@bufbuild/protobuf";
14
13
  import {
15
14
  AfterAgentResponseRequestResponseSchema,
16
15
  AfterAgentThoughtRequestResponseSchema,
@@ -64,7 +63,8 @@ import {
64
63
  StopRequestResponseSchema,
65
64
  SubagentStartRequestResponseSchema,
66
65
  SubagentStopRequestResponseSchema,
67
- } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
66
+ } from "@oh-my-pi/pi-catalog/discovery/cursor-proto";
67
+ import { create } from "@oh-my-pi/pi-catalog/discovery/protobuf";
68
68
  import type { ToolResultMessage } from "../../types";
69
69
 
70
70
  /**
@@ -1,5 +1,4 @@
1
1
  import type http2 from "node:http2";
2
- import { create, toBinary } from "@bufbuild/protobuf";
3
2
  import {
4
3
  AgentClientMessageSchema,
5
4
  AskQuestionInteractionResponseSchema,
@@ -21,7 +20,8 @@ import {
21
20
  WebFetchRequestResponseSchema,
22
21
  WebSearchRequestResponse_ApprovedSchema,
23
22
  WebSearchRequestResponseSchema,
24
- } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
23
+ } from "@oh-my-pi/pi-catalog/discovery/cursor-proto";
24
+ import { create, toBinary } from "@oh-my-pi/pi-catalog/discovery/protobuf";
25
25
  import { $env } from "@oh-my-pi/pi-utils";
26
26
 
27
27
  const NOT_IMPLEMENTED_SUFFIX = "not implemented by this client";
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * Kept apart from `cursor/exec-modern.ts` on purpose: these are pure
11
11
  * string/path functions with no protobuf coupling, while that module pulls in
12
- * `@bufbuild/protobuf` and the generated `agent_pb` graph. The legacy shim is
12
+ * the generated cursor protobuf graph. The legacy shim is
13
13
  * compiled into the bundled virtual module registry, so importing it from a
14
14
  * nested path would drag the whole exec implementation in with it — and
15
15
  * `./providers/*` is a single-segment wildcard export that cannot serve a
@@ -1,9 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import http2 from "node:http2";
4
- import { create, fromBinary, fromJson, type JsonValue, toBinary, toJson } from "@bufbuild/protobuf";
5
- import { ValueSchema } from "@bufbuild/protobuf/wkt";
6
- import type { ConversationStep, McpToolDefinition } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
4
+ import type { ConversationStep, McpToolDefinition } from "@oh-my-pi/pi-catalog/discovery/cursor-proto";
7
5
  import {
8
6
  AgentClientMessageSchema,
9
7
  AgentConversationTurnStructureSchema,
@@ -139,11 +137,21 @@ import {
139
137
  WriteShellStdinErrorSchema,
140
138
  WriteShellStdinResultSchema,
141
139
  WriteSuccessSchema,
142
- } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
140
+ } from "@oh-my-pi/pi-catalog/discovery/cursor-proto";
141
+ import {
142
+ create,
143
+ decodeJsonValue,
144
+ encodeJsonValue,
145
+ fromBinary,
146
+ type JsonValue,
147
+ toBinary,
148
+ toJson,
149
+ } from "@oh-my-pi/pi-catalog/discovery/protobuf";
143
150
  import { isKimiK3ModelId } from "@oh-my-pi/pi-catalog/identity";
144
151
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
145
152
  import {
146
153
  $env,
154
+ isRecord,
147
155
  logger,
148
156
  parseJsonWithRepair,
149
157
  parseStreamingJson,
@@ -3099,8 +3107,7 @@ function parseToolArgsJson(text: string): unknown {
3099
3107
 
3100
3108
  function decodeMcpArgValue(value: Uint8Array): unknown {
3101
3109
  try {
3102
- const parsedValue = fromBinary(ValueSchema, value);
3103
- const jsonValue = toJson(ValueSchema, parsedValue) as JsonValue;
3110
+ const jsonValue = decodeJsonValue(value);
3104
3111
  if (typeof jsonValue === "string") {
3105
3112
  return parseToolArgsJson(jsonValue);
3106
3113
  }
@@ -4212,10 +4219,10 @@ export function buildMcpToolDefinitions(tools: Tool[] | undefined): McpToolDefin
4212
4219
  return forwarded.map(tool => {
4213
4220
  const jsonSchema = toolWireSchema(tool);
4214
4221
  const schemaValue: JsonValue =
4215
- jsonSchema && typeof jsonSchema === "object"
4216
- ? (jsonSchema as JsonValue)
4222
+ jsonSchema !== null && !Array.isArray(jsonSchema) && isJsonValue(jsonSchema)
4223
+ ? jsonSchema
4217
4224
  : { type: "object", properties: {}, required: [] };
4218
- const inputSchema = toBinary(ValueSchema, fromJson(ValueSchema, schemaValue));
4225
+ const inputSchema = encodeJsonValue(schemaValue);
4219
4226
  return create(McpToolDefinitionSchema, {
4220
4227
  name: tool.name,
4221
4228
  description: tool.description || "",
@@ -4463,17 +4470,11 @@ function buildRootPromptMessagesJson(
4463
4470
  return entries;
4464
4471
  }
4465
4472
 
4466
- function isPlainRecord(value: unknown): value is Record<string, unknown> {
4467
- if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
4468
- const prototype = Object.getPrototypeOf(value);
4469
- return prototype === Object.prototype || prototype === null;
4470
- }
4471
-
4472
4473
  function isJsonValue(value: unknown): value is JsonValue {
4473
4474
  if (value === null || typeof value === "string" || typeof value === "boolean") return true;
4474
4475
  if (typeof value === "number") return Number.isFinite(value);
4475
4476
  if (Array.isArray(value)) return value.every(isJsonValue);
4476
- if (!isPlainRecord(value)) return false;
4477
+ if (!isRecord(value)) return false;
4477
4478
  for (const key in value) {
4478
4479
  if (!isJsonValue(value[key])) return false;
4479
4480
  }
@@ -4488,7 +4489,7 @@ function encodeCursorMcpArguments(toolCall: ToolCall): Record<string, Uint8Array
4488
4489
  if (!isJsonValue(value)) {
4489
4490
  throw new AIError.ValidationError(`Cursor tool argument ${toolCall.name}.${name} is not JSON-serializable`);
4490
4491
  }
4491
- encoded[name] = toBinary(ValueSchema, fromJson(ValueSchema, value));
4492
+ encoded[name] = encodeJsonValue(value);
4492
4493
  }
4493
4494
  return encoded;
4494
4495
  }
@@ -1,32 +1,26 @@
1
1
  import { gunzipSync, gzipSync } from "node:zlib";
2
- import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
3
- import {
4
- ChatMessageRequestType,
5
- GetChatMessageRequestSchema,
6
- GetChatMessageResponseSchema,
7
- } from "@oh-my-pi/pi-catalog/discovery/devin-gen/exa/api_server_pb/api_server_pb";
8
- import {
9
- GetUserJwtRequestSchema,
10
- GetUserJwtResponseSchema,
11
- } from "@oh-my-pi/pi-catalog/discovery/devin-gen/exa/auth_pb/auth_pb";
12
2
  import {
13
3
  CacheControlType,
14
4
  type ChatMessagePrompt,
15
5
  ChatMessagePromptSchema,
16
- ChatToolChoiceSchema,
17
- ChatToolDefinitionSchema,
18
- PromptCacheOptionsSchema,
19
- } from "@oh-my-pi/pi-catalog/discovery/devin-gen/exa/chat_pb/chat_pb";
20
- import {
6
+ ChatMessageRequestType,
21
7
  ChatMessageSource,
22
8
  type ChatToolCall,
23
9
  ChatToolCallSchema,
10
+ ChatToolChoiceSchema,
11
+ ChatToolDefinitionSchema,
24
12
  CompletionConfigurationSchema,
25
13
  ConversationalPlannerMode,
14
+ GetChatMessageRequestSchema,
15
+ GetChatMessageResponseSchema,
16
+ GetUserJwtRequestSchema,
17
+ GetUserJwtResponseSchema,
26
18
  ImageDataSchema,
27
19
  MetadataSchema,
20
+ PromptCacheOptionsSchema,
28
21
  StopReason,
29
- } from "@oh-my-pi/pi-catalog/discovery/devin-gen/exa/codeium_common_pb/codeium_common_pb";
22
+ } from "@oh-my-pi/pi-catalog/discovery/devin-proto";
23
+ import { create, fromBinary, toBinary } from "@oh-my-pi/pi-catalog/discovery/protobuf";
30
24
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
31
25
  import { logger, parseStreamingJson, parseStreamingJsonThrottled } from "@oh-my-pi/pi-utils";
32
26
  import * as AIError from "../error";
@@ -106,6 +106,7 @@ import {
106
106
  resolveOpenAICompletionsOutputClamp,
107
107
  resolveOpenAIOutputTokenParam,
108
108
  resolveOpenAIRequestSetup,
109
+ shouldDropAutoToolChoiceForReasoning,
109
110
  shouldRetryWithoutStrictTools,
110
111
  } from "./openai-shared";
111
112
  import { transformMessages } from "./transform-messages";
@@ -1692,6 +1693,10 @@ function buildParams(
1692
1693
  delete params.tool_choice;
1693
1694
  }
1694
1695
 
1696
+ if (shouldDropAutoToolChoiceForReasoning(model, initialCompat, params.tool_choice, options)) {
1697
+ delete params.tool_choice;
1698
+ }
1699
+
1695
1700
  const finalPolicy = resolveOpenAICompatPolicy(model, {
1696
1701
  endpoint: "chat-completions",
1697
1702
  reasoning: options?.reasoning,
@@ -1,8 +1,17 @@
1
1
  import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
2
2
  import type { CapturedHttpErrorResponse } from "../utils/http-inspector";
3
3
 
4
+ /**
5
+ * Fallback marker: the server rejected the `chat_template_kwargs.reasoning_effort`
6
+ * spelling itself (strict kwargs whitelists — Ninfer-style servers), not the
7
+ * effort value. Apply strips the kwarg and hoists the value onto the top-level
8
+ * `reasoning_effort` field when that spelling is absent.
9
+ * @internal
10
+ */
11
+ export const STRIP_TEMPLATE_KWARG_REASONING_EFFORT = Symbol("strip-template-kwarg-reasoning-effort");
12
+
4
13
  /** @internal */
5
- export type OpenAIReasoningEffortFallback = string | null;
14
+ export type OpenAIReasoningEffortFallback = string | null | typeof STRIP_TEMPLATE_KWARG_REASONING_EFFORT;
6
15
 
7
16
  /** @internal */
8
17
  export interface OpenAIReasoningEffortFallbackState {
@@ -73,7 +82,23 @@ export function readOpenAIReasoningEffort(params: unknown): string | undefined {
73
82
  if (!isRecord(params)) return undefined;
74
83
  if (typeof params.reasoning_effort === "string") return params.reasoning_effort;
75
84
  const reasoning = params.reasoning;
76
- return isRecord(reasoning) && typeof reasoning.effort === "string" ? reasoning.effort : undefined;
85
+ if (isRecord(reasoning) && typeof reasoning.effort === "string") return reasoning.effort;
86
+ return readTemplateKwargReasoningEffort(params);
87
+ }
88
+
89
+ function readTemplateKwargReasoningEffort(params: Record<string, unknown>): string | undefined {
90
+ const kwargs = params.chat_template_kwargs;
91
+ return isRecord(kwargs) && typeof kwargs.reasoning_effort === "string" ? kwargs.reasoning_effort : undefined;
92
+ }
93
+
94
+ /** Remove `chat_template_kwargs.reasoning_effort`, dropping the kwargs object when it becomes empty. */
95
+ function deleteTemplateKwargReasoningEffort(kwargs: Record<string, unknown>, parent: Record<string, unknown>): void {
96
+ delete kwargs.reasoning_effort;
97
+ for (const key in kwargs) {
98
+ void key;
99
+ return;
100
+ }
101
+ delete parent.chat_template_kwargs;
77
102
  }
78
103
 
79
104
  function deleteReasoningEffort(reasoning: Record<string, unknown>, parent: Record<string, unknown>): boolean {
@@ -89,6 +114,17 @@ function deleteReasoningEffort(reasoning: Record<string, unknown>, parent: Recor
89
114
  /** @internal */
90
115
  export function applyOpenAIReasoningEffortFallback(params: unknown, fallback: OpenAIReasoningEffortFallback): boolean {
91
116
  if (!isRecord(params)) return false;
117
+ if (fallback === STRIP_TEMPLATE_KWARG_REASONING_EFFORT) {
118
+ const kwargs = params.chat_template_kwargs;
119
+ if (!isRecord(kwargs) || typeof kwargs.reasoning_effort !== "string") return false;
120
+ const effort = kwargs.reasoning_effort;
121
+ deleteTemplateKwargReasoningEffort(kwargs, params);
122
+ // The `qwen-chat-template` dialect rides kwargs alone; keep the effort
123
+ // selection alive on the standard OpenAI field (Ninfer-style servers
124
+ // accept it, vLLM-style renderers ignore it).
125
+ if (typeof params.reasoning_effort !== "string") params.reasoning_effort = effort;
126
+ return true;
127
+ }
92
128
  let changed = false;
93
129
  if (typeof params.reasoning_effort === "string") {
94
130
  if (fallback === null) {
@@ -107,6 +143,17 @@ export function applyOpenAIReasoningEffortFallback(params: unknown, fallback: Op
107
143
  changed = true;
108
144
  }
109
145
  }
146
+ // Keep the Qwen template kwarg twin in lockstep — a value remap or drop
147
+ // must not leave a stale effort for kwargs-reading renderers.
148
+ const kwargs = params.chat_template_kwargs;
149
+ if (isRecord(kwargs) && typeof kwargs.reasoning_effort === "string") {
150
+ if (fallback === null) {
151
+ deleteTemplateKwargReasoningEffort(kwargs, params);
152
+ } else {
153
+ kwargs.reasoning_effort = fallback;
154
+ }
155
+ changed = true;
156
+ }
110
157
  return changed;
111
158
  }
112
159
 
@@ -165,13 +212,17 @@ function isInvalidReasoningEffortError(
165
212
  if (/reasoning[_ ]content/i.test(message) && !REASONING_EFFORT_FIELD_PATTERN.test(message)) return false;
166
213
  if (/invalid[^\n]*(?:reasoning[_. ]effort|reasoning value)/i.test(message)) return true;
167
214
  if (
168
- /(?:reasoning[_. ]effort|reasoning value)[^\n]*(?:invalid|unsupported|not supported|must be|expected)/i.test(
215
+ /(?:reasoning[_. ]effort|reasoning value)[^\n]*(?:invalid|unsupported|not supported|not permitted|must be|expected|unknown|unexpected|unrecognized)/i.test(
169
216
  message,
170
217
  )
171
218
  ) {
172
219
  return true;
173
220
  }
174
- if (/(?:unsupported|not supported)[^\n]*(?:reasoning[_. ]effort|reasoning value)/i.test(message)) {
221
+ if (
222
+ /(?:unsupported|not supported|not permitted|unknown|unexpected|unrecognized|extra)[^\n]*(?:reasoning[_. ]effort|reasoning value)/i.test(
223
+ message,
224
+ )
225
+ ) {
175
226
  return true;
176
227
  }
177
228
  // Gateways put the rejected value first (`level "none" not supported`), the
@@ -258,6 +309,35 @@ function nearestEnabledReasoningFallback(currentEffort: string, allowed: Set<str
258
309
  return best;
259
310
  }
260
311
 
312
+ /**
313
+ * Text that identifies a rejection of the kwargs spelling itself: the server
314
+ * names `chat_template_kwargs` together with `reasoning_effort` (Ninfer-style
315
+ * strict kwargs whitelists: `chat_template_kwargs.reasoning_effort is not
316
+ * supported`).
317
+ */
318
+ const TEMPLATE_KWARG_EFFORT_PATTERN =
319
+ /chat_template_kwargs[^\n]{0,120}reasoning[_. ]effort|reasoning[_. ]effort[^\n]{0,120}chat_template_kwargs/i;
320
+ const FIELD_REJECTION_PATTERN =
321
+ /invalid|unsupported|not supported|not permitted|unknown|unexpected|unrecognized|rejected|extra input/i;
322
+
323
+ function resolveStripTemplateKwargFallback(
324
+ error: unknown,
325
+ captured: CapturedHttpErrorResponse | undefined,
326
+ params: unknown,
327
+ ): typeof STRIP_TEMPLATE_KWARG_REASONING_EFFORT | undefined {
328
+ if (!isRecord(params)) return undefined;
329
+ const effort = readTemplateKwargReasoningEffort(params);
330
+ if (effort === undefined) return undefined;
331
+ const status = extractHttpStatusFromError(error) ?? captured?.status;
332
+ if (status !== 400 && status !== 422) return undefined;
333
+ const message = collectMessageParts(error, captured);
334
+ if (!TEMPLATE_KWARG_EFFORT_PATTERN.test(message) || !FIELD_REJECTION_PATTERN.test(message)) return undefined;
335
+ // A value-level rejection listing allowed levels wants the value remapped
336
+ // (in every spelling) by the ordinary flow, not the kwarg stripped.
337
+ if (parseAllowedReasoningValues(message, effort) !== undefined) return undefined;
338
+ return STRIP_TEMPLATE_KWARG_REASONING_EFFORT;
339
+ }
340
+
261
341
  /** @internal */
262
342
  export function resolveOpenAIReasoningEffortFallback(
263
343
  error: unknown,
@@ -265,6 +345,8 @@ export function resolveOpenAIReasoningEffortFallback(
265
345
  params: unknown,
266
346
  options?: { explicitDisable?: boolean },
267
347
  ): OpenAIReasoningEffortFallback | undefined {
348
+ const strip = resolveStripTemplateKwargFallback(error, captured, params);
349
+ if (strip !== undefined) return strip;
268
350
  const currentEffort = readOpenAIReasoningEffort(params);
269
351
  if (!currentEffort || !KNOWN_REASONING_VALUE[currentEffort.toLowerCase()]) return undefined;
270
352
  if (!isInvalidReasoningEffortError(error, captured, currentEffort)) return undefined;
@@ -99,6 +99,7 @@ import {
99
99
  resolveOpenAIOutputTokenParam,
100
100
  resolveOpenAIRequestSetup,
101
101
  resolveOpenAIResponsesOutputClamp,
102
+ shouldDropAutoToolChoiceForReasoning,
102
103
  shouldRetryWithoutStrictTools,
103
104
  } from "./openai-shared";
104
105
 
@@ -1276,6 +1277,10 @@ export function buildParams(
1276
1277
  }
1277
1278
  }
1278
1279
 
1280
+ if (shouldDropAutoToolChoiceForReasoning(model, model.compat, params.tool_choice, options)) {
1281
+ delete params.tool_choice;
1282
+ }
1283
+
1279
1284
  const reasoningPolicy = resolveOpenAICompatPolicy(model, {
1280
1285
  endpoint: "responses",
1281
1286
  reasoning: options?.reasoning,
@@ -844,6 +844,29 @@ function isImplicitDisableWhenNotRequested(disableMode: OpenAIReasoningDisableMo
844
844
  );
845
845
  }
846
846
 
847
+ /**
848
+ * Whether a redundant `tool_choice: "auto"` should be dropped to keep
849
+ * reasoning alive. Hosts with `disableReasoningOnToolChoice` (DeepSeek family
850
+ * on e.g. Fireworks) silently turn reasoning off whenever any `tool_choice`
851
+ * is present. "auto" is already the provider default, so omitting it is
852
+ * wire-neutral for tool selection; forced and "none" choices are semantic and
853
+ * still win over reasoning (#1207).
854
+ */
855
+ export function shouldDropAutoToolChoiceForReasoning(
856
+ model: Pick<Model, "reasoning">,
857
+ compat: { disableReasoningOnToolChoice: boolean },
858
+ toolChoice: unknown,
859
+ options: { reasoning?: string; disableReasoning?: boolean } | undefined,
860
+ ): boolean {
861
+ return (
862
+ toolChoice === "auto" &&
863
+ compat.disableReasoningOnToolChoice &&
864
+ Boolean(model.reasoning) &&
865
+ options?.reasoning !== undefined &&
866
+ !options.disableReasoning
867
+ );
868
+ }
869
+
847
870
  export function resolveOpenAICompatPolicy<TApi extends Api>(
848
871
  model: Model<TApi>,
849
872
  options: ResolveOpenAICompatPolicyOptions,
package/src/stream.ts CHANGED
@@ -40,7 +40,7 @@ import type { OpenAICompletionsOptions } from "./providers/openai-completions";
40
40
  import { streamPiNative } from "./providers/pi-native-client";
41
41
  // Heavy provider stream functions are imported lazily via register-builtins,
42
42
  // which wraps each provider module in a dynamic import. This keeps the
43
- // AWS SDK, google-auth-library, @google/genai, @bufbuild/protobuf, and
43
+ // AWS SDK, google-auth-library, @google/genai, and
44
44
  // other provider SDKs out of the CLI startup parse graph. The
45
45
  // gitlab-duo / kimi / synthetic providers stay eager because their modules
46
46
  // export routing predicates (isGitLabDuoModel, isKimiModel, isSyntheticModel)
package/src/types.ts CHANGED
@@ -32,7 +32,7 @@ import type {
32
32
  ShellResult,
33
33
  WriteArgs,
34
34
  WriteResult,
35
- } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
35
+ } from "@oh-my-pi/pi-catalog/discovery/cursor-proto";
36
36
  import type { Effort } from "@oh-my-pi/pi-catalog/effort";
37
37
  import { isOpenAIModelId } from "@oh-my-pi/pi-catalog/identity/family";
38
38
  import type { Api, FetchImpl, KnownApi, Model, Provider, ThinkingBudgets, Usage } from "@oh-my-pi/pi-catalog/types";
@@ -21,11 +21,11 @@ export interface JsonSchemaValidationIssue {
21
21
  expectedTypes?: string[];
22
22
  keyword?: string;
23
23
  /**
24
- * Marks issues that originate inside a failed `anyOf` / `oneOf` branch.
25
- * Consumers such as the tool-argument coercion layer use this to avoid
26
- * applying type repairs (e.g. singleton-array wrapping) that would be
27
- * authoritative outside of a combinator but are only one candidate
28
- * branch's expectation here.
24
+ * Marks issues surfaced from a failed `anyOf` / `oneOf` branch (at any
25
+ * depth). Such a diagnosis is one candidate branch's guess, not
26
+ * authoritative: the tool-argument coercion layer keeps lossy repairs
27
+ * (container stringification, unrecognized-key deletion, singleton-array
28
+ * wrapping) off for these while still applying lossless ones.
29
29
  */
30
30
  fromUnionBranch?: boolean;
31
31
  }
@@ -68,6 +68,35 @@ function getValueIdentity(ctx: ValidationContext, value: object): number {
68
68
  function isJsonObject(value: unknown): value is Record<string, unknown> {
69
69
  return typeof value === "object" && value !== null && !Array.isArray(value);
70
70
  }
71
+ /**
72
+ * Whether `value` matches every `const`/`enum` discriminator property the
73
+ * branch declares (with at least one such property present and matching).
74
+ * A uniquely tag-selected branch's validation issues are authoritative — the
75
+ * model named its intended variant — so the coercion layer may apply lossy
76
+ * repairs to them; without a unique tag, branch issues are guesses.
77
+ */
78
+ function isTagSelectedBranch(branch: unknown, value: unknown): boolean {
79
+ if (!isJsonObject(branch) || !isJsonObject(value)) return false;
80
+ const props = branch.properties;
81
+ if (!isJsonObject(props)) return false;
82
+ let matched = false;
83
+ for (const key in props) {
84
+ const propSchema = props[key];
85
+ if (!isJsonObject(propSchema)) continue;
86
+ const hasConst = Object.hasOwn(propSchema, "const");
87
+ const enumValues = Array.isArray(propSchema.enum) ? propSchema.enum : undefined;
88
+ if (!hasConst && !enumValues) continue;
89
+ if (!Object.hasOwn(value, key)) return false;
90
+ const candidate = value[key];
91
+ if (hasConst) {
92
+ if (!areJsonValuesEqual(candidate, propSchema.const)) return false;
93
+ } else if (enumValues && !enumValues.some(entry => areJsonValuesEqual(entry, candidate))) {
94
+ return false;
95
+ }
96
+ matched = true;
97
+ }
98
+ return matched;
99
+ }
71
100
 
72
101
  function pushIssue(
73
102
  issues: JsonSchemaValidationIssue[],
@@ -239,27 +268,35 @@ function validateSchemaNode(
239
268
 
240
269
  let matches = 0;
241
270
  let firstIssues: JsonSchemaValidationIssue[] | undefined;
271
+ let selectedIssues: JsonSchemaValidationIssue[] | undefined;
272
+ let selectedCount = 0;
242
273
  for (const branch of branches) {
243
274
  const branchIssues: JsonSchemaValidationIssue[] = [];
244
275
  if (validateSchemaNode(branch, value, path, ctx, branchIssues)) {
245
276
  matches += 1;
246
- } else if (!firstIssues) {
247
- firstIssues = branchIssues;
277
+ continue;
278
+ }
279
+ if (!firstIssues) firstIssues = branchIssues;
280
+ if (isTagSelectedBranch(branch, value)) {
281
+ selectedCount += 1;
282
+ if (selectedCount === 1) selectedIssues = branchIssues;
248
283
  }
249
284
  }
250
285
  const branchValid = keyword === "anyOf" ? matches > 0 : matches === 1;
251
286
  if (!branchValid) {
252
- if (matches === 0 && firstIssues && firstIssues.length > 0) {
253
- // Only tag issues that sit at the combinator's own path as
254
- // union-branch; deeper issues describe a specific field within
255
- // the failed branch and should remain individually repairable.
256
- const unionDepth = path.length;
287
+ if (matches === 0 && selectedCount === 1 && selectedIssues && selectedIssues.length > 0) {
288
+ // A const/enum discriminator uniquely identifies the intended
289
+ // variant, so its diagnosis is authoritative: surface untagged and
290
+ // keep every repair (including lossy ones) available.
291
+ issues.push(...selectedIssues);
292
+ } else if (matches === 0 && firstIssues && firstIssues.length > 0) {
293
+ // No variant matched and no tag picks one: everything reported is
294
+ // the first failing branch's guess — another variant may accept the
295
+ // value as-is. Surface all issues (deep ones remain individually
296
+ // repairable by lossless coercions) but mark their provenance so
297
+ // lossy repairs (stringify, key deletion, singleton wrap) stay off.
257
298
  for (const branchIssue of firstIssues) {
258
- if (branchIssue.path.length === unionDepth) {
259
- issues.push({ ...branchIssue, fromUnionBranch: true });
260
- } else {
261
- issues.push(branchIssue);
262
- }
299
+ issues.push(branchIssue.fromUnionBranch ? branchIssue : { ...branchIssue, fromUnionBranch: true });
263
300
  }
264
301
  } else {
265
302
  pushIssue(
@@ -136,12 +136,20 @@ function tryCoerceBooleanToNumber(value: unknown, expectedTypes: string[]): { va
136
136
  return { value: value ? 1 : 0, changed: true };
137
137
  }
138
138
 
139
- function tryCoerceString(value: unknown, expectedTypes: string[]): { value: unknown; changed: boolean } {
139
+ function tryCoerceString(
140
+ value: unknown,
141
+ expectedTypes: string[],
142
+ allowLossy: boolean,
143
+ ): { value: unknown; changed: boolean } {
140
144
  if (!expectedTypes.includes("string") || typeof value === "string" || value === null || value === undefined) {
141
145
  return { value, changed: false };
142
146
  }
143
147
 
144
148
  if (Array.isArray(value) || typeof value === "object") {
149
+ // JSON.stringify is irreversible (downstream consumers receive encoded
150
+ // text where they expected structure), so it requires an authoritative
151
+ // diagnosis — never a union-branch guess.
152
+ if (!allowLossy) return { value, changed: false };
145
153
  try {
146
154
  const stringified = JSON.stringify(value);
147
155
  if (stringified === undefined) return { value, changed: false };
@@ -158,7 +166,16 @@ function tryCoerceString(value: unknown, expectedTypes: string[]): { value: unkn
158
166
  return { value: String(value), changed: true };
159
167
  }
160
168
 
161
- function tryCoerceForExpectedTypes(value: unknown, expectedTypes: string[]): { value: unknown; changed: boolean } {
169
+ /**
170
+ * Schema-directed value repair for a single type issue. `allowLossy` gates the
171
+ * irreversible repairs (container→string stringification); lossless repairs
172
+ * (JSON parsing, boolean spellings, scalar stringification) always apply.
173
+ */
174
+ function tryCoerceForExpectedTypes(
175
+ value: unknown,
176
+ expectedTypes: string[],
177
+ allowLossy: boolean,
178
+ ): { value: unknown; changed: boolean } {
162
179
  if (typeof value === "string") {
163
180
  const parsed = tryParseJsonForTypes(value, expectedTypes);
164
181
  if (parsed.changed) return parsed;
@@ -171,7 +188,7 @@ function tryCoerceForExpectedTypes(value: unknown, expectedTypes: string[]): { v
171
188
  const numericCoercion = tryCoerceBooleanToNumber(value, expectedTypes);
172
189
  if (numericCoercion.changed) return numericCoercion;
173
190
 
174
- return tryCoerceString(value, expectedTypes);
191
+ return tryCoerceString(value, expectedTypes, allowLossy);
175
192
  }
176
193
 
177
194
  function tryParseLeadingJsonContainer(value: string): unknown | undefined {
@@ -1576,9 +1593,11 @@ function coerceArgsFromIssues(args: unknown, issues: FlatIssue[]): { value: unkn
1576
1593
  // a type coercion actually needs to write into a leaf.
1577
1594
  let owned = false;
1578
1595
  let nextArgs: unknown = args;
1579
-
1580
1596
  for (const issue of issues) {
1597
+ // Failed union branches still contribute schema-directed type repairs.
1598
+ // Container-to-string conversion remains enabled for string branches.
1581
1599
  if (issue.keyword === "unrecognized") {
1600
+ if (issue.unionBranch) continue;
1582
1601
  const previous = nextArgs;
1583
1602
  nextArgs = deleteValueAtPointer(nextArgs, issue.instancePath);
1584
1603
  if (nextArgs !== previous) changed = true;
@@ -1588,7 +1607,7 @@ function coerceArgsFromIssues(args: unknown, issues: FlatIssue[]): { value: unkn
1588
1607
  if (issue.expectedTypes.length === 0) continue;
1589
1608
 
1590
1609
  const currentValue = getValueAtPointer(nextArgs, issue.instancePath);
1591
- const result = tryCoerceForExpectedTypes(currentValue, issue.expectedTypes);
1610
+ const result = tryCoerceForExpectedTypes(currentValue, issue.expectedTypes, true);
1592
1611
  let coercedValue = result.changed ? result.value : undefined;
1593
1612
  if (
1594
1613
  coercedValue === undefined &&
@@ -1,6 +0,0 @@
1
- version: v2
2
- plugins:
3
- - local: /work/pi/node_modules/.bin/protoc-gen-es
4
- out: ../gen
5
- opt:
6
- - target=ts
@@ -1,17 +0,0 @@
1
- version: v2
2
- modules:
3
- - path: .
4
- lint:
5
- use:
6
- - STANDARD
7
- except:
8
- - COMMENT_FIELD
9
- - COMMENT_MESSAGE
10
- - COMMENT_ENUM
11
- - COMMENT_ENUM_VALUE
12
- - COMMENT_RPC
13
- - COMMENT_SERVICE
14
- - COMMENT_ONEOF
15
- breaking:
16
- use:
17
- - FILE
@@ -1,33 +0,0 @@
1
- version: v2
2
- # Codegen is scoped to the api_server.proto transitive closure (the RPCs the
3
- # devin provider actually uses). The rest of the reverse-engineered Codeium
4
- # corpus is kept in this directory for reference/maintenance but is NOT
5
- # generated. Vendored google/* WKTs are intentionally absent — buf bundles them.
6
- inputs:
7
- - directory: .
8
- paths:
9
- - buf/validate/validate.proto
10
- - exa/analytics_pb/analytics.proto
11
- - exa/api_server_pb/api_server.proto
12
- - exa/auth_pb/auth.proto
13
- - exa/auto_cascade_common_pb/auto_cascade_common.proto
14
- - exa/bug_checker_pb/bug_checker.proto
15
- - exa/cascade_plugins_pb/cascade_plugins.proto
16
- - exa/chat_pb/chat.proto
17
- - exa/code_edit/code_edit_pb/code_edit.proto
18
- - exa/codeium_common_pb/codeium_common.proto
19
- - exa/context_module_pb/context_module.proto
20
- - exa/cortex_pb/cortex.proto
21
- - exa/diff_action_pb/diff_action.proto
22
- - exa/index_pb/index.proto
23
- - exa/knowledge_base_pb/knowledge_base.proto
24
- - exa/language_server_pb/language_server.proto
25
- - exa/opensearch_clients_pb/opensearch_clients.proto
26
- - exa/prompt_pb/prompt.proto
27
- - exa/reactive_component_pb/reactive_component.proto
28
- - exa/trust_pb/trust.proto
29
- plugins:
30
- - local: ../../../../../../node_modules/.bin/protoc-gen-es
31
- out: ../../../../../catalog/src/discovery/devin-gen
32
- opt:
33
- - target=ts
@@ -1,17 +0,0 @@
1
- version: v2
2
- modules:
3
- - path: .
4
- lint:
5
- use:
6
- - STANDARD
7
- except:
8
- - COMMENT_FIELD
9
- - COMMENT_MESSAGE
10
- - COMMENT_ENUM
11
- - COMMENT_ENUM_VALUE
12
- - COMMENT_RPC
13
- - COMMENT_SERVICE
14
- - COMMENT_ONEOF
15
- breaking:
16
- use:
17
- - FILE