diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 9d865ca0f3..a6fae6967f 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,31 @@ 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 + +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 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. + +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. + 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..dbe9020f55 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,26 @@ 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 会打印一条警告:registry 端点会完整列出,而你配置的那个 +只列出 origin —— 原本带路径时显示为 `https://host/…`。配置的路径本身可能就是凭据,因此一段都不会记录。 +此时要么删掉 `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 6e8850bcfe..cc5774261d 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 { 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"; @@ -117,6 +118,73 @@ 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(/\/+$/, ""); +} + +/** + * 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 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. +// 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; + // 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); + 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}. 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.`, + ); +} + function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig { const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName); if (!registryEntry) { @@ -163,6 +231,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..c3f091bf10 --- /dev/null +++ b/tests/router-discarded-baseurl-warning.test.ts @@ -0,0 +1,223 @@ +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 warnings = routePinned("https://vertex-relay.example.test/v1"); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain(`provider "${PINNED_PROVIDER}"`); + // 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); +}); + +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"); +}); + +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("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"), + ...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", ""], + ["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([]); +}); + +/** + * 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", + 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); + }); +}