Preserve API keys across provider probes

This commit is contained in:
musistudio
2026-07-31 14:50:19 +08:00
parent 39a5712ae7
commit 3f566d991a
3 changed files with 128 additions and 17 deletions
+32 -15
View File
@@ -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 : [],