diff --git a/.env.example b/.env.example index 60b5074e6e..4a0198dc28 100644 --- a/.env.example +++ b/.env.example @@ -505,7 +505,9 @@ NEXT_PUBLIC_CLOUD_URL= #OMNIROUTE_CROF_USAGE_URL=https://crof.ai/usage_api/ #OMNIROUTE_CODEWHISPERER_BASE_URL=https://codewhisperer.us-east-1.amazonaws.com #OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/quota -#OMNIROUTE_OPENCODE_GO_QUOTA_URL=https://api.z.ai/api/monitor/usage/quota/limit +# OpenCode Go has no public quota API — this has no default and stays +# unset unless you explicitly opt in to a self-hosted/mirrored endpoint: +#OMNIROUTE_OPENCODE_GO_QUOTA_URL= #OMNIROUTE_OPENCODE_GO_DASHBOARD_URL=https://opencode.ai/workspace #OMNIROUTE_OLLAMA_CLOUD_USAGE_URL=https://ollama.com/settings diff --git a/changelog.d/fixes/7022-opencode-go-quota-url-zai.md b/changelog.d/fixes/7022-opencode-go-quota-url-zai.md new file mode 100644 index 0000000000..e136c66551 --- /dev/null +++ b/changelog.d/fixes/7022-opencode-go-quota-url-zai.md @@ -0,0 +1 @@ +- fix(usage): stop opencode-go quota lookup from defaulting to an unrelated Z.AI endpoint (#7022) diff --git a/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md b/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md index b97bf07d96..782d23d747 100644 --- a/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md @@ -272,7 +272,7 @@ OmniRoute 提供两层防护:请求侧的注入扫描和响应侧的 PII 脱 | `OMNIROUTE_PUBLIC_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | 用于组合异步回调 URL 的公共源。kie.ai 回调的最低优先级回退;也用作其他中继的通用公共 URL。 | | `OMNIROUTE_CROF_USAGE_URL` | `https://crof.ai/usage_api/` | `open-sse/services/usage.ts` | Usage 页面使用的 CrofAI 配额查询端点。可覆盖为中继/测试固定件。 | | `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/quota` | `open-sse/services/opencodeQuotaFetcher.ts` | Usage 页面使用的 OpenCode (zen/go) 配额查询端点。可覆盖为中继/测试固定件。 | -| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | `https://api.z.ai/api/monitor/usage/quota/limit` | `open-sse/services/usage.ts` | Usage 页面使用的 OpenCode Go 配额查询端点。可覆盖为中继/测试固定件。 | +| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | _(未设置)_ | `open-sse/services/opencodeOllamaUsage.ts` | Usage 页面使用的 OpenCode Go 配额查询端点。OpenCode Go 没有公开的配额 API,因此没有默认值;除非运维人员显式设置该变量选择接入自建/镜像端点,否则不会发起网络请求。 | | `OMNIROUTE_OPENCODE_GO_DASHBOARD_URL` | `https://opencode.ai/workspace` | `open-sse/services/usage.ts` | 配置了 workspace ID 和 auth Cookie 时用于配额抓取的 OpenCode Go Dashboard 基础 URL。可覆盖为中继/测试固定件。 | | `OPENCODE_GO_WORKSPACE_ID` | _(未设置)_ | `open-sse/services/usage.ts` | 用于 Dashboard 配额抓取的 OpenCode Go workspace ID。配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | | `OMNIROUTE_OPENCODE_GO_WORKSPACE_ID` | _(未设置)_ | `open-sse/services/usage.ts` | OpenCode Go workspace ID 环境变量的备选名,在较短的别名之前使用。配置多个账户时,推荐使用每个连接的 Dashboard 字段。 | diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index bd8fe06946..7e9b2b0773 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -280,7 +280,7 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp | `OMNIROUTE_PUBLIC_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Public origin used to compose async callback URLs. Lowest-priority fallback for kie.ai callbacks; also used as a generic public URL for other relays. | | `OMNIROUTE_CROF_USAGE_URL` | `https://crof.ai/usage_api/` | `open-sse/services/usage.ts` | CrofAI quota lookup endpoint used by the Usage page. Override for relays / test fixtures. | | `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/quota` | `open-sse/services/opencodeQuotaFetcher.ts` | OpenCode (zen/go) quota lookup endpoint used by the Usage page. Override for relays / test fixtures. | -| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | `https://api.z.ai/api/monitor/usage/quota/limit` | `open-sse/services/usage.ts` | OpenCode Go quota lookup endpoint used by the Usage page. Override for relays / test fixtures. | +| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | _(unset)_ | `open-sse/services/opencodeOllamaUsage.ts` | OpenCode Go quota lookup endpoint used by the Usage page. OpenCode Go has no public quota API, so this has no default and the network call is skipped unless the operator opts in to a self-hosted/mirrored endpoint. | | `OMNIROUTE_OPENCODE_GO_DASHBOARD_URL` | `https://opencode.ai/workspace` | `open-sse/services/usage.ts` | OpenCode Go dashboard base URL used for quota scraping when a workspace ID and auth cookie are configured. Override for relays / test fixtures. | | `OPENCODE_GO_WORKSPACE_ID` | _(unset)_ | `open-sse/services/usage.ts` | OpenCode Go workspace ID used for dashboard quota scraping. Prefer the per-connection Dashboard field when multiple accounts are configured. | | `OMNIROUTE_OPENCODE_GO_WORKSPACE_ID` | _(unset)_ | `open-sse/services/usage.ts` | Alternate OpenCode Go workspace ID env var used before the shorter alias. Prefer the per-connection Dashboard field when multiple accounts are configured. | diff --git a/open-sse/services/opencodeOllamaUsage.ts b/open-sse/services/opencodeOllamaUsage.ts index 25cd3c3cc9..b3beb4606d 100644 --- a/open-sse/services/opencodeOllamaUsage.ts +++ b/open-sse/services/opencodeOllamaUsage.ts @@ -13,8 +13,13 @@ type UsageQuota = { currency?: string; }; -const OPENCODE_GO_QUOTA_URL = - process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL ?? "https://api.z.ai/api/monitor/usage/quota/limit"; +// OpenCode Go does not expose a public quota API. There is no working +// opencode.ai endpoint to default to (see #7022) — the quota-by-API-key path +// below is opt-in only and activates exclusively when the operator sets +// OMNIROUTE_OPENCODE_GO_QUOTA_URL explicitly. Never hardcode a third-party +// host here (a previous default silently sent the user's API key to an +// unrelated Z.AI endpoint). +const OPENCODE_GO_QUOTA_URL = process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL?.trim() || ""; const OPENCODE_GO_DASHBOARD_BASE_URL = process.env.OMNIROUTE_OPENCODE_GO_DASHBOARD_URL ?? "https://opencode.ai/workspace"; const OPENCODE_GO_QUOTA_TOTALS = { session: 12, weekly: 30, mcp_monthly: 60 } as const; @@ -335,6 +340,15 @@ export async function getOpenCodeGoUsage(apiKey: string, providerSpecificData?: }; } + if (!OPENCODE_GO_QUOTA_URL) { + return { + message: + "OpenCode Go does not expose a public quota API. " + + "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping, " + + "or set OMNIROUTE_OPENCODE_GO_QUOTA_URL to opt in to an explicit quota endpoint.", + }; + } + try { const res = await fetch(OPENCODE_GO_QUOTA_URL, { headers: { @@ -348,7 +362,8 @@ export async function getOpenCodeGoUsage(apiKey: string, providerSpecificData?: if (res.status === 401 || res.status === 403) { return { message: - "OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " + + "OpenCode Go API key is valid for chat/models but cannot read quota from the configured " + + "OMNIROUTE_OPENCODE_GO_QUOTA_URL endpoint. " + "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping.", }; } @@ -374,7 +389,8 @@ export async function getOpenCodeGoUsage(apiKey: string, providerSpecificData?: ) { return { message: - "OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " + + "OpenCode Go API key is valid for chat/models but cannot read quota from the configured " + + "OMNIROUTE_OPENCODE_GO_QUOTA_URL endpoint. " + "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping.", }; } diff --git a/tests/unit/opencode-go-quota-no-zai.test.ts b/tests/unit/opencode-go-quota-no-zai.test.ts new file mode 100644 index 0000000000..b4775040fa --- /dev/null +++ b/tests/unit/opencode-go-quota-no-zai.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { getOpenCodeGoUsage } from "../../open-sse/services/opencodeOllamaUsage.ts"; + +test("getOpenCodeGoUsage does not send the user's OpenCode Go API key to api.z.ai by default", async () => { + const originalFetch = globalThis.fetch; + const originalEnv = process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL; + delete process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL; + + let calledHost: string | null = null; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + calledHost = new URL(url).host; + throw new Error(`unexpected outbound fetch to ${url}`); + }) as typeof fetch; + + try { + const result = await getOpenCodeGoUsage("sk-fake-opencode-go-key", undefined); + assert.notStrictEqual(calledHost, "api.z.ai"); + assert.strictEqual(calledHost, null); + assert.ok( + typeof result.message === "string" && result.message.length > 0, + "expected a descriptive message when no quota URL is configured" + ); + } finally { + globalThis.fetch = originalFetch; + if (originalEnv === undefined) delete process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL; + else process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL = originalEnv; + } +}); diff --git a/tests/unit/opencode-go-usage.test.ts b/tests/unit/opencode-go-usage.test.ts index 64e6357800..302a95f244 100644 --- a/tests/unit/opencode-go-usage.test.ts +++ b/tests/unit/opencode-go-usage.test.ts @@ -1,9 +1,25 @@ -import test from "node:test"; +import test, { after } from "node:test"; import assert from "node:assert/strict"; +// The OpenCode Go quota-by-API-key path is opt-in only (see #7022 — there is no +// working default quota endpoint, so OMNIROUTE_OPENCODE_GO_QUOTA_URL must be set +// explicitly by the operator). The module reads this env var once at import time, +// so it has to be set BEFORE the dynamic import below for the opt-in tests in this +// file (which simulate an operator who configured the URL) to exercise the fetch path. +const ORIGINAL_OPENCODE_GO_QUOTA_URL = process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL; +process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL = "https://api.z.ai/api/monitor/usage/quota/limit"; + const usage = await import("../../open-sse/services/usage.ts"); const { USAGE_SUPPORTED_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); +after(() => { + if (ORIGINAL_OPENCODE_GO_QUOTA_URL === undefined) { + delete process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL; + } else { + process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL = ORIGINAL_OPENCODE_GO_QUOTA_URL; + } +}); + test("USAGE_SUPPORTED_PROVIDERS includes opencode-go", () => { assert.ok( (USAGE_SUPPORTED_PROVIDERS as string[]).includes("opencode-go"), @@ -298,7 +314,8 @@ test("getUsageForProvider returns message for invalid OpenCode Go API keys", asy })) as { message: string }; assert.equal( result.message, - "OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " + + "OpenCode Go API key is valid for chat/models but cannot read quota from the configured " + + "OMNIROUTE_OPENCODE_GO_QUOTA_URL endpoint. " + "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping." ); } finally { @@ -342,7 +359,8 @@ test("getUsageForProvider returns message when OpenCode Go quota API returns 200 })) as { message: string }; assert.equal( result.message, - "OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " + + "OpenCode Go API key is valid for chat/models but cannot read quota from the configured " + + "OMNIROUTE_OPENCODE_GO_QUOTA_URL endpoint. " + "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping." ); } finally {