From 320cdb5a706918ba3ba485168f88147175714627 Mon Sep 17 00:00:00 2001 From: snowyukitty <270071858+snowyukitty@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:23:45 +0900 Subject: [PATCH 1/5] fix(router): warn when a pinned provider discards a configured baseUrl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A registry entry with a non-template baseUrl and no allowBaseUrlOverride outranks a saved provider baseUrl. That resolution is intentional, but it happens silently: requests go to an endpoint the user never configured, and a wrong-region or wrong-account URL surfaces only as a 401 with nothing pointing back at the discarded setting. Warn once per (provider, discarded, effective) triple. routedProviderConfig runs per request, so an unguarded warn would repeat on every call, and keying on the URLs means a config edited to a different wrong value warns again. Both URLs go through redactUrlForLog because a baseUrl can carry credentials in userinfo or a query string. Routing is unchanged. A hard error would break configs that route fine today, since a stale baseUrl naming the same endpoint the registry pins is harmless — those are compared modulo surrounding space and trailing slashes, matching matchBaseUrlChoice, and stay silent. --- src/router.ts | 34 +++++ .../router-discarded-baseurl-warning.test.ts | 131 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 tests/router-discarded-baseurl-warning.test.ts diff --git a/src/router.ts b/src/router.ts index 6e8850bcfe..13832642dd 100644 --- a/src/router.ts +++ b/src/router.ts @@ -2,6 +2,7 @@ import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "./types"; import { preservesPhysicalComboProvider, tryPickComboModel, type ComboPick } from "./combos"; import { hasOwnProvider, resolveEnvValue } from "./config"; import { assertProviderDestinationAllowed } from "./lib/destination-policy"; +import { redactUrlForLog } from "./lib/redact"; import { PROVIDER_REGISTRY, providerCodexAccountMode } from "./providers/registry"; import { LEGACY_CHATGPT_PROVIDER_ID, LEGACY_OPENAI_MULTI_PROVIDER_ID, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers"; import { decodeRoutedModelId, encodeRoutedModelId } from "./providers/slug-codec"; @@ -117,6 +118,38 @@ function mergeStringArrayRecord( return out; } +/** Same endpoint modulo surrounding space and trailing slashes — matches `matchBaseUrlChoice`. */ +function isSameEndpoint(a: string, b: string): boolean { + return a.trim().replace(/\/+$/, "") === b.trim().replace(/\/+$/, ""); +} + +// `routedProviderConfig` runs per request, so warn once per (provider, discarded, effective) triple. +// Keyed by the URLs too: editing config.json to a different wrong value warns again. +const discardedBaseUrlWarnings = new Set(); + +/** + * A pinned registry entry — non-template `baseUrl`, no `allowBaseUrlOverride` — outranks a saved + * `baseUrl`. Dropping it silently is a footgun: requests go to an endpoint the user never + * configured, and a wrong-region or wrong-account URL then surfaces only as a 401 with nothing + * pointing back at the discarded setting. + * + * Warns rather than throws. The effective route is exactly what it was before, so a hard error + * here would break configs that route fine today (a stale `baseUrl` left over from an earlier + * provider is harmless whenever it names the same endpoint the registry pins). + */ +function warnIfBaseUrlDiscarded(providerName: string, userBaseUrl: string, effectiveBaseUrl: string): void { + if (isSameEndpoint(userBaseUrl, effectiveBaseUrl)) return; + const key = `${providerName} | ${userBaseUrl} | ${effectiveBaseUrl}`; + if (discardedBaseUrlWarnings.has(key)) return; + discardedBaseUrlWarnings.add(key); + console.warn( + // A baseUrl can carry credentials in userinfo or query — redact both before logging. + `⚠️ config.json provider "${providerName}": configured baseUrl ${redactUrlForLog(userBaseUrl)} is ignored` + + ` because this provider's endpoint is fixed at ${redactUrlForLog(effectiveBaseUrl)}. Requests go to the` + + ` fixed endpoint and will fail to authenticate if the configured URL was for a different account or region.`, + ); +} + function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig { const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName); if (!registryEntry) { @@ -163,6 +196,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig) const baseUrl = (registryBaseUrlIsTemplate || registryEntry.allowBaseUrlOverride) && userBaseUrlIsResolved ? userBaseUrl : registryEntry.baseUrl; + if (userBaseUrlIsResolved) warnIfBaseUrlDiscarded(providerName, userBaseUrl, baseUrl); assertProviderDestinationAllowed(providerName, { baseUrl, allowPrivateNetwork: provider.allowPrivateNetwork }); return { diff --git a/tests/router-discarded-baseurl-warning.test.ts b/tests/router-discarded-baseurl-warning.test.ts new file mode 100644 index 0000000000..bed339bca0 --- /dev/null +++ b/tests/router-discarded-baseurl-warning.test.ts @@ -0,0 +1,131 @@ +import { expect, test } from "bun:test"; +import { routeModel } from "../src/router"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +/** + * A pinned registry entry outranks a saved `baseUrl`. That behavior is intentional and is + * asserted in tests/router-template-baseurl.test.ts; these tests cover the diagnostic that + * tells the user it happened, so a wrong-region URL stops surfacing as a bare 401. + * + * `anthropic` is the pinned fixture: a fixed remote registry endpoint, no `allowBaseUrlOverride`. + * Warnings dedupe per (provider, discarded URL, effective URL), so each test uses a distinct + * discarded URL and the suite stays order-independent. + */ +const PINNED_PROVIDER = "anthropic"; +const PINNED_REGISTRY_BASE_URL = "https://api.anthropic.com"; + +function configFor(providerName: string, provider: OcxProviderConfig): OcxConfig { + return { + port: 10100, + defaultProvider: providerName, + providers: { [providerName]: provider }, + }; +} + +/** Route once, capturing anything the router writes to `console.warn`. */ +function routeCapturingWarnings(config: OcxConfig, model: string, times = 1): string[] { + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); }; + try { + for (let i = 0; i < times; i++) routeModel(config, model); + } finally { + console.warn = originalWarn; + } + return warnings; +} + +function routePinned(baseUrl: unknown, times = 1): string[] { + return routeCapturingWarnings( + configFor(PINNED_PROVIDER, { adapter: "anthropic", baseUrl } as OcxProviderConfig), + `${PINNED_PROVIDER}/claude-sonnet-5`, + times, + ); +} + +test("warns when a pinned provider discards a configured baseUrl", () => { + const discarded = "https://vertex-relay.example.test/v1"; + const warnings = routePinned(discarded); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain(`provider "${PINNED_PROVIDER}"`); + expect(warnings[0]).toContain(discarded); + expect(warnings[0]).toContain(PINNED_REGISTRY_BASE_URL); +}); + +test("routing is unchanged by the warning", () => { + const config = configFor(PINNED_PROVIDER, { + adapter: "anthropic", + baseUrl: "https://routing-unchanged.example.test/v1", + }); + const originalWarn = console.warn; + console.warn = () => {}; + try { + expect(routeModel(config, `${PINNED_PROVIDER}/claude-sonnet-5`).provider.baseUrl) + .toBe(PINNED_REGISTRY_BASE_URL); + } finally { + console.warn = originalWarn; + } +}); + +test("warns once per provider and URL pair across repeated routing", () => { + expect(routePinned("https://repeated.example.test/v1", 5)).toHaveLength(1); +}); + +test("redacts credentials in the discarded URL", () => { + const warnings = routePinned("https://user:hunter2@redacted.example.test/v1?api_key=sk-live-abcdefgh"); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("redacted.example.test"); + expect(warnings[0]).not.toContain("hunter2"); + expect(warnings[0]).not.toContain("sk-live-abcdefgh"); +}); + +for (const [label, baseUrl] of [ + ["an absent baseUrl", undefined], + ["an empty baseUrl", ""], + ["a whitespace-only baseUrl", " \t"], + ["an unresolved placeholder", "https://{region}.anthropic.example/v1"], + ["the registry endpoint itself", PINNED_REGISTRY_BASE_URL], + ["the registry endpoint with a trailing slash", `${PINNED_REGISTRY_BASE_URL}/`], + ["the registry endpoint with surrounding space", ` ${PINNED_REGISTRY_BASE_URL} `], +] as const) { + test(`stays silent for ${label}`, () => { + expect(routePinned(baseUrl)).toEqual([]); + }); +} + +test("stays silent for a non-string baseUrl (control: unchanged pre-existing behavior)", () => { + // A non-string baseUrl is already dropped by the `typeof` guard in routedProviderConfig and is + // a config-schema concern, not this diagnostic's. Pinned here so the omission stays deliberate. + expect(routePinned(42 as unknown as string)).toEqual([]); +}); + +for (const { label, id, adapter, baseUrl } of [ + { + label: "a provider that opts into baseUrl override", + id: "ollama", + adapter: "openai-chat", + baseUrl: "http://ollama.lan:3210/v1", + }, + { + label: "a resolved registry template", + id: "azure-openai", + adapter: "azure-openai", + baseUrl: "https://myres.openai.azure.com/openai", + }, + { + label: "a provider absent from the registry", + id: "my-custom-provider", + adapter: "openai-chat", + baseUrl: "https://custom.example.test/v1", + }, +] as const) { + test(`stays silent for ${label}, whose baseUrl is honored`, () => { + const config = configFor(id, { adapter, baseUrl } as OcxProviderConfig); + const warnings = routeCapturingWarnings(config, `${id}/model`); + + expect(warnings).toEqual([]); + expect(routeModel(config, `${id}/model`).provider.baseUrl).toBe(baseUrl); + }); +} From 870202a21055adcf3544ebdacad2cd43c30f908b Mon Sep 17 00:00:00 2001 From: snowyukitty <270071858+snowyukitty@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:49:58 +0900 Subject: [PATCH 2/5] fix(router): mask path secrets and key the warn dedup on redacted URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the discarded-baseUrl warning, plus the docs sync AGENTS.md asks for. redactUrlForLog drops userinfo, query and fragment but keeps the pathname, so a key embedded in a path segment (https://proxy.example/v1/sk-...) went to the log verbatim. AGENTS.md forbids logging API keys, so layer redactSecretString over the result; host and remaining path stay readable because they are what makes the warning diagnostic. The dedup Set also held the raw configured URL for the process lifetime, outliving the config that supplied it. Key it on the redacted forms instead: no raw credential is retained, and rotating a key embedded in the URL no longer re-warns about an endpoint mismatch already reported. Docs: reference/configuration.md now says a pinned built-in endpoint wins over a configured baseUrl, names the six providers that opt into an override, and explains what to do about the warning. English only — the translated locales do not mention baseUrl override, so nothing there contradicts this. --- .../content/docs/reference/configuration.md | 18 ++++++++++++- src/router.ts | 25 +++++++++++++++---- .../router-discarded-baseurl-warning.test.ts | 20 +++++++++++++++ 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 9d865ca0f3..1232b7e0e6 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -146,7 +146,7 @@ network. Only do this on trusted networks, and always set a strong `OPENCODEX_AP | Field | Type | Meaning | | --- | --- | --- | | `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (or alias `azure`). | -| `baseUrl` | `string` | Upstream API base URL. | +| `baseUrl` | `string` | Upstream API base URL. Built-in providers with a fixed endpoint ignore it — see [Fixed provider endpoints](#fixed-provider-endpoints). | | `responsesPath?` | `string` | Optional relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no URL scheme, query, or fragment. When omitted, the adapter keeps its legacy `/v1/responses` URL construction. | | `disabled?` | `boolean` | Keep the provider on disk but exclude it from routing and model/catalog listings. | | `apiKey?` | `string` | API key, or an `${ENV_VAR}` / `$ENV_VAR` reference resolved at request time. | @@ -193,6 +193,22 @@ network. Only do this on trusted networks, and always set a strong `OPENCODEX_AP | `unsafeAllowNativeLocalExec?` | `boolean` | **Cursor adapter only.** Legacy compatibility boolean for the Cursor server-driven local `read` / `write` / `delete` / `ls` / `grep` / `shell` / `fetch` executor. Equivalent to `nativeLocalExec: "on"` when `nativeLocalExec` is unset; an explicit `nativeLocalExec` value always wins. Defaults to `false`. Prefer `nativeLocalExec` for new configs. See [Cursor provider](#cursor-provider-adapter-cursor) below. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | **Cursor adapter only.** Native local exec policy for the Cursor server-driven executor. `"off"` (default) rejects it; `"on"` is the trusted-local opt-in; `"codex-sandbox"` is accepted for backwards compatibility but is fail-closed like `"off"`. See [Cursor provider](#cursor-provider-adapter-cursor) below. | +### Fixed provider endpoints + +Most built-in providers pin their own endpoint, and that pinned value wins over a `baseUrl` in +your config. Only two kinds of entry honor a configured URL: the six that opt into an override — +`ollama`, `vllm`, `lm-studio`, `litellm`, `qwen-cloud` and `alibaba-token-plan-intl` — and +providers whose registry endpoint is a template you are expected to fill in, such as +`azure-openai` and `cloudflare-ai-gateway`. Providers you define yourself always use their own +`baseUrl`. + +When a configured `baseUrl` is discarded this way, opencodex logs a warning naming both URLs. +Credentials in the URL are masked first. If you see it, either drop the `baseUrl` — the pinned +endpoint is what the provider will use regardless — or switch to the provider whose endpoint +matches the URL you wanted. Picking the right entry matters when a vendor runs one product in +several regions: `alibaba-token-plan` is pinned to Beijing, while `alibaba-token-plan-intl` +covers the international endpoints, and a key issued for one is rejected by the other. + For broken `openai-responses` compatibility gateways, `responsesItemIdRepair` belongs on the provider object itself, for example: diff --git a/src/router.ts b/src/router.ts index 13832642dd..189d7dd21c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -2,7 +2,7 @@ import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "./types"; import { preservesPhysicalComboProvider, tryPickComboModel, type ComboPick } from "./combos"; import { hasOwnProvider, resolveEnvValue } from "./config"; import { assertProviderDestinationAllowed } from "./lib/destination-policy"; -import { redactUrlForLog } from "./lib/redact"; +import { redactSecretString, redactUrlForLog } from "./lib/redact"; import { PROVIDER_REGISTRY, providerCodexAccountMode } from "./providers/registry"; import { LEGACY_CHATGPT_PROVIDER_ID, LEGACY_OPENAI_MULTI_PROVIDER_ID, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers"; import { decodeRoutedModelId, encodeRoutedModelId } from "./providers/slug-codec"; @@ -123,6 +123,16 @@ function isSameEndpoint(a: string, b: string): boolean { return a.trim().replace(/\/+$/, "") === b.trim().replace(/\/+$/, ""); } +/** + * `redactUrlForLog` drops userinfo, query and fragment but keeps the pathname, and a configured + * `baseUrl` may carry a key in a path segment (`https://proxy.example/v1/sk-…`). Layer + * `redactSecretString` on top so token-shaped path segments are masked too. The host and the + * remaining path stay readable — they are what makes the warning diagnostic. + */ +function redactBaseUrlForLog(url: string): string { + return redactSecretString(redactUrlForLog(url)); +} + // `routedProviderConfig` runs per request, so warn once per (provider, discarded, effective) triple. // Keyed by the URLs too: editing config.json to a different wrong value warns again. const discardedBaseUrlWarnings = new Set(); @@ -139,13 +149,18 @@ const discardedBaseUrlWarnings = new Set(); */ function warnIfBaseUrlDiscarded(providerName: string, userBaseUrl: string, effectiveBaseUrl: string): void { if (isSameEndpoint(userBaseUrl, effectiveBaseUrl)) return; - const key = `${providerName} | ${userBaseUrl} | ${effectiveBaseUrl}`; + // A baseUrl can carry credentials in userinfo, query, *and* path. redactUrlForLog strips the + // first two but keeps the pathname, so run redactSecretString over the result as well. + const discarded = redactBaseUrlForLog(userBaseUrl); + const effective = redactBaseUrlForLog(effectiveBaseUrl); + // Key off the redacted forms: no raw credential is retained for the process lifetime, and + // rotating a key embedded in the URL no longer re-warns about the same endpoint mismatch. + const key = `${providerName} | ${discarded} | ${effective}`; if (discardedBaseUrlWarnings.has(key)) return; discardedBaseUrlWarnings.add(key); console.warn( - // A baseUrl can carry credentials in userinfo or query — redact both before logging. - `⚠️ config.json provider "${providerName}": configured baseUrl ${redactUrlForLog(userBaseUrl)} is ignored` - + ` because this provider's endpoint is fixed at ${redactUrlForLog(effectiveBaseUrl)}. Requests go to the` + `⚠️ config.json provider "${providerName}": configured baseUrl ${discarded} is ignored` + + ` because this provider's endpoint is fixed at ${effective}. Requests go to the` + ` fixed endpoint and will fail to authenticate if the configured URL was for a different account or region.`, ); } diff --git a/tests/router-discarded-baseurl-warning.test.ts b/tests/router-discarded-baseurl-warning.test.ts index bed339bca0..74654687e9 100644 --- a/tests/router-discarded-baseurl-warning.test.ts +++ b/tests/router-discarded-baseurl-warning.test.ts @@ -81,6 +81,26 @@ test("redacts credentials in the discarded URL", () => { expect(warnings[0]).not.toContain("sk-live-abcdefgh"); }); +test("redacts a token embedded in the URL path, which redactUrlForLog keeps", () => { + const warnings = routePinned("https://path-secret.example.test/v1/sk-live-ijklmnop/chat"); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("path-secret.example.test"); + expect(warnings[0]).not.toContain("sk-live-ijklmnop"); +}); + +test("warns once for URLs that differ only in their credentials", () => { + const warnings = [ + ...routePinned("https://alice:secret-one@shared-endpoint.example.test/v1"), + ...routePinned("https://bob:secret-two@shared-endpoint.example.test/v1"), + ]; + + // Both redact to the same endpoint, so the second is a repeat of a mismatch already reported. + expect(warnings).toHaveLength(1); + expect(warnings[0]).not.toContain("secret-one"); + expect(warnings[0]).not.toContain("secret-two"); +}); + for (const [label, baseUrl] of [ ["an absent baseUrl", undefined], ["an empty baseUrl", ""], From 38b3396842b45762bf05c8e90a80e87580adf405 Mon Sep 17 00:00:00 2001 From: snowyukitty <270071858+snowyukitty@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:01:21 +0900 Subject: [PATCH 3/5] docs(config): scope the pinned-endpoint rule to routing, add zh-cn mirror Review follow-up. The previous wording said only two kinds of entry honor a configured baseUrl, which read as a claim about every layer. It is a claim about routing: routedProviderConfig resolves the endpoint before an adapter sees it, and an adapter may adjust it afterwards. The kiro adapter does exactly that, following the imported credential's API region for a canonical runtime.{region}.kiro.dev host, which reference/adapters.md already documents. - reference/configuration.md now scopes the rule to routing, lists the three kinds of entry that keep the configured URL as a list rather than an "only" clause, and links to the adapter reference for per-adapter rules. - The warning no longer promises where the request lands. It states what routing did and names the common cause of a 401, since an adapter can still change the endpoint downstream. - Mirrored into zh-cn, matching the English-plus-one-locale precedent in a758bfcf (ko) and 6a0f5b5d (zh-cn). ja, ko and ru are left to their owners. --- .../content/docs/reference/configuration.md | 33 +++++++++++-------- .../docs/zh-cn/reference/configuration.md | 21 +++++++++++- src/router.ts | 6 ++-- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 1232b7e0e6..58ea4dd503 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -195,19 +195,26 @@ network. Only do this on trusted networks, and always set a strong `OPENCODEX_AP ### Fixed provider endpoints -Most built-in providers pin their own endpoint, and that pinned value wins over a `baseUrl` in -your config. Only two kinds of entry honor a configured URL: the six that opt into an override — -`ollama`, `vllm`, `lm-studio`, `litellm`, `qwen-cloud` and `alibaba-token-plan-intl` — and -providers whose registry endpoint is a template you are expected to fill in, such as -`azure-openai` and `cloudflare-ai-gateway`. Providers you define yourself always use their own -`baseUrl`. - -When a configured `baseUrl` is discarded this way, opencodex logs a warning naming both URLs. -Credentials in the URL are masked first. If you see it, either drop the `baseUrl` — the pinned -endpoint is what the provider will use regardless — or switch to the provider whose endpoint -matches the URL you wanted. Picking the right entry matters when a vendor runs one product in -several regions: `alibaba-token-plan` is pinned to Beijing, while `alibaba-token-plan-intl` -covers the international endpoints, and a key issued for one is rejected by the other. +Routing resolves a provider's endpoint before any adapter sees it, and for most built-in +providers the registry's own endpoint wins over a `baseUrl` in your config. Three kinds of entry +keep the configured URL at this stage: + +- providers that opt into an override — `ollama`, `vllm`, `lm-studio`, `litellm`, `qwen-cloud` + and `alibaba-token-plan-intl`; +- providers whose registry endpoint is a template you fill in, such as `azure-openai` and + `cloudflare-ai-gateway`; +- providers you define yourself, which are not in the registry at all. + +Adapters may adjust the resolved URL afterwards. The `kiro` adapter, for example, follows the API +region of the imported credential for a canonical `runtime.{region}.kiro.dev` host. See +[Adapters](/reference/adapters/) for per-adapter rules. + +When routing discards a configured `baseUrl`, opencodex logs a warning naming both URLs, with any +credentials in them masked. Either drop the `baseUrl` — the registry endpoint is what routing +will use regardless — or switch to the provider whose endpoint matches the URL you wanted. +Picking the right entry matters when a vendor runs one product in several regions: +`alibaba-token-plan` is pinned to Beijing while `alibaba-token-plan-intl` covers the +international endpoints, and a key issued for one is rejected by the other. For broken `openai-responses` compatibility gateways, `responsesItemIdRepair` belongs on the provider object itself, for example: diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index 3299173b33..acfc2115d5 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -126,7 +126,7 @@ x-opencodex-api-key: your-secret-token | Field | Type | 含义 | | --- | --- | --- | | `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`azure-openai`(或别名 `azure`)之一。 | -| `baseUrl` | `string` | 上游 API base URL。 | +| `baseUrl` | `string` | 上游 API base URL。端点固定的内置 provider 会忽略它 —— 见[固定的 provider 端点](#固定的-provider-端点)。 | | `responsesPath?` | `string` | `key` 认证的 `openai-responses` 请求可选相对 resource path。必须以 `/` 开头,且不得包含 URL scheme、query 或 fragment。省略时保留原有的 `/v1/responses` URL 构造。 | | `disabled?` | `boolean` | 配置保留在磁盘上,但从路由和模型/目录列表排除。 | | `apiKey?` | `string` | API key,或在请求时解析的 `${ENV_VAR}` / `$ENV_VAR` 引用。 | @@ -167,6 +167,25 @@ x-opencodex-api-key: your-secret-token | `desktopExecutor?` | `DesktopExecutorConfig` | **仅 Cursor。** 外部 computer-use/record-screen 命令;字段见下文。 | | `unsafeAllowNativeLocalExec?` | `boolean` | **仅 Cursor adapter。** 允许 Cursor server 驱动本地 `read` / `write` / `delete` / `ls` / `grep` / `shell` / `fetch` 的 opt-in escape hatch。默认 `false`,防止远程 Cursor message 绕过 Codex 审批与 sandbox。见下文 [Cursor provider](#cursor-provideradapter-cursor)。 | +### 固定的 provider 端点 + +路由会在任何 adapter 介入之前解析 provider 的端点;对大多数内置 provider 而言,registry 自带的端点 +优先于你在配置里写的 `baseUrl`。在这一步保留配置 URL 的只有三类: + +- 显式开启覆盖的 provider —— `ollama`、`vllm`、`lm-studio`、`litellm`、`qwen-cloud` 和 + `alibaba-token-plan-intl`; +- registry 端点本身是待填模板的 provider,例如 `azure-openai` 和 `cloudflare-ai-gateway`; +- 你自己定义的 provider,它们根本不在 registry 中。 + +之后 adapter 仍可能调整已解析的 URL。例如 `kiro` adapter 在 host 为标准 +`runtime.{region}.kiro.dev` 时,会改用导入凭据所属的 API region。逐个 adapter 的规则见 +[Adapters](/zh-cn/reference/adapters/)。 + +当路由丢弃配置的 `baseUrl` 时,opencodex 会打印一条同时列出两个 URL 的警告,其中的凭据已被遮蔽。 +此时要么删掉 `baseUrl`(路由本来就只会使用 registry 端点),要么改用端点与目标 URL 相符的 provider。 +当同一产品分区域运营时,选对条目尤其重要:`alibaba-token-plan` 固定指向北京,而 +`alibaba-token-plan-intl` 覆盖国际端点,为其中一个签发的 key 在另一个上会被拒绝。 + ## Cursor provider(`adapter: "cursor"`) Cursor bridge 仍属实验功能。运行 `ocx login cursor` 后,在 diff --git a/src/router.ts b/src/router.ts index 189d7dd21c..4f58af6937 100644 --- a/src/router.ts +++ b/src/router.ts @@ -159,9 +159,11 @@ function warnIfBaseUrlDiscarded(providerName: string, userBaseUrl: string, effec if (discardedBaseUrlWarnings.has(key)) return; discardedBaseUrlWarnings.add(key); console.warn( + // Routing is what this warning speaks for: an adapter may adjust the endpoint again + // downstream (kiro re-derives the region), so do not promise where the request lands. `⚠️ config.json provider "${providerName}": configured baseUrl ${discarded} is ignored` - + ` because this provider's endpoint is fixed at ${effective}. Requests go to the` - + ` fixed endpoint and will fail to authenticate if the configured URL was for a different account or region.`, + + ` because this provider's endpoint is fixed at ${effective}. A URL saved for a different` + + ` account or region is a common cause of 401s here — drop it, or use the provider whose endpoint matches.`, ); } From d9e22f21d858f81444b213ba4e22a40b8665f905 Mon Sep 17 00:00:00 2001 From: snowyukitty <270071858+snowyukitty@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:10:54 +0900 Subject: [PATCH 4/5] docs(config): use the American-English 'afterward' --- docs-site/src/content/docs/reference/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 58ea4dd503..91b729252e 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -205,7 +205,7 @@ keep the configured URL at this stage: `cloudflare-ai-gateway`; - providers you define yourself, which are not in the registry at all. -Adapters may adjust the resolved URL afterwards. The `kiro` adapter, for example, follows the API +Adapters may adjust the resolved URL afterward. The `kiro` adapter, for example, follows the API region of the imported credential for a canonical `runtime.{region}.kiro.dev` host. See [Adapters](/reference/adapters/) for per-adapter rules. From ab9f01cf4947c1f146e19bce2428edab6f7c5a1f Mon Sep 17 00:00:00 2001 From: snowyukitty <270071858+snowyukitty@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:31:24 +0900 Subject: [PATCH 5/5] fix(router): never log a configured baseUrl path, and lock the Alibaba split Pattern redaction was the wrong tool for this value. redactSecretString matches known token shapes, so an opaque account-scoped route token such as https://proxy.example/v1/8fK2mP7qR4nV6x matched nothing and reached console.warn intact. The previous regression used an sk- token, which made the gap look covered. The configured URL is user-controlled, so no path segment is logged at all: configuredOriginForLog returns URL.origin, with a "/..." marker when a path was present so an origin-only config is distinguishable from one whose path was dropped. URL.origin also excludes userinfo, query and fragment. The effective URL keeps its full form on purpose. Past the same-endpoint guard it is necessarily registryEntry.baseUrl -- the caller passes the resolved URL, and whenever resolution kept the user's value the two compare equal and the function has already returned. So that side is a constant from this repo's registry, and it is the half that names where requests go. Tests: opaque high-entropy path credential with no recognizable prefix, and a multi-segment path where no segment survives. Both fail without the change. Also locks the split this diagnostic exists for -- alibaba-token-plan pinned to Beijing and warning about a saved international URL, alibaba-token-plan-intl honoring both its token-plan and payg choices silently. Those pass either way; they are regression locks on the registry split, not proof of this fix. Docs updated in both locales to describe what the warning actually prints. --- .../content/docs/reference/configuration.md | 8 +- .../docs/zh-cn/reference/configuration.md | 3 +- src/router.ts | 40 +++++++--- .../router-discarded-baseurl-warning.test.ts | 78 ++++++++++++++++++- 4 files changed, 111 insertions(+), 18 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 91b729252e..a6fae6967f 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -209,9 +209,11 @@ Adapters may adjust the resolved URL afterward. The `kiro` adapter, for example, region of the imported credential for a canonical `runtime.{region}.kiro.dev` host. See [Adapters](/reference/adapters/) for per-adapter rules. -When routing discards a configured `baseUrl`, opencodex logs a warning naming both URLs, with any -credentials in them masked. Either drop the `baseUrl` — the registry endpoint is what routing -will use regardless — or switch to the provider whose endpoint matches the URL you wanted. +When routing discards a configured `baseUrl`, opencodex logs a warning. It names the registry +endpoint in full and your configured one by origin only, shown as `https://host/…` when it had a +path — a configured path can itself be a credential, so none of it is logged. Either drop the +`baseUrl` — the registry endpoint is what routing will use regardless — or switch to the provider +whose endpoint matches the URL you wanted. Picking the right entry matters when a vendor runs one product in several regions: `alibaba-token-plan` is pinned to Beijing while `alibaba-token-plan-intl` covers the international endpoints, and a key issued for one is rejected by the other. diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index acfc2115d5..dbe9020f55 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -181,7 +181,8 @@ x-opencodex-api-key: your-secret-token `runtime.{region}.kiro.dev` 时,会改用导入凭据所属的 API region。逐个 adapter 的规则见 [Adapters](/zh-cn/reference/adapters/)。 -当路由丢弃配置的 `baseUrl` 时,opencodex 会打印一条同时列出两个 URL 的警告,其中的凭据已被遮蔽。 +当路由丢弃配置的 `baseUrl` 时,opencodex 会打印一条警告:registry 端点会完整列出,而你配置的那个 +只列出 origin —— 原本带路径时显示为 `https://host/…`。配置的路径本身可能就是凭据,因此一段都不会记录。 此时要么删掉 `baseUrl`(路由本来就只会使用 registry 端点),要么改用端点与目标 URL 相符的 provider。 当同一产品分区域运营时,选对条目尤其重要:`alibaba-token-plan` 固定指向北京,而 `alibaba-token-plan-intl` 覆盖国际端点,为其中一个签发的 key 在另一个上会被拒绝。 diff --git a/src/router.ts b/src/router.ts index 4f58af6937..cc5774261d 100644 --- a/src/router.ts +++ b/src/router.ts @@ -124,13 +124,27 @@ function isSameEndpoint(a: string, b: string): boolean { } /** - * `redactUrlForLog` drops userinfo, query and fragment but keeps the pathname, and a configured - * `baseUrl` may carry a key in a path segment (`https://proxy.example/v1/sk-…`). Layer - * `redactSecretString` on top so token-shaped path segments are masked too. The host and the - * remaining path stay readable — they are what makes the warning diagnostic. + * Origin of a user-configured URL, with the path withheld. + * + * A configured `baseUrl` is user-controlled and its path may itself be the credential — an + * account-scoped route token such as `https://proxy.example/v1/8fK2mP7qR4nV6x` is opaque and + * high-entropy, so it matches none of the prefix patterns in `redactSecretString`. Pattern + * redaction cannot be trusted for this value, so no path segment is logged at all. `URL.origin` + * also excludes userinfo, query and fragment. + * + * `…/…` marks that a path was present without revealing it, so a reader can tell an origin-only + * config apart from one whose path was dropped. */ -function redactBaseUrlForLog(url: string): string { - return redactSecretString(redactUrlForLog(url)); +function configuredOriginForLog(url: string): string { + try { + const parsed = new URL(url.trim()); + // "null" is what URL.origin yields for non-special schemes; treat it as unusable. + if (!parsed.origin || parsed.origin === "null") return "(unloggable URL)"; + const hasPath = parsed.pathname !== "" && parsed.pathname !== "/"; + return hasPath ? `${parsed.origin}/…` : parsed.origin; + } catch { + return "(unparseable URL)"; + } } // `routedProviderConfig` runs per request, so warn once per (provider, discarded, effective) triple. @@ -149,12 +163,16 @@ const discardedBaseUrlWarnings = new Set(); */ function warnIfBaseUrlDiscarded(providerName: string, userBaseUrl: string, effectiveBaseUrl: string): void { if (isSameEndpoint(userBaseUrl, effectiveBaseUrl)) return; - // A baseUrl can carry credentials in userinfo, query, *and* path. redactUrlForLog strips the - // first two but keeps the pathname, so run redactSecretString over the result as well. - const discarded = redactBaseUrlForLog(userBaseUrl); - const effective = redactBaseUrlForLog(effectiveBaseUrl); - // Key off the redacted forms: no raw credential is retained for the process lifetime, and + // Asymmetric on purpose. Past the guard above, `effectiveBaseUrl` is necessarily + // `registryEntry.baseUrl`: the caller passes the resolved URL, and whenever that resolution + // kept the user's value the two are equal and we have already returned. So the effective side + // is a constant from this repo's registry and safe to print in full — it is also the useful + // half, naming the endpoint requests will actually use. The configured side is untrusted. + const discarded = configuredOriginForLog(userBaseUrl); + const effective = redactSecretString(redactUrlForLog(effectiveBaseUrl)); + // Key off the logged forms: no raw credential is retained for the process lifetime, and // rotating a key embedded in the URL no longer re-warns about the same endpoint mismatch. + // Coarser than the raw URLs — two bad paths on one host warn once, which is the right grain. const key = `${providerName} | ${discarded} | ${effective}`; if (discardedBaseUrlWarnings.has(key)) return; discardedBaseUrlWarnings.add(key); diff --git a/tests/router-discarded-baseurl-warning.test.ts b/tests/router-discarded-baseurl-warning.test.ts index 74654687e9..c3f091bf10 100644 --- a/tests/router-discarded-baseurl-warning.test.ts +++ b/tests/router-discarded-baseurl-warning.test.ts @@ -44,12 +44,14 @@ function routePinned(baseUrl: unknown, times = 1): string[] { } test("warns when a pinned provider discards a configured baseUrl", () => { - const discarded = "https://vertex-relay.example.test/v1"; - const warnings = routePinned(discarded); + const warnings = routePinned("https://vertex-relay.example.test/v1"); expect(warnings).toHaveLength(1); expect(warnings[0]).toContain(`provider "${PINNED_PROVIDER}"`); - expect(warnings[0]).toContain(discarded); + // The configured side is named by origin only; its path is never logged. + expect(warnings[0]).toContain("https://vertex-relay.example.test/…"); + expect(warnings[0]).not.toContain("/v1"); + // The effective side is a registry constant, so it is printed in full. expect(warnings[0]).toContain(PINNED_REGISTRY_BASE_URL); }); @@ -89,6 +91,28 @@ test("redacts a token embedded in the URL path, which redactUrlForLog keeps", () expect(warnings[0]).not.toContain("sk-live-ijklmnop"); }); +test("withholds an opaque high-entropy path credential with no recognizable prefix", () => { + // The case pattern-based redaction cannot catch: an account-scoped route token that looks + // like an ordinary path segment. Nothing in redactSecretString matches it, so the only safe + // answer is to log no path at all. + const opaque = "8fK2mP7qR4nV6xZ1cT5wY9bJ"; + const warnings = routePinned(`https://opaque.example.test/v1/${opaque}`); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).not.toContain(opaque); + // The origin still names the mismatch, and the marker shows a path existed. + expect(warnings[0]).toContain("https://opaque.example.test/…"); +}); + +test("withholds every path segment, not just the credential-looking one", () => { + const warnings = routePinned("https://segments.example.test/tenant-42/v1/route/Zx9Qw"); + + expect(warnings).toHaveLength(1); + for (const segment of ["tenant-42", "route", "Zx9Qw"]) { + expect(warnings[0]).not.toContain(segment); + } +}); + test("warns once for URLs that differ only in their credentials", () => { const warnings = [ ...routePinned("https://alice:secret-one@shared-endpoint.example.test/v1"), @@ -121,6 +145,54 @@ test("stays silent for a non-string baseUrl (control: unchanged pre-existing beh expect(routePinned(42 as unknown as string)).toEqual([]); }); +/** + * The split this diagnostic exists for (#457): the Beijing Personal Edition entry is pinned, and + * the international Team Edition is a separate provider that honors its configured endpoint. + * Locking it here means a change to either registry entry has to face this test. + */ +const ALIBABA_BEIJING_BASE_URL = "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"; +const ALIBABA_INTL_BASE_URL = "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1"; + +test("alibaba-token-plan is pinned to Beijing and warns about a saved international URL", () => { + const config = configFor("alibaba-token-plan", { + adapter: "openai-chat", + baseUrl: ALIBABA_INTL_BASE_URL, + }); + const warnings = routeCapturingWarnings(config, "alibaba-token-plan/qwen3.8-max-preview"); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("token-plan.ap-southeast-1.maas.aliyuncs.com"); + expect(warnings[0]).toContain(ALIBABA_BEIJING_BASE_URL); + + const originalWarn = console.warn; + console.warn = () => {}; + try { + expect(routeModel(config, "alibaba-token-plan/qwen3.8-max-preview").provider.baseUrl) + .toBe(ALIBABA_BEIJING_BASE_URL); + } finally { + console.warn = originalWarn; + } +}); + +test("alibaba-token-plan-intl honors its configured international endpoint silently", () => { + const config = configFor("alibaba-token-plan-intl", { + adapter: "openai-chat", + baseUrl: ALIBABA_INTL_BASE_URL, + }); + + expect(routeCapturingWarnings(config, "alibaba-token-plan-intl/qwen3.7-max")).toEqual([]); + expect(routeModel(config, "alibaba-token-plan-intl/qwen3.7-max").provider.baseUrl) + .toBe(ALIBABA_INTL_BASE_URL); +}); + +test("alibaba-token-plan-intl honors its pay-as-you-go choice too", () => { + const payg = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"; + const config = configFor("alibaba-token-plan-intl", { adapter: "openai-chat", baseUrl: payg }); + + expect(routeCapturingWarnings(config, "alibaba-token-plan-intl/qwen3.7-max")).toEqual([]); + expect(routeModel(config, "alibaba-token-plan-intl/qwen3.7-max").provider.baseUrl).toBe(payg); +}); + for (const { label, id, adapter, baseUrl } of [ { label: "a provider that opts into baseUrl override",