Merge pull request #2802 from 21307369/fix/alibaba-endpoint-selection-v2
feat(alibaba-coding-plan): add endpoint selection for China/International/Custom
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, string> | undefined;
|
||||
|
||||
@@ -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<string> {
|
||||
export async function loginAlibabaCodingPlan(options: OAuthController): Promise<OAuthCredentials> {
|
||||
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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<typeof apiKeyValidation.validateOpenAICompatibleApiKey>;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user