mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-10-29 18:39:28 +08:00
Add local agent auth provider hook support
This commit is contained in:
@@ -13,14 +13,16 @@ 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, providerCapabilityNameMatches, providerCredentialInternalName, providerProtocolForClientProtocol, sortProviderCredentialsForConfig, toCoreGatewayProviders } from "@ccr/core/providers/runtime-topology";
|
||||
import { buildRawTraceConfig } from "@ccr/core/observability/raw-trace-sync";
|
||||
import { endpoint, resolveUndiciProxyAgentModule, resolveUpstreamHeaderSanitizerEntry, writeGatewayProxyPreloadFile } from "@ccr/core/gateway/core-runtime/supervisor";
|
||||
import { endpoint, resolveLocalAgentAuthProviderHookEntry, resolveUndiciProxyAgentModule, resolveUpstreamHeaderSanitizerEntry, writeGatewayProxyPreloadFile } from "@ccr/core/gateway/core-runtime/supervisor";
|
||||
import { billingUsageSyncHeader, billingUsageSyncPath, claudeCodeOauthBetaHeader, claudeCodeOauthRequiredBeta, coreGatewayAuthHeader, coreGatewayAuthTokenEnv } from "@ccr/core/gateway/internal/shared";
|
||||
import type { BrowserWebSearchMcpIntegration, CoreGatewayProvider } from "@ccr/core/gateway/internal/shared";
|
||||
import { uniqueStrings } from "@ccr/core/gateway/internal/collections";
|
||||
import { isLocalClaudeCodeOauthProviderPlugin, mergeAnthropicBetaValues } from "@ccr/core/providers/oauth-plugin";
|
||||
import { isLocalAgentOauthProviderPlugin } from "@ccr/core/gateway/core-runtime/local-agent-auth-provider-hook";
|
||||
import { resolveConfiguredProviderModelSelector, resolveUniqueConfiguredProviderModelSelector } from "@ccr/core/routing/model-resolution";
|
||||
|
||||
const upstreamHeaderSanitizerPluginKey = "ccr-upstream-header-sanitizer";
|
||||
const localAgentAuthProviderHookPluginKey = "ccr-local-agent-auth-provider-hooks";
|
||||
export const unlimitedVirtualModelToolCalls = Number.MAX_SAFE_INTEGER;
|
||||
export const unlimitedVirtualModelToolTurns = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
@@ -36,7 +38,8 @@ export async function compileCoreGatewayConfig(
|
||||
const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig();
|
||||
const configuredGatewayPlugins = Array.isArray(pluginCoreGatewayConfig.plugins)
|
||||
? pluginCoreGatewayConfig.plugins.filter((plugin) =>
|
||||
!isRecord(plugin) || stringValue(plugin.key) !== upstreamHeaderSanitizerPluginKey
|
||||
!isRecord(plugin) ||
|
||||
![localAgentAuthProviderHookPluginKey, upstreamHeaderSanitizerPluginKey].includes(stringValue(plugin.key) ?? "")
|
||||
)
|
||||
: [];
|
||||
const pluginBillingConfig = isRecord(pluginCoreGatewayConfig.billing) ? pluginCoreGatewayConfig.billing : {};
|
||||
@@ -82,6 +85,7 @@ export async function compileCoreGatewayConfig(
|
||||
.filter((provider): provider is CoreGatewayProvider => Boolean(provider)),
|
||||
...builtinToolArtifacts.providers
|
||||
];
|
||||
const localAgentAuthProviderHookPlugin = localAgentAuthProviderHookPluginConfig(providerPluginsWithCapabilityAliases);
|
||||
const pluginAgentConfig = isRecord(pluginCoreGatewayConfig.agent) ? pluginCoreGatewayConfig.agent : {};
|
||||
const pluginMcpServers = Array.isArray(pluginAgentConfig.mcpServers) ? pluginAgentConfig.mcpServers : [];
|
||||
const externalMcpServers = [
|
||||
@@ -138,6 +142,7 @@ export async function compileCoreGatewayConfig(
|
||||
port: config.gateway.corePort,
|
||||
plugins: [
|
||||
...configuredGatewayPlugins,
|
||||
...(localAgentAuthProviderHookPlugin ? [localAgentAuthProviderHookPlugin] : []),
|
||||
{
|
||||
enabled: true,
|
||||
key: upstreamHeaderSanitizerPluginKey,
|
||||
@@ -156,6 +161,17 @@ export async function compileCoreGatewayConfig(
|
||||
};
|
||||
}
|
||||
|
||||
function localAgentAuthProviderHookPluginConfig(providerPlugins: unknown[]): Record<string, unknown> | undefined {
|
||||
if (!providerPlugins.some(isLocalAgentOauthProviderPlugin)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
enabled: true,
|
||||
key: localAgentAuthProviderHookPluginKey,
|
||||
modulePath: resolveLocalAgentAuthProviderHookEntry()
|
||||
};
|
||||
}
|
||||
|
||||
function withProviderCapabilityPluginAliases(
|
||||
providerPlugins: unknown[],
|
||||
providers: GatewayProviderConfig[]
|
||||
@@ -518,6 +534,7 @@ async function withGrokOauthRuntimeDefaults(providerPlugins: unknown[]): Promise
|
||||
const currentHeaders = isRecord(currentAuth.headers) ? currentAuth.headers : {};
|
||||
const currentRequest = isRecord(plugin.request) ? plugin.request : {};
|
||||
const currentRequestHeaders = isRecord(currentRequest.headers) ? currentRequest.headers : {};
|
||||
const currentBodyRemove = Array.isArray(currentRequest.bodyRemove) ? currentRequest.bodyRemove : [];
|
||||
return {
|
||||
...plugin,
|
||||
auth: {
|
||||
@@ -529,6 +546,12 @@ async function withGrokOauthRuntimeDefaults(providerPlugins: unknown[]): Promise
|
||||
},
|
||||
request: {
|
||||
...currentRequest,
|
||||
bodyRemove: uniqueStrings([
|
||||
...currentBodyRemove
|
||||
.map((value) => stringValue(value))
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
"external_web_access"
|
||||
]),
|
||||
headers: {
|
||||
...currentRequestHeaders,
|
||||
"x-grok-client-identifier": "xai-grok-cli",
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import { readClaudeCodeOauth, readGrokAuth, readKimiAuth, resolveGrokAuth, resolveKimiAuth } from "@ccr/core/agents/local-providers/service";
|
||||
import { grokAccessTokenExpired, grokClientVersion } from "@ccr/core/agents/local-providers/grok";
|
||||
import { kimiAccessTokenExpired, kimiIdentityHeaders } from "@ccr/core/agents/local-providers/kimi";
|
||||
import { transformCodexApplyPatchBridgeRequestBody } from "@ccr/core/gateway/features/codex-patch-bridge";
|
||||
import { claudeCodeOauthBetaHeader, claudeCodeOauthRequiredBeta } from "@ccr/core/gateway/internal/shared";
|
||||
import { isRecord, stringValue } from "@ccr/core/gateway/internal/value";
|
||||
import { mergeAnthropicBetaValues } from "@ccr/core/providers/oauth-plugin";
|
||||
|
||||
const configProviderPluginKeyPrefix = "config:";
|
||||
const localAgentProviderPluginKeyPrefix = "ccr-local-agent-";
|
||||
|
||||
type HeaderRecord = Record<string, string>;
|
||||
|
||||
type UpstreamRequest = {
|
||||
body?: unknown;
|
||||
bodyEncoding?: "bytes" | "form" | "json" | "none" | "text";
|
||||
headers?: HeaderRecord;
|
||||
method?: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type ProviderPluginInput = {
|
||||
model?: string;
|
||||
request?: {
|
||||
headers?: Record<string, string | string[] | undefined>;
|
||||
};
|
||||
upstreamRequest: UpstreamRequest;
|
||||
};
|
||||
|
||||
type ProviderHookResult = {
|
||||
ok: true;
|
||||
value: UpstreamRequest;
|
||||
} | {
|
||||
error: string;
|
||||
ok: false;
|
||||
};
|
||||
|
||||
type ProviderHook = {
|
||||
authenticate?: (input: ProviderPluginInput) => Promise<ProviderHookResult> | ProviderHookResult;
|
||||
key: string;
|
||||
provider?: string;
|
||||
providerName?: string;
|
||||
transformRequest?: (input: ProviderPluginInput) => Promise<ProviderHookResult> | ProviderHookResult;
|
||||
};
|
||||
|
||||
type LocalAgentOauthKind = "claude-code" | "grok" | "kimi";
|
||||
|
||||
export function createGatewayPlugin(input: { config?: Record<string, unknown> } = {}) {
|
||||
return {
|
||||
providerHooks: localAgentOauthProviderHooks(input.config)
|
||||
};
|
||||
}
|
||||
|
||||
export function localAgentOauthProviderHooks(config: Record<string, unknown> | undefined): ProviderHook[] {
|
||||
const providerPlugins = Array.isArray(config?.providerPlugins) ? config.providerPlugins : [];
|
||||
return providerPlugins
|
||||
.map(localAgentOauthProviderHook)
|
||||
.filter((hook): hook is ProviderHook => Boolean(hook));
|
||||
}
|
||||
|
||||
export function isLocalAgentOauthProviderPlugin(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return localAgentOauthKind(value) !== undefined;
|
||||
}
|
||||
|
||||
function localAgentOauthProviderHook(plugin: unknown): ProviderHook | undefined {
|
||||
if (!isRecord(plugin)) {
|
||||
return undefined;
|
||||
}
|
||||
const kind = localAgentOauthKind(plugin);
|
||||
const key = stringValue(plugin.key);
|
||||
if (!kind || !key) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hook: ProviderHook = {
|
||||
key: `${configProviderPluginKeyPrefix}${key}`,
|
||||
provider: stringValue(plugin.provider),
|
||||
providerName: stringValue(plugin.providerName)
|
||||
};
|
||||
|
||||
if (kind === "grok") {
|
||||
hook.authenticate = (input) => authenticateWithBearer(input, () => resolveLiveGrokAccessToken(plugin), plugin, "Grok CLI access token was not found.");
|
||||
hook.transformRequest = (input) => transformGrokRequest(input, plugin);
|
||||
return hook;
|
||||
}
|
||||
|
||||
if (kind === "kimi") {
|
||||
hook.authenticate = (input) => authenticateWithBearer(input, () => resolveLiveKimiAccessToken(plugin), plugin, "Kimi CLI access token was not found.");
|
||||
hook.transformRequest = (input) => transformWithHeaders(input, kimiIdentityHeaders());
|
||||
return hook;
|
||||
}
|
||||
|
||||
hook.authenticate = (input) => authenticateClaudeCode(input, plugin);
|
||||
return hook;
|
||||
}
|
||||
|
||||
function localAgentOauthKind(plugin: Record<string, unknown>): LocalAgentOauthKind | undefined {
|
||||
const key = stringValue(plugin.key)?.toLowerCase();
|
||||
if (!key?.startsWith(localAgentProviderPluginKeyPrefix)) {
|
||||
return undefined;
|
||||
}
|
||||
if (key.includes("grok-cli-oauth")) {
|
||||
return "grok";
|
||||
}
|
||||
if (key.includes("kimi-cli-oauth")) {
|
||||
return "kimi";
|
||||
}
|
||||
if (key.includes("claude-code-oauth")) {
|
||||
return "claude-code";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function authenticateWithBearer(
|
||||
input: ProviderPluginInput,
|
||||
tokenResolver: () => Promise<string | undefined> | string | undefined,
|
||||
plugin: Record<string, unknown>,
|
||||
missingTokenError: string
|
||||
): Promise<ProviderHookResult> {
|
||||
const token = await tokenResolver() || originalBearerToken(plugin);
|
||||
if (!token) {
|
||||
return { error: missingTokenError, ok: false };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
...input.upstreamRequest,
|
||||
headers: withBearerAuth(input.upstreamRequest.headers, token, originalRemoveHeaders(plugin))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function authenticateClaudeCode(
|
||||
input: ProviderPluginInput,
|
||||
plugin: Record<string, unknown>
|
||||
): Promise<ProviderHookResult> {
|
||||
const token = readClaudeCodeOauth()?.accessToken || originalBearerToken(plugin);
|
||||
if (!token) {
|
||||
return { error: "Claude Code access token was not found.", ok: false };
|
||||
}
|
||||
const headers = withBearerAuth(input.upstreamRequest.headers, token, originalRemoveHeaders(plugin));
|
||||
headers[claudeCodeOauthBetaHeader] = mergeAnthropicBetaValues(
|
||||
requestHeader(input.request?.headers, claudeCodeOauthBetaHeader),
|
||||
originalAnthropicBetaDefault(plugin),
|
||||
claudeCodeOauthRequiredBeta
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
...input.upstreamRequest,
|
||||
headers
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveLiveGrokAccessToken(plugin: Record<string, unknown>): Promise<string | undefined> {
|
||||
const auth = await resolveGrokAuth().catch(() => readGrokAuth());
|
||||
if (auth?.accessToken && !grokAccessTokenExpired(auth)) {
|
||||
return auth.accessToken;
|
||||
}
|
||||
return originalBearerToken(plugin);
|
||||
}
|
||||
|
||||
async function resolveLiveKimiAccessToken(plugin: Record<string, unknown>): Promise<string | undefined> {
|
||||
const reference = kimiOauthReference(plugin);
|
||||
const auth = await resolveKimiAuth(reference).catch(() => readKimiAuth(reference));
|
||||
if (auth?.accessToken && !kimiAccessTokenExpired(auth)) {
|
||||
return auth.accessToken;
|
||||
}
|
||||
return originalBearerToken(plugin);
|
||||
}
|
||||
|
||||
function transformWithHeaders(input: ProviderPluginInput, headers: HeaderRecord): ProviderHookResult {
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
...input.upstreamRequest,
|
||||
headers: {
|
||||
...(input.upstreamRequest.headers ?? {}),
|
||||
...headers
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function transformGrokRequest(input: ProviderPluginInput, plugin: Record<string, unknown>): ProviderHookResult {
|
||||
const headers = {
|
||||
...(input.upstreamRequest.headers ?? {}),
|
||||
"x-grok-client-identifier": "xai-grok-cli",
|
||||
"x-grok-client-version": grokClientVersion(),
|
||||
"x-grok-model-override": input.model || originalRequestHeader(plugin, "x-grok-model-override") || ""
|
||||
};
|
||||
const body = transformGrokResponsesBody(input.upstreamRequest.body);
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
...input.upstreamRequest,
|
||||
body: body.changed ? body.value : input.upstreamRequest.body,
|
||||
headers
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function transformGrokResponsesBody(value: unknown): { value: unknown; changed: boolean } {
|
||||
if (!isRecord(value)) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
const patched = transformCodexApplyPatchBridgeRequestBody(value);
|
||||
const sanitizedOptions = sanitizeGrokUnsupportedResponsesOptions(patched.body);
|
||||
const sanitized = sanitizeGrokResponsesTools(sanitizedOptions.value);
|
||||
return {
|
||||
value: sanitized.value,
|
||||
changed: patched.changed || sanitizedOptions.changed || sanitized.changed
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeGrokUnsupportedResponsesOptions(body: Record<string, unknown>): {
|
||||
value: Record<string, unknown>;
|
||||
changed: boolean;
|
||||
} {
|
||||
let next: Record<string, unknown> | undefined;
|
||||
for (const key of grokUnsupportedResponsesOptions) {
|
||||
if (Object.hasOwn(body, key)) {
|
||||
next ??= { ...body };
|
||||
delete next[key];
|
||||
}
|
||||
}
|
||||
return next ? { value: next, changed: true } : { value: body, changed: false };
|
||||
}
|
||||
|
||||
function sanitizeGrokResponsesTools(body: Record<string, unknown>): { value: Record<string, unknown>; changed: boolean } {
|
||||
if (!Array.isArray(body.tools)) {
|
||||
return { value: body, changed: false };
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const removedToolNames = new Set<string>();
|
||||
const tools = body.tools.filter((tool) => {
|
||||
const supported = isGrokSupportedResponsesTool(tool);
|
||||
if (!supported) {
|
||||
changed = true;
|
||||
const name = responseToolName(tool);
|
||||
if (name) {
|
||||
removedToolNames.add(name);
|
||||
}
|
||||
}
|
||||
return supported;
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
return { value: body, changed: false };
|
||||
}
|
||||
|
||||
const next = { ...body };
|
||||
if (tools.length > 0) {
|
||||
next.tools = tools;
|
||||
} else {
|
||||
delete next.tools;
|
||||
delete next.parallel_tool_calls;
|
||||
}
|
||||
if (tools.length === 0 || grokToolChoiceNamesRemovedTool(next.tool_choice, removedToolNames)) {
|
||||
delete next.tool_choice;
|
||||
}
|
||||
return { value: next, changed: true };
|
||||
}
|
||||
|
||||
function isGrokSupportedResponsesTool(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return grokSupportedResponsesToolTypes.has(stringValue(value.type) ?? "");
|
||||
}
|
||||
|
||||
function responseToolName(value: unknown): string | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return stringValue(value.name) || stringValue(value.server_label);
|
||||
}
|
||||
|
||||
function grokToolChoiceNamesRemovedTool(value: unknown, removedToolNames: Set<string>): boolean {
|
||||
if (removedToolNames.size === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const name = stringValue(value.name) || stringValue(value.server_label);
|
||||
return name ? removedToolNames.has(name) : false;
|
||||
}
|
||||
|
||||
function withBearerAuth(
|
||||
headers: HeaderRecord | undefined,
|
||||
token: string,
|
||||
removeHeaders: string[]
|
||||
): HeaderRecord {
|
||||
const next = { ...(headers ?? {}) };
|
||||
for (const name of removeHeaders) {
|
||||
deleteHeader(next, name);
|
||||
}
|
||||
setHeader(next, "authorization", `Bearer ${token}`);
|
||||
return next;
|
||||
}
|
||||
|
||||
function originalRemoveHeaders(plugin: Record<string, unknown>): string[] {
|
||||
const auth = isRecord(plugin.auth) ? plugin.auth : undefined;
|
||||
const removeHeaders = Array.isArray(auth?.removeHeaders) ? auth.removeHeaders : [];
|
||||
return uniqueStrings([
|
||||
...removeHeaders
|
||||
.map((value) => stringValue(value))
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
"x-api-key"
|
||||
]);
|
||||
}
|
||||
|
||||
function originalBearerToken(plugin: Record<string, unknown>): string | undefined {
|
||||
const authorization = originalAuthHeader(plugin, "authorization");
|
||||
const prefix = "Bearer ";
|
||||
return authorization?.startsWith(prefix) ? authorization.slice(prefix.length).trim() || undefined : undefined;
|
||||
}
|
||||
|
||||
function originalAuthHeader(plugin: Record<string, unknown>, name: string): string | undefined {
|
||||
const auth = isRecord(plugin.auth) ? plugin.auth : undefined;
|
||||
const headers = isRecord(auth?.headers) ? auth.headers : undefined;
|
||||
return recordHeader(headers, name);
|
||||
}
|
||||
|
||||
function originalRequestHeader(plugin: Record<string, unknown>, name: string): string | undefined {
|
||||
const request = isRecord(plugin.request) ? plugin.request : undefined;
|
||||
const headers = isRecord(request?.headers) ? request.headers : undefined;
|
||||
return recordHeader(headers, name);
|
||||
}
|
||||
|
||||
function originalAnthropicBetaDefault(plugin: Record<string, unknown>): string | undefined {
|
||||
const value = originalAuthHeaderValue(plugin, claudeCodeOauthBetaHeader);
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return stringValue(value.default);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function originalAuthHeaderValue(plugin: Record<string, unknown>, name: string): unknown {
|
||||
const auth = isRecord(plugin.auth) ? plugin.auth : undefined;
|
||||
const headers = isRecord(auth?.headers) ? auth.headers : undefined;
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedName = name.toLowerCase();
|
||||
return Object.entries(headers).find(([key]) => key.trim().toLowerCase() === normalizedName)?.[1];
|
||||
}
|
||||
|
||||
function recordHeader(headers: Record<string, unknown> | undefined, name: string): string | undefined {
|
||||
const value = recordHeaderValue(headers, name);
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function recordHeaderValue(headers: Record<string, unknown> | undefined, name: string): unknown {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedName = name.toLowerCase();
|
||||
return Object.entries(headers).find(([key]) => key.trim().toLowerCase() === normalizedName)?.[1];
|
||||
}
|
||||
|
||||
function requestHeader(
|
||||
headers: Record<string, string | string[] | undefined> | undefined,
|
||||
name: string
|
||||
): string | undefined {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedName = name.toLowerCase();
|
||||
const value = Object.entries(headers).find(([key]) => key.trim().toLowerCase() === normalizedName)?.[1];
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(",");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function setHeader(headers: HeaderRecord, name: string, value: string): void {
|
||||
deleteHeader(headers, name);
|
||||
headers[name] = value;
|
||||
}
|
||||
|
||||
function deleteHeader(headers: HeaderRecord, name: string): void {
|
||||
const normalizedName = name.toLowerCase();
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.trim().toLowerCase() === normalizedName) {
|
||||
delete headers[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function kimiOauthReference(plugin: Record<string, unknown>): { key?: string; oauthHost?: string } | undefined {
|
||||
const oauth = isRecord(plugin.kimiOauth) ? plugin.kimiOauth : undefined;
|
||||
if (!oauth) {
|
||||
return undefined;
|
||||
}
|
||||
const key = stringValue(oauth.key);
|
||||
const oauthHost = stringValue(oauth.oauthHost) || stringValue(oauth.oauth_host);
|
||||
return key || oauthHost ? { key, oauthHost } : undefined;
|
||||
}
|
||||
|
||||
const grokSupportedResponsesToolTypes = new Set([
|
||||
"function",
|
||||
"x_search",
|
||||
"image_generation",
|
||||
"collections_search",
|
||||
"file_search",
|
||||
"code_execution",
|
||||
"code_interpreter",
|
||||
"mcp",
|
||||
"shell"
|
||||
]);
|
||||
|
||||
const grokUnsupportedResponsesOptions = new Set([
|
||||
"external_web_access"
|
||||
]);
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const unique: string[] = [];
|
||||
for (const value of values) {
|
||||
const key = value.trim().toLowerCase();
|
||||
if (!key || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
unique.push(value);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
@@ -184,6 +184,20 @@ export function resolveUpstreamHeaderSanitizerEntry(): string {
|
||||
].find((candidate) => existsSync(candidate)) ?? pathJoin(__dirname, "upstream-header-sanitizer.js");
|
||||
}
|
||||
|
||||
export function resolveLocalAgentAuthProviderHookEntry(): string {
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
|
||||
return [
|
||||
pathJoin(__dirname, "local-agent-auth-provider-hook.js"),
|
||||
pathJoin(process.cwd(), ".test-dist", "core", "runtime", "local-agent-auth-provider-hook.js"),
|
||||
...(resourcesPath
|
||||
? [
|
||||
pathJoin(resourcesPath, "app.asar", "dist", "main", "local-agent-auth-provider-hook.js"),
|
||||
pathJoin(resourcesPath, "app", "dist", "main", "local-agent-auth-provider-hook.js")
|
||||
]
|
||||
: [])
|
||||
].find((candidate) => existsSync(candidate)) ?? pathJoin(__dirname, "local-agent-auth-provider-hook.js");
|
||||
}
|
||||
|
||||
function resolveGatewayEntry(): string {
|
||||
const override = process.env[gatewayEntryOverrideEnv]?.trim();
|
||||
if (override) {
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
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 {
|
||||
createGatewayPlugin,
|
||||
isLocalAgentOauthProviderPlugin
|
||||
} from "@ccr/core/gateway/core-runtime/local-agent-auth-provider-hook.ts";
|
||||
import { virtualApplyPatchToolName } from "@ccr/core/gateway/internal/shared.ts";
|
||||
|
||||
test("Grok local agent auth hook refreshes live login state before authenticating upstream requests", async (t) => {
|
||||
await withGrokHome(t, async (grokHome) => {
|
||||
writeGrokAuth(grokHome, {
|
||||
expires_at: "2000-01-01T00:00:00Z",
|
||||
key: "expired-grok-access-token",
|
||||
oidc_client_id: "grok-client-id",
|
||||
oidc_issuer: "https://auth.x.ai",
|
||||
refresh_token: "grok-refresh-token"
|
||||
});
|
||||
|
||||
const previousFetch = globalThis.fetch;
|
||||
const previousTokenEndpoint = process.env.GROK_OIDC_TOKEN_ENDPOINT;
|
||||
process.env.GROK_OIDC_TOKEN_ENDPOINT = "http://127.0.0.1/grok/oauth/token";
|
||||
globalThis.fetch = async (input, init) => {
|
||||
assert.equal(String(input), "http://127.0.0.1/grok/oauth/token");
|
||||
assert.equal(String(init?.body ?? ""), "client_id=grok-client-id&grant_type=refresh_token&refresh_token=grok-refresh-token");
|
||||
return new Response(JSON.stringify({
|
||||
access_token: "refreshed-grok-access-token",
|
||||
expires_in: 3600,
|
||||
refresh_token: "refreshed-grok-refresh-token"
|
||||
}), { headers: { "content-type": "application/json" }, status: 200 });
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = previousFetch;
|
||||
restoreEnv("GROK_OIDC_TOKEN_ENDPOINT", previousTokenEndpoint);
|
||||
});
|
||||
|
||||
const [hook] = createGatewayPlugin({
|
||||
config: {
|
||||
providerPlugins: [grokOauthProviderPlugin()]
|
||||
}
|
||||
}).providerHooks;
|
||||
assert.equal(hook.key, "config:ccr-local-agent-grok-cli-api-grok-cli-oauth");
|
||||
|
||||
const patch = "*** Begin Patch\n*** Add File: grok.txt\n+hi\n*** End Patch\n";
|
||||
const upstreamRequest = {
|
||||
body: {
|
||||
external_web_access: true,
|
||||
input: [
|
||||
{ type: "custom_tool_call", call_id: "call_patch", name: "apply_patch", input: patch },
|
||||
{ type: "custom_tool_call_output", call_id: "call_patch", output: "Success" }
|
||||
],
|
||||
model: "wrong-model",
|
||||
parallel_tool_calls: true,
|
||||
tool_choice: "auto",
|
||||
tools: [
|
||||
{ type: "function", name: "exec_command" },
|
||||
{ type: "custom", name: "apply_patch", format: { type: "grammar", syntax: "lark", definition: "start: begin_patch" } },
|
||||
{ type: "namespace", name: "multi_agent_v1" },
|
||||
{ type: "web_search" }
|
||||
]
|
||||
},
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-api-key": "client-key"
|
||||
},
|
||||
method: "POST",
|
||||
url: "https://cli-chat-proxy.grok.com/v1/responses"
|
||||
};
|
||||
const authResult = await hook.authenticate({
|
||||
model: "grok-4.5",
|
||||
upstreamRequest
|
||||
});
|
||||
|
||||
assert.equal(authResult.ok, true);
|
||||
assert.equal(authResult.value.headers.authorization, "Bearer refreshed-grok-access-token");
|
||||
assert.equal(authResult.value.headers["x-api-key"], undefined);
|
||||
assert.equal(upstreamRequest.headers["x-api-key"], "client-key");
|
||||
|
||||
const requestResult = await hook.transformRequest({
|
||||
model: "grok-4.5",
|
||||
upstreamRequest: authResult.value
|
||||
});
|
||||
assert.equal(requestResult.ok, true);
|
||||
assert.equal(requestResult.value.headers["x-grok-client-identifier"], "xai-grok-cli");
|
||||
assert.equal(requestResult.value.headers["x-grok-client-version"], "0.2.93");
|
||||
assert.equal(requestResult.value.headers["x-grok-model-override"], "grok-4.5");
|
||||
assert.equal(requestResult.value.body.external_web_access, undefined);
|
||||
assert.deepEqual(requestResult.value.body.tools.map((tool) => tool.type), ["function", "function"]);
|
||||
assert.equal(requestResult.value.body.tools[1].name, virtualApplyPatchToolName);
|
||||
assert.equal(requestResult.value.body.tools.some((tool) => tool.type === "custom" || tool.type === "namespace"), false);
|
||||
assert.equal(requestResult.value.body.tool_choice, "auto");
|
||||
assert.equal(requestResult.value.body.parallel_tool_calls, true);
|
||||
assert.match(requestResult.value.body.instructions, /When modifying files, call virtual_apply_patch/);
|
||||
assert.equal(requestResult.value.body.input[0].type, "function_call");
|
||||
assert.equal(requestResult.value.body.input[0].name, virtualApplyPatchToolName);
|
||||
assert.deepEqual(JSON.parse(requestResult.value.body.input[0].arguments), { patch });
|
||||
assert.equal(requestResult.value.body.input[1].type, "function_call_output");
|
||||
|
||||
const persisted = JSON.parse(readFileSync(path.join(grokHome, "auth.json"), "utf8"));
|
||||
assert.equal(persisted["https://auth.x.ai::test-account"].key, "refreshed-grok-access-token");
|
||||
});
|
||||
});
|
||||
|
||||
test("Grok local agent request hook removes unsupported Responses tools and stale tool choice", () => {
|
||||
const [hook] = createGatewayPlugin({
|
||||
config: {
|
||||
providerPlugins: [grokOauthProviderPlugin()]
|
||||
}
|
||||
}).providerHooks;
|
||||
const requestResult = hook.transformRequest({
|
||||
model: "grok-4.5",
|
||||
upstreamRequest: {
|
||||
body: {
|
||||
parallel_tool_calls: true,
|
||||
tool_choice: { type: "tool", name: "multi_agent_v1" },
|
||||
tools: [
|
||||
{ type: "namespace", name: "multi_agent_v1" },
|
||||
{ type: "function", name: "exec_command" }
|
||||
]
|
||||
},
|
||||
headers: {},
|
||||
method: "POST",
|
||||
url: "https://cli-chat-proxy.grok.com/v1/responses"
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(requestResult.ok, true);
|
||||
assert.deepEqual(requestResult.value.body.tools, [{ type: "function", name: "exec_command" }]);
|
||||
assert.equal(requestResult.value.body.tool_choice, undefined);
|
||||
assert.equal(requestResult.value.body.parallel_tool_calls, true);
|
||||
});
|
||||
|
||||
test("core gateway config installs the local agent dynamic auth runtime hook when OAuth plugins are present", async () => {
|
||||
const config = createDefaultAppConfig();
|
||||
config.providerPlugins = [grokOauthProviderPlugin()];
|
||||
config.Providers = [
|
||||
{
|
||||
api_base_url: "https://cli-chat-proxy.grok.com/v1",
|
||||
api_key: "ccr-local-agent-login",
|
||||
id: "grok-cli-api",
|
||||
models: ["grok-4.5"],
|
||||
name: "Grok CLI API",
|
||||
type: "openai_responses"
|
||||
}
|
||||
];
|
||||
|
||||
const compiled = await compileCoreGatewayConfig(
|
||||
config,
|
||||
"raw-trace-token",
|
||||
"billing-usage-token",
|
||||
"core-auth-token"
|
||||
);
|
||||
const plugins = Array.isArray(compiled.plugins) ? compiled.plugins : [];
|
||||
const localAgentAuthPlugin = plugins.find((plugin) => plugin.key === "ccr-local-agent-auth-provider-hooks");
|
||||
|
||||
assert.ok(localAgentAuthPlugin);
|
||||
assert.match(localAgentAuthPlugin.modulePath, /local-agent-auth-provider-hook\.js$/);
|
||||
});
|
||||
|
||||
test("core gateway config removes Grok unsupported Responses options through declarative request transforms", async (t) => {
|
||||
await withGrokHome(t, async (grokHome) => {
|
||||
writeGrokAuth(grokHome, {
|
||||
expires_at: "2099-01-01T00:00:00Z",
|
||||
key: "live-grok-access-token",
|
||||
oidc_client_id: "grok-client-id",
|
||||
oidc_issuer: "https://auth.x.ai",
|
||||
refresh_token: "grok-refresh-token"
|
||||
});
|
||||
const plugin = grokOauthProviderPlugin();
|
||||
plugin.request.bodyRemove = ["metadata"];
|
||||
|
||||
const config = createDefaultAppConfig();
|
||||
config.providerPlugins = [plugin];
|
||||
config.Providers = [
|
||||
{
|
||||
api_base_url: "https://cli-chat-proxy.grok.com/v1",
|
||||
api_key: "ccr-local-agent-login",
|
||||
id: "grok-cli-api",
|
||||
models: ["grok-4.5"],
|
||||
name: "Grok CLI API",
|
||||
type: "openai_responses"
|
||||
}
|
||||
];
|
||||
|
||||
const compiled = await compileCoreGatewayConfig(
|
||||
config,
|
||||
"raw-trace-token",
|
||||
"billing-usage-token",
|
||||
"core-auth-token"
|
||||
);
|
||||
const providerPlugins = Array.isArray(compiled.providerPlugins) ? compiled.providerPlugins : [];
|
||||
const grokPlugin = providerPlugins.find((value) => value.key === "ccr-local-agent-grok-cli-api-grok-cli-oauth");
|
||||
|
||||
assert.ok(grokPlugin);
|
||||
assert.deepEqual(grokPlugin.request.bodyRemove, ["metadata", "external_web_access"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("local agent OAuth plugin detector only matches managed OAuth imports", () => {
|
||||
assert.equal(isLocalAgentOauthProviderPlugin(grokOauthProviderPlugin()), true);
|
||||
assert.equal(isLocalAgentOauthProviderPlugin({
|
||||
key: "ccr-local-agent-grok-cli-api-grok-cli-api-key"
|
||||
}), false);
|
||||
assert.equal(isLocalAgentOauthProviderPlugin({
|
||||
key: "external-grok-cli-oauth"
|
||||
}), false);
|
||||
});
|
||||
|
||||
function grokOauthProviderPlugin() {
|
||||
return {
|
||||
auth: {
|
||||
headers: {
|
||||
authorization: "Bearer imported-stale-token"
|
||||
},
|
||||
removeHeaders: ["x-api-key"],
|
||||
strict: true
|
||||
},
|
||||
key: "ccr-local-agent-grok-cli-api-grok-cli-oauth",
|
||||
providerName: "Grok CLI API",
|
||||
request: {
|
||||
headers: {
|
||||
"x-grok-client-identifier": "xai-grok-cli",
|
||||
"x-grok-client-version": "0.2.93",
|
||||
"x-grok-model-override": "{{ model }}"
|
||||
},
|
||||
strict: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function withGrokHome(t, run) {
|
||||
const previousGrokHome = process.env.GROK_HOME;
|
||||
const previousGrokAuthFile = process.env.GROK_AUTH_FILE;
|
||||
const previousGrokCliVersion = process.env.GROK_CLI_VERSION;
|
||||
const grokHome = mkdtempSync(path.join(os.tmpdir(), "ccr-grok-hook-test-"));
|
||||
process.env.GROK_HOME = grokHome;
|
||||
process.env.GROK_CLI_VERSION = "0.2.93";
|
||||
delete process.env.GROK_AUTH_FILE;
|
||||
try {
|
||||
await run(grokHome);
|
||||
} finally {
|
||||
restoreEnv("GROK_HOME", previousGrokHome);
|
||||
restoreEnv("GROK_AUTH_FILE", previousGrokAuthFile);
|
||||
restoreEnv("GROK_CLI_VERSION", previousGrokCliVersion);
|
||||
rmSync(grokHome, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function restoreEnv(name, value) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function writeGrokAuth(grokHome, auth) {
|
||||
writeFileSync(path.join(grokHome, "auth.json"), JSON.stringify({
|
||||
"https://auth.x.ai::test-account": auth
|
||||
}, null, 2));
|
||||
}
|
||||
Reference in New Issue
Block a user