@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 +25 -0
- package/dist/types/providers/anthropic.d.ts +2 -0
- package/dist/types/providers/openai-codex-responses.d.ts +2 -0
- package/dist/types/registry/api-key-login.d.ts +1 -1
- package/dist/types/utils/schema/normalize.d.ts +23 -2
- package/dist/types/utils.d.ts +4 -3
- package/package.json +4 -4
- package/src/auth-storage.ts +84 -18
- package/src/providers/__tests__/kimi-code-thinking.test.ts +47 -0
- package/src/providers/amazon-bedrock.ts +1 -1
- package/src/providers/anthropic.ts +57 -15
- package/src/providers/cursor.ts +1 -1
- package/src/providers/openai-codex-responses.ts +2 -1
- package/src/providers/openai-completions.ts +43 -7
- package/src/providers/openai-responses.ts +273 -165
- package/src/registry/api-key-login.ts +5 -2
- package/src/registry/moonshot.ts +7 -1
- package/src/registry/oauth/kimi.ts +17 -9
- package/src/usage/kimi.ts +10 -4
- package/src/utils/schema/normalize.ts +120 -21
- package/src/utils.ts +10 -5
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
|
-
/**
|
|
6
|
-
|
|
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
|
package/dist/types/utils.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
|
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.
|
|
42
|
-
"@oh-my-pi/pi-utils": "17.0.
|
|
43
|
-
"@oh-my-pi/pi-wire": "17.0.
|
|
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
|
},
|
package/src/auth-storage.ts
CHANGED
|
@@ -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<
|
|
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(
|
|
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 {
|
|
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
|
|
3890
|
-
*
|
|
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
|
|
4139
|
-
//
|
|
4140
|
-
//
|
|
4141
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
package/src/providers/cursor.ts
CHANGED
|
@@ -3913,7 +3913,8 @@ function redactHeaders(headers: Headers): Record<string, string> {
|
|
|
3913
3913
|
return redacted;
|
|
3914
3914
|
}
|
|
3915
3915
|
|
|
3916
|
-
|
|
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 {
|
|
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
|
-
:
|
|
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
|
},
|