fix(gateway): align provider plugin runtime identity

Provider compilation replaces display and capability aliases with stable
runtime identifiers, but plugin targets and fallback headers could retain
the precompiled names. Normalize both paths so routing and authentication
refer to the provider identity that the gateway actually registers.
This commit is contained in:
camjac251
2026-07-15 03:00:10 -04:00
parent f22f2a4c79
commit 24e52a64f3
3 changed files with 153 additions and 6 deletions
@@ -11,7 +11,7 @@ import { isRecord, stringListValue, stringValue } from "@ccr/core/gateway/intern
import { fusionBuiltinToolArtifacts, fusionToolFallbackMcpServer, normalizeFusionWebSearchProfileToolName, toolHubMcpServer, withCodexCompatibleVirtualModelProfiles, withFusionVirtualModelAliases, withFusionWebSearchToolInstructions } from "@ccr/core/mcp/fusion-config";
import { mediaToolsMcpServer } from "@ccr/core/mcp/grok-media-config";
import { resolveGatewayPublicModelId } from "@ccr/core/gateway/features/model-discovery";
import { activeProviderCredentials, inferProtocol, normalizedProviderCapabilities, normalizeProviderProtocol, providerCapabilityForClientProtocol, providerCapabilityInternalName, providerCredentialInternalName, providerProtocolForClientProtocol, sortProviderCredentialsForConfig, toCoreGatewayProviders } from "@ccr/core/providers/runtime-topology";
import { activeProviderCredentials, inferProtocol, normalizedProviderCapabilities, normalizeProviderProtocol, providerCapabilityForClientProtocol, providerCapabilityInternalName, providerCapabilityNameMatches, providerCredentialInternalName, providerProtocolForClientProtocol, sortProviderCredentialsForConfig, toCoreGatewayProviders } from "@ccr/core/providers/runtime-topology";
import { buildRawTraceConfig } from "@ccr/core/observability/raw-trace-sync";
import { endpoint, resolveUndiciProxyAgentModule, writeGatewayProxyPreloadFile } from "@ccr/core/gateway/core-runtime/supervisor";
import { billingUsageSyncHeader, billingUsageSyncPath, claudeCodeOauthBetaHeader, claudeCodeOauthRequiredBeta, coreGatewayAuthHeader, coreGatewayAuthTokenEnv } from "@ccr/core/gateway/internal/shared";
@@ -40,10 +40,15 @@ export async function compileCoreGatewayConfig(
)
: [];
const pluginBillingConfig = isRecord(pluginCoreGatewayConfig.billing) ? pluginCoreGatewayConfig.billing : {};
const configuredProviderPlugins = normalizeClaudeCodeOauthProviderPlugins([
...(config.providerPlugins ?? []).filter(providerPluginEnabled),
...pluginService.getCoreProviderPlugins().filter(providerPluginEnabled)
]);
const configuredProviderPlugins = normalizeClaudeCodeOauthProviderPlugins(
normalizeCoreProviderPluginNames(
[
...(config.providerPlugins ?? []).filter(providerPluginEnabled),
...pluginService.getCoreProviderPlugins().filter(providerPluginEnabled)
],
config.Providers
)
);
const providerPlugins = await withKimiOauthRuntimeDefaults(
await withGrokOauthRuntimeDefaults(withCodexOauthRuntimeDefaults(configuredProviderPlugins))
);
@@ -562,6 +567,57 @@ export function normalizeClaudeCodeOauthProviderPlugins(providerPlugins: unknown
}
function normalizeCoreProviderPluginNames(
providerPlugins: unknown[],
providers: GatewayProviderConfig[]
): unknown[] {
return providerPlugins.map((plugin) => {
if (!isRecord(plugin)) {
return plugin;
}
const configuredName = stringValue(plugin.providerName);
if (!configuredName) {
return plugin;
}
const providerName = compiledProviderNameForPlugin(configuredName, providers);
return providerName === configuredName ? plugin : { ...plugin, providerName };
});
}
function compiledProviderNameForPlugin(
configuredName: string,
providers: GatewayProviderConfig[]
): string {
for (const provider of providers) {
const capabilities = normalizedProviderCapabilities(provider);
if (capabilities.length === 0) {
const protocol: GatewayProviderProtocol =
normalizeProviderProtocol(provider.type) ??
normalizeProviderProtocol(provider.provider) ??
inferProtocol(provider);
const normalizedConfiguredName = configuredName.trim().toLowerCase();
if (
providerRuntimeId(provider).toLowerCase() === normalizedConfiguredName ||
provider.name.trim().toLowerCase() === normalizedConfiguredName ||
providerCapabilityNameMatches(provider, protocol, configuredName)
) {
return providerRuntimeId(provider);
}
continue;
}
for (const capability of capabilities) {
if (providerCapabilityNameMatches(provider, capability.type, configuredName)) {
return providerCapabilityInternalName(provider, capability.type);
}
}
}
return configuredName;
}
function configuredAnthropicBetaDefault(value: unknown): string | undefined {
if (typeof value === "string") {
return value;
@@ -649,7 +649,7 @@ function resolvePlannedProviderCredentialRoutingTarget(
function targetProviderHeaderValue(provider: GatewayProviderConfig, protocol: GatewayProviderProtocol): string {
const capability = normalizedProviderCapabilities(provider).find((item) => item.type === protocol);
return capability ? providerCapabilityInternalName(provider, capability.type) : provider.name || providerRuntimeId(provider);
return capability ? providerCapabilityInternalName(provider, capability.type) : providerRuntimeId(provider);
}
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-compiler.ts";
import { prepareGatewayUpstreamAttemptForTest } from "@ccr/core/gateway/upstream/executor.ts";
test("provider plugins use compiled runtime and capability identities", async () => {
const unchangedPlugin = { key: "unscoped-plugin" };
const config = createDefaultAppConfig({ generatedConfigFile: "/tmp/ccr-provider-plugin-runtime-identity.json" });
config.providerPlugins = [
{
key: "single-protocol-plugin",
providerName: "single-provider::anthropic_messages"
},
{
key: "single-protocol-display-name-plugin",
providerName: "Single Provider"
},
{
key: "multi-protocol-plugin",
providerName: "Multi Provider::openai_responses"
},
{
key: "external-plugin",
providerName: "External Provider"
},
unchangedPlugin
];
config.Providers = [
{
api_base_url: "https://single.example.test/v1",
id: "single-provider",
models: ["single-model"],
name: "Single Provider",
type: "anthropic_messages"
},
{
api_base_url: "https://multi.example.test/v1",
capabilities: [
{ baseUrl: "https://multi.example.test/anthropic", type: "anthropic_messages" },
{ baseUrl: "https://multi.example.test/responses", type: "openai_responses" }
],
id: "multi-provider",
models: ["multi-model"],
name: "Multi Provider",
type: "anthropic_messages"
}
];
const compiled = await compileCoreGatewayConfig(
config,
"raw-trace-token",
"billing-usage-token",
"core-auth-token"
);
const [runtimePlugin, displayNamePlugin, capabilityPlugin, unmatchedPlugin, unscopedPlugin] = compiled.providerPlugins;
assert.equal(runtimePlugin.providerName, "single-provider");
assert.equal(displayNamePlugin.providerName, "single-provider");
assert.equal(capabilityPlugin.providerName, "multi-provider::openai_responses");
assert.equal(unmatchedPlugin.providerName, "External Provider");
assert.equal(unscopedPlugin, unchangedPlugin);
});
test("credential-free fallback headers use the provider runtime identity", () => {
const provider = {
api_base_url: "https://api.example.test",
id: "provider-runtime-test",
models: ["example-model"],
name: "Display Provider",
type: "anthropic_messages"
};
const config = {
Providers: [provider],
Router: { fallback: { mode: "off", models: [], retryCount: 0 } },
gateway: {}
};
const attempt = prepareGatewayUpstreamAttemptForTest({
body: {
messages: [{ content: "hi", role: "user" }],
model: "Display Provider/example-model"
},
config,
headers: {},
method: "POST",
path: "/v1/messages"
});
assert.equal(attempt.headers["x-target-provider"], "provider-runtime-test");
});