mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-09-01 14:52:19 +08:00
Preserve API keys across provider probes
This commit is contained in:
@@ -164,7 +164,7 @@ export async function probeGatewayProviderCandidates(
|
||||
|
||||
try {
|
||||
const probe = await probeGatewayProvider({
|
||||
apiKey: mode === "connectivity" || mode === "models" ? request.apiKey : undefined,
|
||||
apiKey: request.apiKey,
|
||||
baseUrl: candidate.baseUrl,
|
||||
forceRefresh: request.forceRefresh,
|
||||
mode,
|
||||
@@ -337,7 +337,7 @@ function providerProbeCandidateName(candidate: GatewayProviderProbeCandidate | u
|
||||
async function resolveGatewayProviderProbe(request: GatewayProviderProbeRequest): Promise<GatewayProviderProbeResult> {
|
||||
const mode = request.mode ?? "protocols";
|
||||
const safetyIssue = providerApiKeySafetyIssue({
|
||||
apiKey: mode === "connectivity" || mode === "models" ? request.apiKey : undefined,
|
||||
apiKey: request.apiKey,
|
||||
baseUrl: request.baseUrl
|
||||
});
|
||||
if (safetyIssue) {
|
||||
@@ -1458,36 +1458,53 @@ function geminiApiEndpoint(baseUrl: string, path: string, defaultVersion: "v1" |
|
||||
}
|
||||
|
||||
function withGeminiKey(url: string, apiKey: string | undefined): string {
|
||||
if (!apiKey) {
|
||||
const key = apiKeyCredentialValue(apiKey);
|
||||
if (!key) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const parsed = new URL(url);
|
||||
parsed.searchParams.set("key", apiKey);
|
||||
parsed.searchParams.set("key", key);
|
||||
return compactProviderUrl(parsed);
|
||||
}
|
||||
|
||||
function openAiHeaders(apiKey: string | undefined): Record<string, string> {
|
||||
return apiKey
|
||||
? {
|
||||
authorization: `Bearer ${apiKey}`
|
||||
}
|
||||
: {};
|
||||
return authorizationHeaders(apiKey);
|
||||
}
|
||||
|
||||
function anthropicHeaders(apiKey: string | undefined): Record<string, string> {
|
||||
const key = apiKeyCredentialValue(apiKey);
|
||||
return {
|
||||
"anthropic-version": "2023-06-01",
|
||||
...(apiKey ? { "x-api-key": apiKey } : {})
|
||||
...authorizationHeaders(apiKey),
|
||||
...(key ? { "x-api-key": key } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function geminiHeaders(apiKey: string | undefined): Record<string, string> {
|
||||
return apiKey
|
||||
? {
|
||||
"x-goog-api-key": apiKey
|
||||
}
|
||||
: {};
|
||||
const key = apiKeyCredentialValue(apiKey);
|
||||
return {
|
||||
...authorizationHeaders(apiKey),
|
||||
...(key ? { "x-goog-api-key": key } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function authorizationHeaders(apiKey: string | undefined): Record<string, string> {
|
||||
const trimmed = apiKey?.trim();
|
||||
if (!trimmed) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
authorization: /^Bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`
|
||||
};
|
||||
}
|
||||
|
||||
function apiKeyCredentialValue(apiKey: string | undefined): string | undefined {
|
||||
const trimmed = apiKey?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed.replace(/^Bearer\s+/i, "");
|
||||
}
|
||||
|
||||
function headersForProtocol(protocol: GatewayProviderCapabilityProtocol, apiKey: string | undefined): Record<string, string> {
|
||||
|
||||
@@ -12,7 +12,8 @@ import { detectedProviderFromHeaders, newApiKeyUsageAccountConfig, newApiUserSel
|
||||
import {
|
||||
checkGatewayProviderConnectivity,
|
||||
isProviderProtocolEndpointSupportedForProbe,
|
||||
probeGatewayProvider
|
||||
probeGatewayProvider,
|
||||
probeGatewayProviderCandidates
|
||||
} from "@ccr/core/providers/probe.ts";
|
||||
|
||||
test("protocol support probe does not treat Gemini auth errors as every protocol", () => {
|
||||
@@ -218,6 +219,99 @@ test("provider probe exposes image and video capabilities when their endpoints r
|
||||
);
|
||||
});
|
||||
|
||||
test("candidate protocol probe carries the entered API key and Authorization fallback", async (t) => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
const calls = [];
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = new URL(String(input));
|
||||
const headers = new Headers(init?.headers);
|
||||
calls.push({
|
||||
authorization: headers.get("authorization"),
|
||||
pathname: url.pathname,
|
||||
protocol: url.pathname.includes("/messages")
|
||||
? "anthropic"
|
||||
: url.pathname.includes(":generateContent")
|
||||
? "gemini"
|
||||
: "openai",
|
||||
xApiKey: headers.get("x-api-key"),
|
||||
xGoogApiKey: headers.get("x-goog-api-key")
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ error: { message: "Unauthorized" } }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status: 401
|
||||
});
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = previousFetch;
|
||||
});
|
||||
|
||||
await probeGatewayProviderCandidates({
|
||||
apiKey: "sk-probe-key",
|
||||
candidates: [{
|
||||
baseUrl: "http://127.0.0.1:49124",
|
||||
protocols: ["openai_chat_completions", "anthropic_messages", "gemini_generate_content"],
|
||||
source: "custom"
|
||||
}],
|
||||
forceRefresh: true,
|
||||
mode: "protocols",
|
||||
protocols: ["openai_chat_completions", "anthropic_messages", "gemini_generate_content"]
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.protocol),
|
||||
["openai", "anthropic", "gemini"]
|
||||
);
|
||||
assert.equal(calls[0]?.authorization, "Bearer sk-probe-key");
|
||||
assert.equal(calls[1]?.authorization, "Bearer sk-probe-key");
|
||||
assert.equal(calls[1]?.xApiKey, "sk-probe-key");
|
||||
assert.equal(calls[2]?.authorization, "Bearer sk-probe-key");
|
||||
assert.equal(calls[2]?.xGoogApiKey, "sk-probe-key");
|
||||
});
|
||||
|
||||
test("model discovery carries Authorization fallback for protocol-specific API keys", async (t) => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
const calls = [];
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = new URL(String(input));
|
||||
const headers = new Headers(init?.headers);
|
||||
calls.push({
|
||||
authorization: headers.get("authorization"),
|
||||
key: url.searchParams.get("key"),
|
||||
pathname: url.pathname,
|
||||
xApiKey: headers.get("x-api-key"),
|
||||
xGoogApiKey: headers.get("x-goog-api-key")
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ data: [] }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status: 200
|
||||
});
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = previousFetch;
|
||||
});
|
||||
|
||||
await probeGatewayProvider({
|
||||
apiKey: "Bearer sk-model-key",
|
||||
baseUrl: "http://127.0.0.1:49124",
|
||||
forceRefresh: true,
|
||||
mode: "models",
|
||||
protocols: ["openai_chat_completions", "anthropic_messages", "gemini_generate_content"]
|
||||
});
|
||||
|
||||
const modelCalls = calls.filter((call) => call.pathname.endsWith("/models"));
|
||||
assert.equal(modelCalls.length >= 3, true);
|
||||
assert.equal(calls.every((call) => call.authorization === "Bearer sk-model-key"), true);
|
||||
assert.equal(modelCalls.some((call) => call.xApiKey === "sk-model-key"), true);
|
||||
assert.equal(
|
||||
modelCalls.some((call) => call.key === "sk-model-key" && call.xGoogApiKey === "sk-model-key"),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("connectivity probe applies provider plugin auth for local agent imports", async (t) => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
let called = false;
|
||||
|
||||
@@ -1691,7 +1691,7 @@ export async function probeProviderCandidates(
|
||||
): Promise<ProviderProbeCandidateResult | undefined> {
|
||||
const mode = options.mode ?? "protocols";
|
||||
return await window.ccr?.probeProviderCandidates({
|
||||
apiKey: mode === "connectivity" || mode === "models" ? apiKey : undefined,
|
||||
apiKey: apiKey || undefined,
|
||||
candidates,
|
||||
mode,
|
||||
models: mode === "connectivity" ? models : [],
|
||||
|
||||
Reference in New Issue
Block a user