Skip to content

Commit 0338b78

Browse files
olddonkeyclaude
andauthored
feat(kimi): forward prompt_cache_key on the Kimi Coding Plan presets (#597)
* feat(kimi): forward prompt_cache_key on the Kimi Coding Plan presets Kimi's Chat Completions API documents prompt_cache_key as required for Kimi Code Plan cache hits (a stable session/task id, unchanged across exit and resume). Opt the canonical `kimi` OAuth and `kimi-code` API-key presets into the existing openai-chat forwarding flag so the caller-supplied key reaches https://api.kimi.com/coding/v1. The adapter never invents a key: it forwards what the internal request already carries (Codex's session key on /v1/responses, or the session-scoped key the Claude /v1/messages inbound derives), an absent field stays absent, and an explicit provider-level `promptCacheKey: false` still opts out. All other OpenAI-compatible presets remain deny-by-default because strict backends reject the OpenAI-specific field. Also persist the flag through providerConfigSeed / enrichProviderFromRegistry like the sibling scalars (parallelToolCalls, modelSuffixBracketStrip): key-pool 429 rotation rebuilds the provider from the persisted config rather than the routed one, so without seeding the retried request would silently drop the key on exactly the quota-sensitive turns that need affinity most. Evidence: https://platform.kimi.com/docs/api/chat Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Preserve Kimi prompt cache key on failover * fix(server): inherit routed provider config across 429 key rotation Generalize the promptCacheKey preservation from the previous commit: the narrow registry backfill in rotateProviderTransportOn429 covered only that one scalar, while every OTHER registry backfill routedProviderConfig merges at request time was still lost when the four 429-failover sites in src/server/responses/core.ts assigned the persisted-config snapshot to route.provider wholesale. Concretely: kimi-code's noTemperatureModels / modelReasoningEfforts / modelSuffixBracketStrip merges, NVIDIA NIM's parallelToolCalls: false, and a registry-pinned baseUrl all silently reverted on the rotated retry and later continuations in the turn. rotateProviderTransportOn429 now takes the request's routed provider and swaps ONLY the API key onto it before re-applying transport metadata, mirroring the OAuth-401 replay path (which already spreads route.provider). The registry lookup becomes unnecessary and is removed. Other failover paths were audited and are sound: Codex multi-account retry strips runtime fields off route.provider, and the Anthropic account pool spreads route.provider directly. Regression coverage: the existing Kimi unit test keeps asserting the wire body on both attempts; a new unit test proves arbitrary registry-backfilled fields survive rotation; a new e2e test on the kimi-code preset asserts prompt_cache_key is present on BOTH the initial attempt and the post-rotation retry (verified to fail against the pre-fix behavior). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ed5ddaf commit 0338b78

14 files changed

Lines changed: 299 additions & 10 deletions

File tree

docs-site/src/content/docs/guides/providers.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ ocx logout <provider>
9090
| `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport, and account-filtered model discovery. |
9191
| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. |
9292

93+
For the canonical Kimi Coding Plan presets (`kimi` account login and `kimi-code` API key),
94+
opencodex forwards only a caller-supplied stable `prompt_cache_key` to the Chat Completions request;
95+
it never generates one. Kimi documents a stable session/task key as required to improve Code Plan
96+
cache hit rates, while requests without a key remain keyless. If an opted-in upstream rejects the
97+
field, opencodex does not strip it and retry or mutate saved configuration. Other providers remain
98+
deny-by-default.
99+
93100
You can also start OAuth from the [web dashboard](/guides/web-dashboard/).
94101

95102
### Multiple OAuth accounts

docs-site/src/content/docs/ja/guides/providers.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ ocx logout <provider>
8484
| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。 |
8585
| `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 |
8686

87+
正規の Kimi Coding Plan プリセット(`kimi` アカウントログインと `kimi-code` API key)では、
88+
opencodex は呼び出し元が指定した安定した `prompt_cache_key` だけを Chat Completions リクエストへ
89+
転送し、自ら生成しません。Kimi のドキュメントでは、Code Plan のキャッシュヒット率を高めるために
90+
安定したセッション/タスク key が必須とされています。key のないリクエストは keyless のままです。
91+
opt-in した上流がこのフィールドを拒否しても、opencodex はフィールドを削除して再試行したり、保存済み
92+
設定を変更したりしません。他のプロバイダーは deny-by-default のままです。
93+
8794
[ウェブダッシュボード](/ja/guides/web-dashboard/)からも OAuth を開始できます。
8895

8996
### 複数の OAuth アカウント

docs-site/src/content/docs/ko/guides/providers.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ ocx logout <provider>
8484
| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. |
8585
| `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. |
8686

87+
정식 Kimi Coding Plan 프리셋(`kimi` 계정 로그인과 `kimi-code` API key)의 경우, opencodex는
88+
호출자가 제공한 안정적인 `prompt_cache_key`만 Chat Completions 요청으로 전달하며 직접 생성하지
89+
않습니다. Kimi 문서는 Code Plan 캐시 적중률을 높이기 위해 안정적인 세션/작업 key가 필요하다고
90+
명시합니다. key가 없는 요청은 keyless 상태로 유지됩니다. opt-in한 업스트림이 이 필드를 거부해도
91+
opencodex는 필드를 제거해 재시도하거나 저장된 설정을 변경하지 않습니다. 다른 프로바이더는
92+
deny-by-default 상태로 유지됩니다.
93+
8794
[웹 대시보드](/ko/guides/web-dashboard/)에서도 OAuth를 시작할 수 있습니다.
8895

8996
### 여러 OAuth 계정

docs-site/src/content/docs/ru/guides/providers.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ ocx logout <provider>
9393
| `cursor` | `cursor` | `https://api2.cursor.sh` | Экспериментальный PKCE-вход, живой транспорт HTTP/2 и обнаружение моделей с фильтрацией по аккаунту. |
9494
| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. |
9595

96+
Для канонических пресетов Kimi Coding Plan (вход через аккаунт `kimi` и API-ключ `kimi-code`)
97+
opencodex передаёт в запрос Chat Completions только стабильный `prompt_cache_key`, предоставленный
98+
вызывающей стороной, и никогда не создаёт его сам. Документация Kimi требует стабильный ключ
99+
сессии/задачи для повышения доли попаданий в кэш Code Plan; запрос без ключа остаётся без ключа.
100+
Если включённый провайдер отклоняет поле, opencodex не удаляет его для повторной попытки и не
101+
изменяет сохранённую конфигурацию. Для остальных провайдеров действует deny-by-default.
102+
96103
OAuth можно запустить и из [веб-дашборда](/ru/guides/web-dashboard/).
97104

98105
### Несколько OAuth-аккаунтов

docs-site/src/content/docs/zh-cn/guides/providers.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ ocx logout <provider>
7878
| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。 |
7979
| `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、HTTP/2 传输和按账号筛选的模型发现。 |
8080

81+
对于规范的 Kimi Coding Plan 预设(`kimi` 账号登录和 `kimi-code` API key),opencodex
82+
只会把调用方提供的稳定 `prompt_cache_key` 转发到 Chat Completions 请求,绝不自行生成。Kimi
83+
文档要求使用稳定的会话/任务 key 来提高 Code Plan 缓存命中率;没有 key 的请求仍保持不带 key。
84+
若已 opt-in 的上游拒绝该字段,opencodex 不会删除字段后重试,也不会改动已保存配置;其他
85+
provider 仍保持 deny-by-default。
86+
8187
你也可以从 [web 仪表盘](/zh-cn/guides/web-dashboard/) 启动 OAuth。
8288

8389
### 多个 OAuth 账号

src/providers/derive.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
129129
...(entry.noTopPModels ? { noTopPModels: [...entry.noTopPModels] } : {}),
130130
...(entry.noPenaltyModels ? { noPenaltyModels: [...entry.noPenaltyModels] } : {}),
131131
...(entry.parallelToolCalls !== undefined ? { parallelToolCalls: entry.parallelToolCalls } : {}),
132+
...(entry.promptCacheKey !== undefined ? { promptCacheKey: entry.promptCacheKey } : {}),
132133
...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
133134
...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
134135
...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}),
@@ -242,6 +243,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
242243
if (!prov.noTopPModels && seed.noTopPModels) prov.noTopPModels = [...seed.noTopPModels];
243244
if (!prov.noPenaltyModels && seed.noPenaltyModels) prov.noPenaltyModels = [...seed.noPenaltyModels];
244245
if (prov.parallelToolCalls === undefined && seed.parallelToolCalls !== undefined) prov.parallelToolCalls = seed.parallelToolCalls;
246+
if (prov.promptCacheKey === undefined && seed.promptCacheKey !== undefined) prov.promptCacheKey = seed.promptCacheKey;
245247
if (!prov.autoToolChoiceOnlyModels && seed.autoToolChoiceOnlyModels) prov.autoToolChoiceOnlyModels = [...seed.autoToolChoiceOnlyModels];
246248
if (!prov.preserveReasoningContentModels && seed.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...seed.preserveReasoningContentModels];
247249
if (!prov.reasoningSplitModels && seed.reasoningSplitModels) prov.reasoningSplitModels = [...seed.reasoningSplitModels];

src/providers/key-failover.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
*/
1111
import { saveConfigPreservingClaudeCode } from "../config";
1212
import type { OcxConfig, OcxProviderConfig } from "../types";
13-
import { resolveProviderTransport } from "./xai-transport";
13+
import { resolveProviderTransport, type OcxProviderTransport } from "./xai-transport";
1414

1515
// ---- cooldown state (in-memory, same as codex/routing.ts) ----
1616

@@ -69,6 +69,11 @@ export function hasKeyPoolFailover(provider: OcxProviderConfig): boolean {
6969
*
7070
* @returns A new OcxProviderConfig with the swapped key (and mutated config on disk),
7171
* or `null` when no alternative key is available (all in cooldown or pool < 2).
72+
*
73+
* The returned object is a snapshot of the PERSISTED config — it carries none of the
74+
* registry backfills `routedProviderConfig` merges in at request time. Request paths must
75+
* not assign it to an active route wholesale; use `rotateProviderTransportOn429`, which
76+
* takes only the swapped key and keeps the routed provider intact.
7277
*/
7378
export function rotateKeyOn429(
7479
config: OcxConfig,
@@ -133,12 +138,23 @@ interface RotateProviderTransportOptions {
133138
promptCacheKey?: string;
134139
}
135140

136-
/** Rotate a failed key and re-apply provider-specific transport metadata to the replacement. */
141+
/**
142+
* Rotate a failed key and re-apply provider-specific transport metadata to the replacement.
143+
*
144+
* `routedProvider` is the request's active provider (the `routedProviderConfig` output the
145+
* route was built with). The result inherits it and swaps ONLY the API key: the persisted
146+
* config that `rotateKeyOn429` snapshots predates registry backfill, so building the retry
147+
* provider from that snapshot would silently drop every field the registry merged in at
148+
* routing time (scalar flags like `promptCacheKey`/`parallelToolCalls`, merged model
149+
* metadata such as `noTemperatureModels`, a pinned baseUrl). Mirrors the OAuth-401 replay
150+
* path in src/server/responses/core.ts, which spreads `route.provider` for the same reason.
151+
*/
137152
export function rotateProviderTransportOn429(
138153
config: OcxConfig,
139154
providerName: string,
155+
routedProvider: OcxProviderTransport,
140156
options: RotateProviderTransportOptions = {},
141-
): OcxProviderConfig | null {
157+
): OcxProviderTransport | null {
142158
const rotated = rotateKeyOn429(
143159
config,
144160
providerName,
@@ -147,7 +163,11 @@ export function rotateProviderTransportOn429(
147163
options.attemptedKey,
148164
);
149165
return rotated
150-
? resolveProviderTransport(providerName, rotated, options.promptCacheKey)
166+
? resolveProviderTransport(
167+
providerName,
168+
{ ...routedProvider, apiKey: rotated.apiKey },
169+
options.promptCacheKey,
170+
)
151171
: null;
152172
}
153173

src/providers/registry.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,12 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
480480
baseUrl: "https://api.kimi.com/coding/v1",
481481
authKind: "oauth",
482482
modelSuffixBracketStrip: true,
483+
// Kimi Code Plan documents a stable session/task prompt_cache_key as required to improve
484+
// cache hit rates.
485+
// The chat adapter only forwards a key already on the internal request (Codex's session key,
486+
// or the one the Claude /v1/messages inbound derives); the adapter itself never invents one.
487+
// Evidence: https://platform.kimi.com/docs/api/chat
488+
promptCacheKey: true,
483489
featured: true,
484490
oauthId: "kimi",
485491
jawcodeBundle: "moonshot",
@@ -1018,6 +1024,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
10181024
id: "kimi-code", label: "Kimi (coding)", baseUrl: "https://api.kimi.com/coding/v1", adapter: "openai-chat", authKind: "key",
10191025
dashboardUrl: "https://platform.moonshot.cn/console/api-keys", defaultModel: "kimi-k2.7-code",
10201026
modelSuffixBracketStrip: true,
1027+
// API-key form of the same Kimi Code Plan transport; keep cache affinity identical to OAuth.
1028+
promptCacheKey: true,
10211029
models: KIMI_CODING_MODELS,
10221030
modelContextWindows: KIMI_CODING_MODEL_CONTEXT_WINDOWS,
10231031
modelInputModalities: KIMI_CODING_MODEL_INPUT_MODALITIES,

src/server/responses/core.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1756,7 +1756,7 @@ export async function handleResponses(
17561756
}
17571757
},
17581758
on429: retryAfter => {
1759-
const rotated = rotateProviderTransportOn429(config, route.providerName, {
1759+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
17601760
retryAfter,
17611761
now: Date.now(),
17621762
attemptedKey: route.provider.apiKey,
@@ -1822,7 +1822,7 @@ export async function handleResponses(
18221822
routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
18231823
stallTimeoutSec: wsPlan.stallTimeoutSec,
18241824
on429: retryAfter => {
1825-
const rotated = rotateProviderTransportOn429(config, route.providerName, {
1825+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
18261826
retryAfter,
18271827
now: Date.now(),
18281828
attemptedKey: route.provider.apiKey,
@@ -2094,7 +2094,7 @@ export async function handleResponses(
20942094
// SAME request once per remaining key. OAuth/forward providers and single-key pools
20952095
// return null immediately, so this stays a no-op for them (src/providers/key-failover.ts).
20962096
while (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) {
2097-
const rotated = rotateProviderTransportOn429(config, route.providerName, {
2097+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
20982098
retryAfter: upstreamResponse.headers.get("retry-after"),
20992099
now: Date.now(),
21002100
attemptedKey: route.provider.apiKey,
@@ -2257,7 +2257,7 @@ export async function handleResponses(
22572257
}
22582258

22592259
if (response.status === 429 && hasKeyPoolFailover(route.provider)) {
2260-
const rotated = rotateProviderTransportOn429(config, route.providerName, {
2260+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
22612261
retryAfter: response.headers.get("retry-after"),
22622262
now: Date.now(),
22632263
attemptedKey: route.provider.apiKey,

structure/04_transports-and-sidecars.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,22 @@ lookalike hosts, and custom proxy paths fail validation. A model override replac
271271
merges the provider-wide default, keeping precedence deterministic. With no preference configured,
272272
the request body is byte-for-byte unchanged in this area and OpenRouter retains its default routing.
273273

274+
## Kimi Coding Plan prompt-cache affinity
275+
276+
The canonical `kimi` OAuth and `kimi-code` API-key presets opt into forwarding the internal
277+
request's `prompt_cache_key` to Kimi's Chat Completions body. Kimi Code Plan documents a stable
278+
session/task key as required to improve cache hit rates. The chat adapter never invents a key of
279+
its own: it forwards what the request already carries — Codex's session key on
280+
`/v1/responses`, or the session-scoped key the Claude `/v1/messages` inbound derives
281+
(metadata.user_id hash, else the system+tools cohort hash) — and a request with no key stays
282+
keyless. An explicit provider-level `promptCacheKey: false` continues to opt out, and the flag is
283+
persisted through `providerConfigSeed`/`enrichProviderFromRegistry` for new configs; key-pool 429
284+
rotation keeps it — along with every other registry backfill — because the retry inherits the
285+
request's routed provider and swaps only the API key (`rotateProviderTransportOn429` in
286+
src/providers/key-failover.ts). If an opted-in upstream rejects the field, OpenCodex does not strip it and retry or mutate the
287+
saved configuration. Other OpenAI-compatible providers remain deny-by-default because strict
288+
backends may reject the OpenAI-specific field.
289+
274290
## xAI Grok hardening (official Grok Build contract parity)
275291

276292
Grounded in the open-sourced official client (xai-org/grok-build); unit + evidence:

0 commit comments

Comments
 (0)