@oh-my-pi/pi-ai 17.0.3 → 17.0.5

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,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.5] - 2026-07-18
6
+
7
+ ### Changed
8
+
9
+ - Changed Anthropic API-key requests to default to a 1-hour prompt-cache retention (using the extended-cache-ttl-2025-04-11 beta) to prevent cold-misses during idle sessions, with support for PI_CACHE_RETENTION values "short" and "none" to override this behavior.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed transient OpenAI stream truncations by retrying once before output becomes replay-unsafe, preventing recoverable transport errors from failing the turn.
14
+ - Fixed native Kimi Code K3 thinking being disabled during named function selection by utilizing generic required tool choice.
15
+ - Fixed /login moonshot validating China-platform API keys against the international host instead of honoring MOONSHOT_BASE_URL.
16
+ - Fixed Anthropic session stickiness suppressing usage-based re-ranking indefinitely by gating stickiness on a 1-hour cache warmth window (configurable via ANTHROPIC_SESSION_STICKY_CACHE_WARM_MS) to restore proactive multi-account load balancing after long idle periods.
17
+ - Fixed credential ranking where clockless Anthropic usage windows incorrectly outranked clocked sibling credentials.
18
+ - Fixed tool request failures (HTTP 400) on local grammar-constrained OpenAI-compatible backends (such as llama.cpp, LM Studio, and vLLM) by widening bare boolean subschemas into a value-accepting primitive union.
19
+ - Fixed custom OAuth Anthropic-compatible endpoints receiving generated Claude Code fingerprint headers even when explicit header overrides were provided.
20
+ - Fixed active sessions for plan-gated OpenAI Codex models (Sol/Luna) silently re-routing to sibling OAuth accounts when usage headroom changed, ensuring session stickiness is preserved as long as the preferred credential remains usable and eligible.
21
+
22
+ ## [17.0.4] - 2026-07-18
23
+
24
+ ### Fixed
25
+
26
+ - Fixed Kimi Code usage reports dropping the 5h window reset time (`omp usage` showed no "resets in …" for the 5h limit): the API returns `resetTime` on the limit `detail`, not on `window`, so the parsed row-level reset is now carried onto the window when the window itself has none.
27
+ - Made Kimi device-id persistence best-effort: a missing or unwritable `~/.omp/agent` directory no longer throws during Kimi header construction, which silently nulled every `kimi-code` usage probe on fresh installs.
28
+ - Coerced boolean tool-schema subschemas to MFJS object forms for native Moonshot/Kimi endpoints, preventing the task tool's `outputSchema` field from causing HTTP 400 responses ([#5952](https://github.com/can1357/oh-my-pi/issues/5952)).
29
+
5
30
  ## [17.0.3] - 2026-07-17
6
31
 
7
32
  ### Fixed
@@ -11,6 +11,8 @@ export type AnthropicHeaderOptions = {
11
11
  isCloudflareAiGateway?: boolean;
12
12
  claudeCodeSessionId?: string;
13
13
  claudeCodeBetas?: readonly string[];
14
+ /** Allow explicit fingerprint headers to replace OAuth defaults on non-official endpoints. */
15
+ allowAnthropicHeaderOverrides?: boolean;
14
16
  };
15
17
  export declare function normalizeAnthropicBaseUrl(baseUrl?: string): string | undefined;
16
18
  export declare function buildBetaHeader(baseBetas: readonly string[], extraBetas: readonly string[]): string;
@@ -155,6 +155,8 @@ export declare function getOpenAICodexTransportDetails(model: Model<"openai-code
155
155
  preferWebsockets?: boolean;
156
156
  providerSessionState?: Map<string, ProviderSessionState>;
157
157
  }): OpenAICodexTransportDetails;
158
+ /** Resolve a Codex Responses endpoint exactly as the chat and compaction transports do. */
159
+ export declare function resolveCodexResponsesUrl(baseUrl: string | undefined): string;
158
160
  declare function convertMessages(model: Model<"openai-codex-responses">, context: Context): ResponseInput;
159
161
  /** @internal Exported for tests. */
160
162
  export { convertMessages as convertCodexResponsesMessages };
@@ -21,7 +21,7 @@ type AnthropicMessagesValidation = {
21
21
  type ModelsEndpointValidation = {
22
22
  kind: "models-endpoint";
23
23
  provider: string;
24
- modelsUrl: string;
24
+ modelsUrl: string | (() => string);
25
25
  headers?: Record<string, string> | (() => Record<string, string> | undefined);
26
26
  };
27
27
  export type ApiKeyLoginConfig = {
@@ -2,8 +2,11 @@ import { type DescriptionSpillFormat } from "./spill.js";
2
2
  import { type JsonObject } from "./types.js";
3
3
  export type ResidualSchemaIncompatibility = "type-array" | "type-null" | "nullable" | "combiners" | "not";
4
4
  export interface NormalizeSchemaOptions {
5
- /** Coerce JSON Schema boolean subschemas for providers whose wire cannot encode them. */
6
- coerceBooleanSubschemas?: boolean;
5
+ /**
6
+ * Coerce boolean subschemas to object forms. `standard` preserves `false`
7
+ * with `not`; `permissive` uses `{}` when the provider cannot express it.
8
+ */
9
+ coerceBooleanSubschemas?: "standard" | "permissive";
7
10
  unsupportedFields: (key: string) => boolean;
8
11
  normalizeFieldNames: boolean;
9
12
  collapseNullFields: boolean;
@@ -62,6 +65,9 @@ export declare function normalizeSchemaForMCP(value: unknown): unknown;
62
65
  * `default` and `description` are MFJS Meta Data fields and are preserved.
63
66
  * - `additionalProperties` (boolean or schema) and `type: "null"` (incl.
64
67
  * inside `anyOf`) are kept.
68
+ * - Boolean subschemas are object-coerced; MFJS has no exact `false` schema,
69
+ * so both values become the permissive empty schema while local tool
70
+ * validation remains authoritative.
65
71
  *
66
72
  * Out of scope (absent from the built-in tool surface, spec-ambiguous to
67
73
  * rewrite blindly): `allOf` intersection merging, external/recursive `$ref`,
@@ -73,6 +79,21 @@ export declare function normalizeSchemaForMoonshot(value: unknown): unknown;
73
79
  * cannot unmarshal into its object-shaped `Schema` struct.
74
80
  */
75
81
  export declare function sanitizeSchemaForOllama(schema: JsonObject): JsonObject;
82
+ /**
83
+ * Rewrites the one JSON Schema form that grammar-constrained OpenAI-compatible
84
+ * backends (llama.cpp, LM Studio, vLLM) cannot compile to GBNF: a bare boolean
85
+ * subschema. `toolWireSchema` normalizes `{}` open subschemas to boolean `true`
86
+ * (issue #1179); llama.cpp's `json-schema-to-grammar.cpp` `visit()` has no case
87
+ * for a boolean schema and throws `Unrecognized schema: true` → HTTP 400 before
88
+ * the model is consulted (issue #5914).
89
+ *
90
+ * Narrower than {@link sanitizeSchemaForOllama}: only genuine subschema slots
91
+ * are widened. Boolean `additionalProperties`/`unevaluatedProperties` stay
92
+ * intact because the converter reads those as closed/open-object grammar
93
+ * semantics, and dropping `additionalProperties: false` would silently reopen
94
+ * every declared object.
95
+ */
96
+ export declare function sanitizeSchemaForGrammar(schema: JsonObject): JsonObject;
76
97
  /**
77
98
  * OpenAI Responses rejects `oneOf` in tool schemas even when strict mode is
78
99
  * disabled, and rejects every schema node with `type: "object"` unless it has
@@ -33,7 +33,8 @@ export declare function createOpenAIResponsesHistoryPayload(provider: string, it
33
33
  export declare function getOpenAIResponsesHistoryPayload(providerPayload: ProviderPayload | undefined, currentProvider: string, fallbackProvider?: string): OpenAIResponsesHistoryPayload | undefined;
34
34
  export declare function getOpenAIResponsesHistoryItems(providerPayload: ProviderPayload | undefined, currentProvider: string, fallbackProvider?: string): Array<Record<string, unknown>> | undefined;
35
35
  /**
36
- * Resolve cache retention preference.
37
- * Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility.
36
+ * Resolve cache retention preference: explicit request option first, then the
37
+ * `PI_CACHE_RETENTION` env override (`long` | `short` | `none`), then the
38
+ * provider-supplied fallback.
38
39
  */
39
- export declare function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention;
40
+ export declare function resolveCacheRetention(cacheRetention?: CacheRetention, fallback?: CacheRetention): CacheRetention;
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.0.3",
4
+ "version": "17.0.5",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.1",
41
- "@oh-my-pi/pi-catalog": "17.0.3",
42
- "@oh-my-pi/pi-utils": "17.0.3",
43
- "@oh-my-pi/pi-wire": "17.0.3",
41
+ "@oh-my-pi/pi-catalog": "17.0.5",
42
+ "@oh-my-pi/pi-utils": "17.0.5",
43
+ "@oh-my-pi/pi-wire": "17.0.5",
44
44
  "arktype": "2.2.3",
45
45
  "zod": "^4"
46
46
  },
@@ -73,6 +73,15 @@ function fingerprintOAuthBearer(bearer: string): string {
73
73
  return createHash("sha256").update(bearer).digest("base64url");
74
74
  }
75
75
  const SESSION_STICKY_CACHE_PREFIX = "session:sticky:";
76
+ /**
77
+ * Anthropic-only idle window after which a session's pinned credential no
78
+ * longer suppresses usage-based re-ranking. Anthropic caps OAuth prompt-cache
79
+ * retention at `ttl: "1h"` (ephemeral ~5min otherwise), so after this long
80
+ * without an Anthropic resolve the conversation-prefix cache is no longer
81
+ * guaranteed warm. Other providers retain indefinite stickiness until their
82
+ * own cache lifetimes are verified.
83
+ */
84
+ const ANTHROPIC_SESSION_STICKY_CACHE_WARM_MS = 60 * 60_000;
76
85
 
77
86
  // ─────────────────────────────────────────────────────────────────────────────
78
87
  // Credential Types
@@ -1137,7 +1146,10 @@ export class AuthStorage {
1137
1146
  /** Tracks next credential index per provider:type key for round-robin distribution (non-session use). */
1138
1147
  #providerRoundRobinIndex: Map<string, number> = new Map();
1139
1148
  /** Tracks the last used credential per provider for a session (used for rate-limit switching). */
1140
- #sessionLastCredential: Map<string, Map<string, { type: AuthCredential["type"]; index: number }>> = new Map();
1149
+ #sessionLastCredential: Map<
1150
+ string,
1151
+ Map<string, { type: AuthCredential["type"]; index: number; lastUsedAtMs?: number }>
1152
+ > = new Map();
1141
1153
  /** Recent bearer fingerprints resolved for each durable OAuth row; used only for delayed usage-limit attribution. */
1142
1154
  #oauthBearerFingerprints: Map<string, Map<number, string[]>> = new Map();
1143
1155
  /** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */
@@ -1685,17 +1697,18 @@ export class AuthStorage {
1685
1697
  index: number,
1686
1698
  ): void {
1687
1699
  if (!sessionId) return;
1700
+ const nowMs = Date.now();
1688
1701
  const sessionMap = this.#sessionLastCredential.get(provider) ?? new Map();
1689
- sessionMap.set(sessionId, { type, index });
1702
+ sessionMap.set(sessionId, { type, index, lastUsedAtMs: nowMs });
1690
1703
  this.#sessionLastCredential.set(provider, sessionMap);
1691
1704
 
1692
1705
  try {
1693
1706
  const credentialId = this.#getStoredCredentials(provider)[index]?.id;
1694
1707
  if (credentialId !== undefined) {
1695
1708
  const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
1696
- const cacheValue = JSON.stringify({ type, index, credentialId });
1709
+ const cacheValue = JSON.stringify({ type, index, credentialId, lastUsedAtMs: nowMs });
1697
1710
  // Expires in 30 days
1698
- const expiresAtSec = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60;
1711
+ const expiresAtSec = Math.floor(nowMs / 1000) + 30 * 24 * 60 * 60;
1699
1712
  this.#store.setCache(cacheKey, cacheValue, expiresAtSec);
1700
1713
  }
1701
1714
  } catch (err) {
@@ -1707,7 +1720,7 @@ export class AuthStorage {
1707
1720
  #getSessionCredential(
1708
1721
  provider: string,
1709
1722
  sessionId: string | undefined,
1710
- ): { type: AuthCredential["type"]; index: number } | undefined {
1723
+ ): { type: AuthCredential["type"]; index: number; lastUsedAtMs?: number } | undefined {
1711
1724
  if (!sessionId) return undefined;
1712
1725
  let sessionMap = this.#sessionLastCredential.get(provider);
1713
1726
  if (sessionMap?.has(sessionId)) {
@@ -1717,7 +1730,12 @@ export class AuthStorage {
1717
1730
  const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
1718
1731
  const raw = this.#store.getCache(cacheKey);
1719
1732
  if (raw) {
1720
- const val = JSON.parse(raw) as { type: AuthCredential["type"]; index: number; credentialId?: number };
1733
+ const val = JSON.parse(raw) as {
1734
+ type: AuthCredential["type"];
1735
+ index: number;
1736
+ credentialId?: number;
1737
+ lastUsedAtMs?: number;
1738
+ };
1721
1739
 
1722
1740
  if (val.credentialId !== undefined) {
1723
1741
  const stored = this.#getStoredCredentials(provider);
@@ -1737,7 +1755,7 @@ export class AuthStorage {
1737
1755
  sessionMap = new Map();
1738
1756
  this.#sessionLastCredential.set(provider, sessionMap);
1739
1757
  }
1740
- const sessionVal = { type: val.type, index: val.index };
1758
+ const sessionVal = { type: val.type, index: val.index, lastUsedAtMs: val.lastUsedAtMs };
1741
1759
  sessionMap.set(sessionId, sessionVal);
1742
1760
  return sessionVal;
1743
1761
  }
@@ -3886,16 +3904,15 @@ export class AuthStorage {
3886
3904
  * how fast the window's remaining quota must be consumed to fully use it
3887
3905
  * before it resets and expires. Higher = more headroom at risk of expiring
3888
3906
  * unused = ranked first, so selection chases quota that is about to be
3889
- * wasted ("use it or lose it"). Without a reset clock the headroom
3890
- * fraction alone is returned, degrading to most-headroom-first.
3907
+ * wasted ("use it or lose it"). Without a reset clock, the full window
3908
+ * duration is assumed to remain so clocked and clockless scores stay comparable.
3891
3909
  */
3892
3910
  #computeWindowRequiredDrain(limit: UsageLimit | undefined, nowMs: number, fallbackDurationMs: number): number {
3893
3911
  const headroom = 1 - this.#normalizeUsageFraction(limit);
3894
3912
  if (headroom <= 0) return 0;
3895
3913
  const resetAt = this.#resolveWindowResetAt(limit?.window);
3896
- if (resetAt === undefined) return headroom;
3897
3914
  const durationMs = limit?.window?.durationMs ?? fallbackDurationMs;
3898
- let remainingMs = resetAt - nowMs;
3915
+ let remainingMs = resetAt === undefined ? durationMs : resetAt - nowMs;
3899
3916
  if (Number.isFinite(durationMs) && durationMs > 0) {
3900
3917
  remainingMs = Math.min(remainingMs, durationMs);
3901
3918
  }
@@ -4135,16 +4152,39 @@ export class AuthStorage {
4135
4152
  sessionPreferredCredential !== undefined &&
4136
4153
  (sessionPreferredCredential.refresh.trim().length > 0 ||
4137
4154
  Date.now() + OAUTH_REFRESH_SKEW_MS < sessionPreferredCredential.expires);
4138
- // Skip ranking only when the session already has a working preferred credential re-ranking
4139
- // mid-session causes account switches that cold-start the server-side prompt cache. New sessions
4140
- // (no preference) and sessions whose preferred is blocked still rank, so we pick the account
4141
- // with the most headroom proactively and fall back intelligently when rate-limited.
4155
+ // Skip ranking when the session already has a working preferred credential and its prompt
4156
+ // cache may still be warm. Only Anthropic has a verified idle boundary here; unverified
4157
+ // providers retain indefinite stickiness rather than risk switching while their prompt cache
4158
+ // remains warm. New Anthropic sessions (no preference), sessions whose preferred is blocked,
4159
+ // and sessions idle past {@link ANTHROPIC_SESSION_STICKY_CACHE_WARM_MS} still rank. Legacy
4160
+ // pins predating `lastUsedAtMs` count as warm until the next resolve rewrites the row.
4161
+ const sessionPreferredLastUsedAtMs =
4162
+ sessionCredential?.type === "oauth" ? sessionCredential.lastUsedAtMs : undefined;
4163
+ const sessionPreferredIsWarm =
4164
+ provider !== "anthropic" ||
4165
+ sessionPreferredLastUsedAtMs === undefined ||
4166
+ Date.now() - sessionPreferredLastUsedAtMs < ANTHROPIC_SESSION_STICKY_CACHE_WARM_MS;
4142
4167
  const sessionPreferredIsAvailable =
4143
4168
  sessionPreferredIndex !== undefined &&
4144
4169
  sessionPreferredCanRefreshOrUse &&
4145
4170
  !this.#isCredentialBlocked(provider, providerKey, sessionPreferredIndex, blockScope);
4146
- const shouldRank = checkUsage && (!sessionPreferredIsAvailable || hasPlanRequirement);
4147
- const rankingOrder = shouldRank && sessionId ? credentials.map((_credential, index) => index) : order;
4171
+ const shouldRank = checkUsage && (!sessionPreferredIsAvailable || !sessionPreferredIsWarm || hasPlanRequirement);
4172
+ // When ranking, seed the pinned credential first in the evaluation order so it wins genuine
4173
+ // ties (the ranked comparator falls back to `orderPos`) without overriding a strictly-better
4174
+ // sibling — this respects the residual value of a same-account shared static prefix that other
4175
+ // workspace traffic may have kept warm, while still rotating away from a clearly-worse account.
4176
+ const baseRankingOrder = credentials.map((_credential, index) => index);
4177
+ let rankingOrder = shouldRank && sessionId ? baseRankingOrder : order;
4178
+ const sessionPreferredRankingPos =
4179
+ shouldRank && sessionId && sessionPreferredIndex !== undefined && !hasPlanRequirement
4180
+ ? credentials.findIndex(entry => entry.index === sessionPreferredIndex)
4181
+ : -1;
4182
+ if (sessionPreferredRankingPos > 0) {
4183
+ rankingOrder = [
4184
+ sessionPreferredRankingPos,
4185
+ ...baseRankingOrder.filter(index => index !== sessionPreferredRankingPos),
4186
+ ];
4187
+ }
4148
4188
  const candidates = shouldRank
4149
4189
  ? await this.#rankOAuthSelections({
4150
4190
  providerKey,
@@ -4162,7 +4202,10 @@ export class AuthStorage {
4162
4202
  .filter((selection): selection is { credential: OAuthCredential; index: number } => Boolean(selection))
4163
4203
  .map(selection => ({ selection, usage: null, usageChecked: false }));
4164
4204
 
4165
- if (sessionPreferredIndex !== undefined && !hasPlanRequirement) {
4205
+ // On the warm skip path the candidate list follows the round-robin `order`, not the pin, so
4206
+ // hoist the pinned credential to the front to actually reuse it. When ranking ran, the pin is
4207
+ // already a mere tie-break via `rankingOrder`; do not override the ranked result here.
4208
+ if (!shouldRank && sessionPreferredIndex !== undefined && !hasPlanRequirement) {
4166
4209
  const sessionPreferredCandidate = candidates.findIndex(
4167
4210
  candidate =>
4168
4211
  !this.#isCredentialBlocked(provider, providerKey, candidate.selection.index, blockScope) &&
@@ -4249,6 +4292,29 @@ export class AuthStorage {
4249
4292
  hasPlanRequirement &&
4250
4293
  candidates.some(candidate => getOpenAICodexPlanEligibility(candidate.usage, planRequirement) === true);
4251
4294
 
4295
+ // Plan-gated Codex models rank on every resolve to re-verify account tiers,
4296
+ // so the drain-urgency order can flip between two eligible accounts as their
4297
+ // usage headroom shifts. Promote the session-preferred credential back to the
4298
+ // front while it is unblocked and still plan-eligible (or the requirement is
4299
+ // unenforced and the pin is not known-ineligible) so an active session never
4300
+ // silently migrates accounts mid-conversation; blocked, exhausted, or
4301
+ // known-ineligible pins still fall through to the ranked sibling.
4302
+ if (hasPlanRequirement && sessionPreferredIndex !== undefined) {
4303
+ const sessionPreferredCandidate = candidates.findIndex(
4304
+ candidate =>
4305
+ !this.#isCredentialBlocked(provider, providerKey, candidate.selection.index, blockScope) &&
4306
+ candidate.selection.index === sessionPreferredIndex,
4307
+ );
4308
+ if (sessionPreferredCandidate > 0) {
4309
+ const preferred = candidates[sessionPreferredCandidate]!;
4310
+ const planEligibility = getOpenAICodexPlanEligibility(preferred.usage, planRequirement);
4311
+ if (planEligibility === true || (!enforcePlanRequirement && planEligibility !== false)) {
4312
+ candidates.splice(sessionPreferredCandidate, 1);
4313
+ candidates.unshift(preferred);
4314
+ }
4315
+ }
4316
+ }
4317
+
4252
4318
  const passes: Array<{ allowBlocked: boolean; enforcePlanRequirement: boolean }> = [
4253
4319
  { allowBlocked: false, enforcePlanRequirement },
4254
4320
  { allowBlocked: true, enforcePlanRequirement },
@@ -157,6 +157,53 @@ describe("Kimi K3 thinking transport", () => {
157
157
  expect(payload).toMatchObject({ thinking: { type: "enabled", effort: Effort.Low } });
158
158
  expect(payload).not.toMatchObject({ thinking: { type: "disabled" } });
159
159
  });
160
+
161
+ it("downgrades named tool choice to required for K3 thinking", async () => {
162
+ vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
163
+ const bundledModel = getBundledModel<"openai-completions">("kimi-code", "k3");
164
+ expect(bundledModel.compat.thinkingFormat).toBe("zai");
165
+ let payload: unknown;
166
+ const capturePayload = async (
167
+ model: Model<"openai-completions">,
168
+ toolChoice: "required" | { type: "tool"; name: string },
169
+ tools = TITLE_CONTEXT.tools,
170
+ ) => {
171
+ const stream = streamKimi(
172
+ model,
173
+ { ...TITLE_CONTEXT, tools },
174
+ {
175
+ apiKey: "test-key",
176
+ format: "openai",
177
+ reasoning: Effort.Max,
178
+ toolChoice,
179
+ onPayload: body => {
180
+ payload = body;
181
+ throw new Error("stop after payload capture");
182
+ },
183
+ },
184
+ );
185
+ await stream.result();
186
+ };
187
+
188
+ for (const model of [K3_MODEL, bundledModel]) {
189
+ await capturePayload(model, { type: "tool", name: "set_title" });
190
+ expect(payload).toMatchObject({
191
+ thinking: { type: "enabled" },
192
+ tool_choice: "required",
193
+ tools: [{ type: "function", function: { name: "set_title" } }],
194
+ });
195
+
196
+ await capturePayload(model, "required");
197
+ expect(payload).toMatchObject({
198
+ thinking: { type: "enabled" },
199
+ tool_choice: "required",
200
+ tools: [{ type: "function", function: { name: "set_title" } }],
201
+ });
202
+ }
203
+
204
+ await capturePayload(K3_MODEL, { type: "tool", name: "missing_tool" }, []);
205
+ expect((payload as { tool_choice?: unknown }).tool_choice).toBeUndefined();
206
+ });
160
207
  });
161
208
 
162
209
  describe("Kimi K2.7 Code thinking policy", () => {
@@ -334,7 +334,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
334
334
  toolConfig,
335
335
  additionalModelRequestFields,
336
336
  };
337
- options?.onPayload?.(commandInput);
337
+ options?.onPayload?.(commandInput, model);
338
338
 
339
339
  const host = `bedrock-runtime.${region}.amazonaws.com`;
340
340
  const url = `https://${host}/model/${encodeURIComponent(model.id)}/converse-stream`;
@@ -100,6 +100,8 @@ export type AnthropicHeaderOptions = {
100
100
  isCloudflareAiGateway?: boolean;
101
101
  claudeCodeSessionId?: string;
102
102
  claudeCodeBetas?: readonly string[];
103
+ /** Allow explicit fingerprint headers to replace OAuth defaults on non-official endpoints. */
104
+ allowAnthropicHeaderOverrides?: boolean;
103
105
  };
104
106
 
105
107
  export function normalizeAnthropicBaseUrl(baseUrl?: string): string | undefined {
@@ -144,7 +146,8 @@ const claudeCodeAgentBetaDefaults = [
144
146
  midConversationSystemBeta,
145
147
  "advanced-tool-use-2025-11-20",
146
148
  ] as const;
147
- const claudeCodeAgentPostEffortBetas = ["extended-cache-ttl-2025-04-11"] as const;
149
+ const extendedCacheTtlBeta = "extended-cache-ttl-2025-04-11";
150
+ const claudeCodeAgentPostEffortBetas = [extendedCacheTtlBeta] as const;
148
151
  const fineGrainedToolStreamingBeta = "fine-grained-tool-streaming-2025-05-14";
149
152
  const interleavedThinkingBeta = "interleaved-thinking-2025-05-14";
150
153
  // Asks the API to redact thinking blocks from responses. Only sent when the
@@ -225,22 +228,36 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
225
228
  const acceptHeader = oauthToken ? "application/json" : stream ? "text/event-stream" : "application/json";
226
229
  const isCloudflare = options.isCloudflareAiGateway ?? false;
227
230
  const honorAuthorization = !oauthToken && !isCloudflare;
231
+ const allowAnthropicHeaderOverrides =
232
+ oauthToken &&
233
+ options.allowAnthropicHeaderOverrides === true &&
234
+ !isCloudflare &&
235
+ !isOfficialAnthropicApiUrl(options.baseUrl);
228
236
  const honorApiKey = !isCloudflare;
229
237
  const modelHeaders: Record<string, string> = {};
238
+ const anthropicHeaderOverrides: Record<string, string> = {};
230
239
  const filteredEnforcedKeys: string[] = [];
231
- for (const [key, value] of Object.entries(options.modelHeaders ?? {})) {
232
- const lowerKey = key.toLowerCase();
233
- if (enforcedHeaderKeys.has(lowerKey)) {
234
- // user-agent is always re-applied explicitly. authorization / x-api-key
235
- // are silently re-applied in honoring branches and dropped + logged
236
- // where the branch enforces its own credential.
237
- if (lowerKey === "user-agent") continue;
238
- if (lowerKey === "authorization" && honorAuthorization) continue;
239
- if (lowerKey === "x-api-key" && honorApiKey) continue;
240
- filteredEnforcedKeys.push(key);
241
- continue;
240
+ const headerSource = options.modelHeaders;
241
+ if (headerSource) {
242
+ for (const key in headerSource) {
243
+ const value = headerSource[key];
244
+ const lowerKey = key.toLowerCase();
245
+ if (enforcedHeaderKeys.has(lowerKey)) {
246
+ if (allowAnthropicHeaderOverrides && overridableAnthropicHeaderKeys.has(lowerKey)) {
247
+ anthropicHeaderOverrides[key] = value;
248
+ continue;
249
+ }
250
+ // user-agent is always re-applied explicitly. authorization / x-api-key
251
+ // are silently re-applied in honoring branches and dropped + logged
252
+ // where the branch enforces its own credential.
253
+ if (lowerKey === "user-agent") continue;
254
+ if (lowerKey === "authorization" && honorAuthorization) continue;
255
+ if (lowerKey === "x-api-key" && honorApiKey) continue;
256
+ filteredEnforcedKeys.push(key);
257
+ continue;
258
+ }
259
+ modelHeaders[key] = value;
242
260
  }
243
- modelHeaders[key] = value;
244
261
  }
245
262
  if (filteredEnforcedKeys.length > 0) {
246
263
  // Caller/env-supplied values (options.headers, ANTHROPIC_CUSTOM_HEADERS)
@@ -266,7 +283,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
266
283
  const userAgent = isClaudeCodeClientUserAgent(incomingUserAgent)
267
284
  ? incomingUserAgent
268
285
  : `claude-cli/${claudeCodeVersion} (external, local-agent, agent-sdk/${claudeAgentSdkVersion})`;
269
- return {
286
+ const headers = {
270
287
  ...modelHeaders,
271
288
  ...claudeCodeHeaders,
272
289
  Accept: acceptHeader,
@@ -278,6 +295,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
278
295
  "User-Agent": userAgent,
279
296
  ...(incomingApiKey ? { "X-Api-Key": incomingApiKey } : {}),
280
297
  };
298
+ return allowAnthropicHeaderOverrides ? mergeHeaders(headers, anthropicHeaderOverrides) : headers;
281
299
  } else if (!isOfficialAnthropicApiUrl(options.baseUrl)) {
282
300
  return {
283
301
  ...modelHeaders,
@@ -424,7 +442,15 @@ function getCacheControl(
424
442
  cacheRetention: CacheRetention | undefined,
425
443
  isOAuthToken: boolean,
426
444
  ): { retention: CacheRetention; cacheControl?: AnthropicCacheControl } {
427
- const retention = cacheRetention ?? (isOAuthToken ? "long" : resolveCacheRetention(undefined));
445
+ // OAuth mirrors Claude Code and always defaults to 1h retention. API-key
446
+ // requests also default to 1h where the endpoint supports it (canonical
447
+ // Anthropic API, `compat.supportsLongCacheRetention`): agent sessions
448
+ // routinely idle past 5 minutes waiting on background jobs, and a 5m
449
+ // breakpoint cold-misses the entire prefix on resume. PI_CACHE_RETENTION
450
+ // still overrides the API-key default in either direction.
451
+ const retention = isOAuthToken
452
+ ? (cacheRetention ?? "long")
453
+ : resolveCacheRetention(cacheRetention, model.compat.supportsLongCacheRetention ? "long" : "short");
428
454
  if (retention === "none") {
429
455
  return { retention };
430
456
  }
@@ -512,6 +538,10 @@ const enforcedHeaderKeys = new Set(
512
538
  ].map(key => key.toLowerCase()),
513
539
  );
514
540
 
541
+ const overridableAnthropicHeaderKeys = new Set(
542
+ [...Object.keys(claudeCodeHeaders), "anthropic-beta", "User-Agent", "x-app"].map(key => key.toLowerCase()),
543
+ );
544
+
515
545
  const CLAUDE_BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
516
546
 
517
547
  function createClaudeBillingHeader(firstUserMessageText: string): string {
@@ -1853,6 +1883,17 @@ const streamAnthropicOnce = (
1853
1883
  ) {
1854
1884
  extraBetas.push(contextManagementBeta);
1855
1885
  }
1886
+ // `ttl: "1h"` requires the extended-cache-ttl beta on API-key
1887
+ // requests. OAuth requests never add it here: agent requests
1888
+ // already carry it in the Claude Code beta list, and utility
1889
+ // requests must not deviate from CC's header fingerprint.
1890
+ if (
1891
+ !(options?.isOAuth ?? isAnthropicOAuthToken(apiKey)) &&
1892
+ getCacheControl(model, options?.cacheRetention, false).cacheControl?.ttl === "1h" &&
1893
+ !extraBetas.includes(extendedCacheTtlBeta)
1894
+ ) {
1895
+ extraBetas.push(extendedCacheTtlBeta);
1896
+ }
1856
1897
  // Server-side fallback beta chain: opt-in via `options.fallbacks`.
1857
1898
  // Nested overrides (`speed`, `output_config.effort`,
1858
1899
  // `output_config.task_budget`) reuse the same top-level betas
@@ -2791,6 +2832,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
2791
2832
  dynamicHeaders,
2792
2833
  ),
2793
2834
  isCloudflareAiGateway: model.provider === "cloudflare-ai-gateway",
2835
+ allowAnthropicHeaderOverrides: model.compat.allowAnthropicHeaderOverrides,
2794
2836
  claudeCodeSessionId,
2795
2837
  claudeCodeBetas: oauthToken
2796
2838
  ? buildClaudeCodeBetas(
@@ -2868,7 +2868,7 @@ function buildGrpcRequest(
2868
2868
  conversationId: state.conversationId,
2869
2869
  });
2870
2870
 
2871
- options?.onPayload?.(runRequest);
2871
+ options?.onPayload?.(runRequest, model);
2872
2872
 
2873
2873
  // Tools are sent later via requestContext (exec handshake)
2874
2874
 
@@ -3913,7 +3913,8 @@ function redactHeaders(headers: Headers): Record<string, string> {
3913
3913
  return redacted;
3914
3914
  }
3915
3915
 
3916
- function resolveCodexResponsesUrl(baseUrl: string | undefined): string {
3916
+ /** Resolve a Codex Responses endpoint exactly as the chat and compaction transports do. */
3917
+ export function resolveCodexResponsesUrl(baseUrl: string | undefined): string {
3917
3918
  const raw = baseUrl && baseUrl.trim().length > 0 ? baseUrl : CODEX_BASE_URL;
3918
3919
  const normalized = raw.replace(/\/+$/, "");
3919
3920
  if (normalized.endsWith("/codex/responses")) return normalized;
@@ -42,7 +42,13 @@ import {
42
42
  import { OpenAIHttpError, postOpenAIStream } from "../utils/openai-http";
43
43
  import { notifyProviderResponse } from "../utils/provider-response";
44
44
  import { callWithCopilotModelRetry } from "../utils/retry";
45
- import { adaptSchemaForStrict, NO_STRICT, normalizeSchemaForMoonshot, toolWireSchema } from "../utils/schema";
45
+ import {
46
+ adaptSchemaForStrict,
47
+ NO_STRICT,
48
+ normalizeSchemaForMoonshot,
49
+ sanitizeSchemaForGrammar,
50
+ toolWireSchema,
51
+ } from "../utils/schema";
46
52
  import {
47
53
  type HealedToolCall,
48
54
  StreamMarkupHealing,
@@ -671,7 +677,7 @@ const streamOpenAICompletionsOnce = (
671
677
  }
672
678
  activeReasoningEffortFallbackKey = reasoningEffortFallbackKey;
673
679
  activeRequestParams = params;
674
- options?.onPayload?.(params);
680
+ options?.onPayload?.(params, model);
675
681
  rawRequestDump = {
676
682
  provider: model.provider,
677
683
  api: output.api,
@@ -1431,6 +1437,20 @@ function dropOpenRouterKimiForcedToolReasoning(
1431
1437
  }
1432
1438
  }
1433
1439
 
1440
+ function hasActiveNativeKimiK3Reasoning(
1441
+ model: Model<"openai-completions">,
1442
+ options: OpenAICompletionsOptions | undefined,
1443
+ ): boolean {
1444
+ if (model.provider !== "kimi-code" || model.id.toLowerCase() !== "k3" || !model.reasoning) return false;
1445
+ if (options?.reasoning === undefined || options.disableReasoning) return false;
1446
+ try {
1447
+ const url = new URL(model.baseUrl);
1448
+ return url.hostname === "api.kimi.com" && (url.pathname === "/coding" || url.pathname.startsWith("/coding/"));
1449
+ } catch {
1450
+ return false;
1451
+ }
1452
+ }
1453
+
1434
1454
  function buildParams(
1435
1455
  model: Model<"openai-completions">,
1436
1456
  context: Context,
@@ -1518,6 +1538,20 @@ function buildParams(
1518
1538
  ) {
1519
1539
  params.tool_choice = "required";
1520
1540
  }
1541
+ const forcedToolName =
1542
+ typeof params.tool_choice === "object" && params.tool_choice !== null && "function" in params.tool_choice
1543
+ ? params.tool_choice.function.name
1544
+ : undefined;
1545
+ if (
1546
+ forcedToolName !== undefined &&
1547
+ Array.isArray(params.tools) &&
1548
+ params.tools.some(tool => tool.type === "function" && tool.function.name === forcedToolName) &&
1549
+ hasActiveNativeKimiK3Reasoning(model, options)
1550
+ ) {
1551
+ // Native K3 reasoning is incompatible with selecting a specific function.
1552
+ // Preserve the hard tool-use contract while letting K3 choose among tools.
1553
+ params.tool_choice = "required";
1554
+ }
1521
1555
  if (isForcedToolChoice(params.tool_choice) && !initialCompat.supportsForcedToolChoice) {
1522
1556
  // Some thinking-required OpenAI-compatible models reject forced
1523
1557
  // `tool_choice` while still accepting tools with the default auto
@@ -1537,10 +1571,6 @@ function buildParams(
1537
1571
  delete params.tool_choice;
1538
1572
  }
1539
1573
 
1540
- const forcedToolName =
1541
- typeof params.tool_choice === "object" && params.tool_choice !== null && "function" in params.tool_choice
1542
- ? params.tool_choice.function.name
1543
- : undefined;
1544
1574
  if (
1545
1575
  forcedToolName !== undefined &&
1546
1576
  (!Array.isArray(params.tools) ||
@@ -2205,10 +2235,16 @@ function convertTools(
2205
2235
  description: tool.description || "",
2206
2236
  // Moonshot/Kimi native hosts validate against the stricter MFJS subset
2207
2237
  // (const→enum, typed enums, no validators) and 400 otherwise.
2238
+ // Grammar-constrained local backends (llama.cpp, LM Studio, vLLM)
2239
+ // build a GBNF grammar from the schema and 400 with
2240
+ // `Unrecognized schema: true` on the bare boolean subschema
2241
+ // `toolWireSchema` emits for open fields (issue #5914).
2208
2242
  parameters:
2209
2243
  compat.toolSchemaFlavor === "moonshot-mfjs"
2210
2244
  ? (normalizeSchemaForMoonshot(wireParameters) as Record<string, unknown>)
2211
- : wireParameters,
2245
+ : compat.toolSchemaFlavor === "grammar"
2246
+ ? sanitizeSchemaForGrammar(wireParameters)
2247
+ : wireParameters,
2212
2248
  // Only include strict if provider supports it. Some reject unknown fields.
2213
2249
  ...(includeStrict ? { strict: true } : includeExplicitFalse ? { strict: false } : {}),
2214
2250
  },