fix(providers): add China (Beijing) region for alibaba-token-plan
The alibaba-token-plan provider hardcoded the international Singapore endpoint (token-plan.ap-southeast-1.maas.aliyuncs.com) in the login flow, wire credential, openai-shared resolver, and catalog, so region-locked China (Beijing) Token Plan sk-sp- keys were rejected with 401 invalid_api_key with no way to reach token-plan.cn-beijing.maas.aliyuncs.com. Mirror the alibaba-coding-plan pattern: login selects a region (International / China (Beijing) / Custom), validates against that region's /models endpoint, and stores the chosen base URL in the credential. The openai-shared resolver and model discovery both honor the credential's region, while International logins keep their existing bare-token form. Fixes #6682
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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<string> {
|
||||
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.<region>.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<s
|
||||
if (options.signal?.aborted) {
|
||||
throw new AIError.LoginCancelledError();
|
||||
}
|
||||
return serializeAlibabaTokenPlanCredential(apiKey, cookie);
|
||||
|
||||
// International (default) logins keep their existing bare/cookie credential
|
||||
// form; only a diverging region is persisted so it can override the catalog
|
||||
// base URL at inference and discovery time.
|
||||
const regionUrl = baseUrl === ALIBABA_TOKEN_PLAN_BASE_URL ? undefined : baseUrl;
|
||||
return serializeAlibabaTokenPlanCredential(apiKey, cookie, regionUrl);
|
||||
}
|
||||
|
||||
export const alibabaTokenPlanProvider = {
|
||||
|
||||
@@ -5,13 +5,14 @@ import { getOAuthProviders } from "@oh-my-pi/pi-ai/registry/oauth";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
||||
|
||||
describe("QwenCloud Token Plan login", () => {
|
||||
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() ?? "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user