diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 34168bafe..0c56cec96 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -1786,7 +1786,10 @@ export class AuthStorage { return; } const newCredential: OAuthCredential = { type: "oauth", ...result }; - await this.#upsertOAuthCredential(def.storeCredentialsAs ?? provider, newCredential); + // Use set() instead of #upsertOAuthCredential to replace ALL existing credentials + // (including legacy api_key rows from older versions) with the new OAuth credential. + // This ensures getApiKey() doesn't match an old api_key row before the new OAuth row. + await this.set(def.storeCredentialsAs ?? provider, newCredential); } /** diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 395f1ae15..dc3139183 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -1283,6 +1283,19 @@ async function createRequestSetup( copilotPremiumRequests = copilot.premiumRequests; baseUrl = resolveGitHubCopilotBaseUrl(model.baseUrl, rawApiKey) ?? model.baseUrl; } + if (model.provider === "alibaba-coding-plan") { + try { + const parsed = JSON.parse(rawApiKey); + if (typeof parsed?.token === "string") { + apiKey = parsed.token; + } + if (typeof parsed?.enterpriseUrl === "string") { + baseUrl = parsed.enterpriseUrl; + } + } catch { + // Not JSON — use raw apiKey and catalog baseUrl + } + } // Azure OpenAI requires /deployments/{id}/chat/completions?api-version=YYYY-MM-DD. // The generic openai-completions path adds neither, producing silent 404s. let azureDefaultQuery: Record | undefined; diff --git a/packages/ai/src/registry/alibaba-coding-plan.ts b/packages/ai/src/registry/alibaba-coding-plan.ts index f23f5b54e..92937d828 100644 --- a/packages/ai/src/registry/alibaba-coding-plan.ts +++ b/packages/ai/src/registry/alibaba-coding-plan.ts @@ -1,19 +1,58 @@ -import { validateOpenAICompatibleApiKey } from "./api-key-validation"; -import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types"; +import * as apiKeyValidation from "./api-key-validation"; +import type { OAuthController, OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; import type { ProviderDefinition } from "./types"; -const AUTH_URL = "https://modelstudio.console.alibabacloud.com/"; -const API_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; +const DEFAULT_AUTH_URL = "https://modelstudio.console.alibabacloud.com/"; +const CHINA_AUTH_URL = "https://dashscope.console.aliyun.com/"; +const DEFAULT_API_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; +const CHINA_API_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1"; const VALIDATION_MODEL = "qwen3.5-plus"; -export async function loginAlibabaCodingPlan(options: OAuthController): Promise { +export async function loginAlibabaCodingPlan(options: OAuthController): Promise { if (!options.onPrompt) { throw new Error("Alibaba Coding Plan login requires onPrompt callback"); } + // Ask which endpoint to use + const endpointChoice = await options.onPrompt({ + message: "Select Alibaba Coding Plan endpoint: 1=International (default), 2=China, 3=Custom — enter 1, 2, or 3", + placeholder: "1", + }); + + // Check for abort after endpoint selection (Escape returns "") + if (options.signal?.aborted) { + throw new Error("Login cancelled"); + } + + const choice = endpointChoice.trim(); + let baseUrl: string; + let authUrl: string; + let instructions: string; + if (choice === "2") { + baseUrl = CHINA_API_BASE_URL; + authUrl = CHINA_AUTH_URL; + instructions = "Copy your API key from the Alibaba Cloud DashScope console (China mainland)"; + } else if (choice === "3") { + const customUrl = await options.onPrompt({ + message: "Enter custom base URL", + placeholder: "https://your-proxy.com/v1", + }); + const trimmedUrl = customUrl.trim().replace(/\/+$/, ""); + if (!trimmedUrl) { + throw new Error("Custom URL is required for option 3"); + } + baseUrl = trimmedUrl; + authUrl = DEFAULT_AUTH_URL; + instructions = "Copy your API key from the Alibaba Cloud DashScope console"; + } else { + baseUrl = DEFAULT_API_BASE_URL; + authUrl = DEFAULT_AUTH_URL; + instructions = "Copy your API key from the Alibaba Cloud DashScope console (International)"; + } + options.onAuth?.({ - url: AUTH_URL, - instructions: "Copy your API key from the Alibaba Cloud DashScope console", + url: authUrl, + instructions, }); const apiKey = await options.onPrompt({ @@ -31,19 +70,25 @@ export async function loginAlibabaCodingPlan(options: OAuthController): Promise< } options.onProgress?.("Validating API key..."); - await validateOpenAICompatibleApiKey({ + await apiKeyValidation.validateOpenAICompatibleApiKey({ provider: "Alibaba Coding Plan", apiKey: trimmed, - baseUrl: API_BASE_URL, + baseUrl, model: VALIDATION_MODEL, signal: options.signal, }); - return trimmed; + return { + access: trimmed, + refresh: trimmed, + expires: Number.MAX_SAFE_INTEGER, + enterpriseUrl: baseUrl, + }; } export const alibabaCodingPlanProvider = { id: "alibaba-coding-plan", name: "Alibaba Coding Plan", login: (cb: OAuthLoginCallbacks) => loginAlibabaCodingPlan(cb), + getApiKey: (credentials) => credentials.access, } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/oauth/index.ts b/packages/ai/src/registry/oauth/index.ts index a5d2e581d..82cb38f66 100644 --- a/packages/ai/src/registry/oauth/index.ts +++ b/packages/ai/src/registry/oauth/index.ts @@ -136,17 +136,20 @@ export async function getOAuthApiKey( } // For providers that need request-time credential metadata, return JSON. const needsStructuredApiKey = - provider === "github-copilot" || provider === "google-gemini-cli" || provider === "google-antigravity"; + provider === "github-copilot" || + provider === "google-gemini-cli" || + provider === "google-antigravity" || + provider === "alibaba-coding-plan"; const apiKey = needsStructuredApiKey ? JSON.stringify({ - token: creds.access, - enterpriseUrl: creds.enterpriseUrl, - projectId: creds.projectId, - refreshToken: creds.refresh, - expiresAt: creds.expires, - email: creds.email, - accountId: creds.accountId, - }) + token: creds.access, + enterpriseUrl: creds.enterpriseUrl, + projectId: creds.projectId, + refreshToken: creds.refresh, + expiresAt: creds.expires, + email: creds.email, + accountId: creds.accountId, + }) : creds.access; return { newCredentials: creds, apiKey }; } diff --git a/packages/ai/test/alibaba-endpoint-selection.test.ts b/packages/ai/test/alibaba-endpoint-selection.test.ts new file mode 100644 index 000000000..9ad9ed51a --- /dev/null +++ b/packages/ai/test/alibaba-endpoint-selection.test.ts @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import type { OAuthController, OAuthCredentials } from "../src/registry/oauth/types"; +import * as apiKeyValidation from "../src/registry/api-key-validation"; +import { loginAlibabaCodingPlan, alibabaCodingPlanProvider } from "../src/registry/alibaba-coding-plan"; +import { getOAuthApiKey } from "../src/registry/oauth/index"; +import type { Mock } from "bun:test"; + +describe("alibaba-coding-plan endpoint selection", () => { + let validateSpy: Mock; + + beforeEach(() => { + validateSpy = spyOn(apiKeyValidation, "validateOpenAICompatibleApiKey").mockResolvedValue(undefined); + }); + + afterEach(() => { + validateSpy.mockRestore(); + }); + + it("option 1 uses international endpoint and auth URL", async () => { + let capturedAuth: { url: string; instructions?: string } | undefined; + const options: OAuthController = { + onAuth: (info) => { capturedAuth = info; }, + onProgress: () => {}, + onPrompt: async (prompt) => { + if (prompt.message.includes("Select Alibaba")) return "1"; + if (prompt.message.includes("Paste your")) return "sk-test-key"; + return ""; + }, + }; + + const result = await loginAlibabaCodingPlan(options); + + expect(result.access).toBe("sk-test-key"); + expect(result.refresh).toBe("sk-test-key"); + expect(result.enterpriseUrl).toBe("https://coding-intl.dashscope.aliyuncs.com/v1"); + expect(capturedAuth?.url).toBe("https://modelstudio.console.alibabacloud.com/"); + expect(capturedAuth?.instructions).toContain("International"); + + expect(validateSpy).toHaveBeenCalledWith({ + provider: "Alibaba Coding Plan", + apiKey: "sk-test-key", + baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", + model: "qwen3.5-plus", + signal: undefined, + }); + }); + + it("option 2 uses China endpoint and auth URL", async () => { + let capturedAuth: { url: string; instructions?: string } | undefined; + const options: OAuthController = { + onAuth: (info) => { capturedAuth = info; }, + onProgress: () => {}, + onPrompt: async (prompt) => { + if (prompt.message.includes("Select Alibaba")) return "2"; + if (prompt.message.includes("Paste your")) return "sk-cn-key"; + return ""; + }, + }; + + const result = await loginAlibabaCodingPlan(options); + + expect(result.access).toBe("sk-cn-key"); + expect(result.refresh).toBe("sk-cn-key"); + expect(result.enterpriseUrl).toBe("https://coding.dashscope.aliyuncs.com/v1"); + expect(capturedAuth?.url).toBe("https://dashscope.console.aliyun.com/"); + expect(capturedAuth?.instructions).toContain("China mainland"); + + expect(validateSpy).toHaveBeenCalledWith({ + provider: "Alibaba Coding Plan", + apiKey: "sk-cn-key", + baseUrl: "https://coding.dashscope.aliyuncs.com/v1", + model: "qwen3.5-plus", + signal: undefined, + }); + }); + + it("option 3 prompts for custom URL and uses it", async () => { + let capturedAuth: { url: string; instructions?: string } | undefined; + const options: OAuthController = { + onAuth: (info) => { capturedAuth = info; }, + onProgress: () => {}, + onPrompt: async (prompt) => { + if (prompt.message.includes("Select Alibaba")) return "3"; + if (prompt.message.includes("custom base URL")) return "https://my-proxy.com/v1"; + if (prompt.message.includes("Paste your")) return "sk-custom-key"; + return ""; + }, + }; + + const result = await loginAlibabaCodingPlan(options); + + expect(result.access).toBe("sk-custom-key"); + expect(result.refresh).toBe("sk-custom-key"); + expect(result.enterpriseUrl).toBe("https://my-proxy.com/v1"); + expect(capturedAuth?.url).toBe("https://modelstudio.console.alibabacloud.com/"); + + expect(validateSpy).toHaveBeenCalledWith({ + provider: "Alibaba Coding Plan", + apiKey: "sk-custom-key", + baseUrl: "https://my-proxy.com/v1", + model: "qwen3.5-plus", + signal: undefined, + }); + }); + + it("empty input defaults to international endpoint and auth URL", async () => { + const options: OAuthController = { + onAuth: () => {}, + onProgress: () => {}, + onPrompt: async (prompt) => { + if (prompt.message.includes("Select Alibaba")) return ""; + if (prompt.message.includes("Paste your")) return "sk-test-key"; + return ""; + }, + }; + + const result = await loginAlibabaCodingPlan(options); + + expect(result.enterpriseUrl).toBe("https://coding-intl.dashscope.aliyuncs.com/v1"); + }); + + it("strips trailing slashes from custom URL", async () => { + const options: OAuthController = { + onAuth: () => {}, + onProgress: () => {}, + onPrompt: async (prompt) => { + if (prompt.message.includes("Select Alibaba")) return "3"; + if (prompt.message.includes("custom base URL")) return "https://my-proxy.com/v1///"; + if (prompt.message.includes("Paste your")) return "sk-test-key"; + return ""; + }, + }; + + const result = await loginAlibabaCodingPlan(options); + + expect(result.enterpriseUrl).toBe("https://my-proxy.com/v1"); + }); + + it("throws error when custom URL is empty", async () => { + const options: OAuthController = { + onAuth: () => {}, + onProgress: () => {}, + onPrompt: async (prompt) => { + if (prompt.message.includes("Select Alibaba")) return "3"; + if (prompt.message.includes("custom base URL")) return ""; + return ""; + }, + }; + + await expect(loginAlibabaCodingPlan(options)).rejects.toThrow( + "Custom URL is required for option 3" + ); + }); + + it("throws error when API key is empty", async () => { + const options: OAuthController = { + onAuth: () => {}, + onProgress: () => {}, + onPrompt: async (prompt) => { + if (prompt.message.includes("Select Alibaba")) return "1"; + if (prompt.message.includes("Paste your")) return ""; + return ""; + }, + }; + + await expect(loginAlibabaCodingPlan(options)).rejects.toThrow( + "API key is required" + ); + }); + + it("checks abort signal after endpoint selection", async () => { + const controller = new AbortController(); + const options: OAuthController = { + onAuth: () => {}, + onProgress: () => {}, + onPrompt: async (prompt) => { + if (prompt.message.includes("Select Alibaba")) { + controller.abort(); + return ""; + } + return ""; + }, + signal: controller.signal, + }; + + await expect(loginAlibabaCodingPlan(options)).rejects.toThrow( + "Login cancelled" + ); + }); +}); + +describe("alibaba-coding-plan JSON apiKey", () => { + it("getOAuthApiKey returns JSON with token and enterpriseUrl", async () => { + const credentials = { + "alibaba-coding-plan": { + access: "sk-test-key", + refresh: "refresh-token", + expires: Date.now() + 3600000, + enterpriseUrl: "https://coding.dashscope.aliyuncs.com/v1", + }, + }; + const result = await getOAuthApiKey("alibaba-coding-plan", credentials); + expect(result).not.toBeNull(); + const parsed = JSON.parse(result!.apiKey); + expect(parsed.token).toBe("sk-test-key"); + expect(parsed.enterpriseUrl).toBe("https://coding.dashscope.aliyuncs.com/v1"); + }); + + it("JSON apiKey parsing extracts token for Bearer header", () => { + const rawApiKey = JSON.stringify({ + token: "sk-bearer-token", + enterpriseUrl: "https://custom.endpoint.com/v1", + }); + const parsed = JSON.parse(rawApiKey); + const apiKey = typeof parsed?.token === "string" ? parsed.token : rawApiKey; + expect(apiKey).toBe("sk-bearer-token"); + }); + + it("JSON apiKey parsing extracts enterpriseUrl for baseUrl", () => { + const rawApiKey = JSON.stringify({ + token: "sk-test", + enterpriseUrl: "https://china.dashscope.aliyuncs.com/v1", + }); + const parsed = JSON.parse(rawApiKey); + const baseUrl = typeof parsed?.enterpriseUrl === "string" ? parsed.enterpriseUrl : undefined; + expect(baseUrl).toBe("https://china.dashscope.aliyuncs.com/v1"); + }); + + it("non-JSON apiKey falls back to raw value", () => { + const rawApiKey = "sk-plain-key"; + let apiKey = rawApiKey; + try { + const parsed = JSON.parse(rawApiKey); + if (typeof parsed?.token === "string") { + apiKey = parsed.token; + } + } catch { + // Not JSON — use raw apiKey + } + expect(apiKey).toBe("sk-plain-key"); + }); +});