mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-29 03:12:10 +08:00
Expand core functionality and update related components
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import { isGatewayProviderEnabled } from "@ccr/core/contracts/app";
|
||||
import type { AppConfig, GatewayProviderConfig, GatewayProviderProtocol, VirtualModelProfileConfig } from "@ccr/core/contracts/app";
|
||||
import { codexDefaultBaseUrl, kimiAccessTokenExpired, kimiIdentityHeaders, readClaudeCodeOauth, readCodexAuth, readGrokAuth, readKimiAuth, resolveGrokAuth, resolveKimiAuth } from "@ccr/core/agents/local-providers/service";
|
||||
import { codexDefaultBaseUrl, kimiAccessTokenExpired, kimiIdentityHeaders, localAgentProviderApiKey, readClaudeCodeOauth, readCodexAuth, readGrokAuth, readKimiAuth, resolveGrokAuth, resolveKimiAuth } from "@ccr/core/agents/local-providers/service";
|
||||
import { grokAccessTokenExpired, grokClientVersion } from "@ccr/core/agents/local-providers/grok";
|
||||
import { pluginService } from "@ccr/core/plugins/service";
|
||||
import { normalizeRouteSelector, providerRuntimeId } from "@ccr/core/routing/model-registry";
|
||||
@@ -40,12 +40,18 @@ export async function compileCoreGatewayConfig(
|
||||
)
|
||||
: [];
|
||||
const pluginBillingConfig = isRecord(pluginCoreGatewayConfig.billing) ? pluginCoreGatewayConfig.billing : {};
|
||||
const configuredProviderPlugins = normalizeClaudeCodeOauthProviderPlugins([
|
||||
...(config.providerPlugins ?? []).filter(providerPluginEnabled),
|
||||
...pluginService.getCoreProviderPlugins().filter(providerPluginEnabled)
|
||||
const allConfiguredProviderPlugins = normalizeClaudeCodeOauthProviderPlugins([
|
||||
...(config.providerPlugins ?? []),
|
||||
...pluginService.getCoreProviderPlugins()
|
||||
]);
|
||||
const configuredProviderPlugins = allConfiguredProviderPlugins.filter(providerPluginEnabled);
|
||||
const configuredProviderPluginsWithLocalCodexFallbacks = withMissingCodexOauthProviderPlugins(
|
||||
configuredProviderPlugins,
|
||||
allConfiguredProviderPlugins,
|
||||
config.Providers.filter(isGatewayProviderEnabled)
|
||||
);
|
||||
const providerPluginsWithRuntimeDefaults = await withKimiOauthRuntimeDefaults(
|
||||
await withGrokOauthRuntimeDefaults(withClaudeCodeOauthRuntimeDefaults(withCodexOauthRuntimeDefaults(configuredProviderPlugins)))
|
||||
await withGrokOauthRuntimeDefaults(withClaudeCodeOauthRuntimeDefaults(withCodexOauthRuntimeDefaults(configuredProviderPluginsWithLocalCodexFallbacks)))
|
||||
);
|
||||
const codexOauthProviderNames = codexOauthLocalProviderNames(providerPluginsWithRuntimeDefaults);
|
||||
const enabledProviders = config.Providers.filter(isGatewayProviderEnabled);
|
||||
@@ -197,6 +203,88 @@ function providerPluginEnabled(plugin: unknown): boolean {
|
||||
}
|
||||
|
||||
|
||||
function withMissingCodexOauthProviderPlugins(
|
||||
providerPlugins: unknown[],
|
||||
explicitProviderPlugins: unknown[],
|
||||
providers: GatewayProviderConfig[]
|
||||
): unknown[] {
|
||||
const codexAuth = readCodexAuth();
|
||||
if (!codexAuth?.accessToken && !codexAuth?.refreshToken) {
|
||||
return providerPlugins;
|
||||
}
|
||||
|
||||
const codexOauthProviderNames = codexOauthLocalProviderNames(explicitProviderPlugins);
|
||||
const additions: unknown[] = [];
|
||||
for (const provider of providers) {
|
||||
if (!isLocalCodexOauthProvider(provider)) {
|
||||
continue;
|
||||
}
|
||||
const runtimeName = providerRuntimeId(provider);
|
||||
const capabilityName = providerCapabilityInternalName(provider, "openai_responses");
|
||||
if (
|
||||
codexOauthProviderNames.has(provider.name) ||
|
||||
codexOauthProviderNames.has(runtimeName) ||
|
||||
codexOauthProviderNames.has(capabilityName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const keyPrefix = `ccr-local-agent-${providerNameSlug(runtimeName)}`;
|
||||
additions.push({
|
||||
codexOauth: {
|
||||
accessToken: codexAuth.accessToken,
|
||||
...(codexAuth.accountId ? { accountId: codexAuth.accountId } : {}),
|
||||
refreshIfMissingAccessToken: true,
|
||||
refreshToken: codexAuth.refreshToken,
|
||||
required: true
|
||||
},
|
||||
key: `${keyPrefix}-codex-oauth-recovered`,
|
||||
providerName: provider.name
|
||||
});
|
||||
}
|
||||
|
||||
return additions.length > 0 ? [...providerPlugins, ...additions] : providerPlugins;
|
||||
}
|
||||
|
||||
function isLocalCodexOauthProvider(provider: GatewayProviderConfig): boolean {
|
||||
const protocol =
|
||||
normalizeProviderProtocol(provider.type) ??
|
||||
normalizeProviderProtocol(provider.provider) ??
|
||||
inferProtocol(provider);
|
||||
return protocol === "openai_responses" &&
|
||||
providerApiKeyValue(provider) === localAgentProviderApiKey &&
|
||||
normalizeCodexProviderBaseUrl(providerBaseUrlValue(provider)) === normalizeCodexProviderBaseUrl(codexDefaultBaseUrl);
|
||||
}
|
||||
|
||||
function providerApiKeyValue(provider: GatewayProviderConfig): string {
|
||||
return provider.api_key || provider.apiKey || provider.apikey || "";
|
||||
}
|
||||
|
||||
function providerBaseUrlValue(provider: GatewayProviderConfig): string {
|
||||
return provider.api_base_url || provider.baseurl || provider.baseUrl || "";
|
||||
}
|
||||
|
||||
function normalizeCodexProviderBaseUrl(value: string): string {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
url.hash = "";
|
||||
url.search = "";
|
||||
url.pathname = url.pathname.replace(/\/+$/g, "");
|
||||
return url.toString().replace(/\/+$/g, "");
|
||||
} catch {
|
||||
return value.trim().replace(/\/+$/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
function providerNameSlug(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_.-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "provider";
|
||||
}
|
||||
|
||||
|
||||
export function normalizeCoreGatewayVirtualModelProfiles(profiles: unknown[], config: AppConfig): unknown[] {
|
||||
return profiles.map((profile) => normalizeCoreGatewayVirtualModelProfile(profile, config));
|
||||
}
|
||||
@@ -513,14 +601,19 @@ function withCodexOauthProviderBaseUrl(
|
||||
provider: GatewayProviderConfig,
|
||||
codexOauthProviderNames: Set<string>
|
||||
): GatewayProviderConfig {
|
||||
if (!codexOauthProviderNames.has(provider.name)) {
|
||||
return provider;
|
||||
}
|
||||
|
||||
const protocol =
|
||||
normalizeProviderProtocol(provider.type) ??
|
||||
normalizeProviderProtocol(provider.provider) ??
|
||||
inferProtocol(provider);
|
||||
const names = [
|
||||
provider.name,
|
||||
providerRuntimeId(provider),
|
||||
providerCapabilityInternalName(provider, "openai_responses")
|
||||
];
|
||||
if (!names.some((name) => codexOauthProviderNames.has(name))) {
|
||||
return provider;
|
||||
}
|
||||
|
||||
if (protocol !== "openai_responses") {
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type {
|
||||
GatewayProviderConnectivityCheckReport,
|
||||
GatewayProviderConnectivityCheckRequest,
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
GatewayProviderCapabilityProtocol,
|
||||
GatewayProviderProtocol
|
||||
} from "@ccr/core/contracts/app";
|
||||
import { codexDefaultBaseUrl, readCodexAuth } from "@ccr/core/agents/local-providers/codex";
|
||||
import { localAgentProviderApiKey } from "@ccr/core/agents/local-providers/shared";
|
||||
import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue } from "@ccr/core/providers/presets/index";
|
||||
import { getProviderCatalogModels } from "@ccr/core/providers/model-catalog";
|
||||
import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch";
|
||||
@@ -26,6 +28,8 @@ import {
|
||||
newApiKeyUsageAccountConfig,
|
||||
type DetectedProviderKind
|
||||
} from "@ccr/core/providers/new-api";
|
||||
import { recordGatewayRequestLog } from "@ccr/core/observability/request-log-store";
|
||||
import { requestLogSampled } from "@ccr/core/observability/raw-trace-sync";
|
||||
|
||||
type ModelSource = NonNullable<GatewayProviderProbeResult["modelSource"]>;
|
||||
|
||||
@@ -64,6 +68,15 @@ type ProbeCacheEntry = {
|
||||
result: GatewayProviderProbeResult;
|
||||
};
|
||||
|
||||
type GatewayProviderConnectivityCheckOptions = {
|
||||
requestLog?: {
|
||||
bodyCapturePolicy?: "all" | "errors" | "none";
|
||||
enabled?: boolean;
|
||||
maxBodyBytes?: number;
|
||||
successSampleRate?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const protocolOrder: GatewayProviderCapabilityProtocol[] = [
|
||||
"openai_responses",
|
||||
"openai_chat_completions",
|
||||
@@ -82,9 +95,23 @@ const protocolProbeCacheMs = 60 * 1000;
|
||||
const connectivityProbeCacheMs = 15 * 1000;
|
||||
const failedProbeCacheMs = 10 * 1000;
|
||||
const maxProbeCacheEntries = 500;
|
||||
const codexOauthTokenEndpoint = "https://auth.openai.com/oauth/token";
|
||||
const codexOauthClientId = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
const codexOauthDefaultScope = "openid profile email offline_access";
|
||||
const codexOauthRequiredScopes = ["api.connectors.read", "api.connectors.invoke"];
|
||||
const codexOauthDefaultTimeoutMs = 8_000;
|
||||
const codexProbeOauthCache = new Map<string, CodexProbeOauthRefreshResult>();
|
||||
const inFlightCodexProbeOauthRefreshes = new Map<string, Promise<CodexProbeOauthRefreshResult>>();
|
||||
const probeCache = new Map<string, ProbeCacheEntry>();
|
||||
const inFlightProbes = new Map<string, Promise<GatewayProviderProbeResult>>();
|
||||
|
||||
type CodexProbeOauthRefreshResult = {
|
||||
accessToken?: string;
|
||||
accountId?: string;
|
||||
expiresAtMs: number;
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
export async function probeGatewayProvider(request: GatewayProviderProbeRequest): Promise<GatewayProviderProbeResult> {
|
||||
pruneProbeCache();
|
||||
const cacheKey = providerProbeCacheKey(request);
|
||||
@@ -155,11 +182,14 @@ export async function probeGatewayProviderCandidates(
|
||||
}
|
||||
|
||||
export async function checkGatewayProviderConnectivity(
|
||||
request: GatewayProviderConnectivityCheckRequest
|
||||
request: GatewayProviderConnectivityCheckRequest,
|
||||
options: GatewayProviderConnectivityCheckOptions = {}
|
||||
): Promise<GatewayProviderConnectivityCheckReport> {
|
||||
const models = uniqueStrings(request.models);
|
||||
const checks = await Promise.all(
|
||||
models.map(async (model) => {
|
||||
const startedAtMs = Date.now();
|
||||
const startedAt = new Date(startedAtMs).toISOString();
|
||||
try {
|
||||
const result = await probeGatewayProviderCandidates({
|
||||
apiKey: request.apiKey,
|
||||
@@ -172,6 +202,7 @@ export async function checkGatewayProviderConnectivity(
|
||||
});
|
||||
if (!result) {
|
||||
return {
|
||||
durationMs: Date.now() - startedAtMs,
|
||||
model,
|
||||
probe: undefined,
|
||||
report: {
|
||||
@@ -179,12 +210,14 @@ export async function checkGatewayProviderConnectivity(
|
||||
model,
|
||||
protocols: [],
|
||||
supported: false
|
||||
}
|
||||
},
|
||||
startedAt
|
||||
};
|
||||
}
|
||||
|
||||
const supported = providerProbeHasSupportedProtocol(result.probe);
|
||||
return {
|
||||
durationMs: Date.now() - startedAtMs,
|
||||
model,
|
||||
probe: result.probe,
|
||||
report: {
|
||||
@@ -194,10 +227,12 @@ export async function checkGatewayProviderConnectivity(
|
||||
model,
|
||||
protocols: result.probe.protocols,
|
||||
supported
|
||||
}
|
||||
},
|
||||
startedAt
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
durationMs: Date.now() - startedAtMs,
|
||||
model,
|
||||
probe: undefined,
|
||||
report: {
|
||||
@@ -205,11 +240,15 @@ export async function checkGatewayProviderConnectivity(
|
||||
model,
|
||||
protocols: [],
|
||||
supported: false
|
||||
}
|
||||
},
|
||||
startedAt
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
for (const check of checks) {
|
||||
recordProviderConnectivityRequestLog(request, check, options.requestLog);
|
||||
}
|
||||
const reports = checks.map((check) => check.report);
|
||||
return {
|
||||
failed: reports.filter((item) => !item.supported),
|
||||
@@ -219,6 +258,82 @@ export async function checkGatewayProviderConnectivity(
|
||||
};
|
||||
}
|
||||
|
||||
function recordProviderConnectivityRequestLog(
|
||||
request: GatewayProviderConnectivityCheckRequest,
|
||||
check: {
|
||||
durationMs: number;
|
||||
model: string;
|
||||
report: GatewayProviderConnectivityCheckReport["results"][number];
|
||||
startedAt: string;
|
||||
},
|
||||
options: GatewayProviderConnectivityCheckOptions["requestLog"]
|
||||
): void {
|
||||
if (!options?.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = check.report.protocols.find((item) => item.supported) ?? check.report.protocols[0];
|
||||
const candidate = protocol
|
||||
? request.candidates.find((item) => item.baseUrl === protocol.baseUrl)
|
||||
: request.candidates[0];
|
||||
const successful = check.report.supported;
|
||||
const requestId = randomUUID();
|
||||
if (successful && !requestLogSampled(requestId, options.successSampleRate ?? 1)) {
|
||||
return;
|
||||
}
|
||||
const bodyCapturePolicy = options.bodyCapturePolicy ?? "all";
|
||||
const captureBody = bodyCapturePolicy === "all" || (bodyCapturePolicy === "errors" && !successful);
|
||||
const responseBodyText = JSON.stringify({
|
||||
message: check.report.message,
|
||||
protocols: check.report.protocols.map((item) => ({
|
||||
endpoint: item.endpoint,
|
||||
message: item.message,
|
||||
protocol: item.protocol,
|
||||
status: item.status,
|
||||
supported: item.supported
|
||||
})),
|
||||
supported: check.report.supported
|
||||
});
|
||||
|
||||
recordGatewayRequestLog({
|
||||
bodyCapturePolicy,
|
||||
captureBody,
|
||||
client: "provider-connectivity-check",
|
||||
completedAt: new Date(new Date(check.startedAt).getTime() + check.durationMs).toISOString(),
|
||||
durationMs: check.durationMs,
|
||||
error: successful ? undefined : check.report.message,
|
||||
maxBodyBytes: options.maxBodyBytes,
|
||||
method: "POST",
|
||||
model: check.model,
|
||||
path: "/__ccr/provider-connectivity",
|
||||
providerName: providerProbeCandidateName(candidate),
|
||||
providerProtocol: protocol?.protocol as GatewayProviderProtocol | undefined,
|
||||
requestedModel: check.model,
|
||||
requestBody: Buffer.from(JSON.stringify({
|
||||
candidates: request.candidates.map((item) => ({
|
||||
baseUrl: item.baseUrl,
|
||||
name: providerProbeCandidateName(item),
|
||||
protocols: item.protocols
|
||||
})),
|
||||
model: check.model,
|
||||
protocols: request.protocols
|
||||
})),
|
||||
requestHeaders: {},
|
||||
requestId,
|
||||
resolvedModel: check.model,
|
||||
responseBodyText,
|
||||
responseHeaders: {},
|
||||
startedAt: check.startedAt,
|
||||
statusCode: protocol?.status ?? (successful ? 200 : 599),
|
||||
url: protocol?.endpoint ?? candidate?.baseUrl ?? "provider-connectivity-check"
|
||||
});
|
||||
}
|
||||
|
||||
function providerProbeCandidateName(candidate: GatewayProviderProbeCandidate | undefined): string | undefined {
|
||||
const value: unknown = candidate;
|
||||
return isRecord(value) ? readString(value.name) : undefined;
|
||||
}
|
||||
|
||||
async function resolveGatewayProviderProbe(request: GatewayProviderProbeRequest): Promise<GatewayProviderProbeResult> {
|
||||
const mode = request.mode ?? "protocols";
|
||||
const safetyIssue = providerApiKeySafetyIssue({
|
||||
@@ -589,10 +704,12 @@ async function probeProtocolConnectivity(
|
||||
let firstResult: GatewayProviderProbeProtocolResult | undefined;
|
||||
|
||||
for (const candidate of endpoints) {
|
||||
const request = providerProbeAuthRequest(
|
||||
const request = await providerProbeAuthRequest(
|
||||
candidate.endpoint,
|
||||
requestForProtocol(protocol, model, apiKey),
|
||||
providerPlugins
|
||||
providerPlugins,
|
||||
apiKey,
|
||||
{ model }
|
||||
);
|
||||
const result = await requestJson(request.url, request.init);
|
||||
const message = readResponseMessage(result);
|
||||
@@ -732,18 +849,105 @@ function mediaProbeBody(protocol: GatewayProviderCapabilityProtocol): Record<str
|
||||
return {};
|
||||
}
|
||||
|
||||
function providerProbeAuthRequest(
|
||||
async function providerProbeAuthRequest(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
providerPlugins: unknown[]
|
||||
): { init: RequestInit; url: string } {
|
||||
providerPlugins: unknown[],
|
||||
apiKey: string | undefined,
|
||||
context: { model: string }
|
||||
): Promise<{ init: RequestInit; url: string }> {
|
||||
const auth = providerPlugins
|
||||
.map(providerPluginAuth)
|
||||
.find((item): item is Record<string, unknown> => Boolean(item));
|
||||
if (!auth) {
|
||||
return { init, url };
|
||||
let codexOauth = providerPlugins
|
||||
.map(providerPluginCodexOauth)
|
||||
.find((item): item is Record<string, unknown> => Boolean(item));
|
||||
let requestTransform = providerPlugins
|
||||
.map(providerPluginRequest)
|
||||
.find((item): item is Record<string, unknown> => Boolean(item));
|
||||
const liveCodexAuth = providerProbeLiveCodexOauth(url, apiKey);
|
||||
if (codexOauth && liveCodexAuth) {
|
||||
codexOauth = {
|
||||
...codexOauth,
|
||||
...liveCodexAuth.codexOauth
|
||||
};
|
||||
} else {
|
||||
codexOauth ??= liveCodexAuth?.codexOauth;
|
||||
}
|
||||
requestTransform ??= liveCodexAuth?.request;
|
||||
if (codexOauth && apiKey === localAgentProviderApiKey && isCodexProbeEndpoint(url)) {
|
||||
requestTransform = withCodexProbeBackendRequestTransform(requestTransform);
|
||||
}
|
||||
let request = { init, url };
|
||||
|
||||
if (auth) {
|
||||
request = providerProbeStaticAuthRequest(request.url, request.init, auth);
|
||||
}
|
||||
|
||||
if (codexOauth) {
|
||||
request = await providerProbeCodexOauthRequest(request.url, request.init, codexOauth);
|
||||
}
|
||||
|
||||
if (liveCodexAuth?.isFedrampAccount) {
|
||||
const headers = new Headers(request.init.headers);
|
||||
headers.set("X-OpenAI-Fedramp", "true");
|
||||
request = {
|
||||
...request,
|
||||
init: {
|
||||
...request.init,
|
||||
headers
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (requestTransform) {
|
||||
request = providerProbeRequestTransformRequest(request.url, request.init, requestTransform, context);
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
function providerProbeLiveCodexOauth(
|
||||
url: string,
|
||||
apiKey: string | undefined
|
||||
): { codexOauth: Record<string, unknown>; isFedrampAccount?: boolean; request: Record<string, unknown> } | undefined {
|
||||
if (apiKey !== localAgentProviderApiKey || !isCodexProbeEndpoint(url)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const auth = readCodexAuth();
|
||||
if (!auth?.accessToken && !auth?.refreshToken) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
codexOauth: {
|
||||
...(auth.accessToken ? { accessToken: auth.accessToken } : {}),
|
||||
...(auth.accountId ? { accountId: auth.accountId } : {}),
|
||||
refreshIfMissingAccessToken: true,
|
||||
...(auth.refreshToken ? { refreshToken: auth.refreshToken } : {}),
|
||||
required: true
|
||||
},
|
||||
isFedrampAccount: auth.isFedrampAccount,
|
||||
request: codexProbeBackendRequestTransform()
|
||||
};
|
||||
}
|
||||
|
||||
function isCodexProbeEndpoint(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const codexBase = new URL(codexDefaultBaseUrl);
|
||||
return parsed.origin === codexBase.origin &&
|
||||
parsed.pathname.toLowerCase().startsWith(codexBase.pathname.toLowerCase());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function providerProbeStaticAuthRequest(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
auth: Record<string, unknown>
|
||||
): { init: RequestInit; url: string } {
|
||||
const headers = new Headers(init.headers);
|
||||
for (const header of readStringArray(auth.removeHeaders)) {
|
||||
headers.delete(header);
|
||||
@@ -772,6 +976,222 @@ function providerProbeAuthRequest(
|
||||
};
|
||||
}
|
||||
|
||||
function providerProbeRequestTransformRequest(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
requestTransform: Record<string, unknown>,
|
||||
context: { model: string }
|
||||
): { init: RequestInit; url: string } {
|
||||
const headers = new Headers(init.headers);
|
||||
for (const [name, value] of Object.entries(isRecord(requestTransform.headers) ? requestTransform.headers : {})) {
|
||||
const headerValue = renderProviderProbeTemplate(readString(value), context);
|
||||
if (headerValue) {
|
||||
headers.set(name, headerValue);
|
||||
}
|
||||
}
|
||||
|
||||
const nextUrl = new URL(url);
|
||||
for (const [name, value] of Object.entries(isRecord(requestTransform.query) ? requestTransform.query : {})) {
|
||||
const queryValue = renderProviderProbeTemplate(readString(value), context);
|
||||
if (queryValue) {
|
||||
nextUrl.searchParams.set(name, queryValue);
|
||||
}
|
||||
}
|
||||
|
||||
const transformedBody = providerProbeTransformedJsonBody(init.body, requestTransform, context);
|
||||
return {
|
||||
init: {
|
||||
...init,
|
||||
body: transformedBody ?? init.body,
|
||||
headers
|
||||
},
|
||||
url: nextUrl.toString()
|
||||
};
|
||||
}
|
||||
|
||||
function providerProbeTransformedJsonBody(
|
||||
body: BodyInit | null | undefined,
|
||||
requestTransform: Record<string, unknown>,
|
||||
context: { model: string }
|
||||
): string | undefined {
|
||||
const payload = requestBodyJsonObject(body);
|
||||
if (!payload) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
for (const path of readStringArray(requestTransform.bodyRemove)) {
|
||||
changed = deleteJsonPath(payload, path) || changed;
|
||||
}
|
||||
const bodyMerge = isRecord(requestTransform.bodyMerge) ? requestTransform.bodyMerge : undefined;
|
||||
if (bodyMerge) {
|
||||
const renderedMerge = renderJsonTemplateValues(bodyMerge, context);
|
||||
if (isRecord(renderedMerge)) {
|
||||
mergeJsonObject(payload, renderedMerge);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
const bodySet = isRecord(requestTransform.bodySet) ? requestTransform.bodySet : undefined;
|
||||
if (bodySet) {
|
||||
for (const [path, value] of Object.entries(bodySet)) {
|
||||
setJsonPath(payload, path, renderJsonTemplateValues(value, context));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? JSON.stringify(payload) : undefined;
|
||||
}
|
||||
|
||||
function requestBodyJsonObject(body: BodyInit | null | undefined): Record<string, unknown> | undefined {
|
||||
if (typeof body !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const payload = parseJson(body);
|
||||
return isRecord(payload) ? { ...payload } : undefined;
|
||||
}
|
||||
|
||||
function deleteJsonPath(target: Record<string, unknown>, path: string): boolean {
|
||||
const parts = jsonPathParts(path);
|
||||
if (parts.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const parent = jsonPathParent(target, parts);
|
||||
const key = parts[parts.length - 1];
|
||||
if (!parent || key === undefined || !Object.prototype.hasOwnProperty.call(parent, key)) {
|
||||
return false;
|
||||
}
|
||||
delete parent[key];
|
||||
return true;
|
||||
}
|
||||
|
||||
function setJsonPath(target: Record<string, unknown>, path: string, value: unknown): void {
|
||||
const parts = jsonPathParts(path);
|
||||
if (parts.length === 0) {
|
||||
return;
|
||||
}
|
||||
let current = target;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
const next = current[part];
|
||||
if (!isRecord(next)) {
|
||||
current[part] = {};
|
||||
}
|
||||
current = current[part] as Record<string, unknown>;
|
||||
}
|
||||
const key = parts[parts.length - 1];
|
||||
if (key !== undefined) {
|
||||
current[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function jsonPathParent(target: Record<string, unknown>, parts: string[]): Record<string, unknown> | undefined {
|
||||
let current: unknown = target;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
if (!isRecord(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
return isRecord(current) ? current : undefined;
|
||||
}
|
||||
|
||||
function jsonPathParts(path: string): string[] {
|
||||
return path.split(".").map((part) => part.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function mergeJsonObject(target: Record<string, unknown>, source: Record<string, unknown>): void {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (isRecord(value) && isRecord(target[key])) {
|
||||
mergeJsonObject(target[key], value);
|
||||
} else {
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderJsonTemplateValues(value: unknown, context: { model: string }): unknown {
|
||||
if (typeof value === "string") {
|
||||
return renderProviderProbeTemplate(value, context);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => renderJsonTemplateValues(item, context));
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, renderJsonTemplateValues(item, context)])
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderProviderProbeTemplate(value: string | undefined, context: { model: string }): string | undefined {
|
||||
return value
|
||||
?.split("{{ model }}").join(context.model)
|
||||
.split("{{ request.body.model }}").join(context.model)
|
||||
.split("{{ upstreamRequest.body.model }}").join(context.model);
|
||||
}
|
||||
|
||||
function codexProbeBackendRequestTransform(): Record<string, unknown> {
|
||||
return withCodexProbeBackendRequestTransform();
|
||||
}
|
||||
|
||||
function withCodexProbeBackendRequestTransform(requestTransform: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const bodyRemove = readStringArray(requestTransform.bodyRemove);
|
||||
return {
|
||||
...requestTransform,
|
||||
bodyRemove: uniqueStrings([...bodyRemove, "max_output_tokens", "stop"])
|
||||
};
|
||||
}
|
||||
|
||||
async function providerProbeCodexOauthRequest(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
codexOauth: Record<string, unknown>
|
||||
): Promise<{ init: RequestInit; url: string }> {
|
||||
const requiredScopes = codexRequiredScopes(codexOauth.requiredScopes);
|
||||
const scope = codexOauthScope(readString(codexOauth.scope), requiredScopes);
|
||||
let accessToken = readString(codexOauth.accessToken) || readString(codexOauth.access_token);
|
||||
let refreshToken = readString(codexOauth.refreshToken) || readString(codexOauth.refresh_token);
|
||||
let accountId = readString(codexOauth.accountId) || readString(codexOauth.account_id);
|
||||
|
||||
if (refreshToken && shouldRefreshCodexProbeToken(accessToken, codexOauth, requiredScopes)) {
|
||||
const refreshed = await refreshCodexProbeAccessToken(codexOauth, refreshToken, scope);
|
||||
accessToken = refreshed.accessToken || accessToken;
|
||||
refreshToken = refreshed.refreshToken || refreshToken;
|
||||
accountId = refreshed.accountId || accountId;
|
||||
}
|
||||
|
||||
const required = readBoolean(codexOauth.required) !== false;
|
||||
if (!accessToken) {
|
||||
if (required) {
|
||||
throw new Error("Codex OAuth access token is required but missing.");
|
||||
}
|
||||
return { init, url };
|
||||
}
|
||||
|
||||
const missingScopes = codexMissingRequiredScopes(accessToken, requiredScopes);
|
||||
if (missingScopes.length > 0 && required) {
|
||||
throw new Error(`Codex OAuth access token is missing required scopes: ${missingScopes.join(", ")}.`);
|
||||
}
|
||||
|
||||
const headers = new Headers(init.headers);
|
||||
const authHeader = readString(codexOauth.authHeader) || "authorization";
|
||||
const authScheme = readString(codexOauth.authScheme) || "Bearer";
|
||||
headers.set(authHeader, authScheme ? `${authScheme} ${accessToken}` : accessToken);
|
||||
|
||||
accountId = accountId || codexAccountIdFromToken(accessToken);
|
||||
if (accountId) {
|
||||
headers.set("ChatGPT-Account-Id", accountId);
|
||||
}
|
||||
|
||||
return {
|
||||
init: {
|
||||
...init,
|
||||
headers
|
||||
},
|
||||
url
|
||||
};
|
||||
}
|
||||
|
||||
function providerPluginAuth(plugin: unknown): Record<string, unknown> | undefined {
|
||||
if (!isRecord(plugin) || !isRecord(plugin.auth)) {
|
||||
return undefined;
|
||||
@@ -779,12 +1199,138 @@ function providerPluginAuth(plugin: unknown): Record<string, unknown> | undefine
|
||||
return plugin.auth;
|
||||
}
|
||||
|
||||
function providerPluginCodexOauth(plugin: unknown): Record<string, unknown> | undefined {
|
||||
if (!isRecord(plugin) || !isRecord(plugin.codexOauth)) {
|
||||
return undefined;
|
||||
}
|
||||
return plugin.codexOauth;
|
||||
}
|
||||
|
||||
function providerPluginRequest(plugin: unknown): Record<string, unknown> | undefined {
|
||||
if (!isRecord(plugin) || !isRecord(plugin.request)) {
|
||||
return undefined;
|
||||
}
|
||||
return plugin.request;
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.map(readString).filter((item): item is string => Boolean(item))
|
||||
: [];
|
||||
}
|
||||
|
||||
async function refreshCodexProbeAccessToken(
|
||||
codexOauth: Record<string, unknown>,
|
||||
refreshToken: string,
|
||||
scope: string
|
||||
): Promise<CodexProbeOauthRefreshResult> {
|
||||
const tokenEndpoint =
|
||||
readString(codexOauth.tokenEndpoint) ||
|
||||
readString(process.env.CODEX_REFRESH_TOKEN_URL_OVERRIDE) ||
|
||||
codexOauthTokenEndpoint;
|
||||
const clientId = readString(codexOauth.clientId) || codexOauthClientId;
|
||||
const cacheKey = [
|
||||
tokenEndpoint,
|
||||
clientId,
|
||||
scope,
|
||||
hashSensitiveValue(refreshToken)
|
||||
].join("\n");
|
||||
const now = Date.now();
|
||||
const cached = codexProbeOauthCache.get(cacheKey);
|
||||
if (cached?.accessToken && cached.expiresAtMs > now + 60_000) {
|
||||
return cached;
|
||||
}
|
||||
const inFlight = inFlightCodexProbeOauthRefreshes.get(cacheKey);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
const refresh = refreshCodexProbeAccessTokenUncached(codexOauth, refreshToken, scope, tokenEndpoint, clientId, now)
|
||||
.finally(() => {
|
||||
if (inFlightCodexProbeOauthRefreshes.get(cacheKey) === refresh) {
|
||||
inFlightCodexProbeOauthRefreshes.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
inFlightCodexProbeOauthRefreshes.set(cacheKey, refresh);
|
||||
return refresh;
|
||||
}
|
||||
|
||||
async function refreshCodexProbeAccessTokenUncached(
|
||||
codexOauth: Record<string, unknown>,
|
||||
refreshToken: string,
|
||||
scope: string,
|
||||
tokenEndpoint: string,
|
||||
clientId: string,
|
||||
now: number
|
||||
): Promise<CodexProbeOauthRefreshResult> {
|
||||
const timeoutMs = Math.max(1, Number(codexOauth.timeoutMs) || codexOauthDefaultTimeoutMs);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetchWithSystemProxy(tokenEndpoint, {
|
||||
body: JSON.stringify({
|
||||
client_id: clientId,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
scope
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
method: "POST",
|
||||
signal: controller.signal
|
||||
});
|
||||
const text = await response.text();
|
||||
const payload = parseJson(text);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Codex OAuth token refresh returned HTTP ${response.status}${codexTokenRefreshErrorMessage(payload, text)}`);
|
||||
}
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("Codex OAuth token refresh returned an invalid JSON payload.");
|
||||
}
|
||||
|
||||
const accessToken = readString(payload.access_token) || readString(payload.accessToken);
|
||||
if (!accessToken) {
|
||||
throw new Error("Codex OAuth token refresh did not return an access token.");
|
||||
}
|
||||
|
||||
const result = {
|
||||
accessToken,
|
||||
accountId: readString(payload.account_id) || readString(payload.accountId) || codexAccountIdFromToken(accessToken),
|
||||
expiresAtMs: codexTokenExpiresAtMs(accessToken) ?? now + 30 * 60 * 1000,
|
||||
refreshToken: readString(payload.refresh_token) || readString(payload.refreshToken) || refreshToken
|
||||
};
|
||||
codexProbeOauthCache.set([
|
||||
tokenEndpoint,
|
||||
clientId,
|
||||
scope,
|
||||
hashSensitiveValue(refreshToken)
|
||||
].join("\n"), result);
|
||||
pruneCodexProbeOauthCache();
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Error(`Codex OAuth token refresh timed out after ${timeoutMs}ms.`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneCodexProbeOauthCache(): void {
|
||||
if (codexProbeOauthCache.size <= maxProbeCacheEntries) {
|
||||
return;
|
||||
}
|
||||
const oldestEntries = [...codexProbeOauthCache.entries()]
|
||||
.sort(([, left], [, right]) => left.expiresAtMs - right.expiresAtMs)
|
||||
.slice(0, codexProbeOauthCache.size - maxProbeCacheEntries);
|
||||
for (const [key] of oldestEntries) {
|
||||
codexProbeOauthCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson(url: string, init: RequestInit): Promise<FetchJsonResult> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), probeTimeoutMs);
|
||||
@@ -1319,6 +1865,157 @@ function parseJson(value: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
function codexRequiredScopes(value: unknown): string[] {
|
||||
if (value === undefined) {
|
||||
return [...codexOauthRequiredScopes];
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return [...codexOauthRequiredScopes];
|
||||
}
|
||||
if (value.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const scopes: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of value) {
|
||||
if (typeof item !== "string" || !item.trim()) {
|
||||
return [...codexOauthRequiredScopes];
|
||||
}
|
||||
for (const scope of item.split(/\s+/)) {
|
||||
const normalized = scope.trim();
|
||||
if (normalized && !seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
scopes.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
return scopes.length > 0 ? scopes : [...codexOauthRequiredScopes];
|
||||
}
|
||||
|
||||
function codexOauthScope(configuredScope: string | undefined, requiredScopes: string[]): string {
|
||||
return uniqueStrings([
|
||||
...((configuredScope || codexOauthDefaultScope).split(/\s+/)),
|
||||
...requiredScopes
|
||||
]).join(" ");
|
||||
}
|
||||
|
||||
function shouldRefreshCodexProbeToken(
|
||||
accessToken: string | undefined,
|
||||
codexOauth: Record<string, unknown>,
|
||||
requiredScopes: string[]
|
||||
): boolean {
|
||||
if (readBoolean(codexOauth.forceRefresh)) {
|
||||
return true;
|
||||
}
|
||||
if (!accessToken) {
|
||||
return readBoolean(codexOauth.refreshIfMissingAccessToken) !== false;
|
||||
}
|
||||
if (codexAccessTokenExpired(accessToken)) {
|
||||
return true;
|
||||
}
|
||||
return codexMissingRequiredScopes(accessToken, requiredScopes).length > 0;
|
||||
}
|
||||
|
||||
function codexAccessTokenExpired(token: string | undefined): boolean {
|
||||
const expiresAtMs = codexTokenExpiresAtMs(token);
|
||||
return expiresAtMs !== undefined && Date.now() >= expiresAtMs - 60_000;
|
||||
}
|
||||
|
||||
function codexTokenExpiresAtMs(token: string | undefined): number | undefined {
|
||||
const payload = codexJwtPayload(token);
|
||||
const exp = typeof payload?.exp === "number" && Number.isFinite(payload.exp) ? payload.exp : undefined;
|
||||
return exp !== undefined ? exp * 1000 : undefined;
|
||||
}
|
||||
|
||||
function codexMissingRequiredScopes(token: string, requiredScopes: string[]): string[] {
|
||||
if (requiredScopes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const payload = codexJwtPayload(token);
|
||||
if (!payload) {
|
||||
return [];
|
||||
}
|
||||
const scopes = codexTokenScopes(payload);
|
||||
if (scopes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return requiredScopes.filter((scope) => !scopes.includes(scope));
|
||||
}
|
||||
|
||||
function codexTokenScopes(payload: Record<string, unknown>): string[] {
|
||||
const scopes: string[] = [];
|
||||
const pushScope = (value: unknown) => {
|
||||
if (typeof value === "string") {
|
||||
scopes.push(...value.split(/\s+/));
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
scopes.push(...value.map(readString).filter((item): item is string => Boolean(item)));
|
||||
}
|
||||
};
|
||||
pushScope(payload.scope);
|
||||
pushScope(payload.scopes);
|
||||
pushScope(payload.scp);
|
||||
return uniqueStrings(scopes);
|
||||
}
|
||||
|
||||
function codexAccountIdFromToken(token: string): string | undefined {
|
||||
const payload = codexJwtPayload(token);
|
||||
if (!payload) {
|
||||
return undefined;
|
||||
}
|
||||
const auth = isRecord(payload["https://api.openai.com/auth"])
|
||||
? payload["https://api.openai.com/auth"]
|
||||
: {};
|
||||
return readString(auth.chatgpt_account_id) ||
|
||||
readString(auth.account_id) ||
|
||||
readString(auth.accountId) ||
|
||||
readString(payload.account_id) ||
|
||||
readString(payload.accountId);
|
||||
}
|
||||
|
||||
function codexJwtPayload(token: string | undefined): Record<string, unknown> | undefined {
|
||||
const encoded = token?.split(".")[1];
|
||||
if (!encoded) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const padded = encoded.padEnd(encoded.length + ((4 - encoded.length % 4) % 4), "=");
|
||||
const decoded = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
||||
const payload = JSON.parse(decoded) as unknown;
|
||||
return isRecord(payload) ? payload : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function codexTokenRefreshErrorMessage(payload: unknown, text: string): string {
|
||||
const message = readPayloadMessage(payload);
|
||||
if (message) {
|
||||
return `: ${message}`;
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
return trimmed ? `: ${trimmed.slice(0, 500)}` : "";
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown): boolean | undefined {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "true") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "false") {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import { ensureProxyCertificateAuthority } from "@ccr/core/proxy/certificates";
|
||||
import { proxyService } from "@ccr/core/proxy/service";
|
||||
import { listMcpServerTools } from "@ccr/core/mcp/tool-discovery";
|
||||
import { closeRequestLogRuntime, getAgentAnalysis, getAgentTracePayload, getRequestLogDetail, getRequestLogs } from "@ccr/core/observability/request-log-store";
|
||||
import { shouldRecordRequestLogs } from "@ccr/core/observability/raw-trace-sync";
|
||||
import { getUsageStats } from "@ccr/core/usage/store";
|
||||
import { gatewayService } from "@ccr/core/gateway/service";
|
||||
import { shouldRestartGatewayForRuntimeConfigChange } from "@ccr/core/gateway/runtime-change";
|
||||
@@ -272,7 +273,17 @@ const rpcHandlers: Record<string, RpcHandler> = {
|
||||
},
|
||||
applyProfile: async () => applyProfileConfig(await loadAppConfig()),
|
||||
cancelBotGatewayQrLogin: (request) => cancelBotGatewayQrLogin(request as BotGatewayQrLoginCancelRequest),
|
||||
checkProviderConnectivity: (request) => checkGatewayProviderConnectivity(request as GatewayProviderConnectivityCheckRequest),
|
||||
checkProviderConnectivity: async (request) => {
|
||||
const config = await loadAppConfig();
|
||||
return checkGatewayProviderConnectivity(request as GatewayProviderConnectivityCheckRequest, {
|
||||
requestLog: {
|
||||
bodyCapturePolicy: config.observability.requestLogBodyCapture,
|
||||
enabled: shouldRecordRequestLogs(config),
|
||||
maxBodyBytes: config.observability.requestLogMaxBodyBytes,
|
||||
successSampleRate: config.observability.requestLogSuccessSampleRate
|
||||
}
|
||||
});
|
||||
},
|
||||
clearProxyNetworkCaptures: () => proxyService.clearNetworkCaptures(),
|
||||
closeBotGatewayQrWindow: (_request) => ({ closed: false }),
|
||||
detectProviderIcon: (request) => detectProviderIcon(request as ProviderIconDetectionRequest),
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { codexDefaultBaseUrl } from "@ccr/core/agents/local-providers/service.ts";
|
||||
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
|
||||
@@ -93,6 +96,85 @@ test("Codex OAuth plugins retain the default base URL after runtime identity nor
|
||||
assert.equal(compiled.providers[0].baseurl, codexDefaultBaseUrl);
|
||||
});
|
||||
|
||||
test("Codex local providers synthesize OAuth plugins when persisted plugins are missing", async (t) => {
|
||||
const home = useTemporaryCodexHome(t, "ccr-codex-runtime-missing-plugins-");
|
||||
fs.mkdirSync(path.join(home, ".codex"), { recursive: true });
|
||||
fs.writeFileSync(path.join(home, ".codex", "auth.json"), JSON.stringify({
|
||||
tokens: {
|
||||
access_token: "access-live",
|
||||
account_id: "acct-live-runtime",
|
||||
refresh_token: "refresh-live"
|
||||
}
|
||||
}));
|
||||
|
||||
const config = createDefaultAppConfig();
|
||||
config.providerPlugins = [];
|
||||
config.Providers = [
|
||||
{
|
||||
api_base_url: codexDefaultBaseUrl,
|
||||
api_key: "ccr-local-agent-login",
|
||||
id: "codex-api",
|
||||
models: ["gpt-5.5"],
|
||||
name: "Codex API",
|
||||
type: "openai_responses"
|
||||
}
|
||||
];
|
||||
|
||||
const compiled = await compileCoreGatewayConfig(
|
||||
config,
|
||||
"raw-trace-token",
|
||||
"billing-usage-token",
|
||||
"core-auth-token"
|
||||
);
|
||||
const codexPlugins = compiled.providerPlugins.filter((item) => String(item.key).includes("codex-oauth"));
|
||||
|
||||
assert.equal(compiled.providers[0].baseurl, codexDefaultBaseUrl);
|
||||
assert.equal(codexPlugins.length, 1);
|
||||
assert.equal(codexPlugins[0].providerName, "codex-api");
|
||||
assert.equal(codexPlugins[0].codexOauth.refreshToken, "refresh-live");
|
||||
});
|
||||
|
||||
test("Codex local provider fallback respects disabled OAuth plugins", async (t) => {
|
||||
const home = useTemporaryCodexHome(t, "ccr-codex-runtime-disabled-plugin-");
|
||||
fs.mkdirSync(path.join(home, ".codex"), { recursive: true });
|
||||
fs.writeFileSync(path.join(home, ".codex", "auth.json"), JSON.stringify({
|
||||
tokens: {
|
||||
access_token: "access-live",
|
||||
refresh_token: "refresh-live"
|
||||
}
|
||||
}));
|
||||
|
||||
const config = createDefaultAppConfig();
|
||||
config.providerPlugins = [{
|
||||
codexOauth: {
|
||||
refreshToken: "refresh-disabled"
|
||||
},
|
||||
enabled: false,
|
||||
key: "ccr-local-agent-codex-api-codex-oauth",
|
||||
providerName: "Codex API"
|
||||
}];
|
||||
config.Providers = [
|
||||
{
|
||||
api_base_url: codexDefaultBaseUrl,
|
||||
api_key: "ccr-local-agent-login",
|
||||
id: "codex-api",
|
||||
models: ["gpt-5.5"],
|
||||
name: "Codex API",
|
||||
type: "openai_responses"
|
||||
}
|
||||
];
|
||||
|
||||
const compiled = await compileCoreGatewayConfig(
|
||||
config,
|
||||
"raw-trace-token",
|
||||
"billing-usage-token",
|
||||
"core-auth-token"
|
||||
);
|
||||
const codexPlugins = compiled.providerPlugins.filter((item) => String(item.key).includes("codex-oauth"));
|
||||
|
||||
assert.equal(codexPlugins.length, 0);
|
||||
});
|
||||
|
||||
test("credential-free fallback headers use the provider runtime identity", () => {
|
||||
const provider = {
|
||||
api_base_url: "https://api.example.test",
|
||||
@@ -120,3 +202,17 @@ test("credential-free fallback headers use the provider runtime identity", () =>
|
||||
|
||||
assert.equal(attempt.headers["x-target-provider"], "provider-runtime-test");
|
||||
});
|
||||
|
||||
function useTemporaryCodexHome(t, prefix) {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
const previousHome = process.env.CCR_INTERNAL_HOME_DIR;
|
||||
process.env.CCR_INTERNAL_HOME_DIR = home;
|
||||
t.after(() => {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.CCR_INTERNAL_HOME_DIR;
|
||||
} else {
|
||||
process.env.CCR_INTERNAL_HOME_DIR = previousHome;
|
||||
}
|
||||
});
|
||||
return home;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
newApiKeyUsageFallbackMessageForTest,
|
||||
@@ -270,6 +273,452 @@ test("connectivity probe applies provider plugin auth for local agent imports",
|
||||
assert.equal(report.results[0]?.supported, true);
|
||||
});
|
||||
|
||||
test("connectivity probe applies provider plugin request transforms", async (t) => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
let called = false;
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
called = true;
|
||||
const url = new URL(String(input));
|
||||
const headers = new Headers(init?.headers);
|
||||
const body = JSON.parse(String(init?.body ?? "{}"));
|
||||
|
||||
assert.equal(url.origin, "http://127.0.0.1:49124");
|
||||
assert.equal(url.pathname, "/v1/responses");
|
||||
assert.equal(url.searchParams.get("probe_model"), "probe-model");
|
||||
assert.equal(headers.get("x-probe-model"), "probe-model");
|
||||
assert.equal(body.model, "probe-model");
|
||||
assert.equal(body.max_output_tokens, undefined);
|
||||
|
||||
return new Response(JSON.stringify({ id: "ok" }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status: 200
|
||||
});
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = previousFetch;
|
||||
});
|
||||
|
||||
const report = await checkGatewayProviderConnectivity({
|
||||
apiKey: "sk-test",
|
||||
candidates: [{
|
||||
baseUrl: "http://127.0.0.1:49124/v1",
|
||||
name: "Local Agent",
|
||||
protocols: ["openai_responses"],
|
||||
source: "custom"
|
||||
}],
|
||||
forceRefresh: true,
|
||||
models: ["probe-model"],
|
||||
providerPlugins: [{
|
||||
request: {
|
||||
bodyRemove: ["max_output_tokens"],
|
||||
headers: {
|
||||
"x-probe-model": "{{ model }}"
|
||||
},
|
||||
query: {
|
||||
probe_model: "{{ request.body.model }}"
|
||||
}
|
||||
}
|
||||
}],
|
||||
protocols: ["openai_responses"]
|
||||
});
|
||||
|
||||
assert.equal(called, true);
|
||||
assert.equal(report.passed.length, 1);
|
||||
assert.equal(report.failed.length, 0);
|
||||
});
|
||||
|
||||
test("connectivity probe refreshes Codex OAuth plugin auth", async (t) => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
const accessToken = jwt({
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct-refreshed"
|
||||
},
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
scope: "api.connectors.read api.connectors.invoke"
|
||||
});
|
||||
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"),
|
||||
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
||||
chatgptAccountId: headers.get("chatgpt-account-id"),
|
||||
method: init?.method,
|
||||
pathname: url.pathname,
|
||||
url: url.toString()
|
||||
});
|
||||
|
||||
if (url.toString() === "http://127.0.0.1:49122/oauth/token") {
|
||||
return new Response(JSON.stringify({
|
||||
access_token: accessToken,
|
||||
refresh_token: "refresh-next"
|
||||
}), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status: 200
|
||||
});
|
||||
}
|
||||
|
||||
if (url.origin === "http://127.0.0.1:49122" && url.pathname === "/codex/responses") {
|
||||
const ok =
|
||||
headers.get("authorization") === `Bearer ${accessToken}` &&
|
||||
headers.get("chatgpt-account-id") === "acct-refreshed";
|
||||
return new Response(JSON.stringify(ok ? { id: "ok" } : { error: { message: "Unauthorized" } }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status: ok ? 200 : 401
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: { message: "Unexpected request" } }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status: 404
|
||||
});
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = previousFetch;
|
||||
});
|
||||
|
||||
const report = await checkGatewayProviderConnectivity({
|
||||
apiKey: "ccr-local-agent-login",
|
||||
candidates: [{
|
||||
baseUrl: "http://127.0.0.1:49122/codex",
|
||||
name: "Codex API",
|
||||
protocols: ["openai_responses"],
|
||||
source: "custom"
|
||||
}],
|
||||
forceRefresh: true,
|
||||
models: ["gpt-5-codex"],
|
||||
providerPlugins: [{
|
||||
codexOauth: {
|
||||
refreshToken: "refresh-current",
|
||||
tokenEndpoint: "http://127.0.0.1:49122/oauth/token"
|
||||
},
|
||||
key: "ccr-local-agent-codex-api-codex-oauth",
|
||||
providerName: "Codex API"
|
||||
}],
|
||||
protocols: ["openai_responses"]
|
||||
});
|
||||
|
||||
assert.equal(report.passed.length, 1);
|
||||
assert.equal(report.failed.length, 0);
|
||||
assert.deepEqual(calls.map((call) => call.pathname), ["/oauth/token", "/codex/responses"]);
|
||||
assert.deepEqual(calls[0]?.body, {
|
||||
client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: "refresh-current",
|
||||
scope: "openid profile email offline_access api.connectors.read api.connectors.invoke"
|
||||
});
|
||||
assert.equal(calls[1]?.authorization, `Bearer ${accessToken}`);
|
||||
assert.equal(calls[1]?.chatgptAccountId, "acct-refreshed");
|
||||
});
|
||||
|
||||
test("connectivity probe applies Codex request defaults for OAuth plugins", async (t) => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
const accessToken = jwt({
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct-codex-defaults"
|
||||
},
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
scope: "api.connectors.read api.connectors.invoke"
|
||||
});
|
||||
let requestBody;
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = new URL(String(input));
|
||||
const headers = new Headers(init?.headers);
|
||||
requestBody = init?.body ? JSON.parse(String(init.body)) : undefined;
|
||||
|
||||
if (url.toString() === "https://chatgpt.com/backend-api/codex/responses") {
|
||||
const ok =
|
||||
headers.get("authorization") === `Bearer ${accessToken}` &&
|
||||
headers.get("chatgpt-account-id") === "acct-codex-defaults" &&
|
||||
requestBody?.max_output_tokens === undefined;
|
||||
return new Response(JSON.stringify(ok ? { id: "ok" } : { error: { message: "Bad request" } }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status: ok ? 200 : 400
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: { message: "Unexpected request" } }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status: 404
|
||||
});
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = previousFetch;
|
||||
});
|
||||
|
||||
const report = await checkGatewayProviderConnectivity({
|
||||
apiKey: "ccr-local-agent-login",
|
||||
candidates: [{
|
||||
baseUrl: "https://chatgpt.com/backend-api/codex",
|
||||
name: "Codex API",
|
||||
protocols: ["openai_responses"],
|
||||
source: "custom"
|
||||
}],
|
||||
forceRefresh: true,
|
||||
models: ["gpt-5-codex"],
|
||||
providerPlugins: [{
|
||||
codexOauth: {
|
||||
accessToken
|
||||
},
|
||||
key: "ccr-local-agent-codex-api-codex-oauth",
|
||||
providerName: "Codex API"
|
||||
}],
|
||||
protocols: ["openai_responses"]
|
||||
});
|
||||
|
||||
assert.equal(report.passed.length, 1);
|
||||
assert.equal(report.failed.length, 0);
|
||||
assert.equal(requestBody?.model, "gpt-5-codex");
|
||||
assert.equal(requestBody?.max_output_tokens, undefined);
|
||||
});
|
||||
|
||||
test("connectivity probe prefers live Codex auth over saved OAuth plugin tokens", async (t) => {
|
||||
useTemporaryCodexHome(t, "ccr-codex-probe-live-over-plugin-");
|
||||
const previousFetch = globalThis.fetch;
|
||||
const savedToken = jwt({
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct-saved-plugin"
|
||||
},
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
scope: "api.connectors.read api.connectors.invoke"
|
||||
});
|
||||
const liveToken = jwt({
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct-live-token"
|
||||
},
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
scope: "api.connectors.read api.connectors.invoke"
|
||||
});
|
||||
const calls = [];
|
||||
|
||||
fs.mkdirSync(path.join(process.env.CCR_INTERNAL_HOME_DIR, ".codex"), { recursive: true });
|
||||
fs.writeFileSync(path.join(process.env.CCR_INTERNAL_HOME_DIR, ".codex", "auth.json"), JSON.stringify({
|
||||
tokens: {
|
||||
access_token: liveToken,
|
||||
account_id: "acct-live-file",
|
||||
refresh_token: "refresh-live"
|
||||
}
|
||||
}));
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = new URL(String(input));
|
||||
const headers = new Headers(init?.headers);
|
||||
calls.push({
|
||||
authorization: headers.get("authorization"),
|
||||
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
||||
chatgptAccountId: headers.get("chatgpt-account-id"),
|
||||
pathname: url.pathname,
|
||||
url: url.toString()
|
||||
});
|
||||
|
||||
if (url.toString() === "https://chatgpt.com/backend-api/codex/responses") {
|
||||
const ok =
|
||||
headers.get("authorization") === `Bearer ${liveToken}` &&
|
||||
headers.get("chatgpt-account-id") === "acct-live-file";
|
||||
return new Response(JSON.stringify(ok ? { id: "ok" } : { error: { message: "Unauthorized" } }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status: ok ? 200 : 401
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: { message: "Unexpected request" } }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status: 404
|
||||
});
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = previousFetch;
|
||||
});
|
||||
|
||||
const report = await checkGatewayProviderConnectivity({
|
||||
apiKey: "ccr-local-agent-login",
|
||||
candidates: [{
|
||||
baseUrl: "https://chatgpt.com/backend-api/codex",
|
||||
name: "Codex API",
|
||||
protocols: ["openai_responses"],
|
||||
source: "custom"
|
||||
}],
|
||||
forceRefresh: true,
|
||||
models: ["gpt-5-codex"],
|
||||
providerPlugins: [{
|
||||
codexOauth: {
|
||||
accessToken: savedToken,
|
||||
accountId: "acct-saved-plugin"
|
||||
},
|
||||
key: "ccr-local-agent-codex-api-codex-oauth",
|
||||
providerName: "Codex API"
|
||||
}],
|
||||
protocols: ["openai_responses"]
|
||||
});
|
||||
|
||||
assert.equal(report.passed.length, 1);
|
||||
assert.equal(report.failed.length, 0);
|
||||
assert.deepEqual(calls.map((call) => call.pathname), ["/backend-api/codex/responses"]);
|
||||
assert.equal(calls[0]?.authorization, `Bearer ${liveToken}`);
|
||||
assert.equal(calls[0]?.chatgptAccountId, "acct-live-file");
|
||||
assert.equal(calls[0]?.body?.max_output_tokens, undefined);
|
||||
});
|
||||
|
||||
test("connectivity probe shares concurrent Codex OAuth refreshes", async (t) => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
const accessToken = jwt({
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct-shared-refresh"
|
||||
},
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
scope: "api.connectors.read api.connectors.invoke"
|
||||
});
|
||||
let refreshCalls = 0;
|
||||
let responseCalls = 0;
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = new URL(String(input));
|
||||
const headers = new Headers(init?.headers);
|
||||
|
||||
if (url.toString() === "http://127.0.0.1:49125/oauth/token") {
|
||||
refreshCalls += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
return new Response(JSON.stringify({
|
||||
access_token: accessToken,
|
||||
refresh_token: "refresh-shared-next"
|
||||
}), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status: 200
|
||||
});
|
||||
}
|
||||
|
||||
if (url.origin === "http://127.0.0.1:49125" && url.pathname === "/codex/responses") {
|
||||
responseCalls += 1;
|
||||
const ok =
|
||||
headers.get("authorization") === `Bearer ${accessToken}` &&
|
||||
headers.get("chatgpt-account-id") === "acct-shared-refresh";
|
||||
return new Response(JSON.stringify(ok ? { id: "ok" } : { error: { message: "Unauthorized" } }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status: ok ? 200 : 401
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: { message: "Unexpected request" } }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status: 404
|
||||
});
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = previousFetch;
|
||||
});
|
||||
|
||||
const report = await checkGatewayProviderConnectivity({
|
||||
apiKey: "ccr-local-agent-login",
|
||||
candidates: [{
|
||||
baseUrl: "http://127.0.0.1:49125/codex",
|
||||
name: "Codex API",
|
||||
protocols: ["openai_responses"],
|
||||
source: "custom"
|
||||
}],
|
||||
forceRefresh: true,
|
||||
models: ["gpt-5-a", "gpt-5-b"],
|
||||
providerPlugins: [{
|
||||
codexOauth: {
|
||||
refreshToken: "refresh-shared-current",
|
||||
tokenEndpoint: "http://127.0.0.1:49125/oauth/token"
|
||||
},
|
||||
key: "ccr-local-agent-codex-api-codex-oauth",
|
||||
providerName: "Codex API"
|
||||
}],
|
||||
protocols: ["openai_responses"]
|
||||
});
|
||||
|
||||
assert.equal(refreshCalls, 1);
|
||||
assert.equal(responseCalls, 2);
|
||||
assert.equal(report.passed.length, 2);
|
||||
assert.equal(report.failed.length, 0);
|
||||
});
|
||||
|
||||
test("connectivity probe recovers Codex OAuth auth when saved plugin is missing", async (t) => {
|
||||
useTemporaryCodexHome(t, "ccr-codex-probe-live-auth-");
|
||||
const previousFetch = globalThis.fetch;
|
||||
const accessToken = jwt({
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct-live-probe"
|
||||
},
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
scope: "api.connectors.read api.connectors.invoke"
|
||||
});
|
||||
const calls = [];
|
||||
|
||||
fs.mkdirSync(path.join(process.env.CCR_INTERNAL_HOME_DIR, ".codex"), { recursive: true });
|
||||
fs.writeFileSync(path.join(process.env.CCR_INTERNAL_HOME_DIR, ".codex", "auth.json"), JSON.stringify({
|
||||
tokens: {
|
||||
refresh_token: "refresh-live"
|
||||
}
|
||||
}));
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = new URL(String(input));
|
||||
const headers = new Headers(init?.headers);
|
||||
calls.push({
|
||||
authorization: headers.get("authorization"),
|
||||
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
||||
chatgptAccountId: headers.get("chatgpt-account-id"),
|
||||
pathname: url.pathname,
|
||||
url: url.toString()
|
||||
});
|
||||
|
||||
if (url.toString() === "https://auth.openai.com/oauth/token") {
|
||||
return new Response(JSON.stringify({
|
||||
access_token: accessToken
|
||||
}), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status: 200
|
||||
});
|
||||
}
|
||||
|
||||
if (url.toString() === "https://chatgpt.com/backend-api/codex/responses") {
|
||||
const ok =
|
||||
headers.get("authorization") === `Bearer ${accessToken}` &&
|
||||
headers.get("chatgpt-account-id") === "acct-live-probe";
|
||||
return new Response(JSON.stringify(ok ? { id: "ok" } : { error: { message: "Unauthorized" } }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status: ok ? 200 : 401
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: { message: "Unexpected request" } }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status: 404
|
||||
});
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = previousFetch;
|
||||
});
|
||||
|
||||
const report = await checkGatewayProviderConnectivity({
|
||||
apiKey: "ccr-local-agent-login",
|
||||
candidates: [{
|
||||
baseUrl: "https://chatgpt.com/backend-api/codex",
|
||||
name: "Codex API",
|
||||
protocols: ["openai_responses"],
|
||||
source: "custom"
|
||||
}],
|
||||
forceRefresh: true,
|
||||
models: ["gpt-5.5"],
|
||||
providerPlugins: [],
|
||||
protocols: ["openai_responses"]
|
||||
});
|
||||
|
||||
assert.equal(report.passed.length, 1);
|
||||
assert.equal(report.failed.length, 0);
|
||||
assert.deepEqual(calls.map((call) => call.pathname), ["/oauth/token", "/backend-api/codex/responses"]);
|
||||
assert.equal(calls[0]?.body?.refresh_token, "refresh-live");
|
||||
assert.equal(calls[1]?.authorization, `Bearer ${accessToken}`);
|
||||
assert.equal(calls[1]?.chatgptAccountId, "acct-live-probe");
|
||||
assert.equal(calls[1]?.body?.max_output_tokens, undefined);
|
||||
});
|
||||
|
||||
test("New API response headers enable key quota account connector", () => {
|
||||
assert.equal(detectedProviderFromHeaders({ "X-New-Api-Version": "0.8.0" }), "new-api");
|
||||
assert.equal(detectedProviderFromHeaders({ "x-oneapi-request-id": "req-1" }), "new-api");
|
||||
@@ -330,3 +779,29 @@ test("New API user self parser returns user balance", () => {
|
||||
used: 250
|
||||
}]);
|
||||
});
|
||||
|
||||
function jwt(payload) {
|
||||
return [
|
||||
base64url({ alg: "none", typ: "JWT" }),
|
||||
base64url(payload),
|
||||
""
|
||||
].join(".");
|
||||
}
|
||||
|
||||
function base64url(value) {
|
||||
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
}
|
||||
|
||||
function useTemporaryCodexHome(t, prefix) {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
const previousHome = process.env.CCR_INTERNAL_HOME_DIR;
|
||||
process.env.CCR_INTERNAL_HOME_DIR = home;
|
||||
t.after(() => {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.CCR_INTERNAL_HOME_DIR;
|
||||
} else {
|
||||
process.env.CCR_INTERNAL_HOME_DIR = previousHome;
|
||||
}
|
||||
});
|
||||
return home;
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ import {
|
||||
persistLanguagePreference, PluginInstallCandidate, PluginMarketplaceEntry, PluginRoutingConfigTarget, PluginSettingsDraft, presetCapabilitiesFromDraft,
|
||||
probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, profileAgentOptionsForRuntime, profileDraftWithDetectedAppPath, profileEnvRowsForAgent, ProfileConfig, ProfileOpenSurface, ProfileRuntimeStatus, profileConfigFromDraft, providerAccountApiKeySafetyIssue,
|
||||
profileOpenCommandFallback, profileOpenSurfaces, ProviderAccountSnapshot, providerApiKeySafetyIssue, ProviderConnectivityCheckReport, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerIdentitySafetyIssue, providerProbeCandidates,
|
||||
providerBaseUrl, providerCapabilitiesForProtocols, providerCapabilitiesForSave, providerConnectivityApiKeyFromDraft, providerGlobalBaseUrlForProbe, providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerProtocolOptions, providerSelectableProtocolsFromProbe, ProxyNetworkSnapshot,
|
||||
providerBaseUrl, providerCapabilitiesForProtocols, providerCapabilitiesForSave, providerConnectivityApiKeyFromDraft, providerConnectivityProviderPlugins, providerGlobalBaseUrlForProbe, providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerProtocolOptions, providerSelectableProtocolsFromProbe, ProxyNetworkSnapshot,
|
||||
ProxyStatus, readLanguagePreference, RequestLogListFilter, RequestLogPage, ResolvedLanguage,
|
||||
ResolvedTheme, resolvePluginInstallPlan, resolveProviderDeepLinkCatalogModels, RouterRule, SettingsPageId,
|
||||
ResolvedTheme, resolvePluginInstallPlan, resolveProviderDeepLinkCatalogModels, removeLocalAgentProviderPluginsForProvider, RouterRule, SettingsPageId,
|
||||
routingRewriteFromDraftRow, setProviderPresets, splitLines, translateAppErrorMessage, translateText, TrayBalanceProgressConfig, TrayWidgetConfig,
|
||||
uniqueProviderProtocols, uniqueRoutingRuleId, updateApiKeyEditableConfig, UsageStatsFilter, UsageStatsRange, UsageStatsSnapshot, useEffect,
|
||||
useMemo, useReducedMotion, useRef, useState, validateVirtualModelDraft, ViewId,
|
||||
@@ -128,41 +128,6 @@ function providerPluginKey(value: unknown): string | undefined {
|
||||
return isPlainRecord(value) && typeof value.key === "string" && value.key.trim() ? value.key.trim() : undefined;
|
||||
}
|
||||
|
||||
function removeLocalAgentProviderPluginsForProvider(
|
||||
current: unknown[] | undefined,
|
||||
provider: GatewayProviderConfig | undefined
|
||||
): unknown[] | undefined {
|
||||
if (!provider || providerApiKeyValue(provider) !== localAgentProviderApiKey) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const providerNames = new Set([
|
||||
provider.name,
|
||||
provider.type ? `${provider.name}::${provider.type}` : ""
|
||||
].map((value) => value.trim().toLowerCase()).filter(Boolean));
|
||||
return (current ?? []).filter((plugin) => !localAgentProviderPluginMatchesProvider(plugin, providerNames));
|
||||
}
|
||||
|
||||
function localAgentProviderPluginMatchesProvider(plugin: unknown, providerNames: Set<string>): boolean {
|
||||
if (!isPlainRecord(plugin)) {
|
||||
return false;
|
||||
}
|
||||
const key = typeof plugin.key === "string" ? plugin.key.trim().toLowerCase() : "";
|
||||
if (!key.startsWith("ccr-local-agent-")) {
|
||||
return false;
|
||||
}
|
||||
const pluginProviderName = typeof plugin.providerName === "string"
|
||||
? plugin.providerName
|
||||
: typeof plugin.provider === "string"
|
||||
? plugin.provider
|
||||
: "";
|
||||
return providerNames.has(pluginProviderName.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function providerApiKeyValue(provider: GatewayProviderConfig): string {
|
||||
return provider.api_key || provider.apiKey || provider.apikey || "";
|
||||
}
|
||||
|
||||
function providerNameSlug(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
@@ -1439,6 +1404,8 @@ function App() {
|
||||
const apiKey = providerConnectivityApiKeyFromDraft(providerDraft);
|
||||
const models = mergeProviderModelLists(modelsToCheck ?? mergeProviderModelLists(providerDraft.selectedModels, splitLines(providerDraft.modelsText)));
|
||||
const protocols = providerDraft.selectedProtocols.length > 0 ? providerDraft.selectedProtocols : [providerDraft.protocol];
|
||||
const existingProvider = providerEditIndex === undefined ? undefined : draftConfig.Providers[providerEditIndex];
|
||||
const providerPlugins = providerConnectivityProviderPlugins(providerDraft, draftConfig.providerPlugins, existingProvider);
|
||||
const candidates = providerProbeCandidates(providerDraft)
|
||||
.map((candidate) => ({
|
||||
...candidate,
|
||||
@@ -1477,7 +1444,7 @@ function App() {
|
||||
candidates,
|
||||
forceRefresh: true,
|
||||
models,
|
||||
providerPlugins: providerDraft.providerPlugins,
|
||||
providerPlugins,
|
||||
protocols
|
||||
});
|
||||
if (providerConnectivityRequestId.current !== requestId) {
|
||||
|
||||
@@ -751,6 +751,113 @@ export function createProviderDraftFromProvider(provider: GatewayProviderConfig)
|
||||
};
|
||||
}
|
||||
|
||||
export function providerConnectivityProviderPlugins(
|
||||
draft: AddProviderDraft,
|
||||
providerPlugins: unknown[] | undefined,
|
||||
existingProvider?: GatewayProviderConfig
|
||||
): unknown[] {
|
||||
if (draft.providerPlugins.length > 0) {
|
||||
return draft.providerPlugins.filter(providerPluginEnabled);
|
||||
}
|
||||
if (providerConnectivityApiKeyFromDraft(draft) !== localAgentProviderApiKeyValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const names = localAgentProviderPluginNamesForDraft(draft, existingProvider);
|
||||
return (providerPlugins ?? []).filter((plugin) =>
|
||||
providerPluginEnabled(plugin) &&
|
||||
localAgentProviderPluginMatchesNames(plugin, names)
|
||||
);
|
||||
}
|
||||
|
||||
export function removeLocalAgentProviderPluginsForProvider(
|
||||
current: unknown[] | undefined,
|
||||
provider: GatewayProviderConfig | undefined
|
||||
): unknown[] | undefined {
|
||||
if (!provider || providerApiKey(provider) !== localAgentProviderApiKeyValue) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const names = localAgentProviderPluginNamesForProvider(provider);
|
||||
return (current ?? []).filter((plugin) => !localAgentProviderPluginMatchesNames(plugin, names));
|
||||
}
|
||||
|
||||
function localAgentProviderPluginNamesForDraft(
|
||||
draft: AddProviderDraft,
|
||||
existingProvider: GatewayProviderConfig | undefined
|
||||
): Set<string> {
|
||||
const existingProviderIdOrName = existingProvider?.id || existingProvider?.name;
|
||||
return providerPluginMatchNames([
|
||||
draft.name,
|
||||
existingProvider?.name,
|
||||
existingProvider?.id,
|
||||
existingProviderIdOrName ? providerNameSlug(existingProviderIdOrName) : undefined
|
||||
], draft.protocol);
|
||||
}
|
||||
|
||||
function localAgentProviderPluginNamesForProvider(provider: GatewayProviderConfig): Set<string> {
|
||||
const protocol = toProviderProtocol(provider.type) ?? toProviderProtocol(provider.provider);
|
||||
const providerIdOrName = provider.id || provider.name;
|
||||
return providerPluginMatchNames([
|
||||
provider.name,
|
||||
provider.id,
|
||||
providerIdOrName ? providerNameSlug(providerIdOrName) : undefined
|
||||
], protocol);
|
||||
}
|
||||
|
||||
function providerPluginMatchNames(
|
||||
names: Array<string | undefined>,
|
||||
protocol: GatewayProviderProtocol | undefined
|
||||
): Set<string> {
|
||||
const values = new Set<string>();
|
||||
for (const name of names) {
|
||||
const normalized = normalizeProviderPluginName(name);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
values.add(normalized);
|
||||
if (protocol) {
|
||||
values.add(normalizeProviderPluginName(`${normalized}::${protocol}`) || "");
|
||||
}
|
||||
}
|
||||
values.delete("");
|
||||
return values;
|
||||
}
|
||||
|
||||
function localAgentProviderPluginMatchesNames(plugin: unknown, names: Set<string>): boolean {
|
||||
if (!isPlainRecord(plugin)) {
|
||||
return false;
|
||||
}
|
||||
const key = typeof plugin.key === "string" ? plugin.key.trim().toLowerCase() : "";
|
||||
if (!key.startsWith("ccr-local-agent-")) {
|
||||
return false;
|
||||
}
|
||||
const pluginProviderName = typeof plugin.providerName === "string"
|
||||
? plugin.providerName
|
||||
: typeof plugin.provider === "string"
|
||||
? plugin.provider
|
||||
: "";
|
||||
const normalizedProviderName = normalizeProviderPluginName(pluginProviderName);
|
||||
return Boolean(normalizedProviderName && names.has(normalizedProviderName));
|
||||
}
|
||||
|
||||
function providerPluginEnabled(plugin: unknown): boolean {
|
||||
return !isPlainRecord(plugin) || plugin.enabled !== false;
|
||||
}
|
||||
|
||||
function normalizeProviderPluginName(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed.toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
export function providerNameSlug(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_.-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "provider";
|
||||
}
|
||||
|
||||
export function createProviderCredentialDraft(index = 0): ProviderCredentialDraft {
|
||||
return {
|
||||
apiKey: "",
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
providerCapabilitiesForSave,
|
||||
providerCapabilityBaseUrlForProtocol,
|
||||
providerConnectivityApiKeyFromDraft,
|
||||
providerConnectivityProviderPlugins,
|
||||
providerDisplayIcon,
|
||||
providerAccountConnectorsTextWithNewApiUserBalanceTemplate,
|
||||
providerGlobalBaseUrlForProbe,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
providerProtocolOptions,
|
||||
providerProbeCandidates,
|
||||
providerSelectableProtocolsFromProbe,
|
||||
removeLocalAgentProviderPluginsForProvider,
|
||||
setProviderPresets
|
||||
} from "@ccr/ui/pages/home/shared/index.tsx";
|
||||
import { installBrowserGlobals } from "../fixtures/index.ts";
|
||||
@@ -645,6 +647,72 @@ test("provider connectivity API key follows selected credential mode", () => {
|
||||
assert.equal(providerConnectivityApiKeyFromDraft({ ...draft, credentialMode: "pool" }), "sk-pool");
|
||||
});
|
||||
|
||||
test("provider connectivity includes saved Codex OAuth plugins while editing", () => {
|
||||
const provider = {
|
||||
api_base_url: "https://chatgpt.com/backend-api/codex",
|
||||
api_key: "ccr-local-agent-login",
|
||||
id: "codex-api",
|
||||
models: ["gpt-5.5"],
|
||||
name: "Codex API",
|
||||
type: "openai_responses" as const
|
||||
};
|
||||
const draft = {
|
||||
...createProviderDraftFromProvider(provider),
|
||||
name: "Renamed Codex API"
|
||||
};
|
||||
const displayPlugin = {
|
||||
codexOauth: { refreshToken: "refresh-display" },
|
||||
key: "ccr-local-agent-codex-api-codex-oauth",
|
||||
providerName: "Codex API"
|
||||
};
|
||||
const runtimePlugin = {
|
||||
codexOauth: { refreshToken: "refresh-runtime" },
|
||||
key: "ccr-local-agent-codex-api-codex-oauth-internal",
|
||||
providerName: "codex-api::openai_responses"
|
||||
};
|
||||
const disabledRuntimePlugin = {
|
||||
codexOauth: { refreshToken: "refresh-disabled" },
|
||||
enabled: false,
|
||||
key: "ccr-local-agent-codex-api-codex-oauth-disabled",
|
||||
providerName: "codex-api::openai_responses"
|
||||
};
|
||||
const otherPlugin = {
|
||||
codexOauth: { refreshToken: "refresh-other" },
|
||||
key: "ccr-local-agent-other-codex-oauth",
|
||||
providerName: "Other Codex API"
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
providerConnectivityProviderPlugins(draft, [displayPlugin, runtimePlugin, disabledRuntimePlugin, otherPlugin], provider),
|
||||
[displayPlugin, runtimePlugin]
|
||||
);
|
||||
assert.deepEqual(
|
||||
providerConnectivityProviderPlugins({ ...draft, providerPlugins: [otherPlugin] }, [displayPlugin, runtimePlugin], provider),
|
||||
[otherPlugin]
|
||||
);
|
||||
assert.deepEqual(
|
||||
removeLocalAgentProviderPluginsForProvider([displayPlugin, runtimePlugin, otherPlugin], provider),
|
||||
[otherPlugin]
|
||||
);
|
||||
|
||||
const poolProvider = {
|
||||
...provider,
|
||||
api_key: "",
|
||||
credentials: [{
|
||||
api_key: "ccr-local-agent-login",
|
||||
enabled: true,
|
||||
id: "login",
|
||||
name: "Login"
|
||||
}]
|
||||
};
|
||||
const poolDraft = createProviderDraftFromProvider(poolProvider);
|
||||
assert.equal(poolDraft.credentialMode, "pool");
|
||||
assert.deepEqual(
|
||||
providerConnectivityProviderPlugins(poolDraft, [displayPlugin, runtimePlugin, otherPlugin], poolProvider),
|
||||
[displayPlugin, runtimePlugin]
|
||||
);
|
||||
});
|
||||
|
||||
test("provider probe keeps catalog model defaults separate from user overrides", () => {
|
||||
const draft = {
|
||||
...createProviderDraft([]),
|
||||
|
||||
Reference in New Issue
Block a user