Merge remote-tracking branch 'origin/release/v3.8.0' into bugs/2120_docker

# Conflicts:
#	open-sse/executors/antigravity.ts
#	open-sse/services/accountFallback.ts
#	open-sse/services/usage.ts
#	open-sse/translator/helpers/geminiHelper.ts
#	open-sse/utils/error.ts
#	src/lib/db/apiKeys.ts
This commit is contained in:
diegosouzapw
2026-05-10 21:22:21 -03:00
34 changed files with 501 additions and 238 deletions

View File

@@ -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

View File

@@ -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" },

View File

@@ -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" },
];

View File

@@ -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.

View File

@@ -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;

View File

@@ -4200,7 +4200,6 @@ export async function handleChatCore({
success: true,
response: new Response(JSON.stringify(translatedResponse), {
headers: {
...Object.fromEntries(providerHeaders.entries()),
"Content-Type": "application/json",
[OMNIROUTE_RESPONSE_HEADERS.cache]: "MISS",
...buildOmniRouteResponseMetaHeaders({

View File

@@ -5,7 +5,7 @@ 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 { defaultLogger as log } from "@omniroute/open-sse/utils/logger";
/** Minimal connection shape needed for virtual auto-combo factory */
interface VirtualFactoryConn extends ConnectionFields {

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -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">

View File

@@ -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" },

View File

@@ -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");

View File

@@ -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",

View File

@@ -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",
});
}

View File

@@ -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);
}

View File

@@ -285,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);
}
@@ -311,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, "-");

View File

@@ -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);
}
@@ -1880,6 +1913,37 @@ export function isSelfHostedChatProvider(providerId: unknown): boolean {
return typeof providerId === "string" && SELF_HOSTED_CHAT_PROVIDER_IDS.has(providerId);
}
// ── Cloud Agent Providers ───────────────────────────────────────────────────
export const CLOUD_AGENT_PROVIDERS = {
jules: {
id: "jules",
alias: "jules",
name: "Jules",
icon: "engineering",
color: "#EAB308",
textIcon: "JU",
website: "https://jules.google.com",
},
devin: {
id: "devin",
alias: "devin",
name: "Devin",
icon: "smart_toy",
color: "#2563EB",
textIcon: "DV",
website: "https://devin.ai",
},
"codex-cloud": {
id: "codex-cloud",
alias: "codex-cloud",
name: "Codex Cloud",
icon: "code",
color: "#10B981",
textIcon: "CX",
website: "https://chatgpt.com/codex",
},
};
// ── System Providers (virtual, not user-connectable) ──────────────────────────
export const SYSTEM_PROVIDERS = {
auto: {

View File

@@ -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) {

View File

@@ -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)}%`
: ""

View File

@@ -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 };

View File

@@ -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", () => {

View File

@@ -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 () => {

View File

@@ -1354,6 +1354,25 @@ test("chatCore attaches OmniRoute response metadata headers to non-stream respon
assert.match(String(result.response.headers.get("X-OmniRoute-Response-Cost")), /^\d+\.\d{10}$/);
});
test("chatCore does not expose provider request credentials in non-stream response headers", async () => {
const { result } = await invokeChatCore({
provider: "openai",
model: "gpt-4o-mini",
body: {
model: "gpt-4o-mini",
stream: false,
messages: [{ role: "user", content: "hide provider credentials" }],
},
responseFormat: "openai",
});
assert.equal(result.success, true);
assert.equal(result.response.headers.get("authorization"), null);
assert.equal(result.response.headers.get("x-api-key"), null);
assert.equal(result.response.headers.get("Content-Type"), "application/json");
assert.equal(result.response.headers.get("X-OmniRoute-Cache"), "MISS");
});
test("chatCore normalizes tool finish reasons and estimates usage when upstream omits it", async () => {
const { result } = await invokeChatCore({
provider: "openai",

View File

@@ -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 /);

View File

@@ -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:/);
});

View File

@@ -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 () => {

View File

@@ -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: [],

View File

@@ -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");
});

View File

@@ -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", {

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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) => {