mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 22:02:08 +03:00
refactor(core): strengthen typing and normalize auth and model flows
Tighten executor, usage, model-resolution, and state-management code with explicit types and safer record handling to reduce runtime edge cases across providers. Also normalize management-token failures to 403 responses, require API keys consistently on cloud agent task routes with CORS-safe errors, refresh stale Gemini CLI project IDs, prioritize Gemini search tools correctly, add new provider/model registry entries, and serialize integration tests for more reliable CI.
This commit is contained in:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -436,7 +436,7 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run check:node-runtime
|
||||
- run: node --import tsx/esm --test --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts
|
||||
- run: node --import tsx/esm --test --test-force-exit --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts
|
||||
|
||||
test-security:
|
||||
name: Security Tests
|
||||
|
||||
@@ -591,6 +591,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
},
|
||||
models: [
|
||||
{ id: "auto-kiro", name: "Auto (Kiro picks best model)" },
|
||||
{ id: "claude-opus-4.7", name: "Claude Opus 4.7" },
|
||||
{ id: "claude-opus-4.6", name: "Claude Opus 4.6" },
|
||||
{ id: "claude-opus-4.5", name: "Claude Opus 4.5" },
|
||||
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" },
|
||||
@@ -599,6 +600,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
|
||||
{ id: "claude-3.7-sonnet", name: "Claude 3.7 Sonnet" },
|
||||
// Dash aliases — Claude Code sends dashes, Kiro API uses dots
|
||||
{ id: "claude-opus-4-7", name: "Claude Opus 4.7" },
|
||||
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
|
||||
{ id: "claude-opus-4-5", name: "Claude Opus 4.5" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||
|
||||
@@ -4,6 +4,7 @@ export const RUNWAYML_API_VERSION = "2024-11-06";
|
||||
export const RUNWAYML_SUPPORTED_VIDEO_MODELS = [
|
||||
{ id: "gen4.5", name: "Gen-4.5" },
|
||||
{ id: "gen4_turbo", name: "Gen-4 Turbo" },
|
||||
{ id: "gen3a_turbo", name: "Gen-3 Alpha Turbo" },
|
||||
{ id: "veo3.1", name: "Veo 3.1" },
|
||||
{ id: "veo3.1_fast", name: "Veo 3.1 Fast" },
|
||||
];
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import crypto, { randomUUID } from "crypto";
|
||||
import { BaseExecutor, mergeUpstreamExtraHeaders, type ExecuteInput } from "./base.ts";
|
||||
import {
|
||||
BaseExecutor,
|
||||
mergeUpstreamExtraHeaders,
|
||||
type ExecuteInput,
|
||||
type ExecutorLog,
|
||||
type ProviderCredentials,
|
||||
} from "./base.ts";
|
||||
import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts";
|
||||
import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS } from "../config/constants.ts";
|
||||
import { scrubProxyAndFingerprintHeaders } from "../services/antigravityHeaderScrub.ts";
|
||||
@@ -42,11 +48,14 @@ const CREDITS_EXHAUSTED_TTL_MS = 5 * 60 * 60 * 1000; // 5 hours
|
||||
|
||||
const BARE_PRO_IDS = new Set(["gemini-3.1-pro"]);
|
||||
|
||||
function getChunkedOrFixedBody(bodyStr: string, stream: boolean) {
|
||||
function getChunkedOrFixedBody(bodyStr: string, stream: boolean): BodyInit {
|
||||
if (stream) {
|
||||
return (async function* () {
|
||||
yield new TextEncoder().encode(bodyStr);
|
||||
})();
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(bodyStr));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
return bodyStr;
|
||||
}
|
||||
@@ -92,6 +101,20 @@ type AntigravityRequestEnvelope = Record<string, unknown> & {
|
||||
request: Record<string, unknown>;
|
||||
enabledCreditTypes?: string[];
|
||||
};
|
||||
type AntigravityCredentials = ProviderCredentials & {
|
||||
projectId?: string | null;
|
||||
};
|
||||
type AntigravityPart = Record<string, unknown> & {
|
||||
text?: unknown;
|
||||
thought?: unknown;
|
||||
thoughtSignature?: unknown;
|
||||
functionResponse?: unknown;
|
||||
functionCall?: { name?: unknown } | null;
|
||||
};
|
||||
type AntigravityContent = Record<string, unknown> & {
|
||||
role?: unknown;
|
||||
parts?: AntigravityPart[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-account GOOGLE_ONE_AI credits-exhausted tracker.
|
||||
@@ -160,7 +183,7 @@ function markCreditsExhausted(accountId: string): void {
|
||||
function processAntigravitySSEPayload(
|
||||
payload: string,
|
||||
collected: AntigravityCollectedStream,
|
||||
log?: { debug?: (scope: string, message: string) => void }
|
||||
log?: { debug?: (scope: string, message: string) => void } | null
|
||||
) {
|
||||
if (!payload || payload === "[DONE]") return;
|
||||
try {
|
||||
@@ -199,7 +222,7 @@ function processAntigravitySSEText(
|
||||
text: string,
|
||||
partialLine: { value: string },
|
||||
collected: AntigravityCollectedStream,
|
||||
log?: { debug?: (scope: string, message: string) => void }
|
||||
log?: { debug?: (scope: string, message: string) => void } | null
|
||||
) {
|
||||
partialLine.value += text;
|
||||
const lines = partialLine.value.split("\n");
|
||||
@@ -215,7 +238,7 @@ function processAntigravitySSEText(
|
||||
function flushAntigravitySSEText(
|
||||
partialLine: { value: string },
|
||||
collected: AntigravityCollectedStream,
|
||||
log?: { debug?: (scope: string, message: string) => void }
|
||||
log?: { debug?: (scope: string, message: string) => void } | null
|
||||
) {
|
||||
const trimmed = partialLine.value.trim();
|
||||
partialLine.value = "";
|
||||
@@ -366,7 +389,9 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
super("antigravity", PROVIDERS.antigravity);
|
||||
}
|
||||
|
||||
buildUrl(model, stream, urlIndex = 0) {
|
||||
buildUrl(model: string, stream: boolean, urlIndex = 0) {
|
||||
void model;
|
||||
void stream;
|
||||
const baseUrls = this.getBaseUrls();
|
||||
const baseUrl = baseUrls[urlIndex] || baseUrls[0];
|
||||
// Always use streaming endpoint — the non-streaming `generateContent` causes
|
||||
@@ -377,7 +402,8 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
return `${baseUrl}/v1internal:streamGenerateContent?alt=sse`;
|
||||
}
|
||||
|
||||
buildHeaders(credentials, stream = true) {
|
||||
buildHeaders(credentials: AntigravityCredentials, stream = true) {
|
||||
void stream;
|
||||
const raw = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${credentials.accessToken}`,
|
||||
@@ -389,11 +415,18 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
return scrubProxyAndFingerprintHeaders(raw);
|
||||
}
|
||||
|
||||
transformRequest(model, body, stream, credentials): AntigravityRequestEnvelope | Response {
|
||||
transformRequest(
|
||||
model: string,
|
||||
body: unknown,
|
||||
stream: boolean,
|
||||
credentials: AntigravityCredentials
|
||||
): AntigravityRequestEnvelope | Response {
|
||||
void stream;
|
||||
// TODO: Consider removing project override like gemini-cli.ts — stored projectId
|
||||
// can become stale for Cloud Code accounts, causing 403 "has not been used in project X".
|
||||
// Antigravity accounts may have more stable project IDs, but the risk exists.
|
||||
const bodyProjectId = body?.project;
|
||||
const bodyRecord = asRecord(body) ?? {};
|
||||
const bodyProjectId = bodyRecord.project;
|
||||
const credentialsProjectId = credentials?.projectId;
|
||||
const allowBodyProjectOverride = process.env.OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE === "1";
|
||||
|
||||
@@ -424,36 +457,43 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
// Returning a Response object signals the executor to stop and forward it
|
||||
return resp as unknown as never;
|
||||
}
|
||||
const resolvedProjectId = typeof projectId === "string" ? projectId : String(projectId);
|
||||
|
||||
const upstreamModel = cleanModelName(model);
|
||||
const isClaude = upstreamModel.toLowerCase().includes("claude");
|
||||
const baseBody = body && typeof body === "object" ? body : {};
|
||||
const normalizedBody = shouldStripCloudCodeThinking(this.provider, upstreamModel)
|
||||
? stripCloudCodeThinkingConfig(baseBody)
|
||||
: baseBody;
|
||||
const baseBody = bodyRecord;
|
||||
const normalizedBody = (
|
||||
shouldStripCloudCodeThinking(this.provider, upstreamModel)
|
||||
? stripCloudCodeThinkingConfig(baseBody)
|
||||
: baseBody
|
||||
) as Record<string, unknown>;
|
||||
const normalizedRequest = asRecord(normalizedBody.request) ?? {};
|
||||
|
||||
// Fix contents for Gemini-compatible Cloud Code requests via Antigravity.
|
||||
// Claude-branded Antigravity models use the same streamGenerateContent schema.
|
||||
const normalizedContents =
|
||||
normalizedBody.request?.contents?.map((c) => {
|
||||
let role = c.role;
|
||||
if (c.parts?.some((p) => p.functionResponse)) {
|
||||
role = "user";
|
||||
}
|
||||
const normalizedContents = (
|
||||
Array.isArray(normalizedRequest.contents)
|
||||
? (normalizedRequest.contents as AntigravityContent[])
|
||||
: []
|
||||
).map((c) => {
|
||||
let role = c.role;
|
||||
if (c.parts?.some((p: AntigravityPart) => p.functionResponse)) {
|
||||
role = "user";
|
||||
}
|
||||
|
||||
const hasFunctionCall = c.parts?.some((p) => p.functionCall) || false;
|
||||
const hasFunctionCall = c.parts?.some((p: AntigravityPart) => p.functionCall) || false;
|
||||
|
||||
const parts =
|
||||
c.parts?.filter((p) => {
|
||||
if (typeof p.text === "string" && p.text === "") return false;
|
||||
if (p.functionCall && !p.functionCall.name) return false;
|
||||
const parts =
|
||||
c.parts?.filter((p: AntigravityPart) => {
|
||||
if (typeof p.text === "string" && p.text === "") return false;
|
||||
if (p.functionCall && !p.functionCall.name) return false;
|
||||
|
||||
return !p.thought && (hasFunctionCall || !p.thoughtSignature);
|
||||
}) || [];
|
||||
return { ...c, role, parts };
|
||||
}) || [];
|
||||
return !p.thought && (hasFunctionCall || !p.thoughtSignature);
|
||||
}) || [];
|
||||
return { ...c, role, parts };
|
||||
});
|
||||
|
||||
const contents: any[] = [];
|
||||
const contents: Array<Record<string, unknown> & { parts: unknown[] }> = [];
|
||||
for (const c of normalizedContents) {
|
||||
if (!Array.isArray(c.parts) || c.parts.length === 0) continue;
|
||||
if (contents.length > 0 && contents[contents.length - 1].role === c.role) {
|
||||
@@ -464,14 +504,14 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
const rawTransformedRequest = {
|
||||
...normalizedBody.request,
|
||||
...normalizedRequest,
|
||||
...(contents.length > 0 && { contents }),
|
||||
sessionId: getAntigravitySessionId(credentials, normalizedBody.request?.sessionId),
|
||||
sessionId: getAntigravitySessionId(credentials, normalizedRequest.sessionId),
|
||||
safetySettings: undefined,
|
||||
toolConfig:
|
||||
normalizedBody.request?.tools?.length > 0
|
||||
Array.isArray(normalizedRequest.tools) && normalizedRequest.tools.length > 0
|
||||
? { functionCallingConfig: { mode: "VALIDATED" } }
|
||||
: normalizedBody.request?.toolConfig,
|
||||
: normalizedRequest.toolConfig,
|
||||
};
|
||||
|
||||
const transformedRequest = isClaude
|
||||
@@ -506,7 +546,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
|
||||
const requestType = _requestType === "image_gen" ? "image_gen" : "agent";
|
||||
const envelope: AntigravityRequestEnvelope = {
|
||||
project: projectId,
|
||||
project: resolvedProjectId,
|
||||
requestId: generateAntigravityRequestId(),
|
||||
request: transformedRequest,
|
||||
model: upstreamModel,
|
||||
@@ -522,7 +562,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
return envelope;
|
||||
}
|
||||
|
||||
async refreshCredentials(credentials, log) {
|
||||
async refreshCredentials(credentials: AntigravityCredentials, log?: ExecutorLog | null) {
|
||||
if (!credentials.refreshToken) return null;
|
||||
|
||||
try {
|
||||
@@ -543,17 +583,23 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const tokens = await response.json();
|
||||
const tokens = (await response.json()) as Record<string, unknown>;
|
||||
log?.info?.("TOKEN", "Antigravity refreshed");
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || credentials.refreshToken,
|
||||
accessToken: typeof tokens.access_token === "string" ? tokens.access_token : undefined,
|
||||
refreshToken:
|
||||
typeof tokens.refresh_token === "string"
|
||||
? tokens.refresh_token
|
||||
: credentials.refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
projectId: credentials.projectId,
|
||||
};
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN", `Antigravity refresh error: ${error.message}`);
|
||||
log?.error?.(
|
||||
"TOKEN",
|
||||
`Antigravity refresh error: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -562,7 +608,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
return `-${parseInt(randomUUID().replace(/-/g, "").substring(0, 8), 16) % 9_000_000_000_000_000_000}`;
|
||||
}
|
||||
|
||||
parseRetryHeaders(headers) {
|
||||
parseRetryHeaders(headers: Headers | null | undefined): number | null {
|
||||
if (!headers?.get) return null;
|
||||
|
||||
const retryAfter = headers.get("retry-after");
|
||||
@@ -595,7 +641,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
|
||||
// Parse retry time from Antigravity error message body
|
||||
// Format: "Your quota will reset after 2h7m23s" or "1h30m" or "45m" or "30s"
|
||||
parseRetryFromErrorMessage(errorMessage) {
|
||||
parseRetryFromErrorMessage(errorMessage: unknown): number | null {
|
||||
if (!errorMessage || typeof errorMessage !== "string") return null;
|
||||
|
||||
const match = errorMessage.match(/reset (?:after|in) (\d+h)?(\d+m)?(\d+s)?/i);
|
||||
@@ -619,7 +665,18 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
* Parses Gemini-format SSE chunks and assembles text content + usage into one
|
||||
* OpenAI-format chat.completion payload.
|
||||
*/
|
||||
collectStreamToResponse(response, model, url, headers, transformedBody, log?, signal?) {
|
||||
collectStreamToResponse(
|
||||
response: Response,
|
||||
model: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
transformedBody: Record<string, unknown>,
|
||||
log?: ExecutorLog | null,
|
||||
signal?: AbortSignal | null
|
||||
) {
|
||||
if (!response.body) {
|
||||
return Promise.resolve({ response, url, headers, transformedBody });
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
@@ -709,7 +766,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
let lastError = null;
|
||||
let lastStatus = 0;
|
||||
const MAX_AUTO_RETRIES = 3;
|
||||
const retryAttemptsByUrl = {}; // Track retry attempts per URL
|
||||
const retryAttemptsByUrl: Record<number, number> = {}; // Track retry attempts per URL
|
||||
|
||||
// Always stream upstream — buildUrl always returns the streaming endpoint.
|
||||
// For non-streaming clients, we collect the SSE below and return a synthetic
|
||||
@@ -802,7 +859,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
let response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: finalHeaders,
|
||||
body: getChunkedOrFixedBody(serializedRequest.bodyString, stream) as any,
|
||||
body: getChunkedOrFixedBody(serializedRequest.bodyString, stream),
|
||||
...(stream ? { duplex: "half" } : {}),
|
||||
signal,
|
||||
});
|
||||
@@ -814,7 +871,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: retryHeaders,
|
||||
body: getChunkedOrFixedBody(serializedRequest.bodyString, stream) as any,
|
||||
body: getChunkedOrFixedBody(serializedRequest.bodyString, stream),
|
||||
...(stream ? { duplex: "half" } : {}),
|
||||
signal,
|
||||
});
|
||||
@@ -884,7 +941,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
const creditsResp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: finalCreditsHeaders,
|
||||
body: getChunkedOrFixedBody(serializedCreditsRequest.bodyString, stream) as any,
|
||||
body: getChunkedOrFixedBody(serializedCreditsRequest.bodyString, stream),
|
||||
...(stream ? { duplex: "half" } : {}),
|
||||
signal,
|
||||
});
|
||||
@@ -905,7 +962,9 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
const syntheticJson = await collected.response.clone().json();
|
||||
const rc = syntheticJson?._remainingCredits;
|
||||
if (Array.isArray(rc)) {
|
||||
const googleCredit = rc.find((c) => c.creditType === "GOOGLE_ONE_AI");
|
||||
const googleCredit = rc.find(
|
||||
(c: { creditType?: string }) => c.creditType === "GOOGLE_ONE_AI"
|
||||
);
|
||||
if (googleCredit) {
|
||||
const balance = parseInt(googleCredit.creditAmount, 10);
|
||||
if (!isNaN(balance))
|
||||
@@ -1009,7 +1068,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
) {
|
||||
try {
|
||||
const respBody = await response.clone().text();
|
||||
let obj;
|
||||
let obj: Record<string, unknown>;
|
||||
try {
|
||||
obj = JSON.parse(respBody);
|
||||
} catch {
|
||||
@@ -1125,7 +1184,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
const parsed = JSON.parse(payload);
|
||||
if (Array.isArray(parsed?.remainingCredits)) {
|
||||
const googleCredit = parsed.remainingCredits.find(
|
||||
(c) => c?.creditType === "GOOGLE_ONE_AI"
|
||||
(c: { creditType?: string }) => c?.creditType === "GOOGLE_ONE_AI"
|
||||
);
|
||||
if (googleCredit) {
|
||||
const balance = parseInt(googleCredit.creditAmount, 10);
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
BaseExecutor,
|
||||
mergeUpstreamExtraHeaders,
|
||||
setUserAgentHeader,
|
||||
type ExecutorLog,
|
||||
type ExecuteInput,
|
||||
type ProviderCredentials,
|
||||
} from "./base.ts";
|
||||
import {
|
||||
CODEX_CHAT_DEFAULT_INSTRUCTIONS,
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
applyCodexClientIdentityHeaders,
|
||||
applyCodexClientMetadata,
|
||||
createCodexClientIdentity,
|
||||
type CodexClientIdentity,
|
||||
} from "../config/codexIdentity.ts";
|
||||
import { getAccessToken } from "../services/tokenRefresh.ts";
|
||||
import {
|
||||
@@ -1088,7 +1091,12 @@ export class CodexExecutor extends BaseExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: any = null) {
|
||||
buildUrl(
|
||||
model: string,
|
||||
stream: boolean,
|
||||
urlIndex = 0,
|
||||
credentials: ProviderCredentials | null = null
|
||||
) {
|
||||
void model;
|
||||
void stream;
|
||||
void urlIndex;
|
||||
@@ -1110,7 +1118,7 @@ export class CodexExecutor extends BaseExecutor {
|
||||
* Always request event-stream from upstream, even when client requested stream=false.
|
||||
* Includes chatgpt-account-id header for strict workspace binding.
|
||||
*/
|
||||
buildHeaders(credentials, stream = true) {
|
||||
buildHeaders(credentials: ProviderCredentials, stream = true) {
|
||||
const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath);
|
||||
const headers = super.buildHeaders(credentials, isCompactRequest ? false : true);
|
||||
headers.Version = getCodexClientVersion();
|
||||
@@ -1118,10 +1126,13 @@ export class CodexExecutor extends BaseExecutor {
|
||||
|
||||
// Add workspace binding header if workspaceId is persisted
|
||||
const workspaceId = credentials?.providerSpecificData?.workspaceId;
|
||||
if (workspaceId) {
|
||||
if (typeof workspaceId === "string" && workspaceId) {
|
||||
headers["chatgpt-account-id"] = workspaceId;
|
||||
}
|
||||
const clientIdentity = credentials?.providerSpecificData?.codexClientIdentity;
|
||||
const clientIdentity = credentials?.providerSpecificData?.codexClientIdentity as
|
||||
| CodexClientIdentity
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
// Originator header — identifies the client type to the Codex backend.
|
||||
// Ref: openai/codex login/src/auth/default_client.rs DEFAULT_ORIGINATOR = "codex_cli_rs"
|
||||
@@ -1148,7 +1159,7 @@ export class CodexExecutor extends BaseExecutor {
|
||||
* Ref: openai/codex core/src/client.rs line 853
|
||||
*/
|
||||
private getPromptCacheSessionId(
|
||||
credentials,
|
||||
credentials: ProviderCredentials | null | undefined,
|
||||
body: Record<string, unknown> | null
|
||||
): string | null {
|
||||
const promptCacheKey = normalizeCodexSessionId(body?.prompt_cache_key);
|
||||
@@ -1173,7 +1184,7 @@ export class CodexExecutor extends BaseExecutor {
|
||||
* have expired or become invalid. chatCore.ts calls this on 401; previously the
|
||||
* base class returned null causing the request to fail instead of refreshing.
|
||||
*/
|
||||
async refreshCredentials(credentials, log) {
|
||||
async refreshCredentials(credentials: ProviderCredentials, log?: ExecutorLog | null) {
|
||||
if (!credentials?.refreshToken) {
|
||||
log?.warn?.("TOKEN_REFRESH", "Codex: no refresh token available, re-authentication required");
|
||||
return null;
|
||||
@@ -1192,11 +1203,19 @@ export class CodexExecutor extends BaseExecutor {
|
||||
/**
|
||||
* Transform request before sending - inject default instructions if missing
|
||||
*/
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
transformRequest(
|
||||
model: string,
|
||||
bodyInput: unknown,
|
||||
stream: boolean,
|
||||
credentials: ProviderCredentials
|
||||
) {
|
||||
void stream;
|
||||
// Do not mutate the caller's payload in place. Combo quality checks and
|
||||
// other post-execute paths still inspect the original request body.
|
||||
body =
|
||||
body && typeof body === "object" ? structuredClone(body) : ({} as Record<string, unknown>);
|
||||
const body: Record<string, unknown> =
|
||||
bodyInput && typeof bodyInput === "object"
|
||||
? structuredClone(bodyInput as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const nativeCodexPassthrough = body?._nativeCodexPassthrough === true;
|
||||
const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath);
|
||||
@@ -1259,7 +1278,7 @@ export class CodexExecutor extends BaseExecutor {
|
||||
},
|
||||
];
|
||||
} else if (!body.input && Array.isArray(body.prompt)) {
|
||||
body.input = body.prompt.map((p: any) => ({
|
||||
body.input = body.prompt.map((p: unknown) => ({
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: typeof p === "string" ? p : JSON.stringify(p) }],
|
||||
@@ -1350,10 +1369,14 @@ export class CodexExecutor extends BaseExecutor {
|
||||
if (splitModel.effort) {
|
||||
modelEffort = splitModel.effort;
|
||||
body.model = splitModel.baseModel;
|
||||
cleanModel = body.model;
|
||||
cleanModel = splitModel.baseModel;
|
||||
}
|
||||
|
||||
const explicitReasoning = normalizeEffortValue(body?.reasoning?.effort);
|
||||
const reasoningRecord =
|
||||
body.reasoning && typeof body.reasoning === "object" && !Array.isArray(body.reasoning)
|
||||
? (body.reasoning as Record<string, unknown>)
|
||||
: null;
|
||||
const explicitReasoning = normalizeEffortValue(reasoningRecord?.effort);
|
||||
const requestReasoningEffort = normalizeEffortValue(body.reasoning_effort);
|
||||
const fallbackReasoningEffort = allowConnectionReasoningDefaults
|
||||
? requestDefaults.reasoningEffort || "medium"
|
||||
@@ -1363,12 +1386,12 @@ export class CodexExecutor extends BaseExecutor {
|
||||
|
||||
if (explicitReasoning) {
|
||||
body.reasoning = {
|
||||
...(body.reasoning && typeof body.reasoning === "object" ? body.reasoning : {}),
|
||||
...(reasoningRecord || {}),
|
||||
effort: clampEffort(cleanModel, explicitReasoning),
|
||||
};
|
||||
} else if (rawEffort) {
|
||||
body.reasoning = {
|
||||
...(body.reasoning && typeof body.reasoning === "object" ? body.reasoning : {}),
|
||||
...(reasoningRecord || {}),
|
||||
effort: clampEffort(cleanModel, rawEffort),
|
||||
};
|
||||
}
|
||||
@@ -1401,7 +1424,13 @@ export class CodexExecutor extends BaseExecutor {
|
||||
}
|
||||
}
|
||||
if (!isCompactRequest) {
|
||||
applyCodexClientMetadata(body, credentials?.providerSpecificData?.codexClientIdentity);
|
||||
applyCodexClientMetadata(
|
||||
body,
|
||||
credentials?.providerSpecificData?.codexClientIdentity as
|
||||
| CodexClientIdentity
|
||||
| null
|
||||
| undefined
|
||||
);
|
||||
}
|
||||
|
||||
// Delete session_id and conversation_id from the body.
|
||||
|
||||
@@ -310,13 +310,15 @@ export class GeminiCLIExecutor extends BaseExecutor {
|
||||
? cloneGeminiCliRecord(bodyRecord.request as Record<string, any>)
|
||||
: {};
|
||||
|
||||
const storedProject =
|
||||
bodyRecord.project ||
|
||||
credentials.projectId ||
|
||||
(credentials.providerSpecificData as Record<string, unknown>)?.projectId ||
|
||||
"";
|
||||
|
||||
const envelope: Record<string, any> = {
|
||||
model: currentModel,
|
||||
project:
|
||||
bodyRecord.project ||
|
||||
credentials.projectId ||
|
||||
(credentials.providerSpecificData as Record<string, unknown>)?.projectId ||
|
||||
"",
|
||||
project: storedProject,
|
||||
user_prompt_id: bodyRecord.user_prompt_id || generateGeminiCliRequestId(),
|
||||
request: {
|
||||
...requestRecord,
|
||||
@@ -330,9 +332,9 @@ export class GeminiCLIExecutor extends BaseExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the project ID via loadCodeAssist (cached for 30s) only when project not provided
|
||||
// and credentials have an access token
|
||||
if (!envelope.project && credentials.accessToken) {
|
||||
// Native Gemini CLI refreshes the Cloud Code project periodically because
|
||||
// stored project IDs can go stale. Keep the stored value as a fallback.
|
||||
if (credentials.accessToken) {
|
||||
const freshProject = await this.refreshProject(credentials.accessToken, currentModel);
|
||||
if (freshProject) {
|
||||
envelope.project = freshProject;
|
||||
|
||||
@@ -57,6 +57,24 @@ type ModelFailureState = {
|
||||
lastFailureAt: number;
|
||||
resetAfterMs: number;
|
||||
};
|
||||
type ModelLockoutInfo = {
|
||||
provider: string;
|
||||
connectionId: string;
|
||||
model: string;
|
||||
reason: string;
|
||||
remainingMs: number;
|
||||
failureCount: number;
|
||||
lockedAt: string;
|
||||
};
|
||||
type RetryHintHeaders = Headers | Record<string, string | number | null | undefined>;
|
||||
type AccountCooldownState = {
|
||||
id?: string;
|
||||
rateLimitedUntil?: string | null;
|
||||
backoffLevel?: number;
|
||||
lastError?: string | null;
|
||||
lastErrorAt?: string | null;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
// Provider-level failure tracking for circuit breaker behavior
|
||||
// Error codes that count toward provider-level failure threshold
|
||||
@@ -210,7 +228,11 @@ function buildProviderProfile(
|
||||
* Get the resilience profile for a provider (oauth or apikey).
|
||||
* @param {string} provider - Provider ID or alias
|
||||
*/
|
||||
export function getProviderProfile(provider) {
|
||||
function asJsonRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
export function getProviderProfile(provider: string) {
|
||||
const category = getProviderCategory(provider);
|
||||
return buildProviderProfile(category);
|
||||
}
|
||||
@@ -227,7 +249,7 @@ export async function getRuntimeProviderProfile(provider: string | null | undefi
|
||||
const category = getProviderCategory(provider || "");
|
||||
return buildProviderProfile(category, settings);
|
||||
} catch {
|
||||
return getProviderProfile(provider);
|
||||
return getProviderProfile(provider || "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,11 +332,11 @@ function ensureCleanupTimer() {
|
||||
* @param {number} cooldownMs
|
||||
*/
|
||||
export function lockModel(
|
||||
provider,
|
||||
connectionId,
|
||||
model,
|
||||
reason,
|
||||
cooldownMs,
|
||||
provider: string,
|
||||
connectionId: string,
|
||||
model: string | null | undefined,
|
||||
reason: string,
|
||||
cooldownMs: number,
|
||||
metadata: Partial<ModelLockoutEntry> = {}
|
||||
) {
|
||||
if (!model) return; // No model → skip model-level locking
|
||||
@@ -400,7 +422,11 @@ export function recordModelLockoutFailure(
|
||||
};
|
||||
}
|
||||
|
||||
export function clearModelLock(provider, connectionId, model) {
|
||||
export function clearModelLock(
|
||||
provider: string,
|
||||
connectionId: string,
|
||||
model: string | null | undefined
|
||||
) {
|
||||
if (!model) return false;
|
||||
const key = getModelLockKey(provider, connectionId, model);
|
||||
const hadLock = modelLockouts.delete(key);
|
||||
@@ -470,7 +496,11 @@ export function shouldMarkAccountExhaustedFrom429(
|
||||
* Check if a specific model on a specific account is locked
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isModelLocked(provider, connectionId, model) {
|
||||
export function isModelLocked(
|
||||
provider: string,
|
||||
connectionId: string,
|
||||
model: string | null | undefined
|
||||
) {
|
||||
if (!model) return false;
|
||||
const key = getModelLockKey(provider, connectionId, model);
|
||||
cleanupModelLockKey(key);
|
||||
@@ -481,7 +511,11 @@ export function isModelLocked(provider, connectionId, model) {
|
||||
/**
|
||||
* Get model lockout info (for debugging/dashboard)
|
||||
*/
|
||||
export function getModelLockoutInfo(provider, connectionId, model) {
|
||||
export function getModelLockoutInfo(
|
||||
provider: string,
|
||||
connectionId: string,
|
||||
model: string | null | undefined
|
||||
) {
|
||||
if (!model) return null;
|
||||
const key = getModelLockKey(provider, connectionId, model);
|
||||
cleanupModelLockKey(key);
|
||||
@@ -500,7 +534,7 @@ export function getModelLockoutInfo(provider, connectionId, model) {
|
||||
*/
|
||||
export function getAllModelLockouts() {
|
||||
const now = Date.now();
|
||||
const active: any[] = [];
|
||||
const active: ModelLockoutInfo[] = [];
|
||||
for (const key of modelLockouts.keys()) {
|
||||
cleanupModelLockKey(key, now);
|
||||
}
|
||||
@@ -513,6 +547,7 @@ export function getAllModelLockouts() {
|
||||
reason: entry.reason,
|
||||
remainingMs: entry.until - now,
|
||||
failureCount: entry.failureCount,
|
||||
lockedAt: new Date(entry.lockedAt).toISOString(),
|
||||
});
|
||||
}
|
||||
return active;
|
||||
@@ -668,10 +703,13 @@ export function isProviderFailureCode(status: number): boolean {
|
||||
* @param {string|object} responseBody - Raw response body or parsed JSON
|
||||
* @returns {{ retryAfterMs: number|null, reason: string }}
|
||||
*/
|
||||
export function parseRetryAfterFromBody(responseBody) {
|
||||
let body;
|
||||
export function parseRetryAfterFromBody(responseBody: string | JsonRecord | null | undefined) {
|
||||
let body: JsonRecord;
|
||||
try {
|
||||
body = typeof responseBody === "string" ? JSON.parse(responseBody) : responseBody;
|
||||
body =
|
||||
typeof responseBody === "string"
|
||||
? asJsonRecord(JSON.parse(responseBody))
|
||||
: asJsonRecord(responseBody);
|
||||
} catch {
|
||||
return { retryAfterMs: null, reason: RateLimitReason.UNKNOWN };
|
||||
}
|
||||
@@ -679,18 +717,20 @@ export function parseRetryAfterFromBody(responseBody) {
|
||||
if (!body) return { retryAfterMs: null, reason: RateLimitReason.UNKNOWN };
|
||||
|
||||
// Gemini: { error: { details: [{ retryDelay: "33s" }] } }
|
||||
const details = body.error?.details || body.details || [];
|
||||
const error = asJsonRecord(body.error);
|
||||
const details = error.details || body.details || [];
|
||||
for (const detail of Array.isArray(details) ? details : []) {
|
||||
if (detail.retryDelay) {
|
||||
const detailRecord = asJsonRecord(detail);
|
||||
if (detailRecord.retryDelay) {
|
||||
return {
|
||||
retryAfterMs: parseDelayString(detail.retryDelay),
|
||||
retryAfterMs: parseDelayString(detailRecord.retryDelay),
|
||||
reason: RateLimitReason.RATE_LIMIT_EXCEEDED,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI: "Please retry after 20s" in message
|
||||
const msg = body.error?.message || body.message || "";
|
||||
const msg = String(error.message || body.message || "");
|
||||
const retryMatch = msg.match(/retry\s+after\s+(\d+)\s*s/i);
|
||||
if (retryMatch) {
|
||||
return {
|
||||
@@ -700,7 +740,7 @@ export function parseRetryAfterFromBody(responseBody) {
|
||||
}
|
||||
|
||||
// Anthropic: error type classification
|
||||
const errorType = body.error?.type || body.type || "";
|
||||
const errorType = String(error.type || body.type || "");
|
||||
if (errorType === "rate_limit_error") {
|
||||
return { retryAfterMs: null, reason: RateLimitReason.RATE_LIMIT_EXCEEDED };
|
||||
}
|
||||
@@ -713,7 +753,7 @@ export function parseRetryAfterFromBody(responseBody) {
|
||||
/**
|
||||
* Parse delay strings like "33s", "2m", "1h", "1500ms"
|
||||
*/
|
||||
function parseDelayString(value) {
|
||||
function parseDelayString(value: unknown): number | null {
|
||||
if (!value) return null;
|
||||
const str = String(value).trim();
|
||||
const msMatch = str.match(/^(\d+)\s*ms$/i);
|
||||
@@ -737,7 +777,7 @@ function parseDelayString(value) {
|
||||
* @param {string} errorText - Error message text from response body
|
||||
* @returns {number|null} Retry duration in milliseconds
|
||||
*/
|
||||
export function parseRetryFromErrorText(errorText) {
|
||||
export function parseRetryFromErrorText(errorText: string | null | undefined): number | null {
|
||||
if (!errorText || typeof errorText !== "string") return null;
|
||||
|
||||
const match = errorText.match(/reset after (\d+h)?(\d+m)?(\d+s)?/i);
|
||||
@@ -754,7 +794,7 @@ export function parseRetryFromErrorText(errorText) {
|
||||
/**
|
||||
* Compute total milliseconds from regex match groups (Xh)(Ym)(Zs)
|
||||
*/
|
||||
function computeDurationMs(match) {
|
||||
function computeDurationMs(match: RegExpMatchArray): number | null {
|
||||
let totalMs = 0;
|
||||
if (match[1]) totalMs += parseInt(match[1], 10) * 3600 * 1000; // hours
|
||||
if (match[2]) totalMs += parseInt(match[2], 10) * 60 * 1000; // minutes
|
||||
@@ -767,7 +807,7 @@ function computeDurationMs(match) {
|
||||
/**
|
||||
* Classify error text into RateLimitReason
|
||||
*/
|
||||
export function classifyErrorText(errorText) {
|
||||
export function classifyErrorText(errorText: string | null | undefined) {
|
||||
if (!errorText) return RateLimitReason.UNKNOWN;
|
||||
const lower = String(errorText).toLowerCase();
|
||||
|
||||
@@ -810,7 +850,7 @@ export function classifyErrorText(errorText) {
|
||||
/**
|
||||
* Classify HTTP status + error text into RateLimitReason
|
||||
*/
|
||||
export function classifyError(status, errorText) {
|
||||
export function classifyError(status: number, errorText: string | null | undefined) {
|
||||
// Text classification takes priority (more specific)
|
||||
const textReason = classifyErrorText(errorText);
|
||||
if (textReason !== RateLimitReason.UNKNOWN) return textReason;
|
||||
@@ -876,7 +916,7 @@ export function isDailyQuotaExhausted(errorText: string): boolean {
|
||||
* @param {number} failureCount - Number of consecutive failures
|
||||
* @returns {number} Duration in ms
|
||||
*/
|
||||
export function getBackoffDuration(failureCount) {
|
||||
export function getBackoffDuration(failureCount: number) {
|
||||
const idx = Math.min(failureCount, BACKOFF_STEPS_MS.length - 1);
|
||||
return BACKOFF_STEPS_MS[idx];
|
||||
}
|
||||
@@ -908,7 +948,7 @@ export function checkFallbackError(
|
||||
backoffLevel: number = 0,
|
||||
_model: string | null = null,
|
||||
provider: string | null = null,
|
||||
headers: any = null,
|
||||
headers: RetryHintHeaders | null = null,
|
||||
profileOverride: ProviderProfile | null = null
|
||||
): {
|
||||
shouldFallback: boolean;
|
||||
@@ -933,32 +973,35 @@ export function checkFallbackError(
|
||||
HTTP_STATUS.GATEWAY_TIMEOUT,
|
||||
]);
|
||||
|
||||
function parseResetFromHeaders(headers) {
|
||||
function parseResetFromHeaders(headers: RetryHintHeaders | null) {
|
||||
if (!headers) return null;
|
||||
|
||||
// Retry-After header
|
||||
const retryAfter =
|
||||
typeof headers.get === "function"
|
||||
? headers.get("retry-after")
|
||||
: headers["retry-after"] || headers["Retry-After"];
|
||||
typeof (headers as Headers).get === "function"
|
||||
? (headers as Headers).get("retry-after")
|
||||
: (headers as Record<string, string | number | null | undefined>)["retry-after"] ||
|
||||
(headers as Record<string, string | number | null | undefined>)["Retry-After"];
|
||||
|
||||
if (retryAfter) {
|
||||
const seconds = parseInt(retryAfter, 10);
|
||||
if (!isNaN(seconds) && String(seconds) === String(retryAfter).trim()) {
|
||||
const retryAfterText = String(retryAfter);
|
||||
const seconds = parseInt(retryAfterText, 10);
|
||||
if (!isNaN(seconds) && String(seconds) === retryAfterText.trim()) {
|
||||
return Date.now() + seconds * 1000;
|
||||
}
|
||||
const date = new Date(retryAfter);
|
||||
const date = new Date(retryAfterText);
|
||||
if (!isNaN(date.getTime())) return date.getTime();
|
||||
}
|
||||
|
||||
// X-RateLimit-Reset
|
||||
const rlReset =
|
||||
typeof headers.get === "function"
|
||||
? headers.get("x-ratelimit-reset")
|
||||
: headers["x-ratelimit-reset"] || headers["X-RateLimit-Reset"];
|
||||
typeof (headers as Headers).get === "function"
|
||||
? (headers as Headers).get("x-ratelimit-reset")
|
||||
: (headers as Record<string, string | number | null | undefined>)["x-ratelimit-reset"] ||
|
||||
(headers as Record<string, string | number | null | undefined>)["X-RateLimit-Reset"];
|
||||
|
||||
if (rlReset) {
|
||||
const ts = parseInt(rlReset, 10);
|
||||
const ts = parseInt(String(rlReset), 10);
|
||||
if (!isNaN(ts)) {
|
||||
return ts > 10000000000 ? ts : ts * 1000;
|
||||
}
|
||||
@@ -982,7 +1025,7 @@ export function checkFallbackError(
|
||||
return null;
|
||||
}
|
||||
|
||||
function getScaledBaseCooldown(reason, level = backoffLevel) {
|
||||
function getScaledBaseCooldown(reason: string, level = backoffLevel) {
|
||||
const baseCooldownMs =
|
||||
typeof profile?.baseCooldownMs === "number" && profile.baseCooldownMs >= 0
|
||||
? profile.baseCooldownMs
|
||||
@@ -994,7 +1037,7 @@ export function checkFallbackError(
|
||||
};
|
||||
}
|
||||
|
||||
function buildRetryableFallback(reason) {
|
||||
function buildRetryableFallback(reason: string) {
|
||||
const upstreamRetryHintMs = getUpstreamRetryHintMs();
|
||||
if (typeof upstreamRetryHintMs === "number" && upstreamRetryHintMs > 0) {
|
||||
return {
|
||||
@@ -1122,7 +1165,7 @@ export function checkFallbackError(
|
||||
/**
|
||||
* Check if account is currently unavailable (cooldown not expired)
|
||||
*/
|
||||
export function isAccountUnavailable(unavailableUntil) {
|
||||
export function isAccountUnavailable(unavailableUntil: string | null | undefined) {
|
||||
if (!unavailableUntil) return false;
|
||||
return new Date(unavailableUntil).getTime() > Date.now();
|
||||
}
|
||||
@@ -1130,14 +1173,14 @@ export function isAccountUnavailable(unavailableUntil) {
|
||||
/**
|
||||
* Calculate unavailable until timestamp
|
||||
*/
|
||||
export function getUnavailableUntil(cooldownMs) {
|
||||
export function getUnavailableUntil(cooldownMs: number) {
|
||||
return new Date(Date.now() + cooldownMs).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the earliest rateLimitedUntil from a list of accounts
|
||||
*/
|
||||
export function getEarliestRateLimitedUntil(accounts) {
|
||||
export function getEarliestRateLimitedUntil(accounts: AccountCooldownState[]) {
|
||||
let earliest: number | null = null;
|
||||
const now = Date.now();
|
||||
for (const acc of accounts) {
|
||||
@@ -1153,7 +1196,7 @@ export function getEarliestRateLimitedUntil(accounts) {
|
||||
/**
|
||||
* Format rateLimitedUntil to human-readable "reset after Xm Ys"
|
||||
*/
|
||||
export function formatRetryAfter(rateLimitedUntil) {
|
||||
export function formatRetryAfter(rateLimitedUntil: string | null | undefined) {
|
||||
if (!rateLimitedUntil) return "";
|
||||
const diffMs = new Date(rateLimitedUntil).getTime() - Date.now();
|
||||
if (diffMs <= 0) return "reset after 0s";
|
||||
@@ -1171,7 +1214,10 @@ export function formatRetryAfter(rateLimitedUntil) {
|
||||
/**
|
||||
* Filter available accounts (not in cooldown)
|
||||
*/
|
||||
export function filterAvailableAccounts(accounts, excludeId = null) {
|
||||
export function filterAvailableAccounts(
|
||||
accounts: AccountCooldownState[],
|
||||
excludeId: string | null = null
|
||||
) {
|
||||
const now = Date.now();
|
||||
return accounts.filter((acc) => {
|
||||
if (excludeId && acc.id === excludeId) return false;
|
||||
@@ -1186,7 +1232,9 @@ export function filterAvailableAccounts(accounts, excludeId = null) {
|
||||
/**
|
||||
* Reset account state when request succeeds
|
||||
*/
|
||||
export function resetAccountState(account) {
|
||||
export function resetAccountState<TAccount extends AccountCooldownState | null | undefined>(
|
||||
account: TAccount
|
||||
) {
|
||||
if (!account) return account;
|
||||
return {
|
||||
...account,
|
||||
@@ -1200,7 +1248,12 @@ export function resetAccountState(account) {
|
||||
/**
|
||||
* Apply error state to account
|
||||
*/
|
||||
export function applyErrorState(account, status, errorText, provider = null) {
|
||||
export function applyErrorState<TAccount extends AccountCooldownState | null | undefined>(
|
||||
account: TAccount,
|
||||
status: number,
|
||||
errorText: string | null,
|
||||
provider: string | null = null
|
||||
) {
|
||||
if (!account) return account;
|
||||
|
||||
const backoffLevel = account.backoffLevel || 0;
|
||||
@@ -1223,7 +1276,10 @@ export function applyErrorState(account, status, errorText, provider = null) {
|
||||
* @param {object} account
|
||||
* @returns {number} score 0 = unhealthy, 100 = perfectly healthy
|
||||
*/
|
||||
export function getAccountHealth(account, model?: unknown) {
|
||||
export function getAccountHealth(
|
||||
account: AccountCooldownState | null | undefined,
|
||||
model?: unknown
|
||||
) {
|
||||
if (!account) return 0;
|
||||
let score = 100;
|
||||
score -= (account.backoffLevel || 0) * 10;
|
||||
|
||||
@@ -5,7 +5,9 @@ import { AutoVariant } from "./autoPrefix";
|
||||
import { getProviderConnections } from "@/lib/db/providers";
|
||||
import { getProviderRegistry } from "./providerRegistryAccessor";
|
||||
import type { ConnectionFields } from "@/lib/db/encryption";
|
||||
import { log } from "@omniroute/open-sse/utils/logger";
|
||||
import { createLogger } from "@omniroute/open-sse/utils/logger";
|
||||
|
||||
const log = createLogger();
|
||||
|
||||
/** Minimal connection shape needed for virtual auto-combo factory */
|
||||
interface VirtualFactoryConn extends ConnectionFields {
|
||||
|
||||
@@ -2,9 +2,24 @@ import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.
|
||||
import { ANTIGRAVITY_MODEL_ALIASES } from "../config/antigravityModelAliases.ts";
|
||||
import { resolveWildcardAlias } from "./wildcardRouter.ts";
|
||||
|
||||
type ProviderModelAliasMap = Record<string, Record<string, string>>;
|
||||
type ModelAliasValue = string | { provider?: string; model?: string };
|
||||
type ModelAliasMap = Record<string, ModelAliasValue>;
|
||||
type ParsedModel = {
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
isAlias: boolean;
|
||||
providerAlias: string | null;
|
||||
extendedContext: boolean;
|
||||
};
|
||||
type ResolvedModelTarget = {
|
||||
provider?: string | null;
|
||||
model: string | null;
|
||||
};
|
||||
|
||||
// Derive alias→provider mapping from the single source of truth (PROVIDER_ID_TO_ALIAS)
|
||||
// This prevents the two maps from drifting out of sync
|
||||
const ALIAS_TO_PROVIDER_ID = {};
|
||||
const ALIAS_TO_PROVIDER_ID: Record<string, string> = {};
|
||||
for (const [id, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
|
||||
if (ALIAS_TO_PROVIDER_ID[alias]) {
|
||||
console.log(
|
||||
@@ -16,7 +31,7 @@ for (const [id, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
|
||||
|
||||
// Provider-scoped legacy model aliases. Used to normalize provider/model inputs
|
||||
// and keep backward compatibility when upstream IDs change.
|
||||
const PROVIDER_MODEL_ALIASES = {
|
||||
const PROVIDER_MODEL_ALIASES: ProviderModelAliasMap = {
|
||||
github: {
|
||||
"claude-4.5-opus": "claude-opus-4-5-20251101",
|
||||
"claude-opus-4.5": "claude-opus-4-5-20251101",
|
||||
@@ -39,10 +54,10 @@ const PROVIDER_MODEL_ALIASES = {
|
||||
"gpt-oss-20b": "openai/gpt-oss-20b",
|
||||
"nvidia/gpt-oss-20b": "openai/gpt-oss-20b",
|
||||
},
|
||||
antigravity: ANTIGRAVITY_MODEL_ALIASES,
|
||||
antigravity: { ...ANTIGRAVITY_MODEL_ALIASES },
|
||||
};
|
||||
|
||||
const CROSS_PROXY_MODEL_ALIASES = {
|
||||
const CROSS_PROXY_MODEL_ALIASES: Record<string, string> = {
|
||||
"gpt-oss:120b": "gpt-oss-120b",
|
||||
"deepseek-v3.2-chat": "deepseek-v3.2",
|
||||
"deepseek-v3-2": "deepseek-v3.2",
|
||||
@@ -59,7 +74,7 @@ const CROSS_PROXY_MODEL_ALIASES_LOWER = Object.fromEntries(
|
||||
);
|
||||
|
||||
// Reverse index: modelId -> providerIds that expose this model
|
||||
const MODEL_TO_PROVIDERS = new Map();
|
||||
const MODEL_TO_PROVIDERS = new Map<string, string[]>();
|
||||
for (const [aliasOrId, models] of Object.entries(PROVIDER_MODELS)) {
|
||||
const providerId = ALIAS_TO_PROVIDER_ID[aliasOrId] || aliasOrId;
|
||||
for (const modelEntry of models || []) {
|
||||
@@ -86,7 +101,8 @@ interface ProviderConnectionLike {
|
||||
/**
|
||||
* Resolve provider alias to provider ID
|
||||
*/
|
||||
export function resolveProviderAlias(aliasOrId) {
|
||||
export function resolveProviderAlias(aliasOrId: string | null | undefined): string | null {
|
||||
if (typeof aliasOrId !== "string") return null;
|
||||
return ALIAS_TO_PROVIDER_ID[aliasOrId] || aliasOrId;
|
||||
}
|
||||
|
||||
@@ -95,9 +111,17 @@ function isCrossProxyModelCompatEnabled() {
|
||||
return raw !== "false" && raw !== "0";
|
||||
}
|
||||
|
||||
export function normalizeCrossProxyModelId(modelId) {
|
||||
export function normalizeCrossProxyModelId(modelId: unknown): {
|
||||
modelId: string | null;
|
||||
applied: boolean;
|
||||
original: string | null;
|
||||
} {
|
||||
if (!modelId || typeof modelId !== "string" || !isCrossProxyModelCompatEnabled()) {
|
||||
return { modelId, applied: false, original: null };
|
||||
return {
|
||||
modelId: typeof modelId === "string" ? modelId : null,
|
||||
applied: false,
|
||||
original: null,
|
||||
};
|
||||
}
|
||||
|
||||
const normalized =
|
||||
@@ -114,17 +138,22 @@ export function normalizeCrossProxyModelId(modelId) {
|
||||
/**
|
||||
* Resolve provider-specific legacy model alias to canonical model ID.
|
||||
*/
|
||||
function resolveProviderModelAlias(providerOrAlias, modelId) {
|
||||
function resolveProviderModelAlias(
|
||||
providerOrAlias: string | null | undefined,
|
||||
modelId: string | null | undefined
|
||||
) {
|
||||
if (!modelId || typeof modelId !== "string") return modelId;
|
||||
const providerId = resolveProviderAlias(providerOrAlias);
|
||||
if (typeof providerId !== "string") return modelId;
|
||||
const aliases = PROVIDER_MODEL_ALIASES[providerId];
|
||||
return aliases?.[modelId] || modelId;
|
||||
}
|
||||
|
||||
function hasKnownProviderModel(providerOrAlias, modelId) {
|
||||
function hasKnownProviderModel(providerOrAlias: string | null | undefined, modelId: string | null) {
|
||||
if (!providerOrAlias || !modelId) return false;
|
||||
|
||||
const providerId = resolveProviderAlias(providerOrAlias);
|
||||
if (typeof providerId !== "string") return false;
|
||||
const providerAlias = PROVIDER_ID_TO_ALIAS[providerId] || providerId;
|
||||
const models = PROVIDER_MODELS[providerAlias] || PROVIDER_MODELS[providerId] || [];
|
||||
|
||||
@@ -134,7 +163,7 @@ function hasKnownProviderModel(providerOrAlias, modelId) {
|
||||
return canonicalModel !== modelId && models.some((entry) => entry?.id === canonicalModel);
|
||||
}
|
||||
|
||||
function hasCodexPreferredUnprefixedModel(modelId) {
|
||||
function hasCodexPreferredUnprefixedModel(modelId: string) {
|
||||
const canonicalModel = CODEX_PREFERRED_UNPREFIXED_MODEL_ALIASES.get(modelId);
|
||||
if (!canonicalModel) return false;
|
||||
|
||||
@@ -143,7 +172,7 @@ function hasCodexPreferredUnprefixedModel(modelId) {
|
||||
return models.some((entry) => entry?.id === canonicalModel);
|
||||
}
|
||||
|
||||
function resolveInferredProviderModel(provider, modelId) {
|
||||
function resolveInferredProviderModel(provider: string, modelId: string) {
|
||||
const codexPreferredModel = CODEX_PREFERRED_UNPREFIXED_MODEL_ALIASES.get(modelId);
|
||||
if (provider === "codex" && codexPreferredModel) {
|
||||
return codexPreferredModel;
|
||||
@@ -151,7 +180,7 @@ function resolveInferredProviderModel(provider, modelId) {
|
||||
return resolveProviderModelAlias(provider, modelId);
|
||||
}
|
||||
|
||||
function getInferredProvidersForModel(modelId) {
|
||||
function getInferredProvidersForModel(modelId: string) {
|
||||
const providers = [...(MODEL_TO_PROVIDERS.get(modelId) || [])];
|
||||
|
||||
if (
|
||||
@@ -196,7 +225,7 @@ async function getActiveProviderSet() {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldTreatAsExactModelId(modelStr) {
|
||||
function shouldTreatAsExactModelId(modelStr: string | null) {
|
||||
if (!modelStr || typeof modelStr !== "string" || !modelStr.includes("/")) return false;
|
||||
if (!KNOWN_MODEL_IDS.has(modelStr)) return false;
|
||||
|
||||
@@ -210,7 +239,10 @@ function shouldTreatAsExactModelId(modelStr) {
|
||||
* Resolve a provider/model pair into canonical provider ID + provider-scoped model ID.
|
||||
* Keeps provider-specific legacy aliases out of downstream capability and budget lookups.
|
||||
*/
|
||||
export function resolveCanonicalProviderModel(providerOrAlias, modelId) {
|
||||
export function resolveCanonicalProviderModel(
|
||||
providerOrAlias: string | null | undefined,
|
||||
modelId: string | null | undefined
|
||||
) {
|
||||
if (!modelId || typeof modelId !== "string") {
|
||||
return {
|
||||
provider: resolveProviderAlias(providerOrAlias),
|
||||
@@ -229,7 +261,7 @@ export function resolveCanonicalProviderModel(providerOrAlias, modelId) {
|
||||
* Parse model string: "alias/model" or "provider/model" or just alias
|
||||
* Supports [1m] suffix for extended 1M context window (e.g. "claude-sonnet-4-6[1m]")
|
||||
*/
|
||||
export function parseModel(modelStr) {
|
||||
export function parseModel(modelStr: string | null | undefined): ParsedModel {
|
||||
if (!modelStr) {
|
||||
return {
|
||||
provider: null,
|
||||
@@ -264,7 +296,7 @@ export function parseModel(modelStr) {
|
||||
// Normalize known cross-proxy provider/model dialects before deciding whether
|
||||
// the slash belongs to a provider prefix or to the model ID itself.
|
||||
if (cleanStr.includes("/")) {
|
||||
cleanStr = normalizeCrossProxyModelId(cleanStr).modelId;
|
||||
cleanStr = normalizeCrossProxyModelId(cleanStr).modelId || cleanStr;
|
||||
}
|
||||
|
||||
if (shouldTreatAsExactModelId(cleanStr)) {
|
||||
@@ -289,7 +321,7 @@ export function parseModel(modelStr) {
|
||||
* Resolve model alias from aliases object
|
||||
* Format: { "alias": "provider/model" }
|
||||
*/
|
||||
export function resolveModelAliasFromMap(alias, aliases) {
|
||||
export function resolveModelAliasFromMap(alias: string | null, aliases: ModelAliasMap | null) {
|
||||
const resolved = resolveModelAliasTarget(alias, aliases);
|
||||
if (!resolved?.provider) return null;
|
||||
return {
|
||||
@@ -298,8 +330,11 @@ export function resolveModelAliasFromMap(alias, aliases) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveModelAliasTarget(alias, aliases) {
|
||||
if (!aliases) return null;
|
||||
function resolveModelAliasTarget(
|
||||
alias: string | null,
|
||||
aliases: ModelAliasMap | null
|
||||
): ResolvedModelTarget | null {
|
||||
if (!alias || !aliases) return null;
|
||||
|
||||
const resolved = aliases[alias];
|
||||
if (!resolved) return null;
|
||||
@@ -308,24 +343,29 @@ function resolveModelAliasTarget(alias, aliases) {
|
||||
return parseAliasTarget(resolved);
|
||||
}
|
||||
|
||||
if (typeof resolved === "object" && resolved.provider && resolved.model) {
|
||||
if (
|
||||
resolved &&
|
||||
typeof resolved === "object" &&
|
||||
typeof resolved.provider === "string" &&
|
||||
typeof resolved.model === "string"
|
||||
) {
|
||||
const normalizedPair = normalizeCrossProxyModelId(
|
||||
`${resolved.provider}/${resolved.model}`
|
||||
).modelId;
|
||||
if (normalizedPair !== `${resolved.provider}/${resolved.model}`) {
|
||||
if (normalizedPair && normalizedPair !== `${resolved.provider}/${resolved.model}`) {
|
||||
return parseAliasTarget(normalizedPair);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: resolveProviderAlias(resolved.provider),
|
||||
model: normalizeCrossProxyModelId(resolved.model).modelId,
|
||||
model: normalizeCrossProxyModelId(resolved.model).modelId || resolved.model,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAliasTarget(target) {
|
||||
function parseAliasTarget(target: string): ResolvedModelTarget | null {
|
||||
const normalizedTarget = normalizeCrossProxyModelId(target).modelId;
|
||||
if (!normalizedTarget || typeof normalizedTarget !== "string") return null;
|
||||
|
||||
@@ -344,7 +384,7 @@ function parseAliasTarget(target) {
|
||||
return { model: normalizedTarget };
|
||||
}
|
||||
|
||||
async function resolveModelByProviderInference(modelId, extendedContext) {
|
||||
async function resolveModelByProviderInference(modelId: string, extendedContext: boolean) {
|
||||
const providers = getInferredProvidersForModel(modelId);
|
||||
|
||||
const nonOpenAIProviders = providers.filter((p) => p !== "openai");
|
||||
@@ -429,7 +469,10 @@ async function resolveModelByProviderInference(modelId, extendedContext) {
|
||||
* @param {string} modelStr - Model string
|
||||
* @param {object|function} aliasesOrGetter - Aliases object or async function to get aliases
|
||||
*/
|
||||
export async function getModelInfoCore(modelStr, aliasesOrGetter) {
|
||||
export async function getModelInfoCore(
|
||||
modelStr: string,
|
||||
aliasesOrGetter: ModelAliasMap | (() => Promise<ModelAliasMap>) | null
|
||||
) {
|
||||
const parsed = parseModel(modelStr);
|
||||
const { extendedContext } = parsed;
|
||||
|
||||
@@ -464,9 +507,9 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
|
||||
if (aliases && typeof aliases === "object") {
|
||||
const aliasEntries = Object.entries(aliases).map(([pattern, target]) => ({
|
||||
pattern,
|
||||
target: target as string,
|
||||
target: typeof target === "string" ? target : "",
|
||||
}));
|
||||
const wildcardMatch = resolveWildcardAlias(parsed.model, aliasEntries);
|
||||
const wildcardMatch = parsed.model ? resolveWildcardAlias(parsed.model, aliasEntries) : null;
|
||||
if (wildcardMatch) {
|
||||
const target = wildcardMatch.target as string;
|
||||
if (target.includes("/")) {
|
||||
@@ -486,5 +529,8 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
|
||||
}
|
||||
|
||||
const normalizedModelId = normalizeCrossProxyModelId(parsed.model).modelId;
|
||||
if (!normalizedModelId) {
|
||||
return { provider: null, model: null, extendedContext };
|
||||
}
|
||||
return await resolveModelByProviderInference(normalizedModelId, extendedContext);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,14 @@ export const ThinkingMode = {
|
||||
CUSTOM: "custom", // Set fixed budget
|
||||
ADAPTIVE: "adaptive", // Scale based on request complexity
|
||||
};
|
||||
export type ThinkingModeValue = (typeof ThinkingMode)[keyof typeof ThinkingMode];
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type ThinkingBudgetConfig = {
|
||||
mode: ThinkingModeValue;
|
||||
customBudget: number;
|
||||
effortLevel: string;
|
||||
};
|
||||
|
||||
import {
|
||||
capThinkingBudget,
|
||||
@@ -21,7 +29,7 @@ import {
|
||||
} from "@/lib/modelCapabilities";
|
||||
|
||||
// Effort → budget token mapping
|
||||
export const EFFORT_BUDGETS = {
|
||||
export const EFFORT_BUDGETS: Record<string, number> = {
|
||||
none: 0,
|
||||
low: 1024,
|
||||
medium: 10240,
|
||||
@@ -32,7 +40,7 @@ export const EFFORT_BUDGETS = {
|
||||
|
||||
// thinkingLevel string → budget token mapping
|
||||
// Used when clients send string-based thinking levels (e.g., VS Code Copilot)
|
||||
export const THINKING_LEVEL_MAP = {
|
||||
export const THINKING_LEVEL_MAP: Record<string, number> = {
|
||||
none: 0,
|
||||
low: 4096,
|
||||
medium: 8192,
|
||||
@@ -46,15 +54,24 @@ export const DEFAULT_THINKING_CONFIG = {
|
||||
mode: ThinkingMode.PASSTHROUGH,
|
||||
customBudget: 10240,
|
||||
effortLevel: "medium",
|
||||
};
|
||||
} satisfies ThinkingBudgetConfig;
|
||||
|
||||
// In-memory config (loaded from DB on startup, or default)
|
||||
let _config = { ...DEFAULT_THINKING_CONFIG };
|
||||
let _config: ThinkingBudgetConfig = { ...DEFAULT_THINKING_CONFIG };
|
||||
|
||||
function toRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function getStringField(record: JsonRecord, key: string): string {
|
||||
const value = record[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the thinking budget config (called from settings API or startup)
|
||||
*/
|
||||
export function setThinkingBudgetConfig(config) {
|
||||
export function setThinkingBudgetConfig(config: Partial<ThinkingBudgetConfig>) {
|
||||
_config = { ...DEFAULT_THINKING_CONFIG, ...config };
|
||||
}
|
||||
|
||||
@@ -73,15 +90,15 @@ export function getThinkingBudgetConfig() {
|
||||
* @param {object} body - Request body
|
||||
* @returns {object} Body with string thinkingLevel converted to numeric budget
|
||||
*/
|
||||
export function normalizeThinkingLevel(body) {
|
||||
export function normalizeThinkingLevel(body: unknown) {
|
||||
if (!body || typeof body !== "object") return body;
|
||||
const result = { ...body };
|
||||
const result: JsonRecord = { ...(body as JsonRecord) };
|
||||
|
||||
// Handle top-level thinkingLevel or thinking_level string fields
|
||||
const levelStr = result.thinkingLevel || result.thinking_level;
|
||||
if (typeof levelStr === "string" && THINKING_LEVEL_MAP[levelStr.toLowerCase()] !== undefined) {
|
||||
const rawBudget = THINKING_LEVEL_MAP[levelStr.toLowerCase()];
|
||||
const budget = capThinkingBudget(result.model || "", rawBudget);
|
||||
const budget = capThinkingBudget(getStringField(result, "model"), rawBudget);
|
||||
// Convert to Claude thinking format as canonical representation
|
||||
result.thinking = {
|
||||
type: budget > 0 ? "enabled" : "disabled",
|
||||
@@ -92,25 +109,29 @@ export function normalizeThinkingLevel(body) {
|
||||
}
|
||||
|
||||
// Handle Gemini's generationConfig.thinkingConfig.thinkingLevel
|
||||
const geminiLevel =
|
||||
result.generationConfig?.thinkingConfig?.thinkingLevel ||
|
||||
result.generationConfig?.thinking_config?.thinkingLevel;
|
||||
const generationConfig = toRecord(result.generationConfig);
|
||||
const thinkingConfig = toRecord(generationConfig.thinkingConfig);
|
||||
const thinkingConfigSnake = toRecord(generationConfig.thinking_config);
|
||||
const geminiLevel = thinkingConfig.thinkingLevel || thinkingConfigSnake.thinkingLevel;
|
||||
if (
|
||||
typeof geminiLevel === "string" &&
|
||||
THINKING_LEVEL_MAP[geminiLevel.toLowerCase()] !== undefined
|
||||
) {
|
||||
const rawBudget = THINKING_LEVEL_MAP[geminiLevel.toLowerCase()];
|
||||
const budget = capThinkingBudget(result.model || "", rawBudget);
|
||||
const budget = capThinkingBudget(getStringField(result, "model"), rawBudget);
|
||||
result.generationConfig = {
|
||||
...result.generationConfig,
|
||||
thinkingConfig: { ...result.generationConfig.thinkingConfig, thinkingBudget: budget },
|
||||
...generationConfig,
|
||||
thinkingConfig: { ...thinkingConfig, thinkingBudget: budget },
|
||||
};
|
||||
// Clean up string variants
|
||||
if (result.generationConfig.thinkingConfig) {
|
||||
delete result.generationConfig.thinkingConfig.thinkingLevel;
|
||||
const nextGenerationConfig = result.generationConfig as JsonRecord;
|
||||
const nextThinkingConfig = toRecord(nextGenerationConfig.thinkingConfig);
|
||||
if (Object.keys(nextThinkingConfig).length > 0) {
|
||||
delete nextThinkingConfig.thinkingLevel;
|
||||
nextGenerationConfig.thinkingConfig = nextThinkingConfig;
|
||||
}
|
||||
if (result.generationConfig.thinking_config) {
|
||||
delete result.generationConfig.thinking_config;
|
||||
if ("thinking_config" in nextGenerationConfig) {
|
||||
delete nextGenerationConfig.thinking_config;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,17 +145,18 @@ export function normalizeThinkingLevel(body) {
|
||||
* @param {object} body - Request body
|
||||
* @returns {object} Body with thinking config auto-injected if needed
|
||||
*/
|
||||
export function ensureThinkingConfig(body) {
|
||||
export function ensureThinkingConfig(body: unknown) {
|
||||
if (!body || typeof body !== "object") return body;
|
||||
const model = body.model || "";
|
||||
const bodyRecord = body as JsonRecord;
|
||||
const model = getStringField(bodyRecord, "model");
|
||||
|
||||
// Only auto-inject for models with -thinking suffix
|
||||
if (!model.endsWith("-thinking")) return body;
|
||||
|
||||
// If thinking config already present, don't override
|
||||
if (body.thinking) return body;
|
||||
if (bodyRecord.thinking) return body;
|
||||
|
||||
const result = { ...body };
|
||||
const result: JsonRecord = { ...bodyRecord };
|
||||
result.thinking = {
|
||||
type: "enabled",
|
||||
budget_tokens: getDefaultThinkingBudget(model) || EFFORT_BUDGETS.medium,
|
||||
@@ -152,13 +174,17 @@ export function ensureThinkingConfig(body) {
|
||||
* @param {object} [config] - Override config (defaults to stored config)
|
||||
* @returns {object} Modified body
|
||||
*/
|
||||
export function applyThinkingBudget(body, config = null) {
|
||||
export function applyThinkingBudget(
|
||||
body: unknown,
|
||||
config: Partial<ThinkingBudgetConfig> | null = null
|
||||
) {
|
||||
const cfg = config || _config;
|
||||
if (!body || typeof body !== "object") return body;
|
||||
|
||||
// Early exit: strip ALL reasoning/thinking params for models that don't support them.
|
||||
// Provider-specific Cloud Code restrictions should be handled at the executor boundary.
|
||||
const modelStr = typeof body.model === "string" ? body.model : "";
|
||||
const bodyRecord = body as JsonRecord;
|
||||
const modelStr = typeof bodyRecord.model === "string" ? bodyRecord.model : "";
|
||||
if (modelStr && !supportsReasoning(modelStr)) {
|
||||
return stripThinkingConfig(body);
|
||||
}
|
||||
@@ -177,7 +203,7 @@ export function applyThinkingBudget(body, config = null) {
|
||||
return processed;
|
||||
|
||||
case ThinkingMode.CUSTOM:
|
||||
return setCustomBudget(processed, cfg.customBudget);
|
||||
return setCustomBudget(processed, cfg.customBudget ?? DEFAULT_THINKING_CONFIG.customBudget);
|
||||
|
||||
case ThinkingMode.ADAPTIVE:
|
||||
return applyAdaptiveBudget(processed, cfg);
|
||||
@@ -190,8 +216,8 @@ export function applyThinkingBudget(body, config = null) {
|
||||
/**
|
||||
* AUTO mode: strip all thinking configuration, let provider decide
|
||||
*/
|
||||
function stripThinkingConfig(body) {
|
||||
const result = { ...body };
|
||||
function stripThinkingConfig(body: unknown) {
|
||||
const result: JsonRecord = { ...toRecord(body) };
|
||||
|
||||
// Claude format
|
||||
delete result.thinking;
|
||||
@@ -202,9 +228,10 @@ function stripThinkingConfig(body) {
|
||||
|
||||
// Gemini format
|
||||
if (result.generationConfig) {
|
||||
result.generationConfig = { ...result.generationConfig };
|
||||
delete result.generationConfig.thinking_config;
|
||||
delete result.generationConfig.thinkingConfig;
|
||||
const generationConfig = { ...toRecord(result.generationConfig) };
|
||||
delete generationConfig.thinking_config;
|
||||
delete generationConfig.thinkingConfig;
|
||||
result.generationConfig = generationConfig;
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -213,8 +240,8 @@ function stripThinkingConfig(body) {
|
||||
/**
|
||||
* CUSTOM mode: set exact budget tokens
|
||||
*/
|
||||
function setCustomBudget(body, budget) {
|
||||
const result = { ...body };
|
||||
function setCustomBudget(body: unknown, budget: number) {
|
||||
const result: JsonRecord = { ...toRecord(body) };
|
||||
|
||||
// If body already has thinking config in Claude format, update it
|
||||
if (result.thinking || hasThinkingCapableModel(result)) {
|
||||
@@ -242,9 +269,10 @@ function setCustomBudget(body, budget) {
|
||||
}
|
||||
|
||||
// Gemini thinking_config
|
||||
if (result.generationConfig?.thinking_config || result.generationConfig?.thinkingConfig) {
|
||||
const generationConfig = toRecord(result.generationConfig);
|
||||
if (generationConfig.thinking_config || generationConfig.thinkingConfig) {
|
||||
result.generationConfig = {
|
||||
...result.generationConfig,
|
||||
...generationConfig,
|
||||
thinking_config: { thinking_budget: budget },
|
||||
};
|
||||
}
|
||||
@@ -255,21 +283,27 @@ function setCustomBudget(body, budget) {
|
||||
/**
|
||||
* ADAPTIVE mode: scale budget based on request complexity
|
||||
*/
|
||||
function applyAdaptiveBudget(body, cfg) {
|
||||
const messages = body.messages || body.input || [];
|
||||
function applyAdaptiveBudget(body: unknown, cfg: Partial<ThinkingBudgetConfig>) {
|
||||
const bodyRecord = toRecord(body);
|
||||
const messages = Array.isArray(bodyRecord.messages)
|
||||
? bodyRecord.messages
|
||||
: Array.isArray(bodyRecord.input)
|
||||
? bodyRecord.input
|
||||
: [];
|
||||
const messageCount = messages.length;
|
||||
const tools = body.tools || [];
|
||||
const tools = Array.isArray(bodyRecord.tools) ? bodyRecord.tools : [];
|
||||
const toolCount = tools.length;
|
||||
|
||||
// Get last user message length
|
||||
let lastMsgLength = 0;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.role === "user") {
|
||||
const msgRecord = toRecord(msg);
|
||||
if (msgRecord.role === "user") {
|
||||
lastMsgLength =
|
||||
typeof msg.content === "string"
|
||||
? msg.content.length
|
||||
: JSON.stringify(msg.content || "").length;
|
||||
typeof msgRecord.content === "string"
|
||||
? msgRecord.content.length
|
||||
: JSON.stringify(msgRecord.content || "").length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -281,10 +315,13 @@ function applyAdaptiveBudget(body, cfg) {
|
||||
if (lastMsgLength > 2000) multiplier += 0.3;
|
||||
|
||||
const baseBudget =
|
||||
EFFORT_BUDGETS[cfg.effortLevel] ||
|
||||
getDefaultThinkingBudget(body.model || "") ||
|
||||
EFFORT_BUDGETS[typeof cfg.effortLevel === "string" ? cfg.effortLevel : "medium"] ||
|
||||
getDefaultThinkingBudget(getStringField(bodyRecord, "model")) ||
|
||||
EFFORT_BUDGETS.medium;
|
||||
const budget = capThinkingBudget(body.model || "", Math.ceil(baseBudget * multiplier));
|
||||
const budget = capThinkingBudget(
|
||||
getStringField(bodyRecord, "model"),
|
||||
Math.ceil(baseBudget * multiplier)
|
||||
);
|
||||
|
||||
return setCustomBudget(body, budget);
|
||||
}
|
||||
@@ -292,8 +329,8 @@ function applyAdaptiveBudget(body, cfg) {
|
||||
/**
|
||||
* Check if model name suggests thinking capability
|
||||
*/
|
||||
export function hasThinkingCapableModel(body) {
|
||||
const model = body.model || "";
|
||||
export function hasThinkingCapableModel(body: unknown) {
|
||||
const model = getStringField(toRecord(body), "model");
|
||||
const resolved = getResolvedModelCapabilities(model);
|
||||
if (resolved.supportsThinking === true) return true;
|
||||
if (resolved.supportsThinking === false) return false;
|
||||
|
||||
@@ -100,6 +100,15 @@ const MINIMAX_USAGE_CONFIG = {
|
||||
} as const;
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type ProviderUsageConnection = {
|
||||
id?: string;
|
||||
provider?: string;
|
||||
accessToken?: string | null;
|
||||
apiKey?: string | null;
|
||||
providerSpecificData?: JsonRecord | null;
|
||||
projectId?: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
type UsageQuota = {
|
||||
used: number;
|
||||
total: number;
|
||||
@@ -644,10 +653,13 @@ async function getGlmUsage(apiKey: string, providerSpecificData?: Record<string,
|
||||
resetAt,
|
||||
displayName: getGlmQuotaDisplayName(quotaName),
|
||||
details: Array.isArray(src.models)
|
||||
? src.models.map((m: any) => ({
|
||||
name: String(m.model || ""),
|
||||
used: toNumber(m.percentage, 0),
|
||||
}))
|
||||
? src.models.map((model) => {
|
||||
const modelRecord = toRecord(model);
|
||||
return {
|
||||
name: String(modelRecord.model || ""),
|
||||
used: toNumber(modelRecord.percentage, 0),
|
||||
};
|
||||
})
|
||||
: [],
|
||||
unlimited: false,
|
||||
};
|
||||
@@ -1002,7 +1014,10 @@ async function getCursorUsage(accessToken: string, providerSpecificData?: unknow
|
||||
* @param {Object} connection - Provider connection with accessToken
|
||||
* @returns {Promise<unknown>} Usage data with quotas
|
||||
*/
|
||||
export async function getUsageForProvider(connection, options: { forceRefresh?: boolean } = {}) {
|
||||
export async function getUsageForProvider(
|
||||
connection: ProviderUsageConnection,
|
||||
options: { forceRefresh?: boolean } = {}
|
||||
) {
|
||||
const { id, provider, accessToken, apiKey, providerSpecificData, projectId, email } = connection;
|
||||
|
||||
switch (provider) {
|
||||
@@ -1015,9 +1030,9 @@ export async function getUsageForProvider(connection, options: { forceRefresh?:
|
||||
case "claude":
|
||||
return await getClaudeUsage(accessToken);
|
||||
case "codex":
|
||||
return await getCodexUsage(accessToken, providerSpecificData);
|
||||
return await getCodexUsage(accessToken, providerSpecificData || {});
|
||||
case "cursor":
|
||||
return await getCursorUsage(accessToken, providerSpecificData);
|
||||
return await getCursorUsage(accessToken || "", providerSpecificData);
|
||||
case "kiro":
|
||||
case "amazon-q":
|
||||
return await getKiroUsage(accessToken, providerSpecificData);
|
||||
@@ -1031,21 +1046,25 @@ export async function getUsageForProvider(connection, options: { forceRefresh?:
|
||||
case "glm-cn":
|
||||
case "zai":
|
||||
case "glmt":
|
||||
return await getGlmUsage(apiKey, {
|
||||
return await getGlmUsage(apiKey || "", {
|
||||
...(providerSpecificData || {}),
|
||||
...(provider === "glm-cn" ? { apiRegion: "china" } : {}),
|
||||
});
|
||||
case "minimax":
|
||||
case "minimax-cn":
|
||||
return await getMiniMaxUsage(apiKey, provider);
|
||||
return await getMiniMaxUsage(apiKey || "", provider);
|
||||
case "crof":
|
||||
return await getCrofUsage(apiKey);
|
||||
return await getCrofUsage(apiKey || "");
|
||||
case "bailian-coding-plan":
|
||||
return await getBailianCodingPlanUsage(id, apiKey, providerSpecificData);
|
||||
return await getBailianCodingPlanUsage(
|
||||
id || "",
|
||||
apiKey || "",
|
||||
providerSpecificData || undefined
|
||||
);
|
||||
case "nanogpt":
|
||||
return await getNanoGptUsage(apiKey);
|
||||
return await getNanoGptUsage(apiKey || "");
|
||||
case "deepseek":
|
||||
return await getDeepseekUsage(id, apiKey);
|
||||
return await getDeepseekUsage(id || "", apiKey || "");
|
||||
default:
|
||||
return { message: `Usage API not implemented for ${provider}` };
|
||||
}
|
||||
@@ -1055,7 +1074,7 @@ export async function getUsageForProvider(connection, options: { forceRefresh?:
|
||||
* Parse reset date/time to ISO string
|
||||
* Handles multiple formats: Unix timestamp (ms), ISO date string, etc.
|
||||
*/
|
||||
function parseResetTime(resetValue) {
|
||||
function parseResetTime(resetValue: unknown): string | null {
|
||||
if (!resetValue) return null;
|
||||
|
||||
try {
|
||||
@@ -1083,7 +1102,10 @@ function parseResetTime(resetValue) {
|
||||
* GitHub Copilot Usage
|
||||
* Uses GitHub accessToken (not copilotToken) to call copilot_internal/user API
|
||||
*/
|
||||
async function getGitHubUsage(accessToken, providerSpecificData) {
|
||||
async function getGitHubUsage(
|
||||
accessToken?: string | null,
|
||||
providerSpecificData: JsonRecord | null = null
|
||||
) {
|
||||
try {
|
||||
if (!accessToken) {
|
||||
throw new Error("No GitHub access token available. Please re-authorize the connection.");
|
||||
@@ -1179,7 +1201,10 @@ async function getGitHubUsage(accessToken, providerSpecificData) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatGitHubQuotaSnapshot(quota, resetAt: string | null = null): UsageQuota | null {
|
||||
function formatGitHubQuotaSnapshot(
|
||||
quota: unknown,
|
||||
resetAt: string | null = null
|
||||
): UsageQuota | null {
|
||||
const source = toRecord(quota);
|
||||
if (Object.keys(source).length === 0) return null;
|
||||
|
||||
@@ -1275,7 +1300,7 @@ function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null):
|
||||
// ── Gemini CLI subscription info cache ──────────────────────────────────────
|
||||
// Prevents duplicate loadCodeAssist calls within the same quota cycle.
|
||||
// Key: accessToken → { data, fetchedAt }
|
||||
const _geminiCliSubCache = new Map();
|
||||
const _geminiCliSubCache = new Map<string, { data: unknown; fetchedAt: number }>();
|
||||
const GEMINI_CLI_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
@@ -1283,17 +1308,22 @@ const GEMINI_CLI_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
* Gemini CLI and Antigravity share the same upstream (cloudcode-pa.googleapis.com),
|
||||
* so this follows the same pattern as getAntigravityUsage().
|
||||
*/
|
||||
async function getGeminiUsage(accessToken, providerSpecificData?, connectionProjectId?) {
|
||||
async function getGeminiUsage(
|
||||
accessToken?: string | null,
|
||||
providerSpecificData: JsonRecord | null = null,
|
||||
connectionProjectId?: string | null
|
||||
) {
|
||||
if (!accessToken) {
|
||||
return { plan: "Free", message: "Gemini CLI access token not available." };
|
||||
}
|
||||
|
||||
try {
|
||||
const subscriptionInfo = await getGeminiCliSubscriptionInfoCached(accessToken);
|
||||
const subscriptionRecord = toRecord(subscriptionInfo);
|
||||
const projectId =
|
||||
connectionProjectId ||
|
||||
providerSpecificData?.projectId ||
|
||||
subscriptionInfo?.cloudaicompanionProject ||
|
||||
subscriptionRecord.cloudaicompanionProject ||
|
||||
null;
|
||||
|
||||
const plan = getGeminiCliPlanLabel(subscriptionInfo);
|
||||
@@ -1354,7 +1384,7 @@ async function getGeminiUsage(accessToken, providerSpecificData?, connectionProj
|
||||
/**
|
||||
* Get Gemini CLI subscription info (cached, 5 min TTL)
|
||||
*/
|
||||
async function getGeminiCliSubscriptionInfoCached(accessToken) {
|
||||
async function getGeminiCliSubscriptionInfoCached(accessToken: string) {
|
||||
const cacheKey = accessToken;
|
||||
const cached = _geminiCliSubCache.get(cacheKey);
|
||||
|
||||
@@ -1370,7 +1400,7 @@ async function getGeminiCliSubscriptionInfoCached(accessToken) {
|
||||
/**
|
||||
* Get Gemini CLI subscription info using correct headers.
|
||||
*/
|
||||
async function getGeminiCliSubscriptionInfo(accessToken) {
|
||||
async function getGeminiCliSubscriptionInfo(accessToken: string): Promise<unknown> {
|
||||
try {
|
||||
const response = await fetch("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", {
|
||||
method: "POST",
|
||||
@@ -1398,13 +1428,15 @@ async function getGeminiCliSubscriptionInfo(accessToken) {
|
||||
/**
|
||||
* Map Gemini CLI subscription tier to display label (same tiers as Antigravity).
|
||||
*/
|
||||
function getGeminiCliPlanLabel(subscriptionInfo) {
|
||||
if (!subscriptionInfo || Object.keys(subscriptionInfo).length === 0) return "Free";
|
||||
function getGeminiCliPlanLabel(subscriptionInfo: unknown) {
|
||||
const subscription = toRecord(subscriptionInfo);
|
||||
if (Object.keys(subscription).length === 0) return "Free";
|
||||
|
||||
let tierId = "";
|
||||
if (Array.isArray(subscriptionInfo.allowedTiers)) {
|
||||
for (const tier of subscriptionInfo.allowedTiers) {
|
||||
if (tier.isDefault && tier.id) {
|
||||
if (Array.isArray(subscription.allowedTiers)) {
|
||||
for (const tierValue of subscription.allowedTiers) {
|
||||
const tier = toRecord(tierValue);
|
||||
if (tier.isDefault && typeof tier.id === "string") {
|
||||
tierId = tier.id.trim().toUpperCase();
|
||||
break;
|
||||
}
|
||||
@@ -1412,7 +1444,8 @@ function getGeminiCliPlanLabel(subscriptionInfo) {
|
||||
}
|
||||
|
||||
if (!tierId) {
|
||||
tierId = (subscriptionInfo.currentTier?.id || "").toUpperCase();
|
||||
const currentTier = toRecord(subscription.currentTier);
|
||||
tierId = typeof currentTier.id === "string" ? currentTier.id.toUpperCase() : "";
|
||||
}
|
||||
|
||||
if (tierId) {
|
||||
@@ -1425,12 +1458,13 @@ function getGeminiCliPlanLabel(subscriptionInfo) {
|
||||
}
|
||||
|
||||
const tierName =
|
||||
subscriptionInfo.currentTier?.name ||
|
||||
subscriptionInfo.currentTier?.displayName ||
|
||||
subscriptionInfo.subscriptionType ||
|
||||
subscriptionInfo.tier ||
|
||||
toRecord(subscription.currentTier).name ||
|
||||
toRecord(subscription.currentTier).displayName ||
|
||||
subscription.subscriptionType ||
|
||||
subscription.tier ||
|
||||
"";
|
||||
const upper = tierName.toUpperCase();
|
||||
const tierNameString = typeof tierName === "string" ? tierName : "";
|
||||
const upper = tierNameString.toUpperCase();
|
||||
|
||||
if (upper.includes("ULTRA")) return "Ultra";
|
||||
if (upper.includes("PRO")) return "Pro";
|
||||
@@ -1438,9 +1472,9 @@ function getGeminiCliPlanLabel(subscriptionInfo) {
|
||||
if (upper.includes("STANDARD") || upper.includes("BUSINESS")) return "Business";
|
||||
if (upper.includes("INDIVIDUAL") || upper.includes("FREE")) return "Free";
|
||||
|
||||
if (subscriptionInfo.currentTier?.upgradeSubscriptionType) return "Free";
|
||||
if (tierName) {
|
||||
return tierName.charAt(0).toUpperCase() + tierName.slice(1).toLowerCase();
|
||||
if (toRecord(subscription.currentTier).upgradeSubscriptionType) return "Free";
|
||||
if (tierNameString) {
|
||||
return tierNameString.charAt(0).toUpperCase() + tierNameString.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
return "Free";
|
||||
@@ -1449,7 +1483,7 @@ function getGeminiCliPlanLabel(subscriptionInfo) {
|
||||
// ── Antigravity subscription info cache ──────────────────────────────────────
|
||||
// Prevents duplicate loadCodeAssist calls within the same quota cycle.
|
||||
// Key: truncated accessToken → { data, fetchedAt }
|
||||
const _antigravitySubCache = new Map();
|
||||
const _antigravitySubCache = new Map<string, { data: unknown; fetchedAt: number }>();
|
||||
const ANTIGRAVITY_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const ANTIGRAVITY_MODELS_CACHE_TTL_MS = 60 * 1000;
|
||||
const ANTIGRAVITY_CREDIT_PROBE_TTL_MS = 5 * 60 * 1000;
|
||||
@@ -1535,14 +1569,16 @@ async function fetchAntigravityAvailableModelsCached(
|
||||
* Extracts tier from allowedTiers[].isDefault (same logic as providers.js postExchange).
|
||||
* Falls back to currentTier.id → currentTier.name → "Free".
|
||||
*/
|
||||
function getAntigravityPlanLabel(subscriptionInfo) {
|
||||
if (!subscriptionInfo || Object.keys(subscriptionInfo).length === 0) return "Free";
|
||||
function getAntigravityPlanLabel(subscriptionInfo: unknown) {
|
||||
const subscription = toRecord(subscriptionInfo);
|
||||
if (Object.keys(subscription).length === 0) return "Free";
|
||||
|
||||
// 1. Extract tier from allowedTiers (primary source — same as providers.js)
|
||||
let tierId = "";
|
||||
if (Array.isArray(subscriptionInfo.allowedTiers)) {
|
||||
for (const tier of subscriptionInfo.allowedTiers) {
|
||||
if (tier.isDefault && tier.id) {
|
||||
if (Array.isArray(subscription.allowedTiers)) {
|
||||
for (const tierValue of subscription.allowedTiers) {
|
||||
const tier = toRecord(tierValue);
|
||||
if (tier.isDefault && typeof tier.id === "string") {
|
||||
tierId = tier.id.trim().toUpperCase();
|
||||
break;
|
||||
}
|
||||
@@ -1551,7 +1587,8 @@ function getAntigravityPlanLabel(subscriptionInfo) {
|
||||
|
||||
// 2. Fall back to currentTier.id
|
||||
if (!tierId) {
|
||||
tierId = (subscriptionInfo.currentTier?.id || "").toUpperCase();
|
||||
const currentTier = toRecord(subscription.currentTier);
|
||||
tierId = typeof currentTier.id === "string" ? currentTier.id.toUpperCase() : "";
|
||||
}
|
||||
|
||||
// 3. Map tier ID to display label
|
||||
@@ -1566,12 +1603,13 @@ function getAntigravityPlanLabel(subscriptionInfo) {
|
||||
|
||||
// 4. Try tier name fields as last resort
|
||||
const tierName =
|
||||
subscriptionInfo.currentTier?.name ||
|
||||
subscriptionInfo.currentTier?.displayName ||
|
||||
subscriptionInfo.subscriptionType ||
|
||||
subscriptionInfo.tier ||
|
||||
toRecord(subscription.currentTier).name ||
|
||||
toRecord(subscription.currentTier).displayName ||
|
||||
subscription.subscriptionType ||
|
||||
subscription.tier ||
|
||||
"";
|
||||
const upper = tierName.toUpperCase();
|
||||
const tierNameString = typeof tierName === "string" ? tierName : "";
|
||||
const upper = tierNameString.toUpperCase();
|
||||
|
||||
if (upper.includes("ULTRA")) return "Ultra";
|
||||
if (upper.includes("PRO")) return "Pro";
|
||||
@@ -1580,11 +1618,11 @@ function getAntigravityPlanLabel(subscriptionInfo) {
|
||||
if (upper.includes("INDIVIDUAL") || upper.includes("FREE")) return "Free";
|
||||
|
||||
// 5. If upgradeSubscriptionType exists, account is on free tier
|
||||
if (subscriptionInfo.currentTier?.upgradeSubscriptionType) return "Free";
|
||||
if (toRecord(subscription.currentTier).upgradeSubscriptionType) return "Free";
|
||||
|
||||
// 6. If we have a tier name that didn't match known patterns, return it title-cased
|
||||
if (tierName) {
|
||||
return tierName.charAt(0).toUpperCase() + tierName.slice(1).toLowerCase();
|
||||
if (tierNameString) {
|
||||
return tierNameString.charAt(0).toUpperCase() + tierNameString.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
return "Free";
|
||||
@@ -1736,10 +1774,10 @@ async function probeAntigravityCreditBalanceUncached(
|
||||
* retrieveUserQuota only returns Gemini models — not suitable for Antigravity.
|
||||
*/
|
||||
async function getAntigravityUsage(
|
||||
accessToken,
|
||||
providerSpecificData,
|
||||
connectionProjectId?,
|
||||
connectionId?,
|
||||
accessToken?: string | null,
|
||||
providerSpecificData: JsonRecord | null = null,
|
||||
connectionProjectId?: string | null,
|
||||
connectionId?: string | null,
|
||||
options: AntigravityUsageOptions = {}
|
||||
) {
|
||||
if (!accessToken) {
|
||||
@@ -1748,7 +1786,10 @@ async function getAntigravityUsage(
|
||||
|
||||
try {
|
||||
const subscriptionInfo = await getAntigravitySubscriptionInfoCached(accessToken);
|
||||
const projectId = connectionProjectId || subscriptionInfo?.cloudaicompanionProject || null;
|
||||
const subscriptionProjectId = toRecord(subscriptionInfo).cloudaicompanionProject;
|
||||
const projectId =
|
||||
connectionProjectId ||
|
||||
(typeof subscriptionProjectId === "string" ? subscriptionProjectId : null);
|
||||
|
||||
// Derive accountId for credit balance cache.
|
||||
// Must match executor key: credentials.connectionId
|
||||
@@ -1836,7 +1877,7 @@ async function getAntigravityUsage(
|
||||
* Get Antigravity subscription info (cached, 5 min TTL)
|
||||
* Prevents duplicate loadCodeAssist calls within the same quota cycle.
|
||||
*/
|
||||
async function getAntigravitySubscriptionInfoCached(accessToken) {
|
||||
async function getAntigravitySubscriptionInfoCached(accessToken: string) {
|
||||
const cacheKey = accessToken.substring(0, 16);
|
||||
const cached = _antigravitySubCache.get(cacheKey);
|
||||
|
||||
@@ -1853,7 +1894,7 @@ async function getAntigravitySubscriptionInfoCached(accessToken) {
|
||||
* Get Antigravity subscription info using correct Antigravity headers.
|
||||
* Must match the headers used in providers.js postExchange (not CLI headers).
|
||||
*/
|
||||
async function getAntigravitySubscriptionInfo(accessToken) {
|
||||
async function getAntigravitySubscriptionInfo(accessToken: string): Promise<unknown> {
|
||||
try {
|
||||
const response = await fetch(ANTIGRAVITY_CONFIG.loadProjectApiUrl, {
|
||||
method: "POST",
|
||||
@@ -1872,9 +1913,9 @@ async function getAntigravitySubscriptionInfo(accessToken) {
|
||||
/**
|
||||
* Claude Usage - Try to fetch from Anthropic API
|
||||
*/
|
||||
async function getClaudeUsage(accessToken) {
|
||||
async function getClaudeUsage(accessToken?: string | null) {
|
||||
// Refresh bootstrap in parallel; best-effort, failure non-fatal.
|
||||
const bootstrapPromise = fetchClaudeBootstrap(accessToken).catch(() => null);
|
||||
const bootstrapPromise = fetchClaudeBootstrap(accessToken || "").catch(() => null);
|
||||
try {
|
||||
// Real CLI uses axios here, not Stainless — UA is `claude-code/<version>`
|
||||
// (not `claude-cli/...`) and the shape is simpler than /v1/messages.
|
||||
@@ -1977,7 +2018,7 @@ async function getClaudeUsage(accessToken) {
|
||||
* Legacy Claude usage fetcher for API key / org admin users.
|
||||
* Uses /v1/settings + /v1/organizations/{org_id}/usage endpoints.
|
||||
*/
|
||||
async function getClaudeUsageLegacy(accessToken) {
|
||||
async function getClaudeUsageLegacy(accessToken?: string | null) {
|
||||
try {
|
||||
const settingsResponse = await fetch(CLAUDE_CONFIG.settingsUrl, {
|
||||
method: "GET",
|
||||
@@ -2030,7 +2071,10 @@ async function getClaudeUsageLegacy(accessToken) {
|
||||
* IMPORTANT: Uses persisted workspaceId from OAuth to ensure correct workspace binding.
|
||||
* No fallback to other workspaces - strict binding to user's selected workspace.
|
||||
*/
|
||||
async function getCodexUsage(accessToken, providerSpecificData: Record<string, unknown> = {}) {
|
||||
async function getCodexUsage(
|
||||
accessToken?: string | null,
|
||||
providerSpecificData: Record<string, unknown> = {}
|
||||
) {
|
||||
try {
|
||||
// Use persisted workspace ID from OAuth - NO FALLBACK
|
||||
const accountId =
|
||||
@@ -2151,7 +2195,10 @@ async function getCodexUsage(accessToken, providerSpecificData: Record<string, u
|
||||
/**
|
||||
* Kiro (AWS CodeWhisperer) Usage
|
||||
*/
|
||||
async function getKiroUsage(accessToken, providerSpecificData) {
|
||||
async function getKiroUsage(
|
||||
accessToken?: string | null,
|
||||
providerSpecificData: JsonRecord | null = null
|
||||
) {
|
||||
try {
|
||||
const profileArn = providerSpecificData?.profileArn;
|
||||
if (!profileArn) {
|
||||
@@ -2185,15 +2232,18 @@ async function getKiroUsage(accessToken, providerSpecificData) {
|
||||
|
||||
// Parse usage data from usageBreakdownList
|
||||
const usageList = data.usageBreakdownList || [];
|
||||
const quotaInfo = {};
|
||||
const quotaInfo: Record<string, UsageQuota> = {};
|
||||
|
||||
// Parse reset time - supports multiple formats (nextDateReset, resetDate, etc.)
|
||||
const resetAt = parseResetTime(data.nextDateReset || data.resetDate);
|
||||
|
||||
usageList.forEach((breakdown) => {
|
||||
const resourceType = breakdown.resourceType?.toLowerCase() || "unknown";
|
||||
const used = breakdown.currentUsageWithPrecision || 0;
|
||||
const total = breakdown.usageLimitWithPrecision || 0;
|
||||
usageList.forEach((breakdown: JsonRecord) => {
|
||||
const resourceType =
|
||||
typeof breakdown.resourceType === "string"
|
||||
? breakdown.resourceType.toLowerCase()
|
||||
: "unknown";
|
||||
const used = toNumber(breakdown.currentUsageWithPrecision, 0);
|
||||
const total = toNumber(breakdown.usageLimitWithPrecision, 0);
|
||||
|
||||
quotaInfo[resourceType] = {
|
||||
used,
|
||||
@@ -2205,8 +2255,9 @@ async function getKiroUsage(accessToken, providerSpecificData) {
|
||||
|
||||
// Add free trial if available
|
||||
if (breakdown.freeTrialInfo) {
|
||||
const freeUsed = breakdown.freeTrialInfo.currentUsageWithPrecision || 0;
|
||||
const freeTotal = breakdown.freeTrialInfo.usageLimitWithPrecision || 0;
|
||||
const freeTrialInfo = toRecord(breakdown.freeTrialInfo);
|
||||
const freeUsed = toNumber(freeTrialInfo.currentUsageWithPrecision, 0);
|
||||
const freeTotal = toNumber(freeTrialInfo.usageLimitWithPrecision, 0);
|
||||
|
||||
quotaInfo[`${resourceType}_freetrial`] = {
|
||||
used: freeUsed,
|
||||
@@ -2232,10 +2283,10 @@ async function getKiroUsage(accessToken, providerSpecificData) {
|
||||
* LEVEL_BASIC = Moderato, LEVEL_INTERMEDIATE = Allegretto,
|
||||
* LEVEL_ADVANCED = Allegro, LEVEL_STANDARD = Vivace
|
||||
*/
|
||||
function getKimiPlanName(level) {
|
||||
if (!level) return "";
|
||||
function getKimiPlanName(level: unknown) {
|
||||
if (typeof level !== "string" || !level) return "";
|
||||
|
||||
const levelMap = {
|
||||
const levelMap: Record<string, string> = {
|
||||
LEVEL_BASIC: "Moderato",
|
||||
LEVEL_INTERMEDIATE: "Allegretto",
|
||||
LEVEL_ADVANCED: "Allegro",
|
||||
@@ -2249,7 +2300,7 @@ function getKimiPlanName(level) {
|
||||
* Kimi Coding Usage - Fetch quota from Kimi API
|
||||
* Uses the official /v1/usages endpoint with custom X-Msh-* headers
|
||||
*/
|
||||
async function getKimiUsage(accessToken) {
|
||||
async function getKimiUsage(accessToken?: string | null) {
|
||||
// Generate device info for headers (same as OAuth flow)
|
||||
const deviceId = "kimi-usage-" + Date.now();
|
||||
const platform = "omniroute";
|
||||
@@ -2401,7 +2452,10 @@ async function getKimiUsage(accessToken) {
|
||||
/**
|
||||
* Qwen Usage
|
||||
*/
|
||||
async function getQwenUsage(accessToken, providerSpecificData) {
|
||||
async function getQwenUsage(
|
||||
accessToken?: string | null,
|
||||
providerSpecificData: JsonRecord | null = null
|
||||
) {
|
||||
try {
|
||||
const resourceUrl = providerSpecificData?.resourceUrl;
|
||||
if (!resourceUrl) {
|
||||
@@ -2418,7 +2472,7 @@ async function getQwenUsage(accessToken, providerSpecificData) {
|
||||
/**
|
||||
* Qoder Usage
|
||||
*/
|
||||
async function getQoderUsage(accessToken) {
|
||||
async function getQoderUsage(accessToken?: string | null) {
|
||||
try {
|
||||
// Qoder may have usage endpoint
|
||||
return { message: "Qoder connected. Usage tracked per request." };
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Gemini helper functions for translator
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
// Unsupported JSON Schema constraints that should be removed for Antigravity.
|
||||
// `additionalProperties` is handled separately so `true` can be preserved.
|
||||
export const GEMINI_UNSUPPORTED_SCHEMA_KEYS = new Set([
|
||||
@@ -89,19 +91,20 @@ export const DEFAULT_SAFETY_SETTINGS = [
|
||||
];
|
||||
|
||||
// Convert OpenAI content to Gemini parts
|
||||
export function convertOpenAIContentToParts(content: any) {
|
||||
const parts: any[] = [];
|
||||
export function convertOpenAIContentToParts(content: unknown) {
|
||||
const parts: Array<Record<string, unknown>> = [];
|
||||
|
||||
if (typeof content === "string") {
|
||||
parts.push({ text: content });
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const item of content) {
|
||||
for (const rawItem of content) {
|
||||
const item = toRecord(rawItem);
|
||||
if (item.type === "text") {
|
||||
parts.push({ text: item.text });
|
||||
} else {
|
||||
// 1. Handle Gemini native inline_data injected into OpenAI arrays (e.g. Cherry Studio)
|
||||
const geminiInline = item.inline_data || item.inlineData;
|
||||
if (geminiInline?.data) {
|
||||
const geminiInline = toRecord(item.inline_data || item.inlineData);
|
||||
if (typeof geminiInline.data === "string") {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: geminiInline.mime_type || geminiInline.mimeType || "application/pdf",
|
||||
@@ -112,23 +115,26 @@ export function convertOpenAIContentToParts(content: any) {
|
||||
}
|
||||
|
||||
// 2. Handle Claude-style source blocks commonly used by AI clients
|
||||
if (item.source?.type === "base64" && item.source?.data) {
|
||||
const source = toRecord(item.source);
|
||||
if (source.type === "base64" && typeof source.data === "string") {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: item.source.media_type || "application/pdf",
|
||||
data: item.source.data.replace(/^data:[a-zA-Z0-9/+-]+;base64,/, ""),
|
||||
mimeType: source.media_type || "application/pdf",
|
||||
data: source.data.replace(/^data:[a-zA-Z0-9/+-]+;base64,/, ""),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Handle raw data strings (e.g. {"type": "file", "data": "JVBER...", "mime_type": "..."})
|
||||
const rawDataStr = item.data || item.file?.data || item.document?.data;
|
||||
const file = toRecord(item.file);
|
||||
const document = toRecord(item.document);
|
||||
const rawDataStr = item.data || file.data || document.data;
|
||||
const mimeTypeFallback =
|
||||
item.mime_type ||
|
||||
item.media_type ||
|
||||
item.file?.mime_type ||
|
||||
item.document?.mime_type ||
|
||||
file.mime_type ||
|
||||
document.mime_type ||
|
||||
"application/octet-stream";
|
||||
if (typeof rawDataStr === "string" && !rawDataStr.startsWith("http")) {
|
||||
const rawData = rawDataStr.replace(/^data:[a-zA-Z0-9/+-]+;base64,/, "");
|
||||
@@ -142,8 +148,9 @@ export function convertOpenAIContentToParts(content: any) {
|
||||
}
|
||||
|
||||
// 4. Standard OpenAI Data URIs
|
||||
const fileData =
|
||||
item.image_url?.url || item.file_url?.url || item.file?.url || item.document?.url;
|
||||
const imageUrl = toRecord(item.image_url);
|
||||
const fileUrl = toRecord(item.file_url);
|
||||
const fileData = imageUrl.url || fileUrl.url || file.url || document.url;
|
||||
if (typeof fileData === "string" && fileData.startsWith("data:")) {
|
||||
const commaIndex = fileData.indexOf(",");
|
||||
if (commaIndex !== -1) {
|
||||
@@ -164,19 +171,20 @@ export function convertOpenAIContentToParts(content: any) {
|
||||
}
|
||||
|
||||
// Extract text content from OpenAI content
|
||||
export function extractTextContent(content) {
|
||||
export function extractTextContent(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((c) => toRecord(c))
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.map((c) => (typeof c.text === "string" ? c.text : ""))
|
||||
.join("");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Try parse JSON safely
|
||||
export function tryParseJSON(str) {
|
||||
export function tryParseJSON(str: unknown): unknown {
|
||||
if (typeof str !== "string") return str;
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
@@ -198,9 +206,9 @@ export function generateSessionId() {
|
||||
return `-${num.toString()}`;
|
||||
}
|
||||
|
||||
function cloneSchemaValue(value) {
|
||||
function cloneSchemaValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneSchemaValue(item));
|
||||
return value.map((item: unknown) => cloneSchemaValue(item));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
@@ -210,18 +218,18 @@ function cloneSchemaValue(value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function toRecord(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
function toRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function decodeJsonPointerSegment(segment) {
|
||||
function decodeJsonPointerSegment(segment: string): string {
|
||||
return String(segment).replace(/~1/g, "/").replace(/~0/g, "~");
|
||||
}
|
||||
|
||||
function resolveLocalReference(root, ref) {
|
||||
function resolveLocalReference(root: unknown, ref: unknown): unknown {
|
||||
if (typeof ref !== "string" || !ref.startsWith("#/")) return null;
|
||||
|
||||
let current = root;
|
||||
let current: unknown = root;
|
||||
const segments = ref
|
||||
.slice(2)
|
||||
.split("/")
|
||||
@@ -229,25 +237,30 @@ function resolveLocalReference(root, ref) {
|
||||
.map((segment) => decodeJsonPointerSegment(segment));
|
||||
|
||||
for (const segment of segments) {
|
||||
if (!current || typeof current !== "object" || !(segment in current)) {
|
||||
const currentRecord = toRecord(current);
|
||||
if (Object.keys(currentRecord).length === 0 || !(segment in currentRecord)) {
|
||||
return null;
|
||||
}
|
||||
current = current[segment];
|
||||
current = currentRecord[segment];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function inlineLocalSchemaRefs(node, root, activeRefs = new Set()) {
|
||||
function inlineLocalSchemaRefs(
|
||||
node: unknown,
|
||||
root: unknown,
|
||||
activeRefs: Set<string> = new Set()
|
||||
): unknown {
|
||||
if (Array.isArray(node)) {
|
||||
return node.map((item) => inlineLocalSchemaRefs(item, root, activeRefs));
|
||||
return node.map((item: unknown) => inlineLocalSchemaRefs(item, root, activeRefs));
|
||||
}
|
||||
|
||||
if (!node || typeof node !== "object") {
|
||||
return node;
|
||||
}
|
||||
|
||||
const record = { ...node };
|
||||
const record = { ...toRecord(node) };
|
||||
const ref = typeof record.$ref === "string" ? record.$ref : "";
|
||||
if (ref.startsWith("#/$defs/") || ref.startsWith("#/definitions/")) {
|
||||
const rest = { ...record };
|
||||
@@ -280,7 +293,7 @@ function inlineLocalSchemaRefs(node, root, activeRefs = new Set()) {
|
||||
}
|
||||
|
||||
// Helper: Remove unsupported keywords recursively from object/array
|
||||
function removeUnsupportedKeywords(obj, keywords) {
|
||||
function removeUnsupportedKeywords(obj: unknown, keywords: Set<string>) {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
@@ -288,14 +301,15 @@ function removeUnsupportedKeywords(obj, keywords) {
|
||||
removeUnsupportedKeywords(item, keywords);
|
||||
}
|
||||
} else {
|
||||
const record = obj as JsonRecord;
|
||||
// Delete unsupported keys at current level
|
||||
for (const key of Object.keys(obj)) {
|
||||
for (const key of Object.keys(record)) {
|
||||
if (keywords.has(key) || key.startsWith("x-")) {
|
||||
delete obj[key];
|
||||
delete record[key];
|
||||
}
|
||||
}
|
||||
// Recurse into remaining values
|
||||
for (const value of Object.values(obj)) {
|
||||
for (const value of Object.values(record)) {
|
||||
if (value && typeof value === "object") {
|
||||
removeUnsupportedKeywords(value, keywords);
|
||||
}
|
||||
@@ -303,7 +317,7 @@ function removeUnsupportedKeywords(obj, keywords) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAdditionalProperties(obj) {
|
||||
function normalizeAdditionalProperties(obj: unknown) {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
@@ -316,11 +330,12 @@ function normalizeAdditionalProperties(obj) {
|
||||
// Gemini API does not support `additionalProperties` at all in function_declarations
|
||||
// schemas (returns 400 "Unknown name"). Since Gemini defaults to allowing additional
|
||||
// properties anyway, stripping it unconditionally is safe and prevents errors (#1421).
|
||||
if ("additionalProperties" in obj) {
|
||||
delete obj.additionalProperties;
|
||||
const record = obj as JsonRecord;
|
||||
if ("additionalProperties" in record) {
|
||||
delete record.additionalProperties;
|
||||
}
|
||||
|
||||
for (const value of Object.values(obj)) {
|
||||
for (const value of Object.values(record)) {
|
||||
if (value && typeof value === "object") {
|
||||
normalizeAdditionalProperties(value);
|
||||
}
|
||||
@@ -328,15 +343,16 @@ function normalizeAdditionalProperties(obj) {
|
||||
}
|
||||
|
||||
// Convert const to enum
|
||||
function convertConstToEnum(obj) {
|
||||
function convertConstToEnum(obj: unknown) {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
if (obj.const !== undefined && !obj.enum) {
|
||||
obj.enum = [obj.const];
|
||||
delete obj.const;
|
||||
const record = obj as JsonRecord;
|
||||
if (record.const !== undefined && !record.enum) {
|
||||
record.enum = [record.const];
|
||||
delete record.const;
|
||||
}
|
||||
|
||||
for (const value of Object.values(obj)) {
|
||||
for (const value of Object.values(record)) {
|
||||
if (value && typeof value === "object") {
|
||||
convertConstToEnum(value);
|
||||
}
|
||||
@@ -345,22 +361,23 @@ function convertConstToEnum(obj) {
|
||||
|
||||
// Convert enum values to strings (Gemini requires string enum values)
|
||||
// For integer types, remove enum entirely as Gemini doesn't support it
|
||||
function convertEnumValuesToStrings(obj) {
|
||||
function convertEnumValuesToStrings(obj: unknown) {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
if (obj.enum && Array.isArray(obj.enum)) {
|
||||
const record = obj as JsonRecord;
|
||||
if (record.enum && Array.isArray(record.enum)) {
|
||||
// Gemini only supports enum for string types, not integer
|
||||
if (obj.type === "integer" || obj.type === "number") {
|
||||
delete obj.enum;
|
||||
if (record.type === "integer" || record.type === "number") {
|
||||
delete record.enum;
|
||||
} else {
|
||||
obj.enum = obj.enum.map((v) => String(v));
|
||||
if (!obj.type) {
|
||||
obj.type = "string";
|
||||
record.enum = record.enum.map((v: unknown) => String(v));
|
||||
if (!record.type) {
|
||||
record.type = "string";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of Object.values(obj)) {
|
||||
for (const value of Object.values(record)) {
|
||||
if (value && typeof value === "object") {
|
||||
convertEnumValuesToStrings(value);
|
||||
}
|
||||
@@ -368,20 +385,24 @@ function convertEnumValuesToStrings(obj) {
|
||||
}
|
||||
|
||||
// Merge allOf schemas
|
||||
function mergeAllOf(obj) {
|
||||
function mergeAllOf(obj: unknown) {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
if (obj.allOf && Array.isArray(obj.allOf)) {
|
||||
const record = obj as JsonRecord;
|
||||
if (record.allOf && Array.isArray(record.allOf)) {
|
||||
const merged: { properties?: Record<string, unknown>; required?: string[] } = {};
|
||||
|
||||
for (const item of obj.allOf) {
|
||||
for (const itemValue of record.allOf) {
|
||||
const item = toRecord(itemValue);
|
||||
if (item.properties) {
|
||||
if (!merged.properties) merged.properties = {};
|
||||
Object.assign(merged.properties, item.properties);
|
||||
Object.assign(merged.properties, toRecord(item.properties));
|
||||
}
|
||||
if (item.required && Array.isArray(item.required)) {
|
||||
if (!merged.required) merged.required = [];
|
||||
for (const req of item.required) {
|
||||
for (const req of item.required.filter(
|
||||
(value: unknown): value is string => typeof value === "string"
|
||||
)) {
|
||||
if (!merged.required.includes(req)) {
|
||||
merged.required.push(req);
|
||||
}
|
||||
@@ -389,12 +410,18 @@ function mergeAllOf(obj) {
|
||||
}
|
||||
}
|
||||
|
||||
delete obj.allOf;
|
||||
if (merged.properties) obj.properties = { ...obj.properties, ...merged.properties };
|
||||
if (merged.required) obj.required = [...(obj.required || []), ...merged.required];
|
||||
delete record.allOf;
|
||||
if (merged.properties)
|
||||
record.properties = { ...toRecord(record.properties), ...merged.properties };
|
||||
if (merged.required) {
|
||||
const existingRequired = Array.isArray(record.required)
|
||||
? record.required.filter((value: unknown): value is string => typeof value === "string")
|
||||
: [];
|
||||
record.required = [...existingRequired, ...merged.required];
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of Object.values(obj)) {
|
||||
for (const value of Object.values(record)) {
|
||||
if (value && typeof value === "object") {
|
||||
mergeAllOf(value);
|
||||
}
|
||||
@@ -402,12 +429,12 @@ function mergeAllOf(obj) {
|
||||
}
|
||||
|
||||
// Select best schema from anyOf/oneOf
|
||||
function selectBest(items) {
|
||||
function selectBest(items: unknown[]): number {
|
||||
let bestIdx = 0;
|
||||
let bestScore = -1;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const item = toRecord(items[i]);
|
||||
let score = 0;
|
||||
const type = item.type;
|
||||
|
||||
@@ -429,30 +456,31 @@ function selectBest(items) {
|
||||
}
|
||||
|
||||
// Flatten anyOf/oneOf
|
||||
function flattenAnyOfOneOf(obj) {
|
||||
function flattenAnyOfOneOf(obj: unknown) {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
if (obj.anyOf && Array.isArray(obj.anyOf) && obj.anyOf.length > 0) {
|
||||
const nonNullSchemas = obj.anyOf.filter((s) => s && s.type !== "null");
|
||||
const record = obj as JsonRecord;
|
||||
if (record.anyOf && Array.isArray(record.anyOf) && record.anyOf.length > 0) {
|
||||
const nonNullSchemas = record.anyOf.filter((s: unknown) => toRecord(s).type !== "null");
|
||||
if (nonNullSchemas.length > 0) {
|
||||
const bestIdx = selectBest(nonNullSchemas);
|
||||
const selected = nonNullSchemas[bestIdx];
|
||||
delete obj.anyOf;
|
||||
Object.assign(obj, selected);
|
||||
delete record.anyOf;
|
||||
Object.assign(record, toRecord(selected));
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.oneOf && Array.isArray(obj.oneOf) && obj.oneOf.length > 0) {
|
||||
const nonNullSchemas = obj.oneOf.filter((s) => s && s.type !== "null");
|
||||
if (record.oneOf && Array.isArray(record.oneOf) && record.oneOf.length > 0) {
|
||||
const nonNullSchemas = record.oneOf.filter((s: unknown) => toRecord(s).type !== "null");
|
||||
if (nonNullSchemas.length > 0) {
|
||||
const bestIdx = selectBest(nonNullSchemas);
|
||||
const selected = nonNullSchemas[bestIdx];
|
||||
delete obj.oneOf;
|
||||
Object.assign(obj, selected);
|
||||
delete record.oneOf;
|
||||
Object.assign(record, toRecord(selected));
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of Object.values(obj)) {
|
||||
for (const value of Object.values(record)) {
|
||||
if (value && typeof value === "object") {
|
||||
flattenAnyOfOneOf(value);
|
||||
}
|
||||
@@ -460,15 +488,16 @@ function flattenAnyOfOneOf(obj) {
|
||||
}
|
||||
|
||||
// Flatten type arrays
|
||||
function flattenTypeArrays(obj) {
|
||||
function flattenTypeArrays(obj: unknown) {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
if (obj.type && Array.isArray(obj.type)) {
|
||||
const nonNullTypes = obj.type.filter((t) => t !== "null");
|
||||
obj.type = nonNullTypes.length > 0 ? nonNullTypes[0] : "string";
|
||||
const record = obj as JsonRecord;
|
||||
if (record.type && Array.isArray(record.type)) {
|
||||
const nonNullTypes = record.type.filter((t: unknown) => t !== "null");
|
||||
record.type = nonNullTypes.length > 0 ? nonNullTypes[0] : "string";
|
||||
}
|
||||
|
||||
for (const value of Object.values(obj)) {
|
||||
for (const value of Object.values(record)) {
|
||||
if (value && typeof value === "object") {
|
||||
flattenTypeArrays(value);
|
||||
}
|
||||
@@ -477,7 +506,7 @@ function flattenTypeArrays(obj) {
|
||||
|
||||
// Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively
|
||||
// Reference: CLIProxyAPI/internal/util/gemini_schema.go
|
||||
export function cleanJSONSchemaForAntigravity(schema) {
|
||||
export function cleanJSONSchemaForAntigravity(schema: unknown): unknown {
|
||||
if (!schema || typeof schema !== "object") return schema;
|
||||
|
||||
const root = cloneSchemaValue(schema);
|
||||
@@ -499,22 +528,25 @@ export function cleanJSONSchemaForAntigravity(schema) {
|
||||
removeUnsupportedKeywords(cleaned, GEMINI_UNSUPPORTED_SCHEMA_KEYS);
|
||||
|
||||
// Phase 5: Cleanup required fields recursively.
|
||||
function cleanupRequired(obj) {
|
||||
function cleanupRequired(obj: unknown) {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
if (obj.required && Array.isArray(obj.required) && obj.properties) {
|
||||
const validRequired = obj.required.filter((field) =>
|
||||
Object.prototype.hasOwnProperty.call(obj.properties, field)
|
||||
const record = obj as JsonRecord;
|
||||
if (record.required && Array.isArray(record.required) && record.properties) {
|
||||
const properties = toRecord(record.properties);
|
||||
const validRequired = record.required.filter(
|
||||
(field: unknown): field is string =>
|
||||
typeof field === "string" && Object.prototype.hasOwnProperty.call(properties, field)
|
||||
);
|
||||
if (validRequired.length === 0) {
|
||||
delete obj.required;
|
||||
delete record.required;
|
||||
} else {
|
||||
obj.required = validRequired;
|
||||
record.required = validRequired;
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into nested objects
|
||||
for (const value of Object.values(obj)) {
|
||||
for (const value of Object.values(record)) {
|
||||
if (value && typeof value === "object") {
|
||||
cleanupRequired(value);
|
||||
}
|
||||
@@ -524,23 +556,24 @@ export function cleanJSONSchemaForAntigravity(schema) {
|
||||
cleanupRequired(cleaned);
|
||||
|
||||
// Phase 6: Add placeholder for empty object schemas (Antigravity requirement).
|
||||
function addPlaceholders(obj) {
|
||||
function addPlaceholders(obj: unknown) {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
if (obj.type === "object") {
|
||||
if (!obj.properties || Object.keys(obj.properties).length === 0) {
|
||||
obj.properties = {
|
||||
const record = obj as JsonRecord;
|
||||
if (record.type === "object") {
|
||||
if (!record.properties || Object.keys(toRecord(record.properties)).length === 0) {
|
||||
record.properties = {
|
||||
reason: {
|
||||
type: "string",
|
||||
description: "Brief explanation of why you are calling this tool",
|
||||
},
|
||||
};
|
||||
obj.required = ["reason"];
|
||||
record.required = ["reason"];
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into nested objects
|
||||
for (const value of Object.values(obj)) {
|
||||
for (const value of Object.values(record)) {
|
||||
if (value && typeof value === "object") {
|
||||
addPlaceholders(value);
|
||||
}
|
||||
|
||||
@@ -211,13 +211,13 @@ export function buildGeminiTools(
|
||||
|
||||
const result: GeminiTool[] = [];
|
||||
|
||||
if (googleSearchTool) {
|
||||
return [googleSearchTool];
|
||||
}
|
||||
|
||||
if (functionDeclarations.length > 0) {
|
||||
result.push({ functionDeclarations });
|
||||
}
|
||||
|
||||
if (googleSearchTool) {
|
||||
result.push(googleSearchTool);
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@ import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts";
|
||||
import { normalizePayloadForLog } from "@/lib/logPayloads";
|
||||
import type { ModelCooldownErrorPayload } from "@/types";
|
||||
|
||||
type ErrorResponseBody = {
|
||||
error: {
|
||||
message: string;
|
||||
type: string;
|
||||
code: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize an error message to prevent stack trace exposure in API responses.
|
||||
* Strips stack traces and internal file paths from error messages before they
|
||||
@@ -22,7 +30,7 @@ function sanitizeErrorMessage(message: unknown): string {
|
||||
* @param {string} message - Error message
|
||||
* @returns {object} Error response object
|
||||
*/
|
||||
export function buildErrorBody(statusCode, message) {
|
||||
export function buildErrorBody(statusCode, message): ErrorResponseBody {
|
||||
const errorInfo = getErrorInfo(statusCode);
|
||||
|
||||
return {
|
||||
@@ -220,10 +228,10 @@ export function createErrorResult(
|
||||
) {
|
||||
const body = buildErrorBody(statusCode, message);
|
||||
if (errorCode) {
|
||||
(body.error as any).code = errorCode;
|
||||
body.error.code = errorCode;
|
||||
}
|
||||
if (errorType) {
|
||||
(body.error as any).type = errorType;
|
||||
body.error.type = errorType;
|
||||
}
|
||||
|
||||
const result: {
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
"typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json",
|
||||
"backfill-aggregation": "node --import tsx/esm src/scripts/backfillAggregation.ts",
|
||||
"env:sync": "node scripts/sync-env.mjs",
|
||||
"test:integration": "node --import tsx/esm --test tests/integration/*.test.ts",
|
||||
"test:integration": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test --test-force-exit --test-concurrency=1 tests/integration/*.test.ts",
|
||||
"test:e2e": "node scripts/run-playwright-tests.mjs test tests/e2e/*.spec.ts",
|
||||
"test:protocols:e2e": "node scripts/run-protocol-clients-tests.mjs",
|
||||
"test:vitest": "vitest run --config vitest.mcp.config.ts",
|
||||
|
||||
@@ -710,7 +710,7 @@ export default function MemorySkillsTab() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Skills Settings (placeholder) */}
|
||||
{/* Skills Settings */}
|
||||
<Card data-testid="skills-settings-card">
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { extractApiKey } from "@/sse/services/auth";
|
||||
import { getAgent } from "@/lib/cloudAgent/registry";
|
||||
import { getCloudAgentTaskById, updateCloudAgentTask } from "@/lib/cloudAgent/db";
|
||||
import {
|
||||
createCloudAgentTaskTable,
|
||||
getCloudAgentTaskById,
|
||||
updateCloudAgentTask,
|
||||
} from "@/lib/cloudAgent/db";
|
||||
import { z } from "zod";
|
||||
import pino from "pino";
|
||||
|
||||
@@ -32,8 +36,34 @@ const CancelSchema = z.object({
|
||||
action: z.literal("cancel"),
|
||||
});
|
||||
|
||||
const TaskActionSchema = z.discriminatedUnion("action", [
|
||||
ApproveSchema,
|
||||
MessageSchema,
|
||||
CancelSchema,
|
||||
]);
|
||||
|
||||
function apiKeyRequiredResponse() {
|
||||
return NextResponse.json(
|
||||
{ error: "API key required" },
|
||||
{ status: 401, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
function getRequiredApiKey(request: NextRequest) {
|
||||
const apiKey = extractApiKey(request);
|
||||
if (!apiKey) {
|
||||
return { apiKey: null, response: apiKeyRequiredResponse() };
|
||||
}
|
||||
return { apiKey, response: null };
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const auth = getRequiredApiKey(request);
|
||||
if (auth.response) return auth.response;
|
||||
|
||||
createCloudAgentTaskTable();
|
||||
|
||||
const { id } = await params;
|
||||
const task = getCloudAgentTaskById(id);
|
||||
|
||||
@@ -44,18 +74,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = extractApiKey(request);
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "API key required" },
|
||||
{ status: 401, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
const agent = getAgent(task.provider_id);
|
||||
if (agent && task.external_id) {
|
||||
try {
|
||||
const statusResult = await agent.getStatus(task.external_id, { apiKey });
|
||||
const statusResult = await agent.getStatus(task.external_id, { apiKey: auth.apiKey });
|
||||
|
||||
updateCloudAgentTask(id, {
|
||||
status: statusResult.status,
|
||||
@@ -104,8 +126,20 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const auth = getRequiredApiKey(request);
|
||||
if (auth.response) return auth.response;
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const validation = TaskActionSchema.safeParse(body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validation failed", details: validation.error.issues },
|
||||
{ status: 400, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
createCloudAgentTaskTable();
|
||||
|
||||
const task = getCloudAgentTaskById(id);
|
||||
if (!task) {
|
||||
@@ -115,27 +149,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = extractApiKey(request);
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "API key required" },
|
||||
{ status: 401, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
let validated;
|
||||
if (body.action === "approve") {
|
||||
validated = ApproveSchema.parse(body);
|
||||
} else if (body.action === "message") {
|
||||
validated = MessageSchema.parse(body);
|
||||
} else if (body.action === "cancel") {
|
||||
validated = CancelSchema.parse(body);
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid action" },
|
||||
{ status: 400, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
const validated = validation.data;
|
||||
|
||||
const agent = getAgent(task.provider_id);
|
||||
if (!agent) {
|
||||
@@ -152,7 +166,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
{ status: 400, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
await agent.approvePlan(task.external_id, { apiKey });
|
||||
await agent.approvePlan(task.external_id, { apiKey: auth.apiKey });
|
||||
updateCloudAgentTask(id, { status: "running" });
|
||||
} else if (validated.action === "message") {
|
||||
if (!task.external_id) {
|
||||
@@ -161,7 +175,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
{ status: 400, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
const activity = await agent.sendMessage(task.external_id, validated.message, { apiKey });
|
||||
const activity = await agent.sendMessage(task.external_id, validated.message, {
|
||||
apiKey: auth.apiKey,
|
||||
});
|
||||
const activities = JSON.parse(task.activities);
|
||||
activities.push(activity);
|
||||
updateCloudAgentTask(id, { activities: JSON.stringify(activities) });
|
||||
@@ -171,12 +187,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
return NextResponse.json({ success: true }, { headers: getCorsHeaders() });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validation failed", details: error.errors },
|
||||
{ status: 400, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
logger.error({ err: error }, "Failed to process task action");
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
|
||||
@@ -2,17 +2,14 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { extractApiKey } from "@/sse/services/auth";
|
||||
import { getAgent } from "@/lib/cloudAgent/registry";
|
||||
import {
|
||||
createCloudAgentTaskTable,
|
||||
insertCloudAgentTask,
|
||||
getCloudAgentTaskById,
|
||||
getAllCloudAgentTasks,
|
||||
getCloudAgentTasksByProvider,
|
||||
getCloudAgentTasksByStatus,
|
||||
updateCloudAgentTask,
|
||||
deleteCloudAgentTask,
|
||||
} from "@/lib/cloudAgent/db";
|
||||
import { CreateCloudAgentTaskSchema } from "@/lib/cloudAgent/types";
|
||||
import { CLOUD_AGENT_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { z } from "zod";
|
||||
import pino from "pino";
|
||||
|
||||
const logger = pino({ name: "cloud-agents-api" });
|
||||
@@ -25,12 +22,32 @@ function getCorsHeaders() {
|
||||
};
|
||||
}
|
||||
|
||||
function apiKeyRequiredResponse() {
|
||||
return NextResponse.json(
|
||||
{ error: "API key required" },
|
||||
{ status: 401, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
function getRequiredApiKey(request: NextRequest) {
|
||||
const apiKey = extractApiKey(request);
|
||||
if (!apiKey) {
|
||||
return { apiKey: null, response: apiKeyRequiredResponse() };
|
||||
}
|
||||
return { apiKey, response: null };
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, { headers: getCorsHeaders() });
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const auth = getRequiredApiKey(request);
|
||||
if (auth.response) return auth.response;
|
||||
|
||||
createCloudAgentTaskTable();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const providerId = searchParams.get("provider");
|
||||
const status = searchParams.get("status");
|
||||
@@ -75,17 +92,20 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const validated = CreateCloudAgentTaskSchema.parse(body);
|
||||
const auth = getRequiredApiKey(request);
|
||||
if (auth.response) return auth.response;
|
||||
|
||||
const apiKey = extractApiKey(request);
|
||||
if (!apiKey) {
|
||||
const body = await request.json();
|
||||
const validation = CreateCloudAgentTaskSchema.safeParse(body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "API key required" },
|
||||
{ status: 401, headers: getCorsHeaders() }
|
||||
{ error: "Validation failed", details: validation.error.issues },
|
||||
{ status: 400, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
const validated = validation.data;
|
||||
|
||||
const agent = getAgent(validated.providerId);
|
||||
if (!agent) {
|
||||
return NextResponse.json(
|
||||
@@ -100,9 +120,10 @@ export async function POST(request: NextRequest) {
|
||||
source: validated.source,
|
||||
options: validated.options || {},
|
||||
},
|
||||
{ apiKey }
|
||||
{ apiKey: auth.apiKey }
|
||||
);
|
||||
|
||||
createCloudAgentTaskTable();
|
||||
insertCloudAgentTask({
|
||||
id: task.id,
|
||||
provider_id: task.providerId,
|
||||
@@ -135,12 +156,6 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 201, headers: getCorsHeaders() }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validation failed", details: error.errors },
|
||||
{ status: 400, headers: getCorsHeaders() }
|
||||
);
|
||||
}
|
||||
logger.error({ err: error }, "Failed to create cloud agent task");
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
@@ -151,6 +166,11 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const auth = getRequiredApiKey(request);
|
||||
if (auth.response) return auth.response;
|
||||
|
||||
createCloudAgentTaskTable();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const taskId = searchParams.get("id");
|
||||
|
||||
|
||||
@@ -296,7 +296,7 @@ export const autoSearchIndex: AutoGenSearchItem[] = [
|
||||
fileName: "AUTO-COMBO.md",
|
||||
section: "Features",
|
||||
content:
|
||||
"Self-managing model chains with adaptive scoring + zero-config auto-routing NEW: No combo creation required. Use auto/ prefix directly in any client. Model ID Variant Behavior ------------------ --------- ------------------------------------------------------------------------ auto default All conne",
|
||||
"Self-managing model chains with adaptive scoring + zero-config auto-routing NEW: No combo creation required. Use auto/ prefix directly in any client. Model ID Variant Behavior -------------- ------- ------------------------------------------------------------------------ auto default All connected p",
|
||||
headings: [
|
||||
"Zero-Config Auto-Routing (auto/ prefix)",
|
||||
"Quick Examples",
|
||||
|
||||
@@ -24,8 +24,8 @@ export async function requireManagementAuth(request: Request): Promise<Response
|
||||
try {
|
||||
if (!(await isValidApiKey(apiKey))) {
|
||||
return createErrorResponse({
|
||||
status: 401,
|
||||
message: "Invalid API key",
|
||||
status: 403,
|
||||
message: "Invalid management token",
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,6 +122,12 @@ const MAX_CACHE_SIZE = 1000;
|
||||
// Compiled regex cache for wildcard patterns
|
||||
const _regexCache = new Map<string, RegExp>();
|
||||
|
||||
interface RedisAuthCacheClient {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string, mode: "EX", ttlSeconds: number): Promise<unknown>;
|
||||
del(...keys: string[]): Promise<unknown>;
|
||||
}
|
||||
|
||||
const API_KEY_COLUMN_FALLBACKS = [
|
||||
{ name: "allowed_models", definition: "allowed_models TEXT" },
|
||||
{ name: "no_log", definition: "no_log INTEGER NOT NULL DEFAULT 0" },
|
||||
@@ -164,6 +170,65 @@ function invalidateCaches() {
|
||||
_lastUsedUpdateCache.clear();
|
||||
}
|
||||
|
||||
function shouldUseRedisAuthCache(): boolean {
|
||||
if (process.env.REDIS_URL && process.env.REDIS_URL.trim() !== "") return true;
|
||||
return process.env.NODE_ENV !== "test" && process.env.DISABLE_SQLITE_AUTO_BACKUP !== "true";
|
||||
}
|
||||
|
||||
async function getRedisAuthCacheClient(): Promise<RedisAuthCacheClient | null> {
|
||||
if (!shouldUseRedisAuthCache()) return null;
|
||||
|
||||
try {
|
||||
const { getRedisClient } = await import("@/shared/utils/rateLimiter");
|
||||
return getRedisClient() as RedisAuthCacheClient;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getRedisAuthCache(hashedKey: string): Promise<string | null> {
|
||||
const redis = await getRedisAuthCacheClient();
|
||||
if (!redis) return null;
|
||||
|
||||
try {
|
||||
return await redis.get(`auth:api_key:${hashedKey}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function setRedisAuthCache(
|
||||
hashedKey: string,
|
||||
value: JsonRecord,
|
||||
ttlSeconds: number
|
||||
): Promise<void> {
|
||||
const redis = await getRedisAuthCacheClient();
|
||||
if (!redis) return;
|
||||
|
||||
try {
|
||||
await redis.set(`auth:api_key:${hashedKey}`, JSON.stringify(value), "EX", ttlSeconds);
|
||||
} catch {
|
||||
// Redis is an optimization; SQLite remains authoritative.
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRedisAuthCacheKeys(...hashes: unknown[]): Promise<void> {
|
||||
const redisKeys = hashes
|
||||
.filter((hash): hash is string => typeof hash === "string" && hash.trim() !== "")
|
||||
.map((hash) => `auth:api_key:${hash}`);
|
||||
|
||||
if (redisKeys.length === 0) return;
|
||||
|
||||
const redis = await getRedisAuthCacheClient();
|
||||
if (!redis) return;
|
||||
|
||||
try {
|
||||
await redis.del(...redisKeys);
|
||||
} catch {
|
||||
// Redis is an optimization; SQLite remains authoritative.
|
||||
}
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" ? (value as JsonRecord) : {};
|
||||
}
|
||||
@@ -402,13 +467,11 @@ function parseRateLimits(value: unknown): RateLimitRule[] | null {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed.filter(
|
||||
(rule: any) =>
|
||||
typeof rule === "object" &&
|
||||
rule !== null &&
|
||||
typeof rule.limit === "number" &&
|
||||
typeof rule.window === "number"
|
||||
) as RateLimitRule[];
|
||||
return parsed.filter((rule): rule is RateLimitRule => {
|
||||
if (!rule || typeof rule !== "object") return false;
|
||||
const candidate = rule as Record<string, unknown>;
|
||||
return typeof candidate.limit === "number" && typeof candidate.window === "number";
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -526,15 +589,7 @@ export async function regenerateApiKey(id: string) {
|
||||
// Invalidate all caches
|
||||
clearApiKeyCaches();
|
||||
|
||||
// Redis invalidation
|
||||
try {
|
||||
const { getRedisClient } = await import("@/shared/utils/rateLimiter");
|
||||
const redis = getRedisClient();
|
||||
if (typeof row.key_hash === "string") await redis.del(`auth:api_key:${row.key_hash}`);
|
||||
await redis.del(`auth:api_key:${newHash}`);
|
||||
} catch (err) {
|
||||
// Fail silent
|
||||
}
|
||||
await deleteRedisAuthCacheKeys(row.key_hash, newHash);
|
||||
|
||||
const { logAuditEvent } = await import("@/lib/compliance");
|
||||
logAuditEvent({
|
||||
@@ -737,12 +792,8 @@ export async function updateApiKeyPermissions(
|
||||
const row = db.prepare("SELECT key_hash FROM api_keys WHERE id = ?").get(id) as
|
||||
| { key_hash: string | null }
|
||||
| undefined;
|
||||
if (row?.key_hash) {
|
||||
const { getRedisClient } = await import("@/shared/utils/rateLimiter");
|
||||
const redis = getRedisClient();
|
||||
await redis.del(`auth:api_key:${row.key_hash}`);
|
||||
}
|
||||
} catch (err) {
|
||||
await deleteRedisAuthCacheKeys(row?.key_hash);
|
||||
} catch {
|
||||
// Fail silent
|
||||
}
|
||||
|
||||
@@ -753,6 +804,7 @@ export async function updateApiKeyPermissions(
|
||||
export async function deleteApiKey(id: string) {
|
||||
const db = getDbInstance() as ApiKeysDbLike;
|
||||
const stmt = getPreparedStatements(db);
|
||||
const row = stmt.getKeyById.get(id) as ApiKeyRow | undefined;
|
||||
const result = stmt.deleteKey.run(id);
|
||||
|
||||
if (result.changes === 0) return false;
|
||||
@@ -763,6 +815,7 @@ export async function deleteApiKey(id: string) {
|
||||
|
||||
// Invalidate caches since a key was removed
|
||||
invalidateCaches();
|
||||
await deleteRedisAuthCacheKeys(row?.key_hash ?? row?.keyHash);
|
||||
|
||||
backupDbFile("pre-write");
|
||||
return true;
|
||||
@@ -786,6 +839,10 @@ export async function revokeApiKey(id: string): Promise<boolean> {
|
||||
if ((result.changes ?? 0) === 0) return false;
|
||||
|
||||
invalidateCaches();
|
||||
const row = db.prepare("SELECT key_hash FROM api_keys WHERE id = ?").get(id) as
|
||||
| { key_hash: string | null }
|
||||
| undefined;
|
||||
await deleteRedisAuthCacheKeys(row?.key_hash);
|
||||
backupDbFile("pre-write");
|
||||
return true;
|
||||
}
|
||||
@@ -804,6 +861,10 @@ export async function setApiKeyExpiry(id: string, expiresAt: string | null): Pro
|
||||
if ((result.changes ?? 0) === 0) return false;
|
||||
|
||||
invalidateCaches();
|
||||
const row = db.prepare("SELECT key_hash FROM api_keys WHERE id = ?").get(id) as
|
||||
| { key_hash: string | null }
|
||||
| undefined;
|
||||
await deleteRedisAuthCacheKeys(row?.key_hash);
|
||||
backupDbFile("pre-write");
|
||||
return true;
|
||||
}
|
||||
@@ -817,10 +878,8 @@ export async function setApiKeyExpiry(id: string, expiresAt: string | null): Pro
|
||||
* - revoked_at IS NULL,
|
||||
* - expires_at IS NULL OR expires_at > now.
|
||||
*
|
||||
* Cache TTL is short (CACHE_TTL) and the metadata cache is also invalidated
|
||||
* by revokeApiKey/updateApiKeyPermissions/deleteApiKey, so a revoke takes
|
||||
* effect within at most CACHE_TTL even without an explicit clear in the
|
||||
* caller.
|
||||
* Cache TTL is short (CACHE_TTL), and every key lifecycle path invalidates
|
||||
* both the in-process caches and the Redis auth fast path.
|
||||
*/
|
||||
export async function validateApiKey(key: string | null | undefined) {
|
||||
if (!key || typeof key !== "string") return false;
|
||||
@@ -836,13 +895,9 @@ export async function validateApiKey(key: string | null | undefined) {
|
||||
return cached.valid;
|
||||
}
|
||||
|
||||
// Try Redis cache for multi-instance consistency
|
||||
try {
|
||||
const { getRedisClient } = await import("@/shared/utils/rateLimiter");
|
||||
const redis = getRedisClient();
|
||||
const redisKey = `auth:api_key:${hashedKey}`;
|
||||
const redisData = await redis.get(redisKey);
|
||||
if (redisData) {
|
||||
const redisData = await getRedisAuthCache(hashedKey);
|
||||
if (redisData) {
|
||||
try {
|
||||
const data = JSON.parse(redisData);
|
||||
const isBanned = !!data.isBanned;
|
||||
const isActive = !!data.isActive;
|
||||
@@ -856,9 +911,9 @@ export async function validateApiKey(key: string | null | undefined) {
|
||||
if (Number.isFinite(expiresMs) && expiresMs <= now) return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
await deleteRedisAuthCacheKeys(hashedKey);
|
||||
}
|
||||
} catch (err) {
|
||||
// Fail silent for Redis lookup
|
||||
}
|
||||
|
||||
const db = getDbInstance() as ApiKeysDbLike;
|
||||
@@ -885,26 +940,17 @@ export async function validateApiKey(key: string | null | undefined) {
|
||||
evictIfNeeded(_keyValidationCache);
|
||||
_keyValidationCache.set(cacheKey, { valid: true, timestamp: now });
|
||||
|
||||
// Update Redis cache for fast validation
|
||||
try {
|
||||
const { getRedisClient } = await import("@/shared/utils/rateLimiter");
|
||||
const redis = getRedisClient();
|
||||
const redisKey = `auth:api_key:${hashedKey}`;
|
||||
await redis.set(
|
||||
redisKey,
|
||||
JSON.stringify({
|
||||
id: row.id,
|
||||
isBanned: parseIsBanned(row.is_banned),
|
||||
isActive: parseIsActive(row.is_active),
|
||||
expiresAt: row.expires_at,
|
||||
revokedAt: row.revoked_at,
|
||||
}),
|
||||
"EX",
|
||||
3600 // 1 hour cache
|
||||
);
|
||||
} catch (err) {
|
||||
// Fail silent for Redis cache update
|
||||
}
|
||||
await setRedisAuthCache(
|
||||
hashedKey,
|
||||
{
|
||||
id: row.id,
|
||||
isBanned: parseIsBanned(row.is_banned),
|
||||
isActive: parseIsActive(row.is_active),
|
||||
expiresAt: row.expires_at,
|
||||
revokedAt: row.revoked_at,
|
||||
},
|
||||
3600
|
||||
);
|
||||
|
||||
markApiKeyUsed(db, row.id, now);
|
||||
|
||||
|
||||
@@ -38,6 +38,24 @@ interface BudgetResetLogRecord {
|
||||
periodEnd: number;
|
||||
}
|
||||
|
||||
interface FallbackChainEntry {
|
||||
provider: string;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface LockoutStateRecord {
|
||||
attempts: number[];
|
||||
lockedUntil: number | null;
|
||||
}
|
||||
|
||||
interface CircuitBreakerStateRecord {
|
||||
state: string;
|
||||
failureCount: number;
|
||||
lastFailureTime: number | null;
|
||||
options?: JsonRecord | null;
|
||||
}
|
||||
|
||||
let _budgetSchemaChecked = false;
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
@@ -114,7 +132,7 @@ function ensureBudgetSchema() {
|
||||
* @param {string} model
|
||||
* @param {Array<{provider: string, priority: number, enabled: boolean}>} chain
|
||||
*/
|
||||
export function saveFallbackChain(model, chain) {
|
||||
export function saveFallbackChain(model: string, chain: FallbackChainEntry[]) {
|
||||
const db = getDbInstance();
|
||||
db.prepare("INSERT OR REPLACE INTO domain_fallback_chains (model, chain) VALUES (?, ?)").run(
|
||||
model,
|
||||
@@ -127,7 +145,7 @@ export function saveFallbackChain(model, chain) {
|
||||
* @param {string} model
|
||||
* @returns {Array<{provider: string, priority: number, enabled: boolean}> | null}
|
||||
*/
|
||||
export function loadFallbackChain(model) {
|
||||
export function loadFallbackChain(model: string): FallbackChainEntry[] | null {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT chain FROM domain_fallback_chains WHERE model = ?").get(model);
|
||||
const chain = asRecord(row).chain;
|
||||
@@ -157,7 +175,7 @@ export function loadAllFallbackChains() {
|
||||
* @param {string} model
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function deleteFallbackChain(model) {
|
||||
export function deleteFallbackChain(model: string) {
|
||||
const db = getDbInstance();
|
||||
const info = db.prepare("DELETE FROM domain_fallback_chains WHERE model = ?").run(model);
|
||||
return info.changes > 0;
|
||||
@@ -178,7 +196,7 @@ export function deleteAllFallbackChains() {
|
||||
* @param {string} apiKeyId
|
||||
* @param {{ dailyLimitUsd: number, monthlyLimitUsd?: number, warningThreshold?: number }} config
|
||||
*/
|
||||
export function saveBudget(apiKeyId, config) {
|
||||
export function saveBudget(apiKeyId: string, config: Partial<BudgetConfigRecord>) {
|
||||
ensureBudgetSchema();
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
@@ -216,7 +234,7 @@ export function saveBudget(apiKeyId, config) {
|
||||
* @param {string} apiKeyId
|
||||
* @returns {{ dailyLimitUsd: number, monthlyLimitUsd: number, warningThreshold: number } | null}
|
||||
*/
|
||||
export function loadBudget(apiKeyId) {
|
||||
export function loadBudget(apiKeyId: string): BudgetConfigRecord | null {
|
||||
ensureBudgetSchema();
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT * FROM domain_budgets WHERE api_key_id = ?").get(apiKeyId);
|
||||
@@ -228,7 +246,9 @@ export function loadBudget(apiKeyId) {
|
||||
monthlyLimitUsd: toNumber(record.monthly_limit_usd),
|
||||
warningThreshold: toNumber(record.warning_threshold, 0.8),
|
||||
resetInterval:
|
||||
typeof record.reset_interval === "string" ? record.reset_interval : ("daily" as const),
|
||||
typeof record.reset_interval === "string"
|
||||
? (record.reset_interval as BudgetResetInterval)
|
||||
: "daily",
|
||||
resetTime: typeof record.reset_time === "string" ? record.reset_time : "00:00",
|
||||
budgetResetAt: toNumber(record.budget_reset_at, 0) || null,
|
||||
lastBudgetResetAt: toNumber(record.last_budget_reset_at, 0) || null,
|
||||
@@ -335,7 +355,7 @@ export function loadBudgetResetLogs(apiKeyId: string, limit = 10) {
|
||||
* Delete a budget config.
|
||||
* @param {string} apiKeyId
|
||||
*/
|
||||
export function deleteBudget(apiKeyId) {
|
||||
export function deleteBudget(apiKeyId: string) {
|
||||
ensureBudgetSchema();
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM domain_budgets WHERE api_key_id = ?").run(apiKeyId);
|
||||
@@ -350,7 +370,7 @@ export function deleteBudget(apiKeyId) {
|
||||
* @param {number} cost
|
||||
* @param {number} [timestamp]
|
||||
*/
|
||||
export function saveCostEntry(apiKeyId, cost, timestamp = Date.now()) {
|
||||
export function saveCostEntry(apiKeyId: string, cost: number, timestamp = Date.now()) {
|
||||
ensureBudgetSchema();
|
||||
const db = getDbInstance();
|
||||
db.prepare("INSERT INTO domain_cost_history (api_key_id, cost, timestamp) VALUES (?, ?, ?)").run(
|
||||
@@ -437,7 +457,7 @@ export function loadCostEntriesInRange(
|
||||
* @param {number} olderThanTimestamp
|
||||
* @returns {number} deleted count
|
||||
*/
|
||||
export function cleanOldCostEntries(olderThanTimestamp) {
|
||||
export function cleanOldCostEntries(olderThanTimestamp: number) {
|
||||
ensureBudgetSchema();
|
||||
const db = getDbInstance();
|
||||
const info = db
|
||||
@@ -450,7 +470,7 @@ export function cleanOldCostEntries(olderThanTimestamp) {
|
||||
* Delete all cost data for an API key.
|
||||
* @param {string} apiKeyId
|
||||
*/
|
||||
export function deleteCostEntries(apiKeyId) {
|
||||
export function deleteCostEntries(apiKeyId: string) {
|
||||
ensureBudgetSchema();
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM domain_cost_history WHERE api_key_id = ?").run(apiKeyId);
|
||||
@@ -474,7 +494,7 @@ export function deleteAllCostData() {
|
||||
* @param {string} identifier
|
||||
* @param {{ attempts: number[], lockedUntil: number|null }} state
|
||||
*/
|
||||
export function saveLockoutState(identifier, state) {
|
||||
export function saveLockoutState(identifier: string, state: LockoutStateRecord) {
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO domain_lockout_state (identifier, attempts, locked_until)
|
||||
@@ -487,7 +507,7 @@ export function saveLockoutState(identifier, state) {
|
||||
* @param {string} identifier
|
||||
* @returns {{ attempts: number[], lockedUntil: number|null } | null}
|
||||
*/
|
||||
export function loadLockoutState(identifier) {
|
||||
export function loadLockoutState(identifier: string): LockoutStateRecord | null {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT * FROM domain_lockout_state WHERE identifier = ?").get(identifier);
|
||||
if (!row) return null;
|
||||
@@ -504,7 +524,7 @@ export function loadLockoutState(identifier) {
|
||||
* Delete lockout state for an identifier.
|
||||
* @param {string} identifier
|
||||
*/
|
||||
export function deleteLockoutState(identifier) {
|
||||
export function deleteLockoutState(identifier: string) {
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM domain_lockout_state WHERE identifier = ?").run(identifier);
|
||||
}
|
||||
@@ -538,7 +558,7 @@ export function loadAllLockedIdentifiers() {
|
||||
* @param {string} name
|
||||
* @param {{ state: string, failureCount: number, lastFailureTime: number|null, options?: object }} cbState
|
||||
*/
|
||||
export function saveCircuitBreakerState(name, cbState) {
|
||||
export function saveCircuitBreakerState(name: string, cbState: CircuitBreakerStateRecord) {
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO domain_circuit_breakers (name, state, failure_count, last_failure_time, options)
|
||||
@@ -557,7 +577,7 @@ export function saveCircuitBreakerState(name, cbState) {
|
||||
* @param {string} name
|
||||
* @returns {{ state: string, failureCount: number, lastFailureTime: number|null, options?: object } | null}
|
||||
*/
|
||||
export function loadCircuitBreakerState(name) {
|
||||
export function loadCircuitBreakerState(name: string): CircuitBreakerStateRecord | null {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT * FROM domain_circuit_breakers WHERE name = ?").get(name);
|
||||
if (!row) return null;
|
||||
@@ -596,7 +616,7 @@ export function loadAllCircuitBreakerStates() {
|
||||
* Delete a circuit breaker state.
|
||||
* @param {string} name
|
||||
*/
|
||||
export function deleteCircuitBreakerState(name) {
|
||||
export function deleteCircuitBreakerState(name: string) {
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM domain_circuit_breakers WHERE name = ?").run(name);
|
||||
}
|
||||
|
||||
@@ -272,7 +272,10 @@ export async function getPricingWithSources(): Promise<{
|
||||
export async function getPricingForModel(provider: string, model: string) {
|
||||
const pricing = await getPricing();
|
||||
|
||||
const findKeyInsensitive = (obj: Record<string, any> | undefined | null, key: string) => {
|
||||
const findKeyInsensitive = <T>(
|
||||
obj: Record<string, T> | undefined | null,
|
||||
key: string
|
||||
): T | undefined => {
|
||||
if (!obj || !key) return undefined;
|
||||
const lowerKey = key.toLowerCase();
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
@@ -282,10 +285,10 @@ export async function getPricingForModel(provider: string, model: string) {
|
||||
};
|
||||
|
||||
const pLower = (provider || "").toLowerCase();
|
||||
let providerPricing = findKeyInsensitive(pricing, pLower);
|
||||
let providerPricing = findKeyInsensitive<PricingModels>(pricing, pLower);
|
||||
|
||||
if (!providerPricing) {
|
||||
const alias = findKeyInsensitive(PROVIDER_ID_TO_ALIAS, pLower);
|
||||
const alias = findKeyInsensitive<string>(PROVIDER_ID_TO_ALIAS, pLower);
|
||||
if (alias) providerPricing = findKeyInsensitive(pricing, alias);
|
||||
}
|
||||
|
||||
@@ -308,7 +311,7 @@ export async function getPricingForModel(provider: string, model: string) {
|
||||
if (!providerPricing) return null;
|
||||
|
||||
const mLower = (model || "").toLowerCase();
|
||||
let modelPricing = findKeyInsensitive(providerPricing, mLower);
|
||||
let modelPricing = findKeyInsensitive<JsonRecord>(providerPricing, mLower);
|
||||
|
||||
if (!modelPricing) {
|
||||
const hyphenModel = mLower.replace(/\./g, "-");
|
||||
|
||||
@@ -1854,6 +1854,39 @@ export const UPSTREAM_PROXY_PROVIDERS = {
|
||||
},
|
||||
};
|
||||
|
||||
export const CLOUD_AGENT_PROVIDERS = {
|
||||
jules: {
|
||||
id: "jules",
|
||||
alias: "jules",
|
||||
name: "Google Jules",
|
||||
icon: "engineering",
|
||||
color: "#4285F4",
|
||||
textIcon: "JL",
|
||||
website: "https://jules.google",
|
||||
authHint: "Jules API key for creating and managing cloud coding tasks.",
|
||||
},
|
||||
devin: {
|
||||
id: "devin",
|
||||
alias: "devin",
|
||||
name: "Devin",
|
||||
icon: "smart_toy",
|
||||
color: "#111827",
|
||||
textIcon: "DV",
|
||||
website: "https://devin.ai",
|
||||
authHint: "Devin API key for cloud agent sessions.",
|
||||
},
|
||||
"codex-cloud": {
|
||||
id: "codex-cloud",
|
||||
alias: "codex-cloud",
|
||||
name: "Codex Cloud",
|
||||
icon: "cloud",
|
||||
color: "#10A37F",
|
||||
textIcon: "CC",
|
||||
website: "https://openai.com/codex",
|
||||
authHint: "OpenAI API key with Codex Cloud task access.",
|
||||
},
|
||||
};
|
||||
|
||||
export function isClaudeCodeCompatibleProvider(providerId: unknown): providerId is string {
|
||||
return typeof providerId === "string" && providerId.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX);
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ export class CircuitBreaker {
|
||||
* @returns {Promise<T>}
|
||||
* @throws {Error} If circuit is OPEN
|
||||
*/
|
||||
async execute(fn) {
|
||||
async execute<T>(fn: () => Promise<T>): Promise<T> {
|
||||
this._refreshOpenState();
|
||||
|
||||
if (this.state === STATE.OPEN) {
|
||||
@@ -319,7 +319,7 @@ export class CircuitBreaker {
|
||||
}
|
||||
}
|
||||
|
||||
_transition(newState) {
|
||||
_transition(newState: CircuitState) {
|
||||
const oldState = this.state;
|
||||
this.state = newState;
|
||||
if (newState === STATE.HALF_OPEN) {
|
||||
|
||||
@@ -1326,21 +1326,26 @@ export async function getProviderCredentialsWithQuotaPreflight(
|
||||
return credentials;
|
||||
}
|
||||
|
||||
const preflight = await preflightQuota(provider, credentials.connectionId, credentials);
|
||||
const connectionId = credentials.connectionId;
|
||||
if (!connectionId) {
|
||||
return credentials;
|
||||
}
|
||||
|
||||
const preflight = await preflightQuota(provider, connectionId, credentials);
|
||||
if (preflight.proceed) {
|
||||
return credentials;
|
||||
}
|
||||
|
||||
blockedByPreflight.push({
|
||||
id: credentials.connectionId,
|
||||
id: connectionId,
|
||||
quotaPercent: preflight.quotaPercent,
|
||||
resetAt: preflight.resetAt ?? null,
|
||||
});
|
||||
excludedConnectionIds.add(credentials.connectionId);
|
||||
excludedConnectionIds.add(connectionId);
|
||||
|
||||
log.info(
|
||||
"AUTH",
|
||||
`${provider} | preflight blocked ${credentials.connectionId.slice(0, 8)}${
|
||||
`${provider} | preflight blocked ${connectionId.slice(0, 8)}${
|
||||
Number.isFinite(preflight.quotaPercent)
|
||||
? ` at ${Math.round((preflight.quotaPercent as number) * 100)}%`
|
||||
: ""
|
||||
|
||||
@@ -111,26 +111,29 @@ function createServerProcess() {
|
||||
const stderrLines: string[] = [];
|
||||
let exitInfo: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
||||
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
["node_modules/next/dist/bin/next", "dev", "--port", String(SERVER_PORT)],
|
||||
{
|
||||
cwd: REPO_ROOT,
|
||||
env: {
|
||||
DATA_DIR: TEST_DATA_DIR,
|
||||
PORT: String(SERVER_PORT),
|
||||
HOST: "127.0.0.1",
|
||||
REQUIRE_API_KEY: "false",
|
||||
API_KEY_SECRET: "batch-e2e-rl-secret",
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true",
|
||||
INITIAL_PASSWORD: "",
|
||||
NEXT_TELEMETRY_DISABLED: "1",
|
||||
OMNIROUTE_E2E_BOOTSTRAP_MODE: "open",
|
||||
PATH: process.env.PATH,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}
|
||||
);
|
||||
const child = spawn(process.execPath, ["scripts/run-next-playwright.mjs", "dev"], {
|
||||
cwd: REPO_ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
DATA_DIR: TEST_DATA_DIR,
|
||||
PORT: String(SERVER_PORT),
|
||||
DASHBOARD_PORT: String(SERVER_PORT),
|
||||
API_PORT: String(SERVER_PORT),
|
||||
HOST: "127.0.0.1",
|
||||
REQUIRE_API_KEY: "false",
|
||||
API_KEY_SECRET: "batch-e2e-rl-secret",
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true",
|
||||
INITIAL_PASSWORD: "",
|
||||
NEXT_TELEMETRY_DISABLED: "1",
|
||||
OMNIROUTE_E2E_BOOTSTRAP_MODE: "open",
|
||||
OMNIROUTE_DISABLE_BACKGROUND_SERVICES: "false",
|
||||
OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: "true",
|
||||
OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: "true",
|
||||
OMNIROUTE_HIDE_HEALTHCHECK_LOGS: "true",
|
||||
PATH: process.env.PATH,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
child.once("exit", (code, signal) => {
|
||||
exitInfo = { code, signal };
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("Chat Pipeline — handleSingleModelChat decomposition", () => {
|
||||
|
||||
it("handleSingleModelChat should use resolveModelOrError", () => {
|
||||
// Extract handleSingleModelChat body
|
||||
assert.match(src, /resolveModelOrError\(modelStr/);
|
||||
assert.match(src, /resolveModelOrError\(\s*modelStr/);
|
||||
});
|
||||
|
||||
it("handleSingleModelChat should use checkPipelineGates", () => {
|
||||
|
||||
@@ -278,11 +278,13 @@ test("requireManagementAuth returns 401 with no credentials", async () => {
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
test("requireManagementAuth returns 401 for an invalid API key", async () => {
|
||||
test("requireManagementAuth returns 403 for an invalid management token", async () => {
|
||||
await setupAuth();
|
||||
const res = await requireManagementAuth(managementRequest("sk-not-a-real-key"));
|
||||
assert.ok(res);
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(res.status, 403);
|
||||
const body = await res.json();
|
||||
assert.equal(body.error?.message, "Invalid management token");
|
||||
});
|
||||
|
||||
test("requireManagementAuth returns 403 for valid key without manage scope", async () => {
|
||||
@@ -314,13 +316,15 @@ test("requireManagementAuth returns null for OMNIROUTE_API_KEY env passthrough",
|
||||
}
|
||||
});
|
||||
|
||||
test("requireManagementAuth returns 401 for revoked key with manage scope", async () => {
|
||||
test("requireManagementAuth returns 403 for revoked key with manage scope", async () => {
|
||||
await setupAuth();
|
||||
const key = await apiKeysDb.createApiKey("revoked-admin", "machine-test", ["manage"]);
|
||||
await apiKeysDb.revokeApiKey(key.id);
|
||||
const res = await requireManagementAuth(managementRequest(key.key));
|
||||
assert.ok(res);
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(res.status, 403);
|
||||
const body = await res.json();
|
||||
assert.equal(body.error?.message, "Invalid management token");
|
||||
});
|
||||
|
||||
test("requireManagementAuth returns null for valid JWT cookie", async () => {
|
||||
|
||||
@@ -82,7 +82,7 @@ test("GeminiCLIExecutor.buildHeaders derives the User-Agent from the request mod
|
||||
assert.notEqual(flashHeaders["User-Agent"], proHeaders["User-Agent"]);
|
||||
});
|
||||
|
||||
test("GeminiCLIExecutor.refreshProject caches loadCodeAssist lookups and transformRequest preserves existing body.project", async () => {
|
||||
test("GeminiCLIExecutor.refreshProject caches loadCodeAssist lookups and transformRequest refreshes stale body.project", async () => {
|
||||
const executor = new GeminiCLIExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
let calls = 0;
|
||||
@@ -107,7 +107,7 @@ test("GeminiCLIExecutor.refreshProject caches loadCodeAssist lookups and transfo
|
||||
assert.equal(first, "fresh-project-id");
|
||||
assert.equal(second, "fresh-project-id");
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(transformed.project, "stale-project");
|
||||
assert.equal(transformed.project, "fresh-project-id");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
@@ -368,7 +368,7 @@ test("GeminiCLIExecutor.execute applies CLI fingerprint to the final Cloud Code
|
||||
"Authorization",
|
||||
]);
|
||||
assert.equal(finalBody.model, "gemini-3.1-pro-preview");
|
||||
assert.equal(finalBody.project, "old-project");
|
||||
assert.equal(finalBody.project, "project-live");
|
||||
assert.match(finalBody.user_prompt_id, /^agent-/);
|
||||
assert.match(finalBody.request.session_id, /^-\d+$/);
|
||||
assert.match(finalCall.headers["User-Agent"], /^GeminiCLI\/0\.41\.2\/gemini-3\.1-pro-preview /);
|
||||
|
||||
@@ -80,8 +80,8 @@ test("model alias route requires a dashboard session when management auth is ena
|
||||
|
||||
assert.equal(unauthenticated.status, 401);
|
||||
assert.equal(unauthenticatedBody.error.message, "Authentication required");
|
||||
assert.equal(invalidToken.status, 401);
|
||||
assert.equal(invalidTokenBody.error.message, "Invalid API key");
|
||||
assert.equal(invalidToken.status, 403);
|
||||
assert.equal(invalidTokenBody.error.message, "Invalid management token");
|
||||
assert.match(unauthenticated.headers.get("X-Model-Catalog-Version") || "", /^model-metadata-v1:/);
|
||||
});
|
||||
|
||||
|
||||
@@ -94,8 +94,8 @@ test("model test route requires management auth when login protection is enabled
|
||||
|
||||
assert.equal(unauthenticated.status, 401);
|
||||
assert.equal(unauthenticatedBody.error.message, "Authentication required");
|
||||
assert.equal(invalidToken.status, 401);
|
||||
assert.equal(invalidTokenBody.error.message, "Invalid API key");
|
||||
assert.equal(invalidToken.status, 403);
|
||||
assert.equal(invalidTokenBody.error.message, "Invalid management token");
|
||||
});
|
||||
|
||||
test("model test route ignores forwarded hosts and works in strict API-key mode", async () => {
|
||||
|
||||
@@ -91,8 +91,8 @@ test("payload rules route requires a dashboard session when management auth is e
|
||||
|
||||
assert.equal(unauthenticated.status, 401);
|
||||
assert.equal(unauthenticatedBody.error.message, "Authentication required");
|
||||
assert.equal(invalidToken.status, 401);
|
||||
assert.equal(invalidTokenBody.error.message, "Invalid API key");
|
||||
assert.equal(invalidToken.status, 403);
|
||||
assert.equal(invalidTokenBody.error.message, "Invalid management token");
|
||||
assert.equal(authenticated.status, 200);
|
||||
assert.deepEqual(authenticatedBody, {
|
||||
default: [],
|
||||
|
||||
@@ -40,8 +40,8 @@ test("getModelInfoCore keeps unprefixed gpt-5.5 on the OpenAI fallback", async (
|
||||
assert.equal(info.model, "gpt-5.5");
|
||||
});
|
||||
|
||||
test("getModelInfoCore keeps explicit gpt-5.5-medium separate from gpt-5.5", async () => {
|
||||
const info = await getModelInfoCore("gpt-5.5-medium", {});
|
||||
test("getModelInfoCore keeps explicit cx/gpt-5.5-medium separate from gpt-5.5", async () => {
|
||||
const info = await getModelInfoCore("cx/gpt-5.5-medium", {});
|
||||
assert.equal(info.provider, "codex");
|
||||
assert.equal(info.model, "gpt-5.5-medium");
|
||||
});
|
||||
|
||||
@@ -133,7 +133,7 @@ test("v1 management proxies main route covers auth, lookup variants, update and
|
||||
}),
|
||||
})
|
||||
);
|
||||
assert.equal(postAuthRes.status, 401);
|
||||
assert.equal(postAuthRes.status, 403);
|
||||
|
||||
const patchAuthRes = await proxyV1Route.PATCH(
|
||||
new Request("http://localhost/api/v1/management/proxies", {
|
||||
@@ -142,7 +142,7 @@ test("v1 management proxies main route covers auth, lookup variants, update and
|
||||
body: JSON.stringify({ id: "proxy-1", notes: "denied" }),
|
||||
})
|
||||
);
|
||||
assert.equal(patchAuthRes.status, 401);
|
||||
assert.equal(patchAuthRes.status, 403);
|
||||
|
||||
const deleteAuthRes = await proxyV1Route.DELETE(
|
||||
new Request("http://localhost/api/v1/management/proxies?id=proxy-1", {
|
||||
|
||||
@@ -174,8 +174,8 @@ test("api keys route covers auth, create, masking, pagination fallback and cloud
|
||||
|
||||
assert.equal(unauthenticated.status, 401);
|
||||
assert.equal(unauthenticatedBody.error.message, "Authentication required");
|
||||
assert.equal(invalidToken.status, 401);
|
||||
assert.equal(invalidTokenBody.error.message, "Invalid API key");
|
||||
assert.equal(invalidToken.status, 403);
|
||||
assert.equal(invalidTokenBody.error.message, "Invalid management token");
|
||||
|
||||
assert.equal(created.status, 201);
|
||||
assert.equal(createdBody.name, "Key / Prod #1");
|
||||
@@ -595,8 +595,8 @@ test("management proxies route covers auth, pagination, lookup, where-used, patc
|
||||
|
||||
assert.equal(unauthenticated.status, 401);
|
||||
assert.equal(unauthenticatedBody.error.message, "Authentication required");
|
||||
assert.equal(invalidToken.status, 401);
|
||||
assert.equal(invalidTokenBody.error.message, "Invalid API key");
|
||||
assert.equal(invalidToken.status, 403);
|
||||
assert.equal(invalidTokenBody.error.message, "Invalid management token");
|
||||
assert.equal(createdResponse.status, 201);
|
||||
assert.equal(pagedList.status, 200);
|
||||
assert.equal(pagedListBody.page.limit, 200);
|
||||
|
||||
@@ -286,7 +286,7 @@ test("resolveQuotaLimitPolicy normalizes Codex windows, thresholds, and defaults
|
||||
});
|
||||
assert.deepEqual(defaults, {
|
||||
enabled: true,
|
||||
thresholdPercent: 90,
|
||||
thresholdPercent: 99,
|
||||
windows: ["session", "weekly"],
|
||||
});
|
||||
assert.deepEqual(generic, {
|
||||
@@ -1063,7 +1063,7 @@ test("markAccountUnavailable auto-disables permanently banned accounts when the
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(updated.isActive, false);
|
||||
assert.equal(updated.testStatus, "unavailable");
|
||||
assert.equal(updated.testStatus, "banned");
|
||||
});
|
||||
|
||||
test("markAccountUnavailable leaves permanently banned accounts active when auto-disable is disabled", async () => {
|
||||
@@ -1083,7 +1083,7 @@ test("markAccountUnavailable leaves permanently banned accounts active when auto
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(updated.isActive, true);
|
||||
assert.equal(updated.testStatus, "unavailable");
|
||||
assert.equal(updated.testStatus, "banned");
|
||||
});
|
||||
|
||||
test("markAccountUnavailable swallows auto-disable persistence errors", async () => {
|
||||
@@ -1127,7 +1127,7 @@ test("markAccountUnavailable swallows auto-disable persistence errors", async ()
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(updated.isActive, true);
|
||||
assert.equal(updated.testStatus, "unavailable");
|
||||
assert.equal(updated.testStatus, "banned");
|
||||
} finally {
|
||||
db.prepare = originalPrepare;
|
||||
}
|
||||
|
||||
@@ -961,8 +961,10 @@ test("usage service covers Qwen, Qoder, GLM, Z.AI and GLMT branches", async () =
|
||||
providerSpecificData: { apiRegion: "international" },
|
||||
});
|
||||
assert.equal(glmt.plan, "Pro");
|
||||
assert.equal(glmt.quotas["5 Hours Quota"].used, 15);
|
||||
assert.equal(glmt.quotas["Weekly Quota"].remaining, 36);
|
||||
assert.equal(glmt.quotas.session.used, 64);
|
||||
assert.equal(glmt.quotas.session.displayName, "5 Hours Quota");
|
||||
assert.equal(glmt.quotas.weekly.remaining, 75);
|
||||
assert.equal(glmt.quotas.weekly.displayName, "Weekly Quota");
|
||||
|
||||
let glmCnUrl = "";
|
||||
globalThis.fetch = async (url) => {
|
||||
|
||||
Reference in New Issue
Block a user