diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index 45851d1fc0..0ad9eaac5f 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -300,7 +300,9 @@ pas les copies externes ; révoquez-la séparément sur le hub si nécessaire. ## Le sélecteur /model (« Depuis la passerelle ») Claude Code 2.1.129+ découvre les modèles de passerelle via `GET /v1/models?limit=1000` et les répertorie dans -le sélecteur natif `/model` intitulé « Depuis la passerelle ». Comme ce sélecteur n'accepte que les identifiants commençant +le sélecteur natif `/model`. Une ligne sans `description` affiche « From gateway » ; opencodex en envoie une pour +chaque ligne du CLI Claude Code (`Routed by OpenCodex to /` ; lignes natives : `Routed by OpenCodex to native ` ; les lignes Fast ajoutent ` · Fast` et les lignes 1M gardent la description de base), que Claude Code 2.1.257+ affiche +à la place. Comme ce sélecteur n'accepte que les identifiants commençant par `claude` ou `anthropic`, opencodex expose les modèles routés sous forme d'alias stables et réversibles : | Surface | Format | Exemple | diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index c1b06b27f8..65e16011c2 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -363,8 +363,10 @@ arbitrary external copies; revoke separately on the hub if desired. ## The /model picker ("From gateway") Claude Code 2.1.129+ discovers gateway models via `GET /v1/models?limit=1000` and lists them in -the native `/model` picker labeled "From gateway". Because the picker only accepts ids beginning -with `claude` or `anthropic`, opencodex exposes routed models as stable, reversible aliases: +the native `/model` picker. A row without a `description` reads "From gateway"; opencodex sends one +for every Claude Code CLI row (`Routed by OpenCodex to /`; native rows use `Routed by OpenCodex to native `, Fast rows append ` · Fast`, and 1M rows keep the base description), which Claude Code +2.1.257+ shows in its place. Because the picker only accepts ids beginning with `claude` or +`anthropic`, opencodex exposes routed models as stable, reversible aliases: | Surface | Format | Example | | --- | --- | --- | diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 390fbccd05..3a91eda222 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -174,7 +174,9 @@ Claude Code CLI 互換性は英語版ドキュメントを参照してくださ ## /model ピッカー("From gateway") Claude Code 2.1.129 以降は `GET /v1/models?limit=1000` でゲートウェイモデルを探し、デフォルトの `/model` -ピッカーの "From gateway" 項目に表示します。ピッカーは `claude` または `anthropic` で始まる ID のみ +ピッカーに表示します。`description` のない行は "From gateway" と表示されます。opencodex は Claude Code CLI +向けの各行に `description`(`Routed by OpenCodex to /`、ネイティブ行は `Routed by OpenCodex to native `、Fast 行は末尾に ` · Fast`、1M 行は元の説明のまま)を送り、Claude Code 2.1.257 以降は +その内容を代わりに表示します。ピッカーは `claude` または `anthropic` で始まる ID のみ 受け付けるため、opencodex はルーティングモデルを安定で元に戻せるエイリアスとして公開します。 | 画面 | 形式 | 例 | diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 27759bbc70..5a3863cd80 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -208,7 +208,9 @@ import/export는 로컬 설정만 다뤄요. 허브 프로필을 바꾸지 않 지정하거나 `/model`에 라우팅 id를 직접 입력하세요 (Claude Code는 문자열을 그대로 통과시킵니다). Claude Code 2.1.129 이상은 `GET /v1/models?limit=1000`에서 게이트웨이 모델을 찾아 기본 `/model` -선택기의 "From gateway" 항목에 표시해요. 선택기는 `claude` 또는 `anthropic`으로 시작하는 ID만 +선택기에 표시해요. `description`이 없는 항목은 "From gateway"로 보이는데, opencodex는 Claude Code CLI용 +항목마다 `description`(`Routed by OpenCodex to /`, 네이티브 항목은 `Routed by OpenCodex to native `, Fast 항목은 끝에 ` · Fast`, 1M 항목은 기본 설명 그대로)을 보내고 Claude Code 2.1.257 이상은 +그 내용을 대신 보여줘요. 선택기는 `claude` 또는 `anthropic`으로 시작하는 ID만 받으므로, opencodex는 라우팅 모델을 안정적이고 되돌릴 수 있는 별칭으로 노출해요. | 화면 | 형식 | 예시 | diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index f82e2e007e..a95fbc4496 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -162,7 +162,9 @@ Claude Desktop: работающий процесс может хранить п ## Селектор /model («From gateway») Claude Code 2.1.129+ обнаруживает модели шлюза через `GET /v1/models?limit=1000` и показывает их -в нативном селекторе `/model` в разделе «From gateway». Поскольку селектор принимает только id, +в нативном селекторе `/model`. Строка без `description` подписана «From gateway»; opencodex отправляет +её для каждой строки Claude Code CLI (`Routed by OpenCodex to /`; для нативных строк — `Routed by OpenCodex to native `, строки Fast добавляют ` · Fast`, строки 1M сохраняют базовое описание), и Claude Code 2.1.257+ +показывает этот текст вместо подписи. Поскольку селектор принимает только id, начинающиеся с `claude` или `anthropic`, opencodex публикует маршрутизируемые модели как стабильные обратимые алиасы: diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index d6f5496db1..e4b5c8b6e2 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -312,8 +312,10 @@ gerekirse anahtarı hub'da ayrıca iptal edin. ## /model seçici ("From gateway") Claude Code 2.1.129+, `GET /v1/models?limit=1000` aracılığıyla ağ geçidi -modellerini keşfeder ve bunları yerel `/model` seçicisinde "From gateway" -etiketiyle listeler. Seçici yalnızca `claude` veya `anthropic` ile başlayan +modellerini keşfeder ve bunları yerel `/model` seçicisinde listeler. `description` +alanı olmayan bir satır "From gateway" olarak görünür; opencodex her Claude Code CLI +satırı için bir tane gönderir (`Routed by OpenCodex to /`; yerel satırlarda `Routed by OpenCodex to native `, Fast satırları sona ` · Fast` ekler, 1M satırları temel açıklamayı korur) ve Claude Code +2.1.257+ onun yerine bunu gösterir. Seçici yalnızca `claude` veya `anthropic` ile başlayan kimlikleri kabul ettiğinden, opencodex yönlendirilen modelleri kararlı, tersine çevrilebilir takma adlar olarak sunar: diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 746eeba39f..fdb4e50125 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -146,7 +146,9 @@ UI。真实 Anthropic 模型保留其原始 id。合成的 2026 日期是内部 中输入任意路由 id(Claude Code 会原样传递字符串)。 Claude Code 2.1.129+ 通过 `GET /v1/models?limit=1000` 发现网关模型,并在原生 `/model` -选择器中以“From gateway”标签列出。由于选择器只接受以 `claude` 或 `anthropic` 开头的 ID, +选择器中列出。没有 `description` 的行显示为“From gateway”;opencodex 会为 Claude Code CLI 的每一行发送 +`description`(`Routed by OpenCodex to /`;原生行为 `Routed by OpenCodex to native `,Fast 行末尾加 ` · Fast`,1M 行沿用基础描述),Claude Code 2.1.257+ 会改为显示它。 +由于选择器只接受以 `claude` 或 `anthropic` 开头的 ID, opencodex 会将已路由模型公开为稳定且可逆的别名: | 界面 | 格式 | 示例 | diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index fc35c52b39..be440122ec 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -222,7 +222,9 @@ apply、輪換/復原或直接 disconnect 均可處理,無須新參數或事 ## /model 選擇器(“From gateway”) Claude Code 2.1.129+ 透過 `GET /v1/models?limit=1000` 發現閘道器模型,並在原生 `/model` -選擇器中以“From gateway”標籤列出。由於選擇器只接受以 `claude` 或 `anthropic` 開頭的 ID, +選擇器中列出。沒有 `description` 的列會顯示為“From gateway”;opencodex 會為 Claude Code CLI 的每一列送出 +`description`(`Routed by OpenCodex to /`;原生列為 `Routed by OpenCodex to native `,Fast 列末尾加上 ` · Fast`,1M 列沿用基礎描述),Claude Code 2.1.257+ 會改為顯示它。 +由於選擇器只接受以 `claude` 或 `anthropic` 開頭的 ID, opencodex 會將已路由模型公開為穩定且可逆的別名: | 介面 | 格式 | 示例 | diff --git a/src/claude/gateway-cache.ts b/src/claude/gateway-cache.ts index 366017a04b..c0000b2a93 100644 --- a/src/claude/gateway-cache.ts +++ b/src/claude/gateway-cache.ts @@ -6,9 +6,10 @@ * subscription-preserving launch deliberately sets no token, so the CLI can never * refresh its picker list itself — it reads whatever cache exists. We therefore * pre-write the cache in the exact on-disk schema the CLI uses: - * { baseUrl, fetchedAt, models: [{ id, display_name? }] } (mode 0600) + * { baseUrl, fetchedAt, models: [{ id, display_name?, description? }] } (mode 0600) * mirroring its `/^(claude|anthropic)/i` usable-id filter. The picker validates * only `baseUrl === ANTHROPIC_BASE_URL`, so a foreign base URL is simply ignored. + * `description` replaces the picker's generic "From gateway" line (Claude Code >= 2.1.257). */ import { mkdirSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; @@ -19,6 +20,7 @@ import type { OcxConfig } from "../types"; export interface GatewayModelRow { id: string; display_name?: string; + description?: string; } export interface GatewayModelCacheRefreshOptions { @@ -56,7 +58,11 @@ export function writeGatewayModelCache(baseUrl: string, models: readonly Gateway const payload = { baseUrl, fetchedAt: Date.now(), - models: usable.map(m => (m.display_name === undefined ? { id: m.id } : { id: m.id, display_name: m.display_name })), + models: usable.map(m => ({ + id: m.id, + ...(m.display_name === undefined ? {} : { display_name: m.display_name }), + ...(m.description === undefined ? {} : { description: m.description }), + })), }; writeFileSync(path, JSON.stringify(payload), { encoding: "utf8", mode: 0o600 }); return path; @@ -110,6 +116,7 @@ export async function refreshGatewayModelCacheFromProxy( .map(m => ({ id: m.id as string, display_name: typeof m.display_name === "string" ? m.display_name : undefined, + description: typeof m.description === "string" ? m.description : undefined, })); return writeGatewayModelCache(baseUrl, models, options.configDir); } catch { diff --git a/src/claude/model-info.ts b/src/claude/model-info.ts index 665183c651..7f5fe0f726 100644 --- a/src/claude/model-info.ts +++ b/src/claude/model-info.ts @@ -80,9 +80,14 @@ export interface AnthropicModelInfo { capabilities: ReturnType; max_input_tokens: number | null; max_tokens: null; + /** + * Claude Code (>= 2.1.257) shows this under the picker row instead of the generic + * "From gateway". Readable (CLI) rows only; Desktop 3P rows keep the ModelInfo shape. + */ + description?: string; } -function modelInfo(id: string, displayName: string, ladder: readonly string[], imageInput: boolean, contextWindow?: number): AnthropicModelInfo { +function modelInfo(id: string, displayName: string, ladder: readonly string[], imageInput: boolean, contextWindow?: number, description?: string): AnthropicModelInfo { return { id, display_name: displayName, @@ -91,6 +96,7 @@ function modelInfo(id: string, displayName: string, ladder: readonly string[], i capabilities: modelCapabilities(ladder, imageInput), max_input_tokens: typeof contextWindow === "number" && contextWindow > 0 ? contextWindow : null, max_tokens: null, + ...(description === undefined ? {} : { description }), }; } @@ -182,7 +188,13 @@ export function buildAnthropicModelInfos( // A real model always wins its own id, whatever the iteration order. if (realDiscoveryIds.has(id) || seen.has(id)) return; seen.add(id); - out.push({ ...base, id, display_name: `${base.display_name} · Fast` }); + out.push({ + ...base, + id, + display_name: `${base.display_name} · Fast`, + // Fast picks a different tier/variant, so the picker line says so like the name does. + ...(base.description === undefined ? {} : { description: `${base.description} · Fast` }), + }); }; for (const slug of nativeSlugs) { const id = idStyle === "readable" ? claudeCodeNativeAlias(slug) : aliasForRoute("native", slug); @@ -192,7 +204,8 @@ export function buildAnthropicModelInfos( const nativeMaxInput = nativeOpenAiMaxInputTokens(slug, nativeContextCap); // max_input_tokens is an INPUT limit, so it follows the measured input ceiling rather // than the total window whenever the model publishes one. - const info = modelInfo(id, `${slug} (native)`, nativeEffectiveLadder(slug), true, nativeMaxInput ?? nativeWindow); + const description = idStyle === "readable" ? `Routed by OpenCodex to native ${slug}` : undefined; + const info = modelInfo(id, `${slug} (native)`, nativeEffectiveLadder(slug), true, nativeMaxInput ?? nativeWindow, description); out.push(info); push1mVariant(info, nativeWindow, nativeMaxInput); // Natives too, not only routed rows: gpt-5.6-sol is the flagship Fast model, and @@ -225,7 +238,8 @@ export function buildAnthropicModelInfos( ? Math.min(m.maxInputTokens, m.contextWindow) : m.maxInputTokens) : undefined; - const info = modelInfo(id, `${listedModelId} (${m.provider})`, ladder, imageInput, routedMaxInput ?? m.contextWindow); + const description = idStyle === "readable" ? `Routed by OpenCodex to ${m.provider}/${listedModelId}` : undefined; + const info = modelInfo(id, `${listedModelId} (${m.provider})`, ladder, imageInput, routedMaxInput ?? m.contextWindow, description); out.push(info); // Anthropic passthrough guard (audit 021 #3): never auto-widen canonical claude // routes — only a genuine >=1M window earns the variant row there. diff --git a/tests/claude-integration/claude-gateway-cache.test.ts b/tests/claude-integration/claude-gateway-cache.test.ts index f690cda619..9f8ef4ffdb 100644 --- a/tests/claude-integration/claude-gateway-cache.test.ts +++ b/tests/claude-integration/claude-gateway-cache.test.ts @@ -195,3 +195,30 @@ describe("Claude Code gateway-model cache pre-write (devlog 260712 030)", () => } }); }); + +describe("gateway-model cache carries the picker description", () => { + test("writer keeps description so the picker stops reading \"From gateway\"", () => { + const dir = tempDir(); + const path = writeGatewayModelCache("http://127.0.0.1:10100", [ + { id: "claude-ocx-xai--grok-4.7", display_name: "grok-4.7 (xai)", description: "Routed by OpenCodex to xai/grok-4.7" }, + { id: "claude-ocx-native--gpt-5.5", display_name: "gpt-5.5 (native)" }, + ], dir); + expect(JSON.parse(readFileSync(path!, "utf8")).models).toEqual([ + { id: "claude-ocx-xai--grok-4.7", display_name: "grok-4.7 (xai)", description: "Routed by OpenCodex to xai/grok-4.7" }, + { id: "claude-ocx-native--gpt-5.5", display_name: "gpt-5.5 (native)" }, + ]); + }); + + test("proxy refresh copies description from /v1/models and ignores non-strings", async () => { + const dir = tempDir(); + const fetchImpl = (async () => new Response(JSON.stringify({ data: [ + { id: "claude-ocx-xai--grok-4.7", display_name: "grok-4.7 (xai)", description: "Routed by OpenCodex to xai/grok-4.7" }, + { id: "claude-ocx-p--odd", display_name: "odd (p)", description: 42 }, + ] }), { headers: { "content-type": "application/json" } })) as unknown as typeof fetch; + const path = await refreshGatewayModelCacheFromProxy(10100, { timeoutMs: 1000, configDir: dir, env: {}, fetchImpl }); + expect(JSON.parse(readFileSync(path!, "utf8")).models).toEqual([ + { id: "claude-ocx-xai--grok-4.7", display_name: "grok-4.7 (xai)", description: "Routed by OpenCodex to xai/grok-4.7" }, + { id: "claude-ocx-p--odd", display_name: "odd (p)" }, + ]); + }); +}); diff --git a/tests/claude-integration/claude-model-info.test.ts b/tests/claude-integration/claude-model-info.test.ts index 7794a73029..5d7189b8e2 100644 --- a/tests/claude-integration/claude-model-info.test.ts +++ b/tests/claude-integration/claude-model-info.test.ts @@ -237,3 +237,33 @@ describe("saved picker order changes groups after identity selection", () => { expect(result.map(row => [row.id, row.display_name])).toEqual([["collision", "a (p)"]]); }); }); + +describe("Claude Code picker description (replaces the generic \"From gateway\" line)", () => { + test("readable rows describe the route OpenCodex serves them through", () => { + const infos = buildAnthropicModelInfos(["gpt-5.5"], [ + { provider: "xai", id: "grok-4.7", contextWindow: 500_000 }, + ], undefined, "readable"); + const native = infos.find(i => i.display_name === "gpt-5.5 (native)"); + const routed = infos.find(i => i.display_name === "grok-4.7 (xai)"); + expect(native?.description).toBe("Routed by OpenCodex to native gpt-5.5"); + expect(routed?.description).toBe("Routed by OpenCodex to xai/grok-4.7"); + }); + + // A 1M row is the same route with a larger window; a Fast row selects a different tier or + // variant, so its description says so the way its display name does. + test("1M siblings keep the base description and Fast siblings name the Fast tier", () => { + const infos = buildAnthropicModelInfos([], [ + { provider: "p", id: "big", contextWindow: 1_000_000 }, + ], undefined, "readable", undefined, undefined, false, () => true); + expect(infos.map(i => [i.display_name, i.description])).toEqual([ + ["big (p)", "Routed by OpenCodex to p/big"], + ["big (p) · 1M", "Routed by OpenCodex to p/big"], + ["big (p) · Fast", "Routed by OpenCodex to p/big · Fast"], + ]); + }); + + test("Desktop 3P rows stay unchanged (no description field)", () => { + const infos = buildAnthropicModelInfos(["gpt-5.5"], [{ provider: "xai", id: "grok-4.7" }], undefined, "desktop3p"); + for (const info of infos) expect("description" in info).toBe(false); + }); +});