diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 98cad82ab..d3e15414c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,6 +8,10 @@ - Added `GET /v1/credentials/disabled` to the auth broker and `AuthBrokerClient.listDisabledCredentials`: disabled-credential tombstones (`DisabledCredentialSummary` — identity, verbatim disable cause, disable timestamp; never token material) so auto-disabled accounts stay visible to clients instead of silently vanishing from the snapshot. `AuthStorage.listDisabledCredentials` serves the same data locally from SQLite; clients of brokers predating the endpoint get an empty list (404 mapped, no error). - Added `AuthStorage.revalidateCredentials()` and the optional `AuthCredentialStore.refreshSnapshot` hook: remote broker stores re-fetch `GET /v1/snapshot` on demand so callers pairing live per-credential data with stored identities (`omp usage`) never render against the up-to-an-hour-stale disk-cached snapshot; local SQLite stores are always current and only reload. +### Fixed + +- Fixed the `alibaba-token-plan` login only supporting the international Singapore endpoint, which rejected China (Beijing) Token Plan `sk-sp-` keys with `401 invalid_api_key`. Login now selects a region (International / China (Beijing) / Custom), validates the key against that region's `/models` endpoint, and stores the chosen base URL in the credential so inference and discovery both target it ([#6682](https://github.com/can1357/oh-my-pi/issues/6682)). + ## [17.1.3] - 2026-07-24 ### Fixed diff --git a/packages/ai/README.md b/packages/ai/README.md index d05ebf13f..e3fe2c325 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -73,7 +73,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an - **Xiaomi MiMo** (requires `XIAOMI_API_KEY`) - **ZenMux** (requires `ZENMUX_API_KEY`) - **Qwen Portal** (supports `QWEN_OAUTH_TOKEN` or `QWEN_PORTAL_API_KEY`) -- **QwenCloud Token Plan** (supports `/login alibaba-token-plan`, `ALIBABA_TOKEN_PLAN_API_KEY`, or `BAILIAN_TOKEN_PLAN_API_KEY`; interactive login optionally stores a `home.qwencloud.com` Cookie request header for best-effort 5-hour and 7-day quota reporting) +- **QwenCloud Token Plan** (supports `/login alibaba-token-plan`, `ALIBABA_TOKEN_PLAN_API_KEY`, or `BAILIAN_TOKEN_PLAN_API_KEY`; interactive login first selects a region — International (Singapore, default), China (Beijing) for 百炼 Token Plan keys, or a custom base URL — since region keys are non-interchangeable, then optionally stores a `home.qwencloud.com` Cookie request header for best-effort 5-hour and 7-day quota reporting) To enable quota reporting, sign in to the Token Plan dashboard, copy the `Cookie` request-header value from a `home.qwencloud.com` request in browser developer tools, and paste it at the second login prompt. Press Enter to skip; the Cookie is sensitive and session-lived, so rerun login when it expires. - **Cloudflare AI Gateway** (requires `CLOUDFLARE_AI_GATEWAY_API_KEY` and provider-specific gateway base URL) - **Ollama** (local OpenAI-compatible runtime; optional `OLLAMA_API_KEY`) diff --git a/packages/ai/src/providers/openai-shared.ts b/packages/ai/src/providers/openai-shared.ts index 1083cadee..0be220b26 100644 --- a/packages/ai/src/providers/openai-shared.ts +++ b/packages/ai/src/providers/openai-shared.ts @@ -272,6 +272,7 @@ export function resolveOpenAIRequestSetup( const credential = parseAlibabaTokenPlanCredential(rawApiKey); if (!credential) throw new AIError.ConfigurationError("Invalid QwenCloud Token Plan credential"); apiKey = credential.token; + if (credential.baseUrl) baseUrl = credential.baseUrl; } if (options.alibabaCodingPlanAuth && model.provider === "alibaba-coding-plan") { diff --git a/packages/ai/src/registry/alibaba-token-plan.ts b/packages/ai/src/registry/alibaba-token-plan.ts index 73b70cf43..0e63b5eb0 100644 --- a/packages/ai/src/registry/alibaba-token-plan.ts +++ b/packages/ai/src/registry/alibaba-token-plan.ts @@ -1,29 +1,90 @@ -import { serializeAlibabaTokenPlanCredential } from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan"; +import { + ALIBABA_TOKEN_PLAN_BASE_URL, + ALIBABA_TOKEN_PLAN_CN_BASE_URL, + serializeAlibabaTokenPlanCredential, +} from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan"; import * as AIError from "../error"; -import { createApiKeyLogin } from "./api-key-login"; +import { validateApiKeyAgainstModelsEndpoint } from "./api-key-validation"; import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; import type { ProviderDefinition } from "./types"; -const TOKEN_PLAN_BASE_URL = "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1"; - -const loginApiKey = createApiKeyLogin({ - providerLabel: "QwenCloud Token Plan", - authUrl: "https://home.qwencloud.com/billing/subscription/token-plan-individual", - instructions: "Subscribe to Token Plan Individual and copy its dedicated API key", - promptMessage: "Paste your QwenCloud Token Plan API key", - placeholder: "sk-sp-...", - validation: { - kind: "models-endpoint", - provider: "QwenCloud Token Plan", - modelsUrl: `${TOKEN_PLAN_BASE_URL}/models`, - }, -}); +const INTERNATIONAL_AUTH_URL = "https://home.qwencloud.com/billing/subscription/token-plan-individual"; +const CHINA_AUTH_URL = "https://www.aliyun.com/benefit/scene/tokenplan"; +/** + * Log in to the QwenCloud Token Plan provider. + * + * The Token Plan ships as two regionally separate products with + * non-interchangeable keys — International (Singapore) and China (Beijing) — + * so login first selects the region (or a custom base URL) before pasting the + * key, mirroring {@link loginAlibabaCodingPlan}. The chosen region is validated + * against its own `/models` endpoint and, when it diverges from the default + * international endpoint, stored in the credential so inference and discovery + * both target it (#6682). + */ export async function loginAlibabaTokenPlan(options: OAuthController): Promise { if (!options.onPrompt) { throw new AIError.OnPromptRequiredError("QwenCloud Token Plan"); } - const apiKey = await loginApiKey(options); + + const endpointChoice = await options.onPrompt({ + message: + "Select QwenCloud Token Plan region: 1=International (default), 2=China (Beijing), 3=Custom — enter 1, 2, or 3", + placeholder: "1", + }); + if (options.signal?.aborted) { + throw new AIError.LoginCancelledError(); + } + + const choice = endpointChoice.trim(); + let baseUrl: string; + let authUrl: string; + let instructions: string; + if (choice === "2") { + baseUrl = ALIBABA_TOKEN_PLAN_CN_BASE_URL; + authUrl = CHINA_AUTH_URL; + instructions = "Subscribe to the China (Beijing) 百炼 Token Plan and copy its dedicated API key"; + } else if (choice === "3") { + const customUrl = await options.onPrompt({ + message: "Enter custom Token Plan base URL", + placeholder: "https://token-plan..maas.aliyuncs.com/compatible-mode/v1", + }); + const trimmedUrl = customUrl.trim().replace(/\/+$/, ""); + if (!trimmedUrl) { + throw new AIError.ConfigurationError("Custom URL is required for option 3"); + } + baseUrl = trimmedUrl; + authUrl = INTERNATIONAL_AUTH_URL; + instructions = "Copy your Token Plan API key for the custom endpoint"; + } else { + baseUrl = ALIBABA_TOKEN_PLAN_BASE_URL; + authUrl = INTERNATIONAL_AUTH_URL; + instructions = "Subscribe to Token Plan Individual and copy its dedicated API key"; + } + + options.onAuth?.({ url: authUrl, instructions }); + + const apiKeyInput = await options.onPrompt({ + message: "Paste your QwenCloud Token Plan API key", + placeholder: "sk-sp-...", + }); + if (options.signal?.aborted) { + throw new AIError.LoginCancelledError(); + } + const apiKey = apiKeyInput.trim(); + if (!apiKey) { + throw new AIError.ApiKeyRequiredError(); + } + + options.onProgress?.("Validating API key..."); + await validateApiKeyAgainstModelsEndpoint({ + provider: "QwenCloud Token Plan", + apiKey, + modelsUrl: `${baseUrl}/models`, + signal: options.signal, + fetch: options.fetch, + }); + const cookie = await options.onPrompt({ message: "Paste the Cookie request header from home.qwencloud.com for optional quota reporting, or press Enter to skip", @@ -33,7 +94,12 @@ export async function loginAlibabaTokenPlan(options: OAuthController): Promise { - test("opens the Individual subscription page and validates without inference", async () => { + test("International (default) region opens Individual page and validates without inference", async () => { const authRequests: { url: string; instructions?: string }[] = []; let requestedUrl = ""; let authorization = ""; + const prompts = ["1", " sk-sp-test "]; const apiKey = await loginAlibabaTokenPlan({ onAuth: request => authRequests.push(request), - onPrompt: async prompt => (prompt.allowEmpty ? "" : " sk-sp-test "), + onPrompt: async prompt => (prompt.allowEmpty ? "" : (prompts.shift() ?? "")), fetch: (input, init) => { requestedUrl = String(input); authorization = new Headers(init?.headers).get("Authorization") ?? ""; @@ -19,6 +20,7 @@ describe("QwenCloud Token Plan login", () => { }, }); + // International (default) keeps the bare-token credential form. expect(apiKey).toBe("sk-sp-test"); expect(authRequests).toEqual([ { @@ -30,8 +32,54 @@ describe("QwenCloud Token Plan login", () => { expect(authorization).toBe("Bearer sk-sp-test"); }); + test("China (Beijing) region validates against and routes inference to cn-beijing", async () => { + const authRequests: { url: string; instructions?: string }[] = []; + let requestedUrl = ""; + const prompts = ["2", "sk-sp-beijing"]; + const credential = await loginAlibabaTokenPlan({ + onAuth: request => authRequests.push(request), + onPrompt: async prompt => (prompt.allowEmpty ? "" : (prompts.shift() ?? "")), + fetch: input => { + requestedUrl = String(input); + return Promise.resolve(Response.json({ data: [{ id: "qwen3.7-plus" }] })); + }, + }); + + expect(requestedUrl).toBe("https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/models"); + expect(authRequests[0]?.url).toBe("https://www.aliyun.com/benefit/scene/tokenplan"); + expect(JSON.parse(credential)).toEqual({ + token: "sk-sp-beijing", + baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + }); + + const model = getBundledModel<"openai-completions">("alibaba-token-plan", "qwen3.7-plus"); + if (!model) throw new Error("expected bundled QwenCloud Token Plan model"); + const setup = resolveOpenAIRequestSetup(model, { apiKey: credential, messages: [] }); + expect(setup.baseUrl).toBe("https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"); + expect(setup.headers.Authorization).toBe("Bearer sk-sp-beijing"); + }); + + test("custom region is validated against and stored as its own base URL", async () => { + let requestedUrl = ""; + const prompts = ["3", "https://token-plan.example.com/v1/", "sk-sp-custom"]; + const credential = await loginAlibabaTokenPlan({ + onAuth: () => {}, + onPrompt: async prompt => (prompt.allowEmpty ? "" : (prompts.shift() ?? "")), + fetch: input => { + requestedUrl = String(input); + return Promise.resolve(Response.json({ data: [] })); + }, + }); + + expect(requestedUrl).toBe("https://token-plan.example.com/v1/models"); + expect(JSON.parse(credential)).toEqual({ + token: "sk-sp-custom", + baseUrl: "https://token-plan.example.com/v1", + }); + }); + test("stores an optional console Cookie while sending only the API key to inference", async () => { - const prompts = ["sk-sp-test", "session_id=test; login_aliyunid_csrf=csrf-token"]; + const prompts = ["1", "sk-sp-test", "session_id=test; login_aliyunid_csrf=csrf-token"]; const credential = await loginAlibabaTokenPlan({ onAuth: () => {}, onPrompt: async () => prompts.shift() ?? "", diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 669081f3f..d3bc34737 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `alibaba-token-plan` locking out China (Beijing) 百炼 Token Plan subscribers: the provider hardcoded the international Singapore endpoint, so Beijing-issued `sk-sp-` keys got `401 invalid_api_key`. The wire credential now carries an optional region base URL, and model discovery targets the credential's region ([#6682](https://github.com/can1357/oh-my-pi/issues/6682)). + ## [17.1.3] - 2026-07-24 ### Fixed diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index b6c56181f..ed3f4f983 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -17,7 +17,7 @@ import type { ModelManagerOptions } from "../model-manager"; import { getBundledModels } from "../models"; import type { Api, FetchImpl, Model, ModelSpec, OpenAICompat, Provider, ThinkingConfig } from "../types"; import { discoveryFetch, isAnthropicOAuthToken, isRecord, toBoolean, toNumber, toPositiveNumber } from "../utils"; -import { parseAlibabaTokenPlanCredential } from "../wire/alibaba-token-plan"; +import { ALIBABA_TOKEN_PLAN_BASE_URL, parseAlibabaTokenPlanCredential } from "../wire/alibaba-token-plan"; import { coreWeaveProjectHeaders } from "../wire/coreweave"; import { COPILOT_API_HEADERS, @@ -2434,7 +2434,7 @@ export function alibabaCodingPlanModelManagerOptions( // Alibaba Token Plan // --------------------------------------------------------------------------- -export const ALIBABA_TOKEN_PLAN_BASE_URL = "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1"; +export { ALIBABA_TOKEN_PLAN_BASE_URL }; const ALIBABA_TOKEN_PLAN_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as const; const ALIBABA_TOKEN_PLAN_COMPAT: OpenAICompat = { @@ -2556,7 +2556,10 @@ export function alibabaTokenPlanModelManagerOptions( ): ModelManagerOptions<"openai-completions"> { const credential = config?.apiKey ? parseAlibabaTokenPlanCredential(config.apiKey) : undefined; const apiKey = credential?.token; - const baseUrl = config?.baseUrl ?? ALIBABA_TOKEN_PLAN_BASE_URL; + // A region-locked credential (China/custom) dictates the discovery endpoint: + // its key only authenticates against its own region, so fetching /models from + // any other base URL would 401 (#6682). + const baseUrl = credential?.baseUrl ?? config?.baseUrl ?? ALIBABA_TOKEN_PLAN_BASE_URL; return { providerId: "alibaba-token-plan", dynamicModelsAuthoritative: true, diff --git a/packages/catalog/src/wire/alibaba-token-plan.ts b/packages/catalog/src/wire/alibaba-token-plan.ts index de3d0fe0c..34f7ab490 100644 --- a/packages/catalog/src/wire/alibaba-token-plan.ts +++ b/packages/catalog/src/wire/alibaba-token-plan.ts @@ -1,6 +1,23 @@ +/** + * International (Singapore) Token Plan endpoint. Default region; keys issued by + * the international product authenticate only here. + */ +export const ALIBABA_TOKEN_PLAN_BASE_URL = "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1"; +/** + * China (Beijing) Token Plan endpoint (百炼 Token Plan). Keys are region-locked: + * a Beijing-issued key is rejected by the international endpoint with + * `invalid_api_key`, and vice versa (#6682). + */ +export const ALIBABA_TOKEN_PLAN_CN_BASE_URL = "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"; + export interface AlibabaTokenPlanCredential { token: string; cookie?: string; + /** + * Region base URL the key authenticates against. Absent means the default + * international endpoint ({@link ALIBABA_TOKEN_PLAN_BASE_URL}). + */ + baseUrl?: string; } const TOKEN_PATTERN = /^sk-[A-Za-z0-9._~+/-]+={0,2}$/; @@ -10,18 +27,28 @@ export function parseAlibabaTokenPlanCredential(value: string): AlibabaTokenPlan if (!trimmed) return null; if (!trimmed.startsWith("{")) return TOKEN_PATTERN.test(trimmed) ? { token: trimmed } : null; try { - const parsed = JSON.parse(trimmed) as { token?: unknown; cookie?: unknown }; + const parsed = JSON.parse(trimmed) as { token?: unknown; cookie?: unknown; baseUrl?: unknown }; if (typeof parsed.token !== "string" || !TOKEN_PATTERN.test(parsed.token.trim())) return null; if (parsed.cookie !== undefined && typeof parsed.cookie !== "string") return null; + if (parsed.baseUrl !== undefined && typeof parsed.baseUrl !== "string") return null; const token = parsed.token.trim(); const cookie = parsed.cookie?.trim(); - return cookie ? { token, cookie } : { token }; + const baseUrl = parsed.baseUrl?.trim(); + const credential: AlibabaTokenPlanCredential = { token }; + if (cookie) credential.cookie = cookie; + if (baseUrl) credential.baseUrl = baseUrl; + return credential; } catch { return null; } } -export function serializeAlibabaTokenPlanCredential(token: string, cookie: string): string { +export function serializeAlibabaTokenPlanCredential(token: string, cookie: string, baseUrl?: string): string { const trimmedCookie = cookie.trim(); - return trimmedCookie ? JSON.stringify({ token, cookie: trimmedCookie }) : token; + const trimmedBaseUrl = baseUrl?.trim(); + if (!trimmedCookie && !trimmedBaseUrl) return token; + const payload: { token: string; cookie?: string; baseUrl?: string } = { token }; + if (trimmedCookie) payload.cookie = trimmedCookie; + if (trimmedBaseUrl) payload.baseUrl = trimmedBaseUrl; + return JSON.stringify(payload); } diff --git a/packages/catalog/test/alibaba-token-plan.test.ts b/packages/catalog/test/alibaba-token-plan.test.ts index d331223d9..f45a3f6a9 100644 --- a/packages/catalog/test/alibaba-token-plan.test.ts +++ b/packages/catalog/test/alibaba-token-plan.test.ts @@ -85,6 +85,28 @@ describe("QwenCloud Token Plan provider", () => { expect(options.dynamicModelsAuthoritative).toBe(true); }); + test("routes discovery to the credential's region when it is China (Beijing)", async () => { + let requestedUrl = ""; + const fetchMock: FetchImpl = input => { + requestedUrl = String(input); + return Promise.resolve(Response.json({ data: [{ id: "qwen3.7-plus", owned_by: "qwencloud" }] })); + }; + + const apiKey = serializeAlibabaTokenPlanCredential( + "sk-sp-beijing", + "", + "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + ); + const options = alibabaTokenPlanModelManagerOptions({ apiKey, fetch: fetchMock }); + const models = await options.fetchDynamicModels?.(); + + expect(requestedUrl).toBe("https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/models"); + expect(models?.[0]).toMatchObject({ + id: "qwen3.7-plus", + baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + }); + }); + test("rejects malformed compound credentials before model discovery", () => { let fetched = false; const fetchMock: FetchImpl = () => {