diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index c6ab8200cc..aa4b057b0f 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -6,6 +6,8 @@ * is auto-generated from this registry. */ +import { platform, arch } from "os"; + // ── Types ───────────────────────────────────────────────────────────────── export interface RegistryModel { @@ -96,6 +98,32 @@ const KIMI_CODING_SHARED = { ] as RegistryModel[], } as const; +function mapStainlessOs() { + switch (platform()) { + case "darwin": + return "MacOS"; + case "win32": + return "Windows"; + case "linux": + return "Linux"; + default: + return `Other::${platform()}`; + } +} + +function mapStainlessArch() { + switch (arch()) { + case "x64": + return "x64"; + case "arm64": + return "arm64"; + case "ia32": + return "x86"; + default: + return `other::${arch()}`; + } +} + // ── Registry ────────────────────────────────────────────────────────────── export const REGISTRY: Record = { @@ -112,19 +140,19 @@ export const REGISTRY: Record = { headers: { "Anthropic-Version": "2023-06-01", "Anthropic-Beta": - "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27", + "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05", "Anthropic-Dangerous-Direct-Browser-Access": "true", - "User-Agent": "claude-cli/1.0.83 (external, cli)", + "User-Agent": "claude-cli/2.1.63 (external, cli)", "X-App": "cli", "X-Stainless-Helper-Method": "stream", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime-Version": "v24.3.0", - "X-Stainless-Package-Version": "0.55.1", + "X-Stainless-Package-Version": "0.74.0", "X-Stainless-Runtime": "node", "X-Stainless-Lang": "js", - "X-Stainless-Arch": "arm64", - "X-Stainless-Os": "MacOS", - "X-Stainless-Timeout": "60", + "X-Stainless-Arch": mapStainlessArch(), + "X-Stainless-Os": mapStainlessOs(), + "X-Stainless-Timeout": "600", }, oauth: { clientIdEnv: "CLAUDE_OAUTH_CLIENT_ID", @@ -159,6 +187,8 @@ export const REGISTRY: Record = { clientSecretDefault: "", }, models: [ + { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" }, + { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" }, { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, { id: "gemini-3-1-pro", name: "Gemini 3.1 Pro (Alt ID)" }, { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, @@ -191,6 +221,8 @@ export const REGISTRY: Record = { clientSecretDefault: "", }, models: [ + { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" }, + { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" }, { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, { id: "gemini-3-1-pro", name: "Gemini 3.1 Pro (Alt ID)" }, { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, @@ -322,7 +354,11 @@ export const REGISTRY: Record = { alias: "ag", format: "antigravity", executor: "antigravity", - baseUrls: ["https://daily-cloudcode-pa.googleapis.com", "https://cloudcode-pa.googleapis.com"], + baseUrls: [ + "https://daily-cloudcode-pa.googleapis.com", + "https://daily-cloudcode-pa.sandbox.googleapis.com", + "https://cloudcode-pa.googleapis.com", + ], urlBuilder: (base, model, stream) => { const path = stream ? "/v1internal:streamGenerateContent?alt=sse" @@ -332,7 +368,7 @@ export const REGISTRY: Record = { authType: "oauth", authHeader: "bearer", headers: { - "User-Agent": "antigravity/1.104.0 darwin/arm64", + "User-Agent": `antigravity/1.107.0 ${platform()}/${arch()}`, }, oauth: { clientIdEnv: "ANTIGRAVITY_OAUTH_CLIENT_ID", @@ -361,9 +397,9 @@ export const REGISTRY: Record = { authHeader: "bearer", headers: { "copilot-integration-id": "vscode-chat", - "editor-version": "vscode/1.107.1", - "editor-plugin-version": "copilot-chat/0.26.7", - "user-agent": "GitHubCopilotChat/0.26.7", + "editor-version": "vscode/1.110.0", + "editor-plugin-version": "copilot-chat/0.38.0", + "user-agent": "GitHubCopilotChat/0.38.0", "openai-intent": "conversation-panel", "x-github-api-version": "2025-04-01", "x-vscode-user-agent-library-version": "electron-fetch", @@ -1146,7 +1182,7 @@ export const REGISTRY: Record = { alias: "vertex", // Vertex AI uses Google's generateContent format (same as Gemini) format: "gemini", - executor: "default", + executor: "vertex", // URL uses {project_id} and {region} from providerSpecificData — handled by custom executor or fallback // Default to us-central1 / generic endpoint; users configure project via providerSpecificData baseUrl: "https://us-central1-aiplatform.googleapis.com/v1/projects", diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index a2147e7383..95c8f1f762 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -1,4 +1,4 @@ -import { BaseExecutor } from "./base.ts"; +import { BaseExecutor, ExecuteInput } from "./base.ts"; import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts"; import { getModelTargetFormat } from "../config/providerModels.ts"; @@ -19,15 +19,82 @@ export class GithubExecutor extends BaseExecutor { return this.config.baseUrl; } + injectResponseFormat(messages: any[], responseFormat: any) { + if (!responseFormat) return messages; + + let formatInstruction = ""; + if (responseFormat.type === "json_object") { + formatInstruction = + "Respond only with valid JSON. Do not include any text before or after the JSON object."; + } else if (responseFormat.type === "json_schema" && responseFormat.json_schema) { + formatInstruction = `Respond only with valid JSON matching this schema:\n${JSON.stringify( + responseFormat.json_schema.schema, + null, + 2 + )}\nDo not include any text before or after the JSON.`; + } + + if (!formatInstruction) return messages; + + const systemIdx = messages.findIndex((m: any) => m.role === "system"); + if (systemIdx >= 0) { + return messages.map((m: any, i: number) => + i === systemIdx ? { ...m, content: `${m.content}\n\n${formatInstruction}` } : m + ); + } + + return [{ role: "system", content: formatInstruction }, ...messages]; + } + + transformRequest(model: string, body: any, stream: boolean, credentials: any): any { + const modifiedBody = JSON.parse(JSON.stringify(body)); + if (modifiedBody.response_format && model.toLowerCase().includes("claude")) { + modifiedBody.messages = this.injectResponseFormat( + modifiedBody.messages, + modifiedBody.response_format + ); + delete modifiedBody.response_format; + } + return modifiedBody; + } + + async execute(input: ExecuteInput) { + const result = await super.execute(input); + if (!result || !result.response?.body) return result; + + const isStreaming = input.stream === true; + if (isStreaming) { + const decoder = new TextDecoder(); + const transformStream = new TransformStream({ + transform(chunk, controller) { + const text = decoder.decode(chunk, { stream: true }); + if (text.includes("data: [DONE]")) { + return; + } + controller.enqueue(chunk); + }, + }); + + const newResponse = new Response(result.response.body.pipeThrough(transformStream), { + status: result.response.status, + statusText: result.response.statusText, + headers: result.response.headers, // Headers class carries over correctly + }); + result.response = newResponse; + } + + return result; + } + buildHeaders(credentials, stream = true) { const token = credentials.copilotToken || credentials.accessToken; return { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "copilot-integration-id": "vscode-chat", - "editor-version": "vscode/1.107.1", - "editor-plugin-version": "copilot-chat/0.26.7", - "user-agent": "GitHubCopilotChat/0.26.7", + "editor-version": "vscode/1.110.0", + "editor-plugin-version": "copilot-chat/0.38.0", + "user-agent": "GitHubCopilotChat/0.38.0", "openai-intent": "conversation-panel", "x-github-api-version": "2025-04-01", "x-request-id": @@ -44,7 +111,7 @@ export class GithubExecutor extends BaseExecutor { headers: { Authorization: `token ${githubAccessToken}`, "User-Agent": "GithubCopilot/1.0", - "Editor-Version": "vscode/1.100.0", + "Editor-Version": "vscode/1.110.0", "Editor-Plugin-Version": "copilot/1.300.0", Accept: "application/json", }, diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 6b49a5d847..768d2d7cd0 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -10,6 +10,7 @@ import { PollinationsExecutor } from "./pollinations.ts"; import { CloudflareAIExecutor } from "./cloudflare-ai.ts"; import { OpencodeExecutor } from "./opencode.ts"; import { PuterExecutor } from "./puter.ts"; +import { VertexExecutor } from "./vertex.ts"; const executors = { antigravity: new AntigravityExecutor(), @@ -28,6 +29,7 @@ const executors = { "opencode-go": new OpencodeExecutor("opencode-go"), puter: new PuterExecutor(), pu: new PuterExecutor(), // Alias + vertex: new VertexExecutor(), }; const defaultCache = new Map(); @@ -55,3 +57,4 @@ export { PollinationsExecutor } from "./pollinations.ts"; export { CloudflareAIExecutor } from "./cloudflare-ai.ts"; export { OpencodeExecutor } from "./opencode.ts"; export { PuterExecutor } from "./puter.ts"; +export { VertexExecutor } from "./vertex.ts"; diff --git a/open-sse/executors/vertex.ts b/open-sse/executors/vertex.ts new file mode 100644 index 0000000000..cfa7b6dfe3 --- /dev/null +++ b/open-sse/executors/vertex.ts @@ -0,0 +1,150 @@ +import { SignJWT, importPKCS8 } from "jose"; +import { BaseExecutor, ExecuteInput } from "./base.ts"; +import { PROVIDERS } from "../config/constants.ts"; + +interface ServiceAccount { + type: string; + project_id: string; + private_key_id: string; + private_key: string; + client_email: string; + [key: string]: unknown; +} + +const TOKEN_CACHE = new Map(); + +function parseSAFromApiKey(apiKey: string): ServiceAccount { + try { + return JSON.parse(apiKey); + } catch { + throw new Error("Vertex AI requires a valid Service Account JSON as the API key"); + } +} + +async function getAccessToken(sa: ServiceAccount): Promise { + if (!sa.client_email || !sa.private_key) { + throw new Error( + "Service Account JSON is missing required fields (client_email or private_key)" + ); + } + + const cacheKey = sa.client_email; + const cached = TOKEN_CACHE.get(cacheKey); + + // Buffer of 60 seconds + if (cached && Date.now() < cached.expiresAt - 60_000) { + return cached.token; + } + + const privateKey = await importPKCS8(sa.private_key, "RS256"); + const now = Math.floor(Date.now() / 1000); + + const jwt = await new SignJWT({ + iss: sa.client_email, + sub: sa.client_email, + aud: "https://oauth2.googleapis.com/token", + iat: now, + exp: now + 3600, + scope: "https://www.googleapis.com/auth/cloud-platform", + }) + .setProtectedHeader({ alg: "RS256", kid: sa.private_key_id }) + .sign(privateKey); + + const tokenRes = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: jwt, + }), + }); + + if (!tokenRes.ok) { + const errorText = await tokenRes.text(); + throw new Error( + `Failed to exchange JWT for Vertex access token: ${tokenRes.status} ${errorText}` + ); + } + + const tokenData = await tokenRes.json(); + const accessToken = tokenData.access_token; + + if (!accessToken) { + throw new Error("Vertex AI token exchange succeeded but no access_token found"); + } + + TOKEN_CACHE.set(cacheKey, { + token: accessToken, + expiresAt: (now + 3600) * 1000, + }); + + return accessToken; +} + +const PARTNER_MODELS = new Set([ + "claude-3-5-sonnet", + "claude-3-opus", + "claude-3-haiku", + "deepseek-v3", + "deepseek-v3.2", + "deepseek-deepseek-r1", + "qwen3-next-80b", + "llama-3.1", + "mistral-", + "glm-5", + "meta/llama", +]); + +function isPartnerModel(model: string) { + return [...PARTNER_MODELS].some((prefix) => model.startsWith(prefix)); +} + +export class VertexExecutor extends BaseExecutor { + constructor() { + super("vertex", PROVIDERS.vertex); + } + + async execute(input: ExecuteInput) { + const { credentials, log } = input; + if (credentials.apiKey && !credentials.accessToken) { + try { + const sa = parseSAFromApiKey(credentials.apiKey); + credentials.accessToken = await getAccessToken(sa); + } catch (err: any) { + log?.error?.("VERTEX", `Failed to generate JWT token: ${err.message}`); + throw err; + } + } + return super.execute(input); + } + + buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: any = null) { + const region = credentials?.providerSpecificData?.region || "us-central1"; + let project = "unknown-project"; + + if (credentials?.apiKey) { + try { + const sa = parseSAFromApiKey(credentials.apiKey); + if (sa.project_id) project = sa.project_id; + } catch { + // Ignored, handled in execute + } + } + + if (isPartnerModel(model)) { + return `https://aiplatform.googleapis.com/v1/projects/${project}/locations/global/endpoints/openapi/chat/completions`; + } + return `https://aiplatform.googleapis.com/v1/projects/${project}/locations/${region}/publishers/google/models/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`; + } + + buildHeaders(credentials: any, stream = true) { + const headers: Record = { "Content-Type": "application/json" }; + if (credentials.accessToken) { + headers["Authorization"] = `Bearer ${credentials.accessToken}`; + } + if (stream) { + headers["Accept"] = "text/event-stream"; + } + return headers; + } +} diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index fed1d53714..cbd10ab118 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -16,6 +16,8 @@ import { resolveModelAlias } from "../services/modelDeprecation.ts"; import { getUnsupportedParams } from "../config/providerRegistry.ts"; import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.ts"; import { HTTP_STATUS } from "../config/constants.ts"; +import { classifyProviderError, PROVIDER_ERROR_TYPES } from "../services/errorClassifier.ts"; +import { updateProviderConnection } from "@/lib/db/providers"; import { handleBypassRequest } from "../utils/bypassHandler.ts"; import { saveRequestUsage, @@ -25,6 +27,11 @@ import { } from "@/lib/usageDb"; import { getModelNormalizeToolCallId, getModelPreserveOpenAIDeveloperRole } from "@/lib/localDb"; import { getExecutor } from "../executors/index.ts"; +import { + parseCodexQuotaHeaders, + getCodexResetTime, + getCodexModelScope, +} from "../executors/codex.ts"; import { translateNonStreamingResponse } from "./responseTranslator.ts"; import { extractUsageFromResponse } from "./usageExtractor.ts"; import { parseSSEToOpenAIResponse, parseSSEToResponsesOutput } from "./sseParser.ts"; @@ -44,6 +51,11 @@ import { getIdempotencyKey, checkIdempotency, saveIdempotency } from "@/lib/idem import { createProgressTransform, wantsProgress } from "../utils/progressTracker.ts"; import { isModelUnavailableError, getNextFamilyFallback } from "../services/modelFamilyFallback.ts"; import { computeRequestHash, deduplicate, shouldDeduplicate } from "../services/requestDedup.ts"; +import { + isBackgroundTask, + getDegradedModel, + getBackgroundDegradationConfig, +} from "../services/backgroundTaskDetector.ts"; import { shouldUseFallback, isFallbackDecision, @@ -93,7 +105,9 @@ export async function handleChatCore({ userAgent, comboName, }) { - const { provider, model, extendedContext } = modelInfo; + let { provider, model, extendedContext } = modelInfo; + const requestedModel = + typeof body?.model === "string" && body.model.trim().length > 0 ? body.model : model; const startTime = Date.now(); const persistFailureUsage = (statusCode: number, errorCode?: string | null) => { saveRequestUsage({ @@ -112,6 +126,67 @@ export async function handleChatCore({ }).catch(() => {}); }; + const persistCodexQuotaState = async ( + headers: Headers | Record | null, + status = 0 + ) => { + if (provider !== "codex" || !connectionId || !headers) return; + + try { + const quota = parseCodexQuotaHeaders(headers as Headers); + if (!quota) return; + + const existingProviderData = + credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object" + ? credentials.providerSpecificData + : {}; + const scope = getCodexModelScope(model || requestedModel || ""); + const quotaState = { + usage5h: quota.usage5h, + limit5h: quota.limit5h, + resetAt5h: quota.resetAt5h, + usage7d: quota.usage7d, + limit7d: quota.limit7d, + resetAt7d: quota.resetAt7d, + scope, + updatedAt: new Date().toISOString(), + }; + + const nextProviderData: Record = { + ...existingProviderData, + codexQuotaState: quotaState, + }; + + // T03/T09: on 429, persist exact reset time per scope to avoid global over-blocking. + if (status === 429) { + const resetTimeMs = getCodexResetTime(quota); + if (resetTimeMs && resetTimeMs > Date.now()) { + const scopeUntil = new Date(resetTimeMs).toISOString(); + const scopeMapRaw = + existingProviderData && + typeof existingProviderData === "object" && + existingProviderData.codexScopeRateLimitedUntil && + typeof existingProviderData.codexScopeRateLimitedUntil === "object" + ? existingProviderData.codexScopeRateLimitedUntil + : {}; + + nextProviderData.codexScopeRateLimitedUntil = { + ...(scopeMapRaw as Record), + [scope]: scopeUntil, + }; + } + } + + await updateProviderConnection(connectionId, { + providerSpecificData: nextProviderData, + }); + + credentials.providerSpecificData = nextProviderData; + } catch (err) { + log?.debug?.("CODEX", `Failed to persist codex quota state: ${err?.message || err}`); + } + }; + // ── Phase 9.2: Idempotency check ── const idempotencyKey = getIdempotencyKey(clientRawRequest?.headers); const cachedIdemp = checkIdempotency(idempotencyKey); @@ -157,6 +232,22 @@ export async function handleChatCore({ // Detect source format and get target format // Model-specific targetFormat takes priority over provider default + // ── Background Task Redirection (T41) ── + const bgConfig = getBackgroundDegradationConfig(); + if (bgConfig.enabled && isBackgroundTask(body, clientRawRequest?.headers)) { + const degradedModel = getDegradedModel(model); + if (degradedModel !== model) { + log?.info?.( + "BACKGROUND", + `Background task detected: Redirecting ${model} → ${degradedModel}` + ); + model = degradedModel; + if (body && typeof body === "object") { + body.model = model; + } + } + } + // Apply custom model aliases (Settings → Model Aliases → Pattern→Target) before routing (#315, #472) // Custom aliases take priority over built-in and must be resolved here so the // downstream getModelTargetFormat() lookup AND the actual provider request use @@ -173,7 +264,17 @@ export async function handleChatCore({ const targetFormat = modelTargetFormat || getTargetFormat(provider); // Default to false unless client explicitly sets stream: true (OpenAI spec compliant) - const stream = body.stream === true; + const acceptHeader = + clientRawRequest?.headers && typeof clientRawRequest.headers.get === "function" + ? clientRawRequest.headers.get("accept") || clientRawRequest.headers.get("Accept") + : (clientRawRequest?.headers || {})["accept"] || (clientRawRequest?.headers || {})["Accept"]; + + const clientWantsJson = + typeof acceptHeader === "string" && + acceptHeader.includes("application/json") && + !acceptHeader.includes("text/event-stream"); + + const stream = body.stream === true && !clientWantsJson; // ── Phase 9.1: Semantic cache check (non-streaming, temp=0 only) ── if (isCacheable(body, clientRawRequest?.headers)) { @@ -457,7 +558,7 @@ export async function handleChatCore({ // Non-stream responses need cloning for shared dedup consumers. const status = rawResult.response.status; const statusText = rawResult.response.statusText; - const headers = Array.from(rawResult.response.headers.entries()); + const headers = Array.from(rawResult.response.headers.entries()) as [string, string][]; const payload = await rawResult.response.text(); return { @@ -532,6 +633,7 @@ export async function handleChatCore({ path: clientRawRequest?.endpoint || "/v1/chat/completions", status: error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY, model, + requestedModel, provider, connectionId, duration: Date.now() - startTime, @@ -603,6 +705,8 @@ export async function handleChatCore({ } } + await persistCodexQuotaState(providerResponse.headers, providerResponse.status); + // Check provider response - return error info for fallback handling if (!providerResponse.ok) { trackPendingRequest(model, provider, connectionId, false); @@ -610,6 +714,54 @@ export async function handleChatCore({ providerResponse, provider ); + + // T06/T10/T36: classify provider errors and persist terminal account states. + const errorType = classifyProviderError(statusCode, message); + if (connectionId && errorType) { + try { + if (errorType === PROVIDER_ERROR_TYPES.FORBIDDEN) { + await updateProviderConnection(connectionId, { + isActive: false, + testStatus: "banned", + lastErrorType: errorType, + lastError: message, + errorCode: statusCode, + }); + console.warn( + `[provider] Node ${connectionId} banned (${statusCode}) — disabling permanently` + ); + } else if (errorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED) { + await updateProviderConnection(connectionId, { + testStatus: "credits_exhausted", + lastErrorType: errorType, + lastError: message, + errorCode: statusCode, + }); + console.warn(`[provider] Node ${connectionId} exhausted quota (${statusCode})`); + } else if (errorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED) { + await updateProviderConnection(connectionId, { + isActive: false, + testStatus: "expired", + lastErrorType: errorType, + lastError: message, + errorCode: statusCode, + }); + console.warn( + `[provider] Node ${connectionId} account deactivated (${statusCode}) — marked expired` + ); + } else if (errorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED) { + // Normal 401 (token/session auth issue): keep account active for refresh/re-auth. + await updateProviderConnection(connectionId, { + lastErrorType: errorType, + lastError: message, + errorCode: statusCode, + }); + } + } catch { + // Best-effort state update; request flow should continue with fallback handling. + } + } + appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch( () => {} ); @@ -618,6 +770,7 @@ export async function handleChatCore({ path: clientRawRequest?.endpoint || "/v1/chat/completions", status: statusCode, model, + requestedModel, provider, connectionId, duration: Date.now() - startTime, @@ -808,6 +961,7 @@ export async function handleChatCore({ path: clientRawRequest?.endpoint || "/v1/chat/completions", status: 200, model, + requestedModel, provider, connectionId, duration: Date.now() - startTime, @@ -848,6 +1002,32 @@ export async function handleChatCore({ ? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) : responseBody; + // T26: Strip markdown code blocks if provider format is Claude + if (sourceFormat === "claude" && !stream) { + if (translatedResponse?.choices?.[0]?.message?.content) { + const text = translatedResponse.choices[0].message.content; + const codeBlockRegex = + /^```(?:json|javascript|typescript|js|ts)?\s*\n?([\s\S]*?)\n?```\s*$/; + const match = text.trim().match(codeBlockRegex); + if (match) { + translatedResponse.choices[0].message.content = match[1].trim(); + } + } + } + + // T18: Normalize finish_reason to 'tool_calls' if tool calls are present + if (translatedResponse?.choices) { + for (const choice of translatedResponse.choices) { + if ( + choice.message?.tool_calls && + choice.message.tool_calls.length > 0 && + choice.finish_reason !== "tool_calls" + ) { + choice.finish_reason = "tool_calls"; + } + } + } + // Sanitize response for OpenAI SDK compatibility // Strips non-standard fields (x_groq, usage_breakdown, service_tier, etc.) // Extracts tags into reasoning_content @@ -921,6 +1101,7 @@ export async function handleChatCore({ path: clientRawRequest?.endpoint || "/v1/chat/completions", status: streamStatus || 200, model, + requestedModel, provider, connectionId, duration: Date.now() - startTime, diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 6fc6f29519..3aae2aa4fe 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -16,6 +16,7 @@ */ import { getImageProvider, parseImageModel } from "../config/imageRegistry.ts"; +import { mapImageSize } from "../translator/image/sizeMapper.ts"; import { saveCallLog } from "@/lib/usageDb"; import { submitComfyWorkflow, @@ -95,11 +96,21 @@ export async function handleImageGeneration({ body, credentials, log, resolvedPr }); } - // Route to format-specific handler if (providerConfig.format === "gemini-image") { return handleGeminiImageGeneration({ model, providerConfig, body, credentials, log }); } + if (providerConfig.format === "imagen3") { + return handleImagen3ImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + }); + } + if (providerConfig.format === "hyperbolic") { return handleHyperbolicImageGeneration({ model, @@ -539,7 +550,7 @@ async function handleNanoBananaImageGeneration({ ? body.aspectRatio : typeof body.aspect_ratio === "string" ? body.aspect_ratio - : inferAspectRatioFromSize(body.size) || "1:1"; + : mapImageSize(body.size); let resolution = typeof body.resolution === "string" @@ -856,18 +867,6 @@ async function normalizeNanoBananaTaskResult(taskData, body, log) { return []; } -function inferAspectRatioFromSize(size) { - if (typeof size !== "string") return null; - const [wRaw, hRaw] = size.split("x"); - const width = Number(wRaw); - const height = Number(hRaw); - if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null; - - const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b)); - const div = gcd(Math.round(width), Math.round(height)); - return `${Math.round(width / div)}:${Math.round(height / div)}`; -} - function inferResolutionFromSize(size) { if (typeof size !== "string") return null; const [wRaw, hRaw] = size.split("x"); @@ -1081,3 +1080,113 @@ async function handleComfyUIImageGeneration({ model, provider, providerConfig, b return { success: false, status: 502, error: `Image provider error: ${err.message}` }; } } + +/** + * Handle Imagen 3 image generation + */ +async function handleImagen3ImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: any) { + const startTime = Date.now(); + const token = credentials.apiKey || credentials.accessToken; + const aspectRatio = mapImageSize(body.size); + + const upstreamBody = { + prompt: body.prompt, + aspect_ratio: aspectRatio, + number_of_images: body.n ?? 1, + }; + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info( + "IMAGE", + `${provider}/${model} (imagen3) | prompt: "${promptPreview}..." | aspect_ratio: ${aspectRatio}` + ); + } + + try { + const response = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(upstreamBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + if (log) + log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: response.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + requestBody: upstreamBody, + }).catch(() => {}); + + return { success: false, status: response.status, error: errorText }; + } + + const data = await response.json(); + + // Normalize response to OpenAI format + const images: any[] = []; + if (Array.isArray(data.images)) { + images.push( + ...data.images.map((img: any) => ({ + b64_json: img.image || img.b64_json || img.url || img, + revised_prompt: body.prompt, + })) + ); + } else if (Array.isArray(data.data)) { + images.push(...data.data); + } else if (data.url || data.b64_json || data.image) { + images.push({ + b64_json: data.image || data.b64_json || data.url, + url: data.url, + revised_prompt: body.prompt, + }); + } + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { images_count: images.length }, + }).catch(() => {}); + + return { + success: true, + data: { created: data.created || Math.floor(Date.now() / 1000), data: images }, + }; + } catch (err: any) { + if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: err.message, + }).catch(() => {}); + + return { success: false, status: 502, error: `Image provider error: ${err.message}` }; + } +} diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index d4caf00eb5..d24213bab9 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -103,6 +103,25 @@ export function translateNonStreamingResponse( message.content = ""; } + if (process.env.DEBUG_RESPONSES_SSE_TO_JSON === "true") { + const msgItems = output.filter((i) => toRecord(i).type === "message"); + console.log(`[ResponsesSSE] ${output.length} output items, ${msgItems.length} message items`); + msgItems.forEach((item, idx) => { + const itemObj = toRecord(item); + let textLen = 0; + if (Array.isArray(itemObj.content)) { + for (const part of itemObj.content) { + const partObj = toRecord(part); + if (partObj.type === "output_text" && typeof partObj.text === "string") { + textLen += partObj.text.length; + } + } + } + console.log(` [${idx}] text length: ${textLen}`); + }); + console.log(` → Final text content length: ${textContent.length}`); + } + const createdAt = toNumber(response.created_at, Math.floor(Date.now() / 1000)); const model = toString(response.model || responseRoot.model, "openai-responses"); const finishReason = toolCalls.length > 0 ? "tool_calls" : "stop"; diff --git a/open-sse/handlers/sseParser.ts b/open-sse/handlers/sseParser.ts index 10f6ec2512..577a13fbe6 100644 --- a/open-sse/handlers/sseParser.ts +++ b/open-sse/handlers/sseParser.ts @@ -23,9 +23,18 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { const first = chunks[0]; const contentParts = []; const reasoningParts = []; + const accumulatedToolCalls = new Map(); + let unknownToolCallSeq = 0; let finishReason = "stop"; let usage = null; + const getToolCallKey = (toolCall: any) => { + if (toolCall?.id) return `id:${toolCall.id}`; + if (Number.isInteger(toolCall?.index)) return `idx:${toolCall.index}`; + unknownToolCallSeq += 1; + return `seq:${unknownToolCallSeq}`; + }; + for (const chunk of chunks) { const choice = chunk?.choices?.[0]; const delta = choice?.delta || {}; @@ -36,6 +45,40 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) { reasoningParts.push(delta.reasoning_content); } + + // T18: Accumulate tool calls correctly across streamed chunks + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + const key = getToolCallKey(tc); + const existing = accumulatedToolCalls.get(key); + const deltaArgs = typeof tc?.function?.arguments === "string" ? tc.function.arguments : ""; + + if (!existing) { + accumulatedToolCalls.set(key, { + id: tc?.id ?? null, + index: Number.isInteger(tc?.index) ? tc.index : accumulatedToolCalls.size, + type: tc?.type || "function", + function: { + name: tc?.function?.name || "unknown", + arguments: deltaArgs, + }, + }); + } else { + existing.id = existing.id || tc?.id || null; + if (!Number.isInteger(existing.index) && Number.isInteger(tc?.index)) { + existing.index = tc.index; + } + if (tc?.function?.name && !existing.function?.name) { + existing.function = existing.function || {}; + existing.function.name = tc.function.name; + } + existing.function = existing.function || {}; + existing.function.arguments = `${existing.function.arguments || ""}${deltaArgs}`; + accumulatedToolCalls.set(key, existing); + } + } + } + if (choice?.finish_reason) { finishReason = choice.finish_reason; } @@ -46,12 +89,22 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { const message: Record = { role: "assistant", - content: contentParts.join(""), + content: contentParts.length > 0 ? contentParts.join("") : null, }; if (reasoningParts.length > 0) { message.reasoning_content = reasoningParts.join(""); } + const finalToolCalls = [...accumulatedToolCalls.values()].filter(Boolean).sort((a, b) => { + const ai = Number.isInteger(a?.index) ? a.index : 0; + const bi = Number.isInteger(b?.index) ? b.index : 0; + return ai - bi; + }); + if (finalToolCalls.length > 0) { + finishReason = "tool_calls"; // T18 normalization + message.tool_calls = finalToolCalls; + } + const result: Record = { id: first.id || `chatcmpl-${Date.now()}`, object: "chat.completion", diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 96cf83d12c..b18ad52255 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -433,23 +433,48 @@ async function handleListModelsCatalog(args: { provider?: string; capability?: s const start = Date.now(); try { let path = "/v1/models"; - const params = new URLSearchParams(); - if (args.provider) params.set("provider", args.provider); - if (args.capability) params.set("capability", args.capability); - if (params.toString()) path += `?${params.toString()}`; + let isProviderSpecific = false; + let source = "local_catalog"; + let warning = undefined; + + if (args.provider && !args.capability) { + // Use direct provider fetch to get real-time API status + path = `/api/providers/${encodeURIComponent(args.provider)}/models`; + isProviderSpecific = true; + } else { + const params = new URLSearchParams(); + if (args.provider) params.set("provider", args.provider); + if (args.capability) params.set("capability", args.capability); + if (params.toString()) path += `?${params.toString()}`; + } const raw = toRecord(await omniRouteFetch(path)); + + // If we used the direct provider endpoint + let rawModels = []; + if (isProviderSpecific) { + rawModels = Array.isArray(raw.models) ? raw.models : []; + source = typeof raw.source === "string" ? raw.source : "api"; + if (raw.warning) warning = String(raw.warning); + } else { + rawModels = Array.isArray(raw.data) ? raw.data : []; + source = "local_catalog"; + // OmniRoute's global /v1/models is always a cached/local catalog + } + const result = { - models: toArray(raw.data).map((rawModel) => { + models: rawModels.map((rawModel) => { const model = toRecord(rawModel); return { id: toString(model.id, ""), - provider: toString(model.owned_by, toString(model.provider, "unknown")), + provider: toString(model.owned_by, toString(model.provider, args.provider || "unknown")), capabilities: toStringArray(model.capabilities, ["chat"]), status: toString(model.status, "available"), pricing: model.pricing, }; }), + source, + ...(warning ? { warning } : {}), }; await logToolCall( diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 415f3bbfa1..44f7ed12dc 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -342,11 +342,45 @@ export function checkFallbackError( errorText, backoffLevel = 0, model = null, - provider = null + provider = null, + headers = null ) { + const errorStr = (errorText || "").toString(); + + function parseResetFromHeaders(headers, errorStr = "") { + if (!headers) return null; + + // Retry-After header + const retryAfter = + typeof headers.get === "function" + ? headers.get("retry-after") + : headers["retry-after"] || headers["Retry-After"]; + + if (retryAfter) { + const seconds = parseInt(retryAfter, 10); + if (!isNaN(seconds) && String(seconds) === String(retryAfter).trim()) { + return Date.now() + seconds * 1000; + } + const date = new Date(retryAfter); + 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"]; + + if (rlReset) { + const ts = parseInt(rlReset, 10); + if (!isNaN(ts)) { + return ts > 10000000000 ? ts : ts * 1000; + } + } + return null; + } // Check error message FIRST - specific patterns take priority over status codes if (errorText) { - const errorStr = typeof errorText === "string" ? errorText : JSON.stringify(errorText); const lowerError = errorStr.toLowerCase(); // T06 (sub2api #1037): Permanent account deactivation — do NOT retry, mark as permanent failure @@ -393,6 +427,18 @@ export function checkFallbackError( lowerError.includes("capacity") || lowerError.includes("overloaded") ) { + const resetTime = parseResetFromHeaders(headers); + if (resetTime) { + const waitMs = resetTime - Date.now(); + if (waitMs > 60_000) { + return { + shouldFallback: true, + cooldownMs: waitMs, + newBackoffLevel: 0, + reason: RateLimitReason.RATE_LIMIT_EXCEEDED, + }; + } + } const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel); const reason = classifyErrorText(errorStr); return { @@ -430,6 +476,19 @@ export function checkFallbackError( // 429 - Rate limit with exponential backoff if (status === HTTP_STATUS.RATE_LIMITED) { + const resetTime = parseResetFromHeaders(headers); + if (resetTime) { + const waitMs = resetTime - Date.now(); + if (waitMs > 60_000) { + return { + shouldFallback: true, + cooldownMs: waitMs, + newBackoffLevel: 0, + reason: RateLimitReason.RATE_LIMIT_EXCEEDED, + }; + } + } + const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel); return { shouldFallback: true, diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index a8963b1a4d..7742ab492f 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -841,7 +841,8 @@ export async function handleComboChat({ errorText, 0, null, - provider + provider, + result.headers ); // Record failure in circuit breaker for transient errors @@ -865,6 +866,12 @@ export async function handleComboChat({ if (!lastStatus) lastStatus = result.status; if (i > 0) fallbackCount++; log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status }); + + if ([502, 503, 504].includes(result.status) && cooldownMs > 0 && cooldownMs <= 5000) { + log.info("COMBO", `Waiting ${cooldownMs}ms before fallback to next model`); + await new Promise((r) => setTimeout(r, cooldownMs)); + } + break; // Move to next model } } @@ -886,7 +893,20 @@ export async function handleComboChat({ ); } - const status = lastStatus || 406; + if (!lastStatus) { + return new Response( + JSON.stringify({ + error: { + message: "Service temporarily unavailable: all upstream accounts are inactive", + type: "service_unavailable", + code: "ALL_ACCOUNTS_INACTIVE", + }, + }), + { status: 503, headers: { "Content-Type": "application/json" } } + ); + } + + const status = lastStatus; const msg = lastError || "All combo models unavailable"; if (earliestRetryAfter) { @@ -941,7 +961,7 @@ async function handleRoundRobinCombo({ const modelCount = orderedModels.length; if (modelCount === 0) { - return unavailableResponse(406, "Round-robin combo has no models"); + return unavailableResponse(503, "Round-robin combo has no models"); } // Get and increment atomic counter @@ -1077,7 +1097,8 @@ async function handleRoundRobinCombo({ errorText, 0, null, - provider + provider, + result.headers ); // Transient errors → mark in semaphore AND record circuit breaker failure @@ -1106,6 +1127,12 @@ async function handleRoundRobinCombo({ if (!lastStatus) lastStatus = result.status; if (offset > 0) fallbackCount++; log.warn("COMBO-RR", `${modelStr} failed, trying next model`, { status: result.status }); + + if ([502, 503, 504].includes(result.status) && cooldownMs > 0 && cooldownMs <= 5000) { + log.info("COMBO-RR", `Waiting ${cooldownMs}ms before fallback to next model`); + await new Promise((r) => setTimeout(r, cooldownMs)); + } + break; } } finally { @@ -1136,7 +1163,20 @@ async function handleRoundRobinCombo({ ); } - const status = lastStatus || 406; + if (!lastStatus) { + return new Response( + JSON.stringify({ + error: { + message: "Service temporarily unavailable: all upstream accounts are inactive", + type: "service_unavailable", + code: "ALL_ACCOUNTS_INACTIVE", + }, + }), + { status: 503, headers: { "Content-Type": "application/json" } } + ); + } + + const status = lastStatus; const msg = lastError || "All round-robin combo models unavailable"; if (earliestRetryAfter) { diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts new file mode 100644 index 0000000000..dc61b11390 --- /dev/null +++ b/open-sse/services/errorClassifier.ts @@ -0,0 +1,53 @@ +import { isAccountDeactivated, isCreditsExhausted } from "./accountFallback.ts"; + +export const PROVIDER_ERROR_TYPES = { + RATE_LIMITED: "rate_limited", // 429 — transient, retry with backoff + UNAUTHORIZED: "unauthorized", // 401 — token expired, refresh + ACCOUNT_DEACTIVATED: "account_deactivated", // 401 + deactivation signal + FORBIDDEN: "forbidden", // 403 — account banned/revoked, disable node + SERVER_ERROR: "server_error", // 500/502/503 — retry limited + QUOTA_EXHAUSTED: "quota_exhausted", // 402/429/400 + billing signals +}; + +function responseBodyToString(responseBody: unknown): string { + if (typeof responseBody === "string") return responseBody; + if (responseBody !== null && typeof responseBody === "object") { + try { + return JSON.stringify(responseBody); + } catch { + return ""; + } + } + return ""; +} + +export function classifyProviderError(statusCode: number, responseBody: unknown): string | null { + const bodyStr = responseBodyToString(responseBody); + const creditsExhausted = isCreditsExhausted(bodyStr); + const accountDeactivated = isAccountDeactivated(bodyStr); + + // T10: credits exhausted is terminal and can appear as 400/402/429 depending on provider. + if ( + creditsExhausted && + (statusCode === 400 || statusCode === 402 || statusCode === 429 || statusCode === 403) + ) { + return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED; + } + + if (statusCode === 429) { + return PROVIDER_ERROR_TYPES.RATE_LIMITED; + } + + // T06: only deactivation-like 401s should be treated as permanent account expiry. + if (statusCode === 401) { + return accountDeactivated + ? PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED + : PROVIDER_ERROR_TYPES.UNAUTHORIZED; + } + + if (statusCode === 402) return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED; + if (statusCode === 403) return PROVIDER_ERROR_TYPES.FORBIDDEN; + if (statusCode >= 500) return PROVIDER_ERROR_TYPES.SERVER_ERROR; + + return null; +} diff --git a/open-sse/services/ipFilter.ts b/open-sse/services/ipFilter.ts index d6538dd987..287b44180c 100644 --- a/open-sse/services/ipFilter.ts +++ b/open-sse/services/ipFilter.ts @@ -4,6 +4,8 @@ * IP-based access control with blacklist, whitelist, priority modes, and temporary bans. */ +import { isIP } from "node:net"; + // In-memory IP lists let _config = { enabled: false, @@ -161,10 +163,10 @@ export function createIPFilterMiddleware() { */ export function checkRequestIP(request) { const ip = - request.headers?.get?.("x-forwarded-for")?.split(",")[0].trim() || - request.headers?.get?.("x-real-ip") || - request.headers?.get?.("cf-connecting-ip") || - request.ip || + pickFirstValidIp(request.headers?.get?.("cf-connecting-ip")) || + pickFirstValidIp(request.headers?.get?.("x-forwarded-for")) || + pickFirstValidIp(request.headers?.get?.("x-real-ip")) || + normalizeIP(request.ip || "") || "unknown"; return checkIP(ip); } @@ -177,6 +179,18 @@ function normalizeIP(ip) { return ip.replace(/^::ffff:/, "").trim(); } +function pickFirstValidIp(rawValue) { + if (typeof rawValue !== "string" || rawValue.trim().length === 0) return null; + const candidates = rawValue.split(","); + for (const candidate of candidates) { + const normalized = normalizeIP(candidate); + if (normalized && isIP(normalized) !== 0) { + return normalized; + } + } + return null; +} + function matchesAny(ip, ipSet) { // Direct match if (ipSet.has(ip)) return true; @@ -225,12 +239,13 @@ function matchesWildcard(ip, pattern) { } function extractClientIP(req) { + const headers = req.headers || {}; return ( - req.headers?.["x-forwarded-for"]?.split(",")[0].trim() || - req.headers?.["x-real-ip"] || - req.headers?.["cf-connecting-ip"] || - req.socket?.remoteAddress || - req.ip || + pickFirstValidIp(headers["cf-connecting-ip"]) || + pickFirstValidIp(headers["x-forwarded-for"]) || + pickFirstValidIp(headers["x-real-ip"]) || + pickFirstValidIp(req.socket?.remoteAddress) || + pickFirstValidIp(req.ip) || "unknown" ); } diff --git a/open-sse/services/modelDeprecation.ts b/open-sse/services/modelDeprecation.ts index 761ceaf7cb..6ca32d1b1d 100644 --- a/open-sse/services/modelDeprecation.ts +++ b/open-sse/services/modelDeprecation.ts @@ -18,6 +18,8 @@ const BUILT_IN_ALIASES: Record = { "gemini-1.5-flash": "gemini-2.5-flash", "gemini-1.0-pro": "gemini-2.5-pro", "gemini-2.0-flash": "gemini-2.5-flash", + "gemini-3-pro-high": "gemini-3.1-pro-high", + "gemini-3-pro-low": "gemini-3.1-pro-low", // Claude legacy → current "claude-3-opus-20240229": "claude-opus-4-20250514", diff --git a/open-sse/services/modelFamilyFallback.ts b/open-sse/services/modelFamilyFallback.ts index 41006d0a4d..dc8a2bfbc2 100644 --- a/open-sse/services/modelFamilyFallback.ts +++ b/open-sse/services/modelFamilyFallback.ts @@ -101,6 +101,7 @@ const MODEL_UNAVAILABLE_FRAGMENTS = [ "does not support", "not enabled for", "access to model", + "improperly formed request", // Kiro 400 (model unavailable) ]; /** diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts index 70e0946cfb..2d7ba8549c 100644 --- a/open-sse/services/rateLimitManager.ts +++ b/open-sse/services/rateLimitManager.ts @@ -12,6 +12,7 @@ import Bottleneck from "bottleneck"; import { parseRetryAfterFromBody, lockModel } from "./accountFallback.ts"; import { getProviderCategory } from "../config/providerRegistry.ts"; import { DEFAULT_API_LIMITS } from "../config/constants.ts"; +import { getCodexRateLimitKey } from "../executors/codex.ts"; interface LearnedLimitEntry { provider: string; @@ -195,8 +196,15 @@ export function isRateLimitEnabled(connectionId) { /** * Get or create a limiter for a given provider+connection combination */ +function getLimiterKey(provider, connectionId, model = null) { + if (provider === "codex" && model) { + return `${provider}:${getCodexRateLimitKey(connectionId, model)}`; + } + return `${provider}:${connectionId}`; +} + function getLimiter(provider, connectionId, model = null) { - const key = model ? `${provider}:${connectionId}:${model}` : `${provider}:${connectionId}`; + const key = getLimiterKey(provider, connectionId, model); if (!limiters.has(key)) { const limiter = new Bottleneck({ @@ -235,7 +243,7 @@ export async function withRateLimit(provider, connectionId, model, fn) { return fn(); } - const limiter = getLimiter(provider, connectionId, null); + const limiter = getLimiter(provider, connectionId, model); return limiter.schedule(fn); } @@ -320,7 +328,7 @@ export function updateFromHeaders(provider, connectionId, headers, status, model if (!enabledConnections.has(connectionId)) return; if (!headers) return; - const limiter = getLimiter(provider, connectionId, null); + const limiter = getLimiter(provider, connectionId, model); const headerMap = provider === "claude" || provider === "anthropic" ? ANTHROPIC_HEADERS : STANDARD_HEADERS; @@ -340,7 +348,7 @@ export function updateFromHeaders(provider, connectionId, headers, status, model if (status === 429) { const retryAfterMs = parseResetTime(retryAfterStr) || 60000; // Default 60s const counts = limiter.counts(); - const limiterKey = `${provider}:${connectionId}`; + const limiterKey = getLimiterKey(provider, connectionId, model); console.log( `🚫 [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — 429 received, pausing for ${Math.ceil(retryAfterMs / 1000)}s, dropping ${counts.QUEUED} queued request(s)` ); @@ -397,7 +405,12 @@ export function updateFromHeaders(provider, connectionId, headers, status, model limiter.updateSettings(updates); // Persist learned limits (debounced) - recordLearnedLimit(provider, connectionId, { limit, remaining, minTime: updates.minTime }); + recordLearnedLimit( + provider, + connectionId, + { limit, remaining, minTime: updates.minTime }, + model + ); } } @@ -459,9 +472,10 @@ export function getLearnedLimits() { function recordLearnedLimit( provider: string, connectionId: string, - limits: Partial> + limits: Partial>, + model: string | null = null ) { - const key = `${provider}:${connectionId}`; + const key = getLimiterKey(provider, connectionId, model); learnedLimits[key] = { ...limits, provider, diff --git a/open-sse/services/sessionManager.ts b/open-sse/services/sessionManager.ts index ebf509b5ab..66ee3d0943 100644 --- a/open-sse/services/sessionManager.ts +++ b/open-sse/services/sessionManager.ts @@ -41,7 +41,13 @@ const SESSION_TTL_MS = 30 * 60 * 1000; const _cleanupTimer = setInterval(() => { const now = Date.now(); for (const [key, entry] of sessions) { - if (now - entry.lastActive > SESSION_TTL_MS) sessions.delete(key); + if (now - entry.lastActive > SESSION_TTL_MS) { + sessions.delete(key); + for (const [apiKeyId, sessionSet] of activeSessionsByKey) { + sessionSet.delete(key); + if (sessionSet.size === 0) activeSessionsByKey.delete(apiKeyId); + } + } } }, 60_000); _cleanupTimer.unref(); @@ -202,6 +208,13 @@ export function registerKeySession(apiKeyId: string, sessionId: string): void { activeSessionsByKey.get(apiKeyId)!.add(sessionId); } +/** + * Check whether a given session is already registered for an API key. + */ +export function isSessionRegisteredForKey(apiKeyId: string, sessionId: string): boolean { + return activeSessionsByKey.get(apiKeyId)?.has(sessionId) === true; +} + /** * T08: Unregister a session from an API key's active set. * Call this when the request closes or the session TTL expires. @@ -256,6 +269,7 @@ export function extractExternalSessionId( const h = headers as Headers; const raw = h.get("x-session-id") ?? // Preferred: hyphenated (passes through Nginx) + h.get("x_session_id") ?? // Underscore variant (direct HTTP / custom clients) h.get("x-omniroute-session") ?? // OmniRoute-specific form h.get("session-id") ?? // Bare session-id null; diff --git a/open-sse/services/thinkingBudget.ts b/open-sse/services/thinkingBudget.ts index fc906ba38e..b0b4b170a6 100644 --- a/open-sse/services/thinkingBudget.ts +++ b/open-sse/services/thinkingBudget.ts @@ -13,12 +13,14 @@ export const ThinkingMode = { ADAPTIVE: "adaptive", // Scale based on request complexity }; +import { capThinkingBudget, getDefaultThinkingBudget } from "@/shared/constants/modelSpecs"; + // Effort → budget token mapping export const EFFORT_BUDGETS = { none: 0, low: 1024, medium: 10240, - high: 131072, + high: 131072, // Handled globally by capThinkingBudget later max: 131072, // T11: Claude "max" / "xhigh" — full budget xhigh: 131072, // T11: explicit alias used internally }; @@ -72,8 +74,8 @@ export function normalizeThinkingLevel(body) { // Handle top-level thinkingLevel or thinking_level string fields const levelStr = result.thinkingLevel || result.thinking_level; - if (typeof levelStr === "string" && THINKING_LEVEL_MAP[levelStr] !== undefined) { - const budget = THINKING_LEVEL_MAP[levelStr]; + if (typeof levelStr === "string" && THINKING_LEVEL_MAP[levelStr.toLowerCase()] !== undefined) { + const budget = THINKING_LEVEL_MAP[levelStr.toLowerCase()]; // Convert to Claude thinking format as canonical representation result.thinking = { type: budget > 0 ? "enabled" : "disabled", @@ -87,15 +89,21 @@ export function normalizeThinkingLevel(body) { const geminiLevel = result.generationConfig?.thinkingConfig?.thinkingLevel || result.generationConfig?.thinking_config?.thinkingLevel; - if (typeof geminiLevel === "string" && THINKING_LEVEL_MAP[geminiLevel] !== undefined) { - const budget = THINKING_LEVEL_MAP[geminiLevel]; + if ( + typeof geminiLevel === "string" && + THINKING_LEVEL_MAP[geminiLevel.toLowerCase()] !== undefined + ) { + const budget = THINKING_LEVEL_MAP[geminiLevel.toLowerCase()]; result.generationConfig = { ...result.generationConfig, - thinking_config: { thinking_budget: budget }, + thinkingConfig: { ...result.generationConfig.thinkingConfig, thinkingBudget: budget }, }; - // Clean up camelCase variant if it was the source + // Clean up string variants if (result.generationConfig.thinkingConfig) { - delete result.generationConfig.thinkingConfig; + delete result.generationConfig.thinkingConfig.thinkingLevel; + } + if (result.generationConfig.thinking_config) { + delete result.generationConfig.thinking_config; } } @@ -122,7 +130,7 @@ export function ensureThinkingConfig(body) { const result = { ...body }; result.thinking = { type: "enabled", - budget_tokens: EFFORT_BUDGETS.medium, // 10240 default + budget_tokens: getDefaultThinkingBudget(model) || EFFORT_BUDGETS.medium, }; return result; } @@ -257,8 +265,11 @@ function applyAdaptiveBudget(body, cfg) { if (toolCount > 3) multiplier += 0.5; if (lastMsgLength > 2000) multiplier += 0.3; - const baseBudget = EFFORT_BUDGETS[cfg.effortLevel] || EFFORT_BUDGETS.medium; - const budget = Math.min(Math.ceil(baseBudget * multiplier), 131072); + const baseBudget = + EFFORT_BUDGETS[cfg.effortLevel] || + getDefaultThinkingBudget(body.model || "") || + EFFORT_BUDGETS.medium; + const budget = capThinkingBudget(body.model || "", Math.ceil(baseBudget * multiplier)); return setCustomBudget(body, budget); } diff --git a/open-sse/transformer/responsesTransformer.ts b/open-sse/transformer/responsesTransformer.ts index f9e9aa23bb..0d20255682 100644 --- a/open-sse/transformer/responsesTransformer.ts +++ b/open-sse/transformer/responsesTransformer.ts @@ -1,5 +1,5 @@ -import * as fs from 'fs'; -import * as path from 'path'; +import * as fs from "fs"; +import * as path from "path"; /** * Responses API Transformer * Converts OpenAI Chat Completions SSE to Codex Responses API SSE format @@ -402,6 +402,16 @@ export function createResponsesApiTransformStream(logger = null) { const newCallId = tc.id; const funcName = tc.function?.name; + // T37: Prevent merging if a new tool_call uses the same index + if (state.funcCallIds[tcIdx] && newCallId && state.funcCallIds[tcIdx] !== newCallId) { + closeToolCall(controller, tcIdx); + delete state.funcCallIds[tcIdx]; + delete state.funcNames[tcIdx]; + delete state.funcArgsBuf[tcIdx]; + delete state.funcArgsDone[tcIdx]; + delete state.funcItemDone[tcIdx]; + } + if (funcName) state.funcNames[tcIdx] = funcName; if (!state.funcCallIds[tcIdx] && newCallId) { diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index 2ae45b8d6a..dbfbf01594 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -172,6 +172,9 @@ function convertEnumValuesToStrings(obj) { if (obj.enum && Array.isArray(obj.enum)) { obj.enum = obj.enum.map((v) => String(v)); + if (!obj.type) { + obj.type = "string"; + } } for (const value of Object.values(obj)) { diff --git a/open-sse/translator/image/sizeMapper.ts b/open-sse/translator/image/sizeMapper.ts new file mode 100644 index 0000000000..877a923200 --- /dev/null +++ b/open-sse/translator/image/sizeMapper.ts @@ -0,0 +1,22 @@ +const OPENAI_SIZE_TO_ASPECT_RATIO: Record = { + "256x256": "1:1", + "512x512": "1:1", + "1024x1024": "1:1", + "1792x1024": "16:9", + "1024x1792": "9:16", + "1536x1024": "3:2", + "1024x1536": "2:3", +}; + +// Supports direct aspect ratios (e.g. "16:9") +const ASPECT_RATIO_PASSTHROUGH = /^\d+:\d+$/; + +export function mapImageSize(sizeParam?: string | null): string { + if (!sizeParam) return "1:1"; // default + + // Native aspect ratio (e.g. "16:9") — pass-through + if (ASPECT_RATIO_PASSTHROUGH.test(sizeParam)) return sizeParam; + + // Map OpenAI sizes to aspect ratios + return OPENAI_SIZE_TO_ASPECT_RATIO[sizeParam] ?? "1:1"; +} diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index e1a4aa7552..7ac3dfd4f5 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -1,6 +1,10 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; -import { DEFAULT_SAFETY_SETTINGS, tryParseJSON } from "../helpers/geminiHelper.ts"; +import { + DEFAULT_SAFETY_SETTINGS, + tryParseJSON, + cleanJSONSchemaForAntigravity, +} from "../helpers/geminiHelper.ts"; import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; /** @@ -154,7 +158,9 @@ export function claudeToGeminiRequest(model, body, stream) { functionDeclarations.push({ name: tool.name, description: tool.description || "", - parameters: tool.input_schema || { type: "object", properties: {} }, + parameters: cleanJSONSchemaForAntigravity( + tool.input_schema || { type: "object", properties: {} } + ), }); } } diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 7ab8453195..efcdb9a728 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -3,6 +3,11 @@ import { FORMATS } from "../formats.ts"; import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/constants.ts"; import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.ts"; +import { + capMaxOutputTokens, + capThinkingBudget, + getDefaultThinkingBudget, +} from "../../../src/shared/constants/modelSpecs.ts"; function generateUUID() { return crypto.randomUUID(); @@ -88,7 +93,9 @@ function openaiToGeminiBase(model, body, stream) { result.generationConfig.topK = body.top_k; } if (body.max_tokens !== undefined) { - result.generationConfig.maxOutputTokens = body.max_tokens; + result.generationConfig.maxOutputTokens = capMaxOutputTokens(model, body.max_tokens); + } else { + result.generationConfig.maxOutputTokens = capMaxOutputTokens(model); } // Build tool_call_id -> name map @@ -283,8 +290,12 @@ export function openaiToGeminiCLIRequest(model, body, stream) { // Add thinking config for CLI if (body.reasoning_effort) { - const budgetMap = { low: 1024, medium: 8192, high: 32768 }; - const budget = budgetMap[body.reasoning_effort] || 8192; + const budgetMap = { + low: 1024, + medium: getDefaultThinkingBudget(model) || 8192, + high: capThinkingBudget(model, 32768), + }; + const budget = budgetMap[body.reasoning_effort] || getDefaultThinkingBudget(model) || 8192; gemini.generationConfig.thinkingConfig = { thinkingBudget: budget, include_thoughts: true, diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 6847ae70b4..96aa862424 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -257,6 +257,17 @@ function emitToolCall(state, emit, tc) { const newCallId = tc.id; const funcName = tc.function?.name; + // T37: If we already have a tool call at this index but the ID changed, + // we must close the current one and start a new one to prevent merging. + if (state.funcCallIds[tcIdx] && newCallId && state.funcCallIds[tcIdx] !== newCallId) { + closeToolCall(state, emit, tcIdx); + delete state.funcCallIds[tcIdx]; + delete state.funcNames[tcIdx]; + delete state.funcArgsBuf[tcIdx]; + delete state.funcArgsDone[tcIdx]; + delete state.funcItemDone[tcIdx]; + } + if (funcName) state.funcNames[tcIdx] = funcName; if (!state.funcCallIds[tcIdx] && newCallId) { diff --git a/open-sse/utils/ollamaTransform.ts b/open-sse/utils/ollamaTransform.ts index 51a60e8357..c6f22335e3 100644 --- a/open-sse/utils/ollamaTransform.ts +++ b/open-sse/utils/ollamaTransform.ts @@ -12,6 +12,7 @@ type PendingToolCall = { export function transformToOllama(response, model) { let buffer = ""; let pendingToolCalls: Record = {}; + const completedToolCalls: PendingToolCall[] = []; const transform = new TransformStream({ transform(chunk, controller) { @@ -41,6 +42,13 @@ export function transformToOllama(response, model) { if (toolCalls) { for (const tc of toolCalls) { const idx = tc.index; + + // T37: Prevent merging tool_calls on same index if ID changes + if (pendingToolCalls[idx] && tc.id && pendingToolCalls[idx].id !== tc.id) { + completedToolCalls.push(pendingToolCalls[idx]); + delete pendingToolCalls[idx]; + } + if (!pendingToolCalls[idx]) { pendingToolCalls[idx] = { id: tc.id, function: { name: "", arguments: "" } }; } @@ -59,7 +67,7 @@ export function transformToOllama(response, model) { const finishReason = parsed.choices?.[0]?.finish_reason; if (finishReason === "tool_calls" || finishReason === "stop") { - const toolCallsArr = Object.values(pendingToolCalls); + const toolCallsArr = [...completedToolCalls, ...Object.values(pendingToolCalls)]; if (toolCallsArr.length > 0) { const formattedCalls = toolCallsArr.map((tc) => ({ function: { diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index e58adc0bf7..74e1e84e48 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -222,16 +222,17 @@ export function createSSEStream(options: StreamOptions = {}) { const extracted = extractUsage(parsed); if (extracted) { // Non-destructive merge: never overwrite a positive value with 0 - // message_start carries input_tokens, message_delta carries output_tokens - if (!usage) usage = {}; - if (extracted.prompt_tokens > 0) usage.prompt_tokens = extracted.prompt_tokens; - if (extracted.completion_tokens > 0) - usage.completion_tokens = extracted.completion_tokens; - if (extracted.total_tokens > 0) usage.total_tokens = extracted.total_tokens; - if (extracted.cache_read_input_tokens) - usage.cache_read_input_tokens = extracted.cache_read_input_tokens; - if (extracted.cache_creation_input_tokens) - usage.cache_creation_input_tokens = extracted.cache_creation_input_tokens; + // message_start carries input_tokens, message_delta carries output_tokens; + if (!usage) usage = {} as any; + const u = usage as Record; + const eu = extracted as Record; + if (eu.prompt_tokens > 0) u.prompt_tokens = eu.prompt_tokens; + if (eu.completion_tokens > 0) u.completion_tokens = eu.completion_tokens; + if (eu.total_tokens > 0) u.total_tokens = eu.total_tokens; + if (eu.cache_read_input_tokens) + u.cache_read_input_tokens = eu.cache_read_input_tokens; + if (eu.cache_creation_input_tokens) + u.cache_creation_input_tokens = eu.cache_creation_input_tokens; } // Track content length and accumulate from Claude format if (parsed.delta?.text) { @@ -263,6 +264,11 @@ export function createSSEStream(options: StreamOptions = {}) { } } + // T18: Track if we saw tool calls + if (delta?.tool_calls && delta.tool_calls.length > 0) { + (state as any).passthroughHasToolCalls = true; + } + const content = delta?.content || delta?.reasoning_content; if (content && typeof content === "string") { totalContentLength += content.length; @@ -278,6 +284,20 @@ export function createSSEStream(options: StreamOptions = {}) { } const isFinishChunk = parsed.choices?.[0]?.finish_reason; + + // T18: Normalize finish_reason to 'tool_calls' if tool calls were used + if ( + isFinishChunk && + (state as any).passthroughHasToolCalls && + parsed.choices[0].finish_reason !== "tool_calls" + ) { + parsed.choices[0].finish_reason = "tool_calls"; + // If we modify it, we must output the modified object + if (!injectedUsage && hasValidUsage(parsed.usage)) { + output = `data: ${JSON.stringify(parsed)}\n`; + injectedUsage = true; + } + } if (isFinishChunk && !hasValidUsage(parsed.usage)) { const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI); parsed.usage = filterUsageForFormat(estimated, FORMATS.OPENAI); @@ -529,19 +549,17 @@ export function createSSEStream(options: StreamOptions = {}) { if (!state.usage) { state.usage = extracted; } else { - if (extracted.prompt_tokens > 0) - state.usage.prompt_tokens = extracted.prompt_tokens; - if (extracted.completion_tokens > 0) - state.usage.completion_tokens = extracted.completion_tokens; - if (extracted.total_tokens > 0) state.usage.total_tokens = extracted.total_tokens; - if (extracted.cache_read_input_tokens > 0) - state.usage.cache_read_input_tokens = extracted.cache_read_input_tokens; - if (extracted.cache_creation_input_tokens > 0) - state.usage.cache_creation_input_tokens = extracted.cache_creation_input_tokens; - if (extracted.cached_tokens > 0) - state.usage.cached_tokens = extracted.cached_tokens; - if (extracted.reasoning_tokens > 0) - state.usage.reasoning_tokens = extracted.reasoning_tokens; + const su = state.usage as Record; + const eu = extracted as Record; + if (eu.prompt_tokens > 0) su.prompt_tokens = eu.prompt_tokens; + if (eu.completion_tokens > 0) su.completion_tokens = eu.completion_tokens; + if (eu.total_tokens > 0) su.total_tokens = eu.total_tokens; + if (eu.cache_read_input_tokens > 0) + su.cache_read_input_tokens = eu.cache_read_input_tokens; + if (eu.cache_creation_input_tokens > 0) + su.cache_creation_input_tokens = eu.cache_creation_input_tokens; + if (eu.cached_tokens > 0) su.cached_tokens = eu.cached_tokens; + if (eu.reasoning_tokens > 0) su.reasoning_tokens = eu.reasoning_tokens; } } diff --git a/open-sse/utils/streamHandler.ts b/open-sse/utils/streamHandler.ts index 58f627f951..40832c341a 100644 --- a/open-sse/utils/streamHandler.ts +++ b/open-sse/utils/streamHandler.ts @@ -134,7 +134,36 @@ export function createDisconnectAwareStream(transformStream, streamController) { controller.enqueue(value); } catch (error) { streamController.handleError(error); - controller.error(error); + + // T35: Encapsulate mid-stream errors as SSE events instead of abruptly aborting + // This prevents TransferEncodingError on the client side + const errorMsg = error instanceof Error ? error.message : "Upstream stream error"; + const statusCode = + typeof error === "object" && error !== null && "statusCode" in error + ? (error as any).statusCode + : 500; + + const errorEvent = { + object: "chat.completion.chunk", + choices: [ + { + index: 0, + delta: {}, + finish_reason: "error", + }, + ], + error: { + message: errorMsg, + type: "upstream_error", + code: statusCode, + }, + }; + + const encoder = new TextEncoder(); + controller.enqueue(encoder.encode(`data: ${JSON.stringify(errorEvent)}\n\n`)); + controller.enqueue(encoder.encode(`data: [DONE]\n\n`)); + + controller.close(); } }, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 5e66e13860..c4d51a7866 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -3017,6 +3017,7 @@ CooldownTimer.propTypes = { const ERROR_TYPE_LABELS = { runtime_error: { labelKey: "errorTypeRuntime", variant: "warning" }, upstream_auth_error: { labelKey: "errorTypeUpstreamAuth", variant: "error" }, + account_deactivated: { labelKey: "Account Deactivated", variant: "error" }, auth_missing: { labelKey: "errorTypeMissingCredential", variant: "warning" }, token_refresh_failed: { labelKey: "errorTypeRefreshFailed", variant: "warning" }, token_expired: { labelKey: "errorTypeTokenExpired", variant: "warning" }, @@ -3025,10 +3026,14 @@ const ERROR_TYPE_LABELS = { network_error: { labelKey: "errorTypeNetworkError", variant: "warning" }, unsupported: { labelKey: "errorTypeTestUnsupported", variant: "default" }, upstream_error: { labelKey: "errorTypeUpstreamError", variant: "error" }, + banned: { labelKey: "403 Banned", variant: "error" }, + credits_exhausted: { labelKey: "No Credits", variant: "warning" }, }; function inferErrorType(connection, isCooldown) { if (isCooldown) return "upstream_rate_limited"; + if (connection.testStatus === "banned") return "banned"; + if (connection.testStatus === "credits_exhausted") return "credits_exhausted"; if (connection.lastErrorType) return connection.lastErrorType; const code = Number(connection.errorCode); @@ -3108,6 +3113,16 @@ function getStatusPresentation(connection, effectiveStatus, isCooldown, t) { }; } + if (errorType === "account_deactivated") { + return { + statusVariant: "error", + statusLabel: t("statusDeactivated", "Deactivated"), + errorType, + errorBadge, + errorTextClass: "text-red-600 font-bold", + }; + } + if ( errorType === "upstream_auth_error" || errorType === "auth_missing" || @@ -3153,6 +3168,26 @@ function getStatusPresentation(connection, effectiveStatus, isCooldown, t) { }; } + if (errorType === "banned") { + return { + statusVariant: "error", + statusLabel: t("statusBanned", "Banned (403)"), + errorType, + errorBadge, + errorTextClass: "text-red-600 font-bold", + }; + } + + if (errorType === "credits_exhausted") { + return { + statusVariant: "warning", + statusLabel: t("statusCreditsExhausted", "Out of Credits"), + errorType, + errorBadge, + errorTextClass: "text-amber-500", + }; + } + const fallbackStatusMap = { unavailable: t("statusUnavailable"), failed: t("statusFailed"), @@ -3520,12 +3555,16 @@ function AddApiKeyModal({ const t = useTranslations("providers"); const isBailian = provider === "bailian-coding-plan"; const defaultBailianUrl = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1"; + const isVertex = provider === "vertex"; + const defaultRegion = "us-central1"; const [formData, setFormData] = useState({ name: "", apiKey: "", priority: 1, baseUrl: isBailian ? defaultBailianUrl : "", + region: isVertex ? defaultRegion : "", + validationModelId: "", }); const [validating, setValidating] = useState(false); const [validationResult, setValidationResult] = useState(null); @@ -3539,7 +3578,11 @@ function AddApiKeyModal({ const res = await fetch("/api/providers/validate", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider, apiKey: formData.apiKey }), + body: JSON.stringify({ + provider, + apiKey: formData.apiKey, + validationModelId: formData.validationModelId || undefined, + }), }); const data = await res.json(); setValidationResult(data.valid ? "success" : "failed"); @@ -3573,7 +3616,11 @@ function AddApiKeyModal({ const res = await fetch("/api/providers/validate", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider, apiKey: formData.apiKey }), + body: JSON.stringify({ + provider, + apiKey: formData.apiKey, + validationModelId: formData.validationModelId || undefined, + }), }); const data = await res.json(); isValid = !!data.valid; @@ -3602,6 +3649,10 @@ function AddApiKeyModal({ payload.providerSpecificData = { baseUrl: validatedBailianBaseUrl, }; + } else if (isVertex) { + payload.providerSpecificData = { + region: formData.region, + }; } const error = await onSave(payload); @@ -3635,6 +3686,7 @@ function AddApiKeyModal({ value={formData.apiKey} onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })} className="flex-1" + placeholder={isVertex ? "Cole o Service Account JSON aqui" : undefined} />
)} + setFormData({ ...formData, validationModelId: e.target.value })} + hint="Usado como fallback se a listagem de models não estiver disponível" + /> )} @@ -3972,6 +4074,16 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec /> )} + {isVertex && ( + setFormData({ ...formData, region: e.target.value })} + placeholder={defaultRegion} + hint="ex: us-central1 ou europe-west4. Partner models usam a região global automaticamente." + /> + )} + {/* T07: Extra API Keys for round-robin rotation */} {!isOAuth && (
diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 0a73753d03..b02cf44de9 100644 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -4,6 +4,7 @@ import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider, } from "@/shared/constants/providers"; +import { PROVIDER_MODELS } from "@/shared/constants/models"; type JsonRecord = Record; @@ -336,39 +337,85 @@ export async function GET(request, { params }) { ); } - let modelsUrl = baseUrl.replace(/\/$/, ""); - if (modelsUrl.endsWith("/chat/completions")) { - modelsUrl = modelsUrl.slice(0, -17) + "/models"; - } else if (modelsUrl.endsWith("/completions")) { - modelsUrl = modelsUrl.slice(0, -12) + "/models"; - } else { - modelsUrl = `${modelsUrl}/models`; + let base = baseUrl.replace(/\/$/, ""); + if (base.endsWith("/chat/completions")) { + base = base.slice(0, -17); + } else if (base.endsWith("/completions")) { + base = base.slice(0, -12); + } else if (base.endsWith("/v1")) { + base = base.slice(0, -3); } - const response = await fetch(modelsUrl, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - }); + // T39: Try multiple endpoint formats + const endpoints = [ + `${base}/v1/models`, + `${base}/models`, + `${baseUrl.replace(/\/$/, "")}/models`, // Original fallback + ]; - if (!response.ok) { - const errorText = await response.text(); - console.log(`Error fetching models from ${provider}:`, errorText); - return NextResponse.json( - { error: `Failed to fetch models: ${response.status}` }, - { status: response.status } - ); + // Remove duplicates + const uniqueEndpoints = [...new Set(endpoints)]; + let models = null; + let lastErrorStatus = null; + + for (const modelsUrl of uniqueEndpoints) { + try { + const response = await fetch(modelsUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + signal: AbortSignal.timeout(5000), // Quick timeout for fallbacks + }); + + if (response.ok) { + const data = await response.json(); + models = data.data || data.models || []; + break; // Success! + } + + if (response.status === 401 || response.status === 403) { + lastErrorStatus = response.status; + throw new Error("auth_failed"); + } + } catch (err: any) { + if (err.message === "auth_failed") break; // Don't try other endpoints if auth failed + } } - const data = await response.json(); - const models = data.data || data.models || []; + // If all endpoints failed (but not because of auth), fallback to local catalog + if (!models) { + if (lastErrorStatus === 401 || lastErrorStatus === 403) { + return NextResponse.json( + { error: `Auth failed: ${lastErrorStatus}` }, + { status: lastErrorStatus } + ); + } + + console.warn(`[models] All endpoints failed for ${provider}, using local catalog`); + const localModels = PROVIDER_MODELS[provider] || []; + models = localModels.map((m: any) => ({ + id: m.id, + name: m.name || m.id, + owned_by: provider, + })); + } + + // Track source for MCP tool T39 requirement + const source = + models === null || (models && models.length > 0 && models[0].owned_by === provider) + ? "local_catalog" + : "api"; return NextResponse.json({ provider, connectionId, models, + source, + ...(source === "local_catalog" + ? { warning: "API unavailable — using cached catalog" } + : {}), }); } diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index 2204f7c26a..ece35e28e5 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -523,9 +523,10 @@ async function testApiKeyConnection(connection: any) { /** * Core test logic — reusable by test-batch without HTTP self-calls. * @param {string} connectionId + * @param {string} validationModelId Optional custom model ID to test connection with * @returns {Promise} Test result (same shape as the JSON response) */ -export async function testSingleConnection(connectionId: string) { +export async function testSingleConnection(connectionId: string, validationModelId?: string) { const connection = await getProviderConnectionById(connectionId); if (!connection) { @@ -567,8 +568,17 @@ export async function testSingleConnection(connectionId: string) { diagnosis: (runtime as any).diagnosis, }; } else if (connection.authType === "apikey") { + const enrichedConnection = validationModelId + ? { + ...connection, + providerSpecificData: { + ...((connection.providerSpecificData as any) || {}), + validationModelId, + }, + } + : connection; result = await runWithProxyContext(proxyInfo?.proxy || null, () => - testApiKeyConnection(connection) + testApiKeyConnection(enrichedConnection) ); } else { result = await runWithProxyContext(proxyInfo?.proxy || null, () => @@ -670,7 +680,17 @@ export async function testSingleConnection(connectionId: string) { export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params; - const data = await testSingleConnection(id); + + // Parse optional body for validationModelId + let validationModelId; + try { + const body = await request.json(); + validationModelId = body?.validationModelId; + } catch { + // Body is optional + } + + const data = await testSingleConnection(id, validationModelId); if (data.error === "Connection not found") { return NextResponse.json({ error: "Connection not found" }, { status: 404 }); diff --git a/src/app/api/providers/validate/route.ts b/src/app/api/providers/validate/route.ts index d75a69af41..61d82a2f16 100644 --- a/src/app/api/providers/validate/route.ts +++ b/src/app/api/providers/validate/route.ts @@ -30,9 +30,9 @@ export async function POST(request) { if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const { provider, apiKey } = validation.data; + const { provider, apiKey, validationModelId } = validation.data; - let providerSpecificData = {}; + let providerSpecificData: any = { validationModelId }; if (isOpenAICompatibleProvider(provider) || isAnthropicCompatibleProvider(provider)) { const node: any = await getProviderNodeById(provider); @@ -44,6 +44,7 @@ export async function POST(request) { ); } providerSpecificData = { + ...providerSpecificData, baseUrl: node.baseUrl, apiType: node.apiType, }; diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index c8470ba3ea..3c73df6243 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -1,6 +1,36 @@ import { NextResponse } from "next/server"; import { getUsageDb } from "@/lib/usageDb"; import { computeAnalytics } from "@/lib/usageAnalytics"; +import { getDbInstance } from "@/lib/db/core"; + +function getRangeStartIso(range: string): string | null { + const end = new Date(); + const start = new Date(end); + + switch (range) { + case "1d": + start.setDate(start.getDate() - 1); + break; + case "7d": + start.setDate(start.getDate() - 7); + break; + case "30d": + start.setDate(start.getDate() - 30); + break; + case "90d": + start.setDate(start.getDate() - 90); + break; + case "ytd": + start.setMonth(0, 1); + start.setHours(0, 0, 0, 0); + break; + case "all": + default: + return null; + } + + return start.toISOString(); +} export async function GET(request) { try { @@ -36,6 +66,47 @@ export async function GET(request) { const analytics = await computeAnalytics(history, range, connectionMap); + // T01: fallback transparency metrics from call_logs (requested_model vs routed model). + try { + const db = getDbInstance(); + const sinceIso = getRangeStartIso(range); + const whereClause = sinceIso ? "WHERE timestamp >= @since" : ""; + const row = db + .prepare( + ` + SELECT + COUNT(*) as total, + SUM(CASE WHEN requested_model IS NOT NULL AND requested_model != '' THEN 1 ELSE 0 END) as with_requested, + SUM(CASE + WHEN requested_model IS NOT NULL + AND requested_model != '' + AND model IS NOT NULL + AND requested_model != model + THEN 1 ELSE 0 END + ) as fallbacks + FROM call_logs + ${whereClause} + ` + ) + .get(sinceIso ? { since: sinceIso } : {}) as + | { total?: number; with_requested?: number; fallbacks?: number } + | undefined; + + const total = Number(row?.total || 0); + const withRequested = Number(row?.with_requested || 0); + const fallbackCount = Number(row?.fallbacks || 0); + + analytics.summary.fallbackCount = fallbackCount; + analytics.summary.fallbackRatePct = + withRequested > 0 ? Number(((fallbackCount / withRequested) * 100).toFixed(2)) : 0; + analytics.summary.requestedModelCoveragePct = + total > 0 ? Number(((withRequested / total) * 100).toFixed(2)) : 0; + } catch { + analytics.summary.fallbackCount = 0; + analytics.summary.fallbackRatePct = 0; + analytics.summary.requestedModelCoveragePct = 0; + } + return NextResponse.json(analytics); } catch (error) { console.error("Error computing analytics:", error); diff --git a/src/shared/components/RequestLoggerDetail.tsx b/src/shared/components/RequestLoggerDetail.tsx index 984c2f08e0..3db9574178 100644 --- a/src/shared/components/RequestLoggerDetail.tsx +++ b/src/shared/components/RequestLoggerDetail.tsx @@ -147,6 +147,21 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
Model
{log.model}
+
+
+ Requested Model +
+
+ {detail?.requestedModel || log.requestedModel || "—"} +
+
Provider diff --git a/tests/unit/auth-terminal-status.test.mjs b/tests/unit/auth-terminal-status.test.mjs new file mode 100644 index 0000000000..9b6cc12b56 --- /dev/null +++ b/tests/unit/auth-terminal-status.test.mjs @@ -0,0 +1,90 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-auth-terminal-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const auth = await import("../../src/sse/services/auth.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("getProviderCredentials skips credits_exhausted connections", async () => { + await resetStorage(); + + const exhausted = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + apiKey: "sk-exhausted", + isActive: true, + testStatus: "credits_exhausted", + }); + + const healthy = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + apiKey: "sk-healthy", + isActive: true, + testStatus: "active", + }); + + const selected = await auth.getProviderCredentials("openai"); + assert.ok(selected); + assert.equal(selected.connectionId, healthy.id); + assert.notEqual(selected.connectionId, exhausted.id); +}); + +test("getProviderCredentials returns null when all active connections are terminal", async () => { + await resetStorage(); + + await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + apiKey: "sk-only-exhausted", + isActive: true, + testStatus: "credits_exhausted", + }); + + const selected = await auth.getProviderCredentials("openai"); + assert.equal(selected, null); +}); + +test("markAccountUnavailable does not overwrite terminal status", async () => { + await resetStorage(); + + const conn = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + apiKey: "sk-terminal", + isActive: true, + testStatus: "credits_exhausted", + lastError: "insufficient_quota", + }); + + const result = await auth.markAccountUnavailable( + conn.id, + 503, + "temporary upstream error", + "openai", + "gpt-4.1" + ); + + assert.equal(result.shouldFallback, true); + assert.equal(result.cooldownMs, 0); + + const after = await providersDb.getProviderConnectionById(conn.id); + assert.equal(after.testStatus, "credits_exhausted"); +}); diff --git a/tests/unit/error-classifier.test.mjs b/tests/unit/error-classifier.test.mjs new file mode 100644 index 0000000000..4f18e4bf71 --- /dev/null +++ b/tests/unit/error-classifier.test.mjs @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { classifyProviderError, PROVIDER_ERROR_TYPES } = + await import("../../open-sse/services/errorClassifier.ts"); + +test("classifyProviderError: 401 + account_deactivated => ACCOUNT_DEACTIVATED", () => { + const body = JSON.stringify({ + error: { message: "account_deactivated: this account has been disabled" }, + }); + const result = classifyProviderError(401, body); + assert.equal(result, PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED); +}); + +test("classifyProviderError: plain 401 => UNAUTHORIZED", () => { + const result = classifyProviderError(401, { error: { message: "token expired" } }); + assert.equal(result, PROVIDER_ERROR_TYPES.UNAUTHORIZED); +}); + +test("classifyProviderError: 402 => QUOTA_EXHAUSTED", () => { + const result = classifyProviderError(402, { error: { message: "payment required" } }); + assert.equal(result, PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED); +}); + +test("classifyProviderError: 400 + billing signal => QUOTA_EXHAUSTED", () => { + const result = classifyProviderError(400, { + error: { message: "insufficient_quota: exceeded your current quota" }, + }); + assert.equal(result, PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED); +}); + +test("classifyProviderError: 429 without billing signal => RATE_LIMITED", () => { + const result = classifyProviderError(429, { error: { message: "too many requests" } }); + assert.equal(result, PROVIDER_ERROR_TYPES.RATE_LIMITED); +}); diff --git a/tests/unit/ip-filter.test.mjs b/tests/unit/ip-filter.test.mjs index 892f03b062..463e73dcc6 100644 --- a/tests/unit/ip-filter.test.mjs +++ b/tests/unit/ip-filter.test.mjs @@ -11,6 +11,7 @@ const { addToWhitelist, removeFromWhitelist, getIPFilterConfig, + checkRequestIP, resetIPFilter, } = await import("../../open-sse/services/ipFilter.ts"); @@ -111,6 +112,48 @@ test("normalizes ::ffff: prefix", () => { assert.equal(checkIP("::ffff:1.2.3.4").allowed, false); }); +// ─── T07: X-Forwarded-For validation ─────────────────────────────────────── + +test("checkRequestIP: skips invalid XFF entries and uses next valid IP", () => { + configureIPFilter({ enabled: true, mode: "whitelist", whitelist: ["1.2.3.4"] }); + const req = { + headers: { + get(name) { + if (name === "x-forwarded-for") return "unknown, 1.2.3.4"; + return null; + }, + }, + }; + assert.equal(checkRequestIP(req).allowed, true); +}); + +test("checkRequestIP: all-invalid XFF falls back to x-real-ip", () => { + configureIPFilter({ enabled: true, mode: "whitelist", whitelist: ["9.9.9.9"] }); + const req = { + headers: { + get(name) { + if (name === "x-forwarded-for") return "unknown, -, not_an_ip"; + if (name === "x-real-ip") return "9.9.9.9"; + return null; + }, + }, + }; + assert.equal(checkRequestIP(req).allowed, true); +}); + +test("checkRequestIP: empty headers fall back to request.ip", () => { + configureIPFilter({ enabled: true, mode: "whitelist", whitelist: ["7.7.7.7"] }); + const req = { + headers: { + get() { + return null; + }, + }, + ip: "7.7.7.7", + }; + assert.equal(checkRequestIP(req).allowed, true); +}); + // ─── Config API ───────────────────────────────────────────────────────────── test("getIPFilterConfig: returns serializable config", () => { diff --git a/tests/unit/nanobanana-image-handler.test.mjs b/tests/unit/nanobanana-image-handler.test.mjs index fc13983ecf..0462622442 100644 --- a/tests/unit/nanobanana-image-handler.test.mjs +++ b/tests/unit/nanobanana-image-handler.test.mjs @@ -13,7 +13,7 @@ test("handleImageGeneration(nanobanana): async submit+poll returns URL payload", if (u.includes("/generate-pro")) { const body = JSON.parse(options.body); assert.equal(body.prompt, "galaxy test"); - assert.equal(body.aspectRatio, "4:5"); + assert.equal(body.aspectRatio, "1:1"); assert.equal(body.resolution, "2K"); return new Response( diff --git a/tests/unit/openai-to-claude-strip-empty.test.mjs b/tests/unit/openai-to-claude-strip-empty.test.mjs new file mode 100644 index 0000000000..497948c186 --- /dev/null +++ b/tests/unit/openai-to-claude-strip-empty.test.mjs @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { stripEmptyTextBlocks, openaiToClaudeRequest } = + await import("../../open-sse/translator/request/openai-to-claude.ts"); + +test("stripEmptyTextBlocks removes empty text recursively inside tool_result content", () => { + const input = [ + { type: "text", text: "" }, + { type: "text", text: "keep-top-level" }, + { + type: "tool_result", + content: [ + { type: "text", text: "" }, + { type: "text", text: "keep-nested" }, + { + type: "tool_result", + content: [ + { type: "text", text: "" }, + { type: "text", text: "keep-deep" }, + ], + }, + ], + }, + ]; + + const out = stripEmptyTextBlocks(input); + assert.deepEqual(out, [ + { type: "text", text: "keep-top-level" }, + { + type: "tool_result", + content: [ + { type: "text", text: "keep-nested" }, + { + type: "tool_result", + content: [{ type: "text", text: "keep-deep" }], + }, + ], + }, + ]); +}); + +test("openaiToClaudeRequest applies strip to tool message array content", () => { + const request = { + messages: [ + { role: "user", content: "run tool" }, + { + role: "assistant", + content: "", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "demo_tool", arguments: "{}" }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_1", + content: [ + { type: "text", text: "" }, + { type: "text", text: "tool ok" }, + ], + }, + ], + }; + + const translated = openaiToClaudeRequest("claude-sonnet-4", request, false); + const toolMessage = translated.messages.find( + (m) => Array.isArray(m.content) && m.content.some((b) => b.type === "tool_result") + ); + assert.ok(toolMessage, "expected a translated tool_result user message"); + const toolResult = toolMessage.content.find((b) => b.type === "tool_result"); + assert.deepEqual(toolResult.content, [{ type: "text", text: "tool ok" }]); +}); diff --git a/tests/unit/plan3-p0.test.mjs b/tests/unit/plan3-p0.test.mjs index eec1f63f51..7ddc04ccae 100644 --- a/tests/unit/plan3-p0.test.mjs +++ b/tests/unit/plan3-p0.test.mjs @@ -11,7 +11,10 @@ import { DefaultExecutor } from "../../open-sse/executors/default.ts"; import { CodexExecutor, setDefaultFastServiceTierEnabled } from "../../open-sse/executors/codex.ts"; import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts"; import { extractUsageFromResponse } from "../../open-sse/handlers/usageExtractor.ts"; -import { parseSSEToResponsesOutput } from "../../open-sse/handlers/sseParser.ts"; +import { + parseSSEToOpenAIResponse, + parseSSEToResponsesOutput, +} from "../../open-sse/handlers/sseParser.ts"; test("getModelInfoCore resolves unique non-openai unprefixed model", async () => { const info = await getModelInfoCore("claude-haiku-4-5-20251001", {}); @@ -382,3 +385,56 @@ test("parseSSEToResponsesOutput returns null for invalid payload", () => { const parsed = parseSSEToResponsesOutput("data: not-json\n\ndata: [DONE]\n", "fallback-model"); assert.equal(parsed, null); }); + +test("parseSSEToOpenAIResponse merges split tool call chunks by id without duplication", () => { + const rawSSE = [ + `data: ${JSON.stringify({ + id: "chatcmpl_1", + object: "chat.completion.chunk", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + id: "call_abc", + index: 0, + type: "function", + function: { name: "sum", arguments: '{"a":' }, + }, + ], + }, + }, + ], + })}`, + `data: ${JSON.stringify({ + id: "chatcmpl_1", + object: "chat.completion.chunk", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + id: "call_abc", + index: 0, + type: "function", + function: { arguments: "1}" }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + })}`, + "data: [DONE]", + ].join("\n"); + + const parsed = parseSSEToOpenAIResponse(rawSSE, "gpt-5.1-codex"); + assert.ok(parsed); + assert.equal(parsed.choices[0].finish_reason, "tool_calls"); + assert.equal(parsed.choices[0].message.tool_calls.length, 1); + assert.equal(parsed.choices[0].message.tool_calls[0].id, "call_abc"); + assert.equal(parsed.choices[0].message.tool_calls[0].function.name, "sum"); + assert.equal(parsed.choices[0].message.tool_calls[0].function.arguments, '{"a":1}'); +}); diff --git a/tests/unit/session-manager.test.mjs b/tests/unit/session-manager.test.mjs index 475bae7d61..f2f008d78a 100644 --- a/tests/unit/session-manager.test.mjs +++ b/tests/unit/session-manager.test.mjs @@ -8,6 +8,11 @@ const { getSessionConnection, getActiveSessionCount, getActiveSessions, + extractExternalSessionId, + checkSessionLimit, + registerKeySession, + unregisterKeySession, + isSessionRegisteredForKey, clearSessions, } = await import("../../open-sse/services/sessionManager.ts"); @@ -39,11 +44,17 @@ test("generateSessionId: different model = different ID", () => { test("generateSessionId: different system prompt = different ID", () => { const body1 = { model: "claude-sonnet-4-20250514", - messages: [{ role: "system", content: "A" }, { role: "user", content: "hi" }], + messages: [ + { role: "system", content: "A" }, + { role: "user", content: "hi" }, + ], }; const body2 = { model: "claude-sonnet-4-20250514", - messages: [{ role: "system", content: "B" }, { role: "user", content: "hi" }], + messages: [ + { role: "system", content: "B" }, + { role: "user", content: "hi" }, + ], }; assert.notEqual(generateSessionId(body1), generateSessionId(body2)); }); @@ -147,3 +158,25 @@ test("touchSession with null sessionId: no-op", () => { touchSession(null); assert.equal(getActiveSessionCount(), 0); }); + +test("extractExternalSessionId accepts hyphen and underscore variants", () => { + const h1 = new Headers({ "x-session-id": "abc-123" }); + const h2 = new Headers({ x_session_id: "def-456" }); + + assert.equal(extractExternalSessionId(h1), "ext:abc-123"); + assert.equal(extractExternalSessionId(h2), "ext:def-456"); +}); + +test("checkSessionLimit enforces max_sessions for new sessions only", () => { + const keyId = "key-1"; + registerKeySession(keyId, "ext:sess-a"); + assert.equal(isSessionRegisteredForKey(keyId, "ext:sess-a"), true); + + const violation = checkSessionLimit(keyId, 1); + assert.ok(violation); + assert.equal(violation.code, "SESSION_LIMIT_EXCEEDED"); + + unregisterKeySession(keyId, "ext:sess-a"); + assert.equal(isSessionRegisteredForKey(keyId, "ext:sess-a"), false); + assert.equal(checkSessionLimit(keyId, 1), null); +}); diff --git a/tests/unit/thinking-budget.test.mjs b/tests/unit/thinking-budget.test.mjs index c18074b5ee..68c2ce73b0 100644 --- a/tests/unit/thinking-budget.test.mjs +++ b/tests/unit/thinking-budget.test.mjs @@ -213,8 +213,8 @@ test("normalizeThinkingLevel: converts Gemini thinkingConfig.thinkingLevel", () }, }; const result = normalizeThinkingLevel(body); - assert.equal(result.generationConfig.thinking_config.thinking_budget, 131072); - assert.equal(result.generationConfig.thinkingConfig, undefined); + assert.equal(result.generationConfig.thinkingConfig.thinkingBudget, 131072); + assert.equal(result.generationConfig.thinking_config, undefined); }); test("normalizeThinkingLevel: ignores unknown string values", () => {