fix(translator): ESM-safe registry + tool-id pairing + responses max_tokens; add real-creds tests

- translator/index.js: replace require() with static side-effect imports (ESM-safe),
  lazy-init registry maps to survive circular import order
- openai-responses->openai: map max_output_tokens -> max_tokens (avoid leaking field upstream)
- gemini/antigravity -> openai: derive deterministic tool_call id from name so
  functionCall/functionResponse pair correctly (fixes provider tool-pairing 400s)
- add offline unit tests (finish-reason, usage, session-manager, ollama malformed args, const guard)
- add real-creds integration tests (provider-cases + all-formats matrix: 6 inbound formats x 4 scenarios)

Includes co-located provider registry refactor (pricing/capabilities/media providers) and sessionManager updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
decolua
2026-06-15 11:38:43 +07:00
co-authored by Cursor
parent 24a2d19bd7
commit aba4c45da6
76 changed files with 2115 additions and 479 deletions
+2 -2
View File
@@ -3,7 +3,7 @@ import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js"; import { PROVIDERS } from "../config/providers.js";
import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, INTERNAL_REQUEST_HEADER, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX } from "../config/appConstants.js"; import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, INTERNAL_REQUEST_HEADER, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX } from "../config/appConstants.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { deriveSessionId } from "../utils/sessionManager.js"; import { resolveSessionId } from "../utils/sessionManager.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { cleanJSONSchemaForAntigravity } from "../translator/formats/gemini.js"; import { cleanJSONSchemaForAntigravity } from "../translator/formats/gemini.js";
@@ -94,7 +94,7 @@ export class AntigravityExecutor extends BaseExecutor {
generationConfig, generationConfig,
...(contents && { contents }), ...(contents && { contents }),
...(tools && { tools }), ...(tools && { tools }),
sessionId: body.request?.sessionId || deriveSessionId(credentials?.email || credentials?.connectionId), sessionId: body.request?.sessionId || resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId: credentials?.email || credentials?.connectionId, scope: "antigravity" }),
safetySettings: undefined, safetySettings: undefined,
...(tools?.length > 0 && { toolConfig: { functionCallingConfig: { mode: "VALIDATED" } } }) ...(tools?.length > 0 && { toolConfig: { functionCallingConfig: { mode: "VALIDATED" } } })
}; };
+11 -85
View File
@@ -1,4 +1,3 @@
import { createHash } from "crypto";
import { BaseExecutor } from "./base.js"; import { BaseExecutor } from "./base.js";
import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js"; import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js";
import { PROVIDERS } from "../config/providers.js"; import { PROVIDERS } from "../config/providers.js";
@@ -9,18 +8,14 @@ import {
import { normalizeResponsesInput } from "../translator/formats/responsesApi.js"; import { normalizeResponsesInput } from "../translator/formats/responsesApi.js";
import { fetchImageAsBase64 } from "../translator/concerns/image.js"; import { fetchImageAsBase64 } from "../translator/concerns/image.js";
import { getModelUpstreamId } from "../config/providerModels.js"; import { getModelUpstreamId } from "../config/providerModels.js";
import { getConsistentMachineId } from "../shared/machineId.js";
import { DEFAULT_RETRY_CONFIG, resolveRetryEntry } from "../config/runtimeConfig.js"; import { DEFAULT_RETRY_CONFIG, resolveRetryEntry } from "../config/runtimeConfig.js";
import { dbg } from "../utils/debugLog.js"; import { dbg } from "../utils/debugLog.js";
import { resolveSessionId } from "../utils/sessionManager.js";
// SSE error patterns inside 200-OK body that should trigger retry as if 503 // SSE error patterns inside 200-OK body that should trigger retry as if 503
const CODEX_SSE_OVERLOADED_PATTERNS = ["server_is_overloaded", "service_unavailable_error"]; const CODEX_SSE_OVERLOADED_PATTERNS = ["server_is_overloaded", "service_unavailable_error"];
const CODEX_SSE_PEEK_BYTES = 4096; const CODEX_SSE_PEEK_BYTES = 4096;
// In-memory map: hash(machineId + first assistant content) → { sessionId, lastUsed }
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
const assistantSessionMap = new Map();
// Server-generated item id prefixes that Codex /responses cannot resolve when store=false // Server-generated item id prefixes that Codex /responses cannot resolve when store=false
const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/; const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/;
@@ -104,86 +99,17 @@ function normalizeCodexTools(body) {
} }
} }
// Cache machine ID at module level (resolved once) // Resolve prompt-cache session id: client session → assistant-text-hash → workspaceId → connection
let cachedMachineId = null; function resolveCacheSessionId(body, credentials) {
getConsistentMachineId().then(id => { cachedMachineId = id; }); return resolveSessionId({
headers: credentials?.rawHeaders,
function hashContent(text) { body,
return createHash("sha256").update(text).digest("hex").slice(0, 16); connectionId: credentials?.connectionId,
workspaceId: credentials?.providerSpecificData?.workspaceId,
scope: "codex"
});
} }
function generateSessionId() {
return `sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
}
// Extract text content from an input item
function extractItemText(item) {
if (!item) return "";
if (typeof item.content === "string") return item.content;
if (Array.isArray(item.content)) {
return item.content.map(c => c.text || c.output || "").filter(Boolean).join("");
}
return "";
}
// Normalize a session id candidate (trim, length cap)
function normalizeSessionId(value) {
if (typeof value !== "string") return null;
const v = value.trim();
if (!v || v.length > 256) return null;
return v;
}
// Resolve prompt-cache session id with priority: body → assistant-text-hash → workspaceId → machineId
function resolveCacheSessionId(body, credentials, machineId) {
// 1. Client-provided session/conversation id (highest priority — stable per conversation)
const fromBody =
normalizeSessionId(body?.prompt_cache_key) ||
normalizeSessionId(body?.session_id) ||
normalizeSessionId(body?.conversation_id);
if (fromBody) return fromBody;
// 2. Hash accumulated assistant text (≥50 chars) — sticky session across turns
if (Array.isArray(body?.input) && body.input.length > 0) {
let text = "";
const MIN_LEN = 50;
const CAP_LEN = 200;
for (const item of body.input) {
if (item?.role !== "assistant") continue;
const t = extractItemText(item);
if (!t) continue;
text += t;
if (text.length >= CAP_LEN) break;
}
if (text.length >= MIN_LEN) {
const hash = hashContent((machineId || "") + text.slice(0, CAP_LEN));
const entry = assistantSessionMap.get(hash);
if (entry) {
entry.lastUsed = Date.now();
return entry.sessionId;
}
const sessionId = generateSessionId();
assistantSessionMap.set(hash, { sessionId, lastUsed: Date.now() });
return sessionId;
}
}
// 3. Account-wide fallback (workspaceId from connection)
const workspaceId = normalizeSessionId(credentials?.providerSpecificData?.workspaceId);
if (workspaceId) return workspaceId;
// 4. Last resort — stable per-machine id
return machineId ? `sess_${hashContent(machineId)}` : generateSessionId();
}
// Cleanup expired entries periodically
setInterval(() => {
const now = Date.now();
for (const [key, entry] of assistantSessionMap) {
if (now - entry.lastUsed > SESSION_TTL_MS) assistantSessionMap.delete(key);
}
}, 10 * 60 * 1000);
/** /**
* Codex Executor - handles OpenAI Codex API (Responses API format) * Codex Executor - handles OpenAI Codex API (Responses API format)
* Automatically injects default instructions if missing * Automatically injects default instructions if missing
@@ -377,7 +303,7 @@ export class CodexExecutor extends BaseExecutor {
this._isCompact = !!body._compact; this._isCompact = !!body._compact;
delete body._compact; delete body._compact;
// Resolve conversation-stable session_id (priority: body → assistant-text → workspace → machine) // Resolve conversation-stable session_id (priority: body → assistant-text → workspace → machine)
this._currentSessionId = resolveCacheSessionId(body, credentials, cachedMachineId); this._currentSessionId = resolveCacheSessionId(body, credentials);
// Convert string input to array format (Codex API requires input as array) // Convert string input to array format (Codex API requires input as array)
const normalized = normalizeResponsesInput(body.input); const normalized = normalizeResponsesInput(body.input);
if (normalized) body.input = normalized; if (normalized) body.input = normalized;
+3
View File
@@ -88,6 +88,9 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
const clientTool = detectClientTool(clientRawRequest?.headers || {}, body); const clientTool = detectClientTool(clientRawRequest?.headers || {}, body);
const passthrough = isNativePassthrough(clientTool, provider); const passthrough = isNativePassthrough(clientTool, provider);
// Expose raw client headers to translators/executors for session-id resolution
if (credentials) credentials.rawHeaders = clientRawRequest?.headers || {};
let translatedBody; let translatedBody;
let toolNameMap; let toolNameMap;
if (passthrough) { if (passthrough) {
+218
View File
@@ -0,0 +1,218 @@
// Model capabilities — what each model can read/do beyond plain text.
//
// Fallback order (first match wins), result merged over DEFAULT_CAPABILITIES:
// 1. PROVIDER_CAPABILITIES[provider][model] — provider-specific override
// 2. MODEL_CAPABILITIES[model] — canonical exact id (handles exceptions)
// 3. PATTERN_CAPABILITIES — glob match, ordered specific -> generic
// 4. DEFAULT_CAPABILITIES — safe floor (always returned)
//
// ── HOW TO ADD / UPDATE A MODEL ──────────────────────────────────────
// Authoritative data source: https://models.dev/api.json (145 providers, 4000+
// models, MIT). Each model exposes the exact fields we map below:
// modalities.input ["text","image","pdf","audio","video"] -> vision / pdf / audioInput / videoInput
// modalities.output ["text","image","audio"] -> imageOutput / audioOutput
// reasoning -> reasoning tool_call -> tools
// limit.context -> contextWindow limit.output -> maxOutput
// Look up the model id, then:
// • If a PATTERN below already covers it correctly -> nothing to do.
// • If it is an exception (pattern would mis-match) -> add an exact entry to
// MODEL_CAPABILITIES (only the fields that differ from DEFAULT).
// • If a whole new family -> add an ordered PATTERN (specific before generic).
// NOTE: models.dev has NO "search" flag (web search is a runtime tool, not a
// model spec); set `search` from vendor docs (Claude 4.x+, GPT-5.x/4o, Gemini
// 2.0+, Grok, Perplexity). Verify with: curl -s https://models.dev/api.json
import { matchPattern } from "./pricing.js";
/**
* Safe floor — every resolved result is merged over this so consumers
* never need null-checks. Most modern LLMs meet these limits.
*/
export const DEFAULT_CAPABILITIES = {
// input modalities
vision: false, // read images
pdf: false, // read PDF / documents
audioInput: false, // read audio
videoInput: false, // read video
// output modalities
imageOutput: false, // generate images
audioOutput: false, // generate audio
// features
search: false, // built-in web search tool / grounding
tools: true, // function / tool calling
reasoning: false, // thinking / reasoning
// limits (tokens)
contextWindow: 200000,
maxOutput: 64000,
};
/**
* Canonical exact-id overrides — used for exceptions that patterns would
* otherwise mis-match. Only declare deltas vs DEFAULT.
*/
export const MODEL_CAPABILITIES = {
// Claude 4.6/4.7 have 1M context (override generic claude pattern at 200k)
"claude-opus-4.6": { vision: true, reasoning: true, search: true, contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-4.7": { vision: true, reasoning: true, search: true, contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-4-6": { vision: true, reasoning: true, search: true, contextWindow: 1000000, maxOutput: 128000 },
"claude-sonnet-4.6": { vision: true, reasoning: true, search: true, contextWindow: 1000000, maxOutput: 64000 },
"claude-sonnet-4-6": { vision: true, reasoning: true, search: true, contextWindow: 1000000, maxOutput: 64000 },
// Gemini image-gen / OpenAI image / xai image variants
"gpt-image-1": { imageOutput: true, tools: false },
// GLM vision variant (text GLM has no vision)
"glm-4.6v": { vision: true, reasoning: true, contextWindow: 128000 },
// Qwen plain coder/text (no vision) — registry "vision-model" / "coder-model" aliases
"vision-model": { vision: true, reasoning: true, contextWindow: 1000000 },
"coder-model": { reasoning: true, contextWindow: 1000000 },
};
/**
* Provider-specific capability overrides. Keyed by provider alias/id.
*/
export const PROVIDER_CAPABILITIES = {};
/**
* Pattern fallback — glob (* = wildcard), matched case-insensitively and
* anchored (^...$) so a pattern must match the full model id. ORDER MATTERS:
* vision/specific variants first, text-only/generic families last, to avoid
* a broad family pattern swallowing an exception (e.g. glm-4.6v vs glm-5).
*/
export const PATTERN_CAPABILITIES = [
// ── Claude (4.x+ = vision + thinking + web search) ───────────────
{ pattern: "*claude*opus*", caps: { vision: true, reasoning: true, search: true } },
{ pattern: "*claude*sonnet*", caps: { vision: true, reasoning: true, search: true } },
{ pattern: "*claude*haiku*", caps: { vision: true, reasoning: true, search: true } },
{ pattern: "*claude*fable*", caps: { vision: true, reasoning: true, search: true, contextWindow: 1000000, maxOutput: 128000 } },
{ pattern: "*claude*mythos*", caps: { vision: true, reasoning: true, search: true, contextWindow: 1000000, maxOutput: 128000 } },
{ pattern: "*claude-3*", caps: { vision: true } },
{ pattern: "*claude*", caps: { vision: true, reasoning: true, search: true } },
// ── Gemini (all 2.0+ multimodal + google_search grounding, 1M ctx) ─
{ pattern: "*gemini*image*", caps: { vision: true, imageOutput: true, contextWindow: 1048576 } },
{ pattern: "*gemini-3*pro*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, contextWindow: 1048576, maxOutput: 65535 } },
{ pattern: "*gemini-3*", caps: { vision: true, audioInput: true, videoInput: true, search: true, contextWindow: 1048576, maxOutput: 65536 } },
{ pattern: "*gemini-2*", caps: { vision: true, audioInput: true, videoInput: true, search: true, contextWindow: 1048576, maxOutput: 65536 } },
{ pattern: "*gemini*", caps: { vision: true, search: true, contextWindow: 1048576 } },
{ pattern: "*gemma*", caps: { vision: true, contextWindow: 128000 } },
{ pattern: "*nanobanana*", caps: { vision: true, imageOutput: true } },
// ── OpenAI GPT-5.x (vision + thinking + web search) ──────────────
{ pattern: "*gpt-5*image*", caps: { imageOutput: true } },
{ pattern: "*gpt-5*codex*", caps: { reasoning: true, search: true, contextWindow: 400000, maxOutput: 128000 } },
{ pattern: "*gpt-5*", caps: { vision: true, reasoning: true, search: true, contextWindow: 400000, maxOutput: 128000 } },
{ pattern: "*gpt-4o*", caps: { vision: true, search: true, contextWindow: 128000, maxOutput: 16384 } },
{ pattern: "*gpt-4.1*", caps: { vision: true, contextWindow: 1000000, maxOutput: 32768 } },
{ pattern: "*gpt-4-turbo*", caps: { vision: true, contextWindow: 128000 } },
{ pattern: "*gpt-4*", caps: { contextWindow: 128000 } },
{ pattern: "*gpt-3.5*", caps: { contextWindow: 16385, maxOutput: 4096 } },
{ pattern: "*gpt-oss*", caps: { reasoning: true, contextWindow: 128000 } },
// ── OpenAI o-series (reasoning, vision) ──────────────────────────
{ pattern: "*o1-mini*", caps: { reasoning: true, contextWindow: 128000 } },
{ pattern: "*o1*", caps: { vision: true, reasoning: true, contextWindow: 200000, maxOutput: 100000 } },
{ pattern: "*o3*", caps: { vision: true, reasoning: true, contextWindow: 200000, maxOutput: 100000 } },
{ pattern: "*o4*", caps: { vision: true, reasoning: true, contextWindow: 200000, maxOutput: 100000 } },
// ── Grok (vision + Live Search) ──────────────────────────────────
{ pattern: "*grok*image*", caps: { imageOutput: true } },
{ pattern: "*grok-code*", caps: { reasoning: true, contextWindow: 256000 } },
{ pattern: "*grok-4*", caps: { vision: true, reasoning: true, search: true, contextWindow: 256000 } },
{ pattern: "*grok-3*", caps: { vision: true, reasoning: true, search: true, contextWindow: 131072 } },
{ pattern: "*grok*", caps: { vision: true, reasoning: true, search: true, contextWindow: 256000 } },
// ── Qwen (VL = vision; max/plus = vision+1M; coder/text last) ─────
{ pattern: "*qwen*vl*", caps: { vision: true, reasoning: true, contextWindow: 262144 } },
{ pattern: "*qwen*max*", caps: { vision: true, reasoning: true, contextWindow: 1000000, maxOutput: 65536 } },
{ pattern: "*qwen*plus*", caps: { vision: true, reasoning: true, contextWindow: 1000000, maxOutput: 65536 } },
{ pattern: "*qwen*235b*", caps: { reasoning: true, contextWindow: 262144 } },
{ pattern: "*qwen*coder*", caps: { reasoning: true, contextWindow: 1000000 } },
{ pattern: "*qwq*", caps: { reasoning: true, contextWindow: 131072 } },
{ pattern: "*qwen*", caps: { reasoning: true, contextWindow: 262144 } },
// ── Kimi (K2.x = vision + thinking, 262K) ────────────────────────
{ pattern: "*kimi*k2*", caps: { vision: true, reasoning: true, contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "*kimi*", caps: { reasoning: true, contextWindow: 262144 } },
// ── GLM (4.6V vision handled by exact id; text GLM = reasoning) ───
{ pattern: "*glm-5*", caps: { reasoning: true, contextWindow: 200000, maxOutput: 128000 } },
{ pattern: "*glm-4.7*", caps: { reasoning: true, contextWindow: 200000, maxOutput: 128000 } },
{ pattern: "*glm-4*", caps: { reasoning: true, contextWindow: 200000 } },
{ pattern: "*glm*", caps: { reasoning: true, contextWindow: 200000 } },
// ── DeepSeek (NO vision; v4 = 1M ctx; r1/reasoner = thinking) ─────
{ pattern: "*deepseek-v4*", caps: { reasoning: true, contextWindow: 1000000, maxOutput: 384000 } },
{ pattern: "*reasoner*", caps: { reasoning: true, contextWindow: 128000 } },
{ pattern: "*deepseek-r*", caps: { reasoning: true, contextWindow: 128000 } },
{ pattern: "*deepseek*", caps: { contextWindow: 128000 } },
// ── MiniMax (M3 = 1M/512K; M2.x = 200K) ──────────────────────────
{ pattern: "*minimax*image*", caps: { imageOutput: true } },
{ pattern: "*minimax-m3*", caps: { reasoning: true, contextWindow: 1048576, maxOutput: 512000 } },
{ pattern: "*minimax-m2.7*", caps: { reasoning: true, contextWindow: 204800, maxOutput: 131072 } },
{ pattern: "*minimax*", caps: { reasoning: true, contextWindow: 200000, maxOutput: 131072 } },
// ── Xiaomi MiMo (vision, 1M / 262K ctx) ──────────────────────────
{ pattern: "*mimo*v2.5*", caps: { vision: true, contextWindow: 1048576, maxOutput: 131072 } },
{ pattern: "*mimo*omni*", caps: { vision: true, audioInput: true, contextWindow: 262144, maxOutput: 131072 } },
{ pattern: "*mimo*", caps: { vision: true, contextWindow: 262144, maxOutput: 131072 } },
// ── Llama (4 = vision/1M; 3.x = text-only/128K) ──────────────────
{ pattern: "*llama-4*", caps: { vision: true, contextWindow: 1000000 } },
{ pattern: "*llama*", caps: { contextWindow: 128000 } },
// ── Mistral (Large 3 = vision/256K; codestral text) ──────────────
{ pattern: "*codestral*", caps: { contextWindow: 256000 } },
{ pattern: "*mistral-large*", caps: { vision: true, contextWindow: 256000 } },
{ pattern: "*mistral*", caps: { contextWindow: 128000 } },
// ── Cohere (Command A Vision = vision; others text) ──────────────
{ pattern: "*command-a-vision*", caps: { vision: true, contextWindow: 128000 } },
{ pattern: "*command*", caps: { contextWindow: 128000 } },
// ── Perplexity (web search native) ───────────────────────────────
{ pattern: "*sonar*", caps: { search: true, contextWindow: 128000 } },
{ pattern: "*pplx*", caps: { search: true, contextWindow: 128000 } },
{ pattern: "*perplexity*", caps: { search: true, contextWindow: 128000 } },
// ── Others ───────────────────────────────────────────────────────
{ pattern: "*hunyuan*", caps: { reasoning: true, contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "hy3*", caps: { reasoning: true, contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "*step-*", caps: { reasoning: true, contextWindow: 128000 } },
{ pattern: "*nemotron*", caps: { reasoning: true, contextWindow: 128000 } },
{ pattern: "*ling-*", caps: { reasoning: true, contextWindow: 128000 } },
];
/**
* Resolve capabilities for a model using the 4-step fallback chain,
* merged over DEFAULT_CAPABILITIES so the result is always complete.
*
* @param {string} provider
* @param {string} model
* @returns {object} full capabilities object
*/
export function getCapabilitiesForModel(provider, model) {
if (!model) return { ...DEFAULT_CAPABILITIES };
// 1. Provider-specific override
if (provider && PROVIDER_CAPABILITIES[provider]?.[model]) {
return { ...DEFAULT_CAPABILITIES, ...PROVIDER_CAPABILITIES[provider][model] };
}
// 2. Canonical exact (strip vendor prefix: "anthropic/claude-opus-4.7" -> "claude-opus-4.7")
const baseModel = model.includes("/") ? model.split("/").pop() : model;
if (MODEL_CAPABILITIES[baseModel]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[baseModel] };
if (MODEL_CAPABILITIES[model]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[model] };
// 3. Pattern match (first match wins)
for (const { pattern, caps } of PATTERN_CAPABILITIES) {
if (matchPattern(pattern, baseModel) || matchPattern(pattern, model)) {
return { ...DEFAULT_CAPABILITIES, ...caps };
}
}
// 4. Floor
return { ...DEFAULT_CAPABILITIES };
}
@@ -207,10 +207,11 @@ export const PATTERN_PRICING = [
]; ];
/** /**
* Match a model ID against a glob pattern (* = wildcard). * Match a model ID against a glob pattern (* = wildcard). Case-insensitive:
* registry ids mix casing (e.g. "MiniMax-M2.5" vs "minimax-m2.5").
*/ */
function matchPattern(pattern, model) { export function matchPattern(pattern, model) {
const regex = new RegExp("^" + pattern.split("*").map(s => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$"); const regex = new RegExp("^" + pattern.split("*").map(s => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$", "i");
return regex.test(model); return regex.test(model);
} }
+45
View File
@@ -0,0 +1,45 @@
export default {
id: "aws-polly",
alias: "polly",
display: {
name: "AWS Polly",
icon: "record_voice_over",
color: "#FF9900",
textIcon: "PL",
website: "https://aws.amazon.com/polly/",
notice: {
text: "Use AWS Secret Access Key as API key; set providerSpecificData.accessKeyId and optional region.",
apiKeyUrl: "https://console.aws.amazon.com/iam/home#/security_credentials"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"tts"
],
ttsConfig: {
baseUrl: "https://polly.{region}.amazonaws.com/v1/speech",
authType: "apikey",
authHeader: "aws-sigv4",
format: "aws-polly",
models: [
{
id: "standard",
name: "Standard"
},
{
id: "neural",
name: "Neural"
},
{
id: "long-form",
name: "Long-form"
},
{
id: "generative",
name: "Generative"
}
]
},
hasProviderSpecificData: true
};
@@ -0,0 +1,35 @@
export default {
id: "brave-search",
alias: "brave",
display: {
name: "Brave Search",
icon: "travel_explore",
color: "#FB542B",
textIcon: "BR",
website: "https://brave.com/search/api",
notice: {
apiKeyUrl: "https://api-dashboard.search.brave.com/app/keys"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"webSearch"
],
searchConfig: {
baseUrl: "https://api.search.brave.com/res/v1",
method: "GET",
authType: "apikey",
authHeader: "x-subscription-token",
costPerQuery: 0.005,
freeMonthlyQuota: 1000,
searchTypes: [
"web",
"news"
],
defaultMaxResults: 5,
maxMaxResults: 20,
timeoutMs: 10000,
cacheTTLMs: 300000
}
};
+1 -1
View File
@@ -1,6 +1,6 @@
export default { export default {
id: "byteplus", id: "byteplus",
priority: 150, priority: 70,
alias: "byteplus", alias: "byteplus",
aliases: [ aliases: [
"bpm", "bpm",
+36
View File
@@ -0,0 +1,36 @@
export default {
id: "cartesia",
alias: "cartesia",
display: {
name: "Cartesia",
icon: "spatial_audio",
color: "#FF4F8B",
textIcon: "CA",
website: "https://cartesia.ai",
notice: {
apiKeyUrl: "https://play.cartesia.ai/keys"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"tts"
],
ttsConfig: {
baseUrl: "https://api.cartesia.ai/tts/bytes",
authType: "apikey",
authHeader: "x-api-key",
format: "cartesia",
models: [
{
id: "sonic-2",
name: "Sonic 2"
},
{
id: "sonic-3",
name: "Sonic 3"
}
]
},
hidden: true
};
+1 -1
View File
@@ -1,6 +1,6 @@
export default { export default {
id: "cline", id: "cline",
priority: 70, priority: 80,
alias: "cl", alias: "cl",
uiAlias: "cl", uiAlias: "cl",
display: { display: {
+1 -1
View File
@@ -1,6 +1,6 @@
export default { export default {
id: "cloudflare-ai", id: "cloudflare-ai",
priority: 20, priority: 60,
hasFree: true, hasFree: true,
alias: "cloudflare-ai", alias: "cloudflare-ai",
aliases: [ aliases: [
+2 -1
View File
@@ -1,6 +1,7 @@
export default { export default {
id: "codebuddy", id: "codebuddy",
priority: 80, hidden: true,
priority: 90,
display: { display: {
name: "CodeBuddy", name: "CodeBuddy",
icon: "smart_toy", icon: "smart_toy",
+30
View File
@@ -0,0 +1,30 @@
export default {
id: "coqui",
alias: "coqui",
display: {
name: "Coqui TTS",
icon: "record_voice_over",
color: "#10B981",
textIcon: "CQ",
website: "https://github.com/coqui-ai/TTS"
},
category: "freeTier",
authType: "none",
serviceKinds: [
"tts"
],
noAuth: true,
ttsConfig: {
baseUrl: "http://localhost:5002/api/tts",
authType: "none",
authHeader: "none",
format: "coqui",
models: [
{
id: "tts_models/en/ljspeech/tacotron2-DDC",
name: "Tacotron2 DDC (LJSpeech)"
}
]
},
hidden: true
};
+1 -1
View File
@@ -1,6 +1,6 @@
export default { export default {
id: "cursor", id: "cursor",
priority: 40, priority: 50,
alias: "cu", alias: "cu",
uiAlias: "cu", uiAlias: "cu",
display: { display: {
+24
View File
@@ -0,0 +1,24 @@
export default {
id: "edge-tts",
alias: "edge-tts",
display: {
name: "Edge TTS",
icon: "record_voice_over",
color: "#0078D4",
textIcon: "ET"
},
category: "freeTier",
authType: "none",
serviceKinds: [
"tts"
],
mediaPriority: 5,
noAuth: true,
ttsConfig: {
baseUrl: "edge-tts",
authType: "none",
authHeader: "none",
format: "edge-tts",
models: []
}
};
+35
View File
@@ -0,0 +1,35 @@
export default {
id: "elevenlabs",
alias: "el",
display: {
name: "ElevenLabs",
icon: "record_voice_over",
color: "#6C47FF",
textIcon: "EL",
website: "https://elevenlabs.io",
notice: {
apiKeyUrl: "https://elevenlabs.io/app/settings/api-keys"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"tts"
],
ttsConfig: {
baseUrl: "https://api.elevenlabs.io/v1/text-to-speech",
authType: "apikey",
authHeader: "xi-api-key",
format: "elevenlabs",
models: [
{
id: "eleven_multilingual_v2",
name: "Eleven Multilingual v2"
},
{
id: "eleven_turbo_v2_5",
name: "Eleven Turbo v2.5"
}
]
}
};
+50
View File
@@ -0,0 +1,50 @@
export default {
id: "exa",
alias: "exa",
display: {
name: "Exa",
icon: "manage_search",
color: "#2563EB",
textIcon: "EX",
website: "https://exa.ai",
notice: {
apiKeyUrl: "https://dashboard.exa.ai/api-keys"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"webSearch",
"webFetch"
],
searchConfig: {
baseUrl: "https://api.exa.ai/search",
method: "POST",
authType: "apikey",
authHeader: "x-api-key",
costPerQuery: 0.007,
freeMonthlyQuota: 1000,
searchTypes: [
"web",
"news"
],
defaultMaxResults: 5,
maxMaxResults: 100,
timeoutMs: 10000,
cacheTTLMs: 300000
},
fetchConfig: {
baseUrl: "https://api.exa.ai/contents",
method: "POST",
authType: "apikey",
authHeader: "x-api-key",
costPerQuery: 0.001,
freeMonthlyQuota: 1000,
formats: [
"text",
"markdown"
],
maxCharacters: 100000,
timeoutMs: 15000
}
};
+34
View File
@@ -0,0 +1,34 @@
export default {
id: "firecrawl",
alias: "firecrawl",
display: {
name: "Firecrawl",
icon: "local_fire_department",
color: "#F59E0B",
textIcon: "FC",
website: "https://firecrawl.dev",
notice: {
apiKeyUrl: "https://www.firecrawl.dev/app/api-keys"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"webFetch"
],
fetchConfig: {
baseUrl: "https://api.firecrawl.dev/v1/scrape",
method: "POST",
authType: "apikey",
authHeader: "bearer",
costPerQuery: 0.002,
freeMonthlyQuota: 500,
formats: [
"markdown",
"html",
"text"
],
maxCharacters: 200000,
timeoutMs: 30000
}
};
+1 -1
View File
@@ -2,7 +2,7 @@ import { GOOGLE_OAUTH_CLIENT } from "../shared.js";
export default { export default {
id: "gemini-cli", id: "gemini-cli",
priority: 130, priority: 20,
hasFree: true, hasFree: true,
alias: "gc", alias: "gc",
uiAlias: "gc", uiAlias: "gc",
+1 -1
View File
@@ -2,7 +2,7 @@ import { GOOGLE_OAUTH_CLIENT } from "../shared.js";
export default { export default {
id: "gemini", id: "gemini",
priority: 10, priority: 50,
hasFree: true, hasFree: true,
alias: "gemini", alias: "gemini",
display: { display: {
+1 -1
View File
@@ -1,6 +1,6 @@
export default { export default {
id: "github", id: "github",
priority: 50, priority: 40,
alias: "gh", alias: "gh",
uiAlias: "gh", uiAlias: "gh",
display: { display: {
+2 -1
View File
@@ -1,6 +1,7 @@
export default { export default {
id: "gitlab", id: "gitlab",
priority: 120, hidden: true,
priority: 100,
display: { display: {
name: "GitLab Duo", name: "GitLab Duo",
icon: "code", icon: "code",
+35
View File
@@ -0,0 +1,35 @@
export default {
id: "google-pse",
alias: "gpse",
display: {
name: "Google PSE",
icon: "search",
color: "#4285F4",
textIcon: "GP",
website: "https://programmablesearchengine.google.com",
notice: {
apiKeyUrl: "https://programmablesearchengine.google.com/controlpanel/create"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"webSearch"
],
searchConfig: {
baseUrl: "https://www.googleapis.com/customsearch/v1",
method: "GET",
authType: "apikey",
authHeader: "key",
costPerQuery: 0.005,
freeMonthlyQuota: 3000,
searchTypes: [
"web",
"news"
],
defaultMaxResults: 5,
maxMaxResults: 10,
timeoutMs: 10000,
cacheTTLMs: 300000
}
};
+24
View File
@@ -0,0 +1,24 @@
export default {
id: "google-tts",
alias: "google-tts",
display: {
name: "Google TTS",
icon: "record_voice_over",
color: "#4285F4",
textIcon: "GT"
},
category: "freeTier",
authType: "none",
serviceKinds: [
"tts"
],
mediaPriority: 5,
noAuth: true,
ttsConfig: {
baseUrl: "google-tts",
authType: "none",
authHeader: "none",
format: "google-tts",
models: []
}
};
+2 -1
View File
@@ -1,6 +1,7 @@
export default { export default {
id: "iflow", id: "iflow",
priority: 170, hidden: true,
priority: 110,
alias: "if", alias: "if",
display: { display: {
name: "iFlow AI", name: "iFlow AI",
+118 -72
View File
@@ -1,75 +1,98 @@
// Auto-generated: static imports of all registry entries // Auto-generated: static imports of all registry entries
import p0 from './alicode-intl.js'; import p0 from "./alicode.js";
import p1 from './alicode.js'; import p1 from "./alicode-intl.js";
import p2 from './anthropic.js'; import p2 from "./anthropic.js";
import p3 from './antigravity.js'; import p3 from "./antigravity.js";
import p4 from './assemblyai.js'; import p4 from "./assemblyai.js";
import p5 from './azure.js'; import p5 from "./aws-polly.js";
import p6 from './black-forest-labs.js'; import p6 from "./azure.js";
import p7 from './blackbox.js'; import p7 from "./black-forest-labs.js";
import p8 from './byteplus.js'; import p8 from "./blackbox.js";
import p9 from './cerebras.js'; import p9 from "./brave-search.js";
import p10 from './chutes.js'; import p10 from "./byteplus.js";
import p11 from './claude.js'; import p11 from "./cartesia.js";
import p12 from './cline.js'; import p12 from "./cerebras.js";
import p13 from './cloudflare-ai.js'; import p13 from "./chutes.js";
import p14 from './codebuddy.js'; import p14 from "./claude.js";
import p15 from './codex.js'; import p15 from "./cline.js";
import p16 from './cohere.js'; import p16 from "./cloudflare-ai.js";
import p17 from './comfyui.js'; import p17 from "./codebuddy.js";
import p18 from './commandcode.js'; import p18 from "./codex.js";
import p19 from './cursor.js'; import p19 from "./cohere.js";
import p20 from './deepgram.js'; import p20 from "./comfyui.js";
import p21 from './deepseek.js'; import p21 from "./commandcode.js";
import p22 from './fal-ai.js'; import p22 from "./coqui.js";
import p23 from './fireworks.js'; import p23 from "./cursor.js";
import p24 from './gemini-cli.js'; import p24 from "./deepgram.js";
import p25 from './gemini.js'; import p25 from "./deepseek.js";
import p26 from './github.js'; import p26 from "./edge-tts.js";
import p27 from './gitlab.js'; import p27 from "./elevenlabs.js";
import p28 from './glm-cn.js'; import p28 from "./exa.js";
import p29 from './glm.js'; import p29 from "./fal-ai.js";
import p30 from './grok-web.js'; import p30 from "./firecrawl.js";
import p31 from './groq.js'; import p31 from "./fireworks.js";
import p32 from './huggingface.js'; import p32 from "./gemini.js";
import p33 from './hyperbolic.js'; import p33 from "./gemini-cli.js";
import p34 from './iflow.js'; import p34 from "./github.js";
import p35 from './kilocode.js'; import p35 from "./gitlab.js";
import p36 from './kimi-coding.js'; import p36 from "./glm.js";
import p37 from './kimi.js'; import p37 from "./glm-cn.js";
import p38 from './kiro.js'; import p38 from "./google-pse.js";
import p39 from './mimo-free.js'; import p39 from "./google-tts.js";
import p40 from './minimax-cn.js'; import p40 from "./grok-web.js";
import p41 from './minimax.js'; import p41 from "./groq.js";
import p42 from './mistral.js'; import p42 from "./huggingface.js";
import p43 from './mmf.js'; import p43 from "./hyperbolic.js";
import p44 from './nanobanana.js'; import p44 from "./iflow.js";
import p45 from './nebius.js'; import p45 from "./inworld.js";
import p46 from './nvidia.js'; import p46 from "./jina-ai.js";
import p47 from './ollama-local.js'; import p47 from "./jina-reader.js";
import p48 from './ollama.js'; import p48 from "./kilocode.js";
import p49 from './openai.js'; import p49 from "./kimi.js";
import p50 from './opencode-go.js'; import p50 from "./kimi-coding.js";
import p51 from './opencode.js'; import p51 from "./kiro.js";
import p52 from './openrouter.js'; import p52 from "./linkup.js";
import p53 from './perplexity-web.js'; import p53 from "./local-device.js";
import p54 from './perplexity.js'; import p54 from "./mimo-free.js";
import p55 from './qoder.js'; import p55 from "./minimax.js";
import p56 from './qwen.js'; import p56 from "./minimax-cn.js";
import p57 from './recraft.js'; import p57 from "./mistral.js";
import p58 from './runwayml.js'; import p58 from "./mmf.js";
import p59 from './sdwebui.js'; import p59 from "./nanobanana.js";
import p60 from './siliconflow.js'; import p60 from "./nebius.js";
import p61 from './stability-ai.js'; import p61 from "./nvidia.js";
import p62 from './together.js'; import p62 from "./ollama.js";
import p63 from './vercel-ai-gateway.js'; import p63 from "./ollama-local.js";
import p64 from './vertex-partner.js'; import p64 from "./openai.js";
import p65 from './vertex.js'; import p65 from "./opencode.js";
import p66 from './volcengine-ark.js'; import p66 from "./opencode-go.js";
import p67 from './voyage-ai.js'; import p67 from "./openrouter.js";
import p68 from './xai.js'; import p68 from "./perplexity.js";
import p69 from './xiaomi-mimo.js'; import p69 from "./perplexity-web.js";
import p70 from './xiaomi-tokenplan.js'; import p70 from "./playht.js";
import p71 from "./qoder.js";
import p72 from "./qwen.js";
import p73 from "./recraft.js";
import p74 from "./runwayml.js";
import p75 from "./sdwebui.js";
import p76 from "./searchapi.js";
import p77 from "./searxng.js";
import p78 from "./serper.js";
import p79 from "./siliconflow.js";
import p80 from "./stability-ai.js";
import p81 from "./tavily.js";
import p82 from "./together.js";
import p83 from "./topaz.js";
import p84 from "./tortoise.js";
import p85 from "./vercel-ai-gateway.js";
import p86 from "./vertex.js";
import p87 from "./vertex-partner.js";
import p88 from "./volcengine-ark.js";
import p89 from "./voyage-ai.js";
import p90 from "./xai.js";
import p91 from "./xiaomi-mimo.js";
import p92 from "./xiaomi-tokenplan.js";
import p93 from "./youcom.js";
export default [ export default [
p0, p0,
@@ -142,5 +165,28 @@ export default [
p67, p67,
p68, p68,
p69, p69,
p70 p70,
p71,
p72,
p73,
p74,
p75,
p76,
p77,
p78,
p79,
p80,
p81,
p82,
p83,
p84,
p85,
p86,
p87,
p88,
p89,
p90,
p91,
p92,
p93
]; ];
+36
View File
@@ -0,0 +1,36 @@
export default {
id: "inworld",
alias: "inworld",
display: {
name: "Inworld TTS",
icon: "record_voice_over",
color: "#FF6B6B",
textIcon: "IW",
website: "https://inworld.ai",
notice: {
text: "Free tier: 40 minutes/month TTS. Paid: TTS-1.5 Mini $0.01/min ($15/1M chars), TTS-1.5 Max $0.025/min ($30/1M chars). 270+ voices, 15 languages.",
apiKeyUrl: "https://platform.inworld.ai/api-keys"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"tts"
],
ttsConfig: {
baseUrl: "https://api.inworld.ai/tts/v1/voice",
authType: "apikey",
authHeader: "basic",
format: "inworld",
models: [
{
id: "inworld-tts-1.5-mini",
name: "Inworld TTS 1.5 Mini ($0.01/min)"
},
{
id: "inworld-tts-1.5-max",
name: "Inworld TTS 1.5 Max ($0.025/min)"
}
]
}
};
+42
View File
@@ -0,0 +1,42 @@
export default {
id: "jina-ai",
alias: "jina",
display: {
name: "Jina AI",
icon: "blur_on",
color: "#2563EB",
textIcon: "JA",
website: "https://jina.ai",
notice: {
text: "10M free tokens on signup (non-commercial), no credit card required.",
apiKeyUrl: "https://jina.ai/?sui=apikey"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"embedding"
],
embeddingConfig: {
baseUrl: "https://api.jina.ai/v1/embeddings",
authType: "apikey",
authHeader: "bearer",
models: [
{
id: "jina-embeddings-v3",
name: "Jina Embeddings v3",
dimensions: 1024
},
{
id: "jina-embeddings-v2-base-en",
name: "Jina Embeddings v2 Base EN",
dimensions: 768
},
{
id: "jina-embeddings-v2-base-code",
name: "Jina Embeddings v2 Base Code",
dimensions: 768
}
]
}
};
@@ -0,0 +1,34 @@
export default {
id: "jina-reader",
alias: "jina-reader",
display: {
name: "Jina Reader",
icon: "menu_book",
color: "#000000",
textIcon: "JR",
website: "https://jina.ai/reader",
notice: {
apiKeyUrl: "https://jina.ai/?sui=apikey"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"webFetch"
],
fetchConfig: {
baseUrl: "https://r.jina.ai",
method: "GET",
authType: "apikey",
authHeader: "bearer",
costPerQuery: 0,
freeMonthlyQuota: 1000000,
formats: [
"markdown",
"text",
"html"
],
maxCharacters: 200000,
timeoutMs: 30000
}
};
+1 -1
View File
@@ -1,6 +1,6 @@
export default { export default {
id: "kilocode", id: "kilocode",
priority: 60, priority: 70,
alias: "kc", alias: "kc",
uiAlias: "kc", uiAlias: "kc",
display: { display: {
+2 -1
View File
@@ -2,7 +2,8 @@ import { CLAUDE_API_HEADERS, KIMI_CODING_BASE_URL } from "../shared.js";
export default { export default {
id: "kimi-coding", id: "kimi-coding",
priority: 180, hidden: true,
priority: 120,
alias: "kmc", alias: "kmc",
display: { display: {
name: "Kimi Coding", name: "Kimi Coding",
+1 -1
View File
@@ -1,6 +1,6 @@
export default { export default {
id: "kiro", id: "kiro",
priority: 80, priority: 10,
alias: "kr", alias: "kr",
uiAlias: "kr", uiAlias: "kr",
display: { display: {
+34
View File
@@ -0,0 +1,34 @@
export default {
id: "linkup",
alias: "linkup",
display: {
name: "Linkup",
icon: "link",
color: "#0EA5E9",
textIcon: "LK",
website: "https://linkup.so",
notice: {
apiKeyUrl: "https://app.linkup.so/api-keys"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"webSearch"
],
searchConfig: {
baseUrl: "https://api.linkup.so/v1/search",
method: "POST",
authType: "apikey",
authHeader: "bearer",
costPerQuery: 0.005,
freeMonthlyQuota: 1000,
searchTypes: [
"web"
],
defaultMaxResults: 5,
maxMaxResults: 50,
timeoutMs: 10000,
cacheTTLMs: 300000
}
};
@@ -0,0 +1,24 @@
export default {
id: "local-device",
alias: "local-device",
display: {
name: "Local Device",
icon: "speaker",
color: "#64748B",
textIcon: "LD"
},
category: "freeTier",
authType: "none",
serviceKinds: [
"tts"
],
mediaPriority: 5,
noAuth: true,
ttsConfig: {
baseUrl: "local-device",
authType: "none",
authHeader: "none",
format: "local-device",
models: []
}
};
+1 -1
View File
@@ -1,6 +1,6 @@
export default { export default {
id: "mimo-free", id: "mimo-free",
priority: 120, priority: 50,
hasFree: true, hasFree: true,
alias: "mmf", alias: "mmf",
uiAlias: "mmf", uiAlias: "mmf",
+1
View File
@@ -1,5 +1,6 @@
export default { export default {
id: "mmf", id: "mmf",
hidden: true,
priority: 200, priority: 200,
display: { display: {
name: "MMF", name: "MMF",
+1 -1
View File
@@ -27,7 +27,7 @@ export default {
{ id: "nanobanana-flash", name: "NanoBanana Flash", params: ["n","size"], kind: "image" }, { id: "nanobanana-flash", name: "NanoBanana Flash", params: ["n","size"], kind: "image" },
{ id: "nanobanana-pro", name: "NanoBanana Pro", params: ["n","size"], kind: "image" }, { id: "nanobanana-pro", name: "NanoBanana Pro", params: ["n","size"], kind: "image" },
], ],
serviceKinds: ["llm","image"], serviceKinds: ["image"],
imageConfig: { imageConfig: {
baseUrl: "https://api.nanobananaapi.ai/api/v1/nanobanana/generate", baseUrl: "https://api.nanobananaapi.ai/api/v1/nanobanana/generate",
pollUrl: "https://api.nanobananaapi.ai/api/v1/nanobanana/record-info", pollUrl: "https://api.nanobananaapi.ai/api/v1/nanobanana/record-info",
+1 -1
View File
@@ -1,6 +1,6 @@
export default { export default {
id: "nvidia", id: "nvidia",
priority: 100, priority: 20,
hasFree: true, hasFree: true,
alias: "nvidia", alias: "nvidia",
display: { display: {
+1 -1
View File
@@ -1,6 +1,6 @@
export default { export default {
id: "ollama", id: "ollama",
priority: 40, priority: 30,
hasFree: true, hasFree: true,
alias: "ollama", alias: "ollama",
display: { display: {
+1 -1
View File
@@ -1,6 +1,6 @@
export default { export default {
id: "opencode", id: "opencode",
priority: 110, priority: 40,
hasFree: true, hasFree: true,
alias: "oc", alias: "oc",
uiAlias: "oc", uiAlias: "oc",
+1 -1
View File
@@ -1,6 +1,6 @@
export default { export default {
id: "openrouter", id: "openrouter",
priority: 30, priority: 10,
hasFree: true, hasFree: true,
alias: "openrouter", alias: "openrouter",
display: { display: {
+36
View File
@@ -0,0 +1,36 @@
export default {
id: "playht",
alias: "playht",
display: {
name: "PlayHT",
icon: "play_circle",
color: "#00B4D8",
textIcon: "PH",
website: "https://play.ht",
notice: {
apiKeyUrl: "https://play.ht/studio/api-access"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"tts"
],
ttsConfig: {
baseUrl: "https://api.play.ht/api/v2/tts/stream",
authType: "apikey",
authHeader: "playht",
format: "playht",
models: [
{
id: "PlayDialog",
name: "PlayDialog"
},
{
id: "Play3.0-mini",
name: "Play 3.0 Mini"
}
]
},
hidden: true
};
+1 -1
View File
@@ -1,6 +1,6 @@
export default { export default {
id: "qoder", id: "qoder",
priority: 230, priority: 30,
alias: "qd", alias: "qd",
uiAlias: "qd", uiAlias: "qd",
display: { display: {
+2 -1
View File
@@ -1,6 +1,7 @@
export default { export default {
id: "qwen", id: "qwen",
priority: 240, hidden: true,
priority: 130,
alias: "qw", alias: "qw",
display: { display: {
name: "Qwen Code", name: "Qwen Code",
+35
View File
@@ -0,0 +1,35 @@
export default {
id: "searchapi",
alias: "searchapi",
display: {
name: "SearchAPI",
icon: "search",
color: "#0EA5A4",
textIcon: "SA",
website: "https://www.searchapi.io",
notice: {
apiKeyUrl: "https://www.searchapi.io/dashboard"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"webSearch"
],
searchConfig: {
baseUrl: "https://www.searchapi.io/api/v1/search",
method: "GET",
authType: "apikey",
authHeader: "api_key",
costPerQuery: 0.004,
freeMonthlyQuota: 100,
searchTypes: [
"web",
"news"
],
defaultMaxResults: 5,
maxMaxResults: 100,
timeoutMs: 10000,
cacheTTLMs: 300000
}
};
+33
View File
@@ -0,0 +1,33 @@
export default {
id: "searxng",
alias: "searxng",
display: {
name: "SearXNG",
icon: "saved_search",
color: "#3B82F6",
textIcon: "SX",
website: "https://docs.searxng.org"
},
category: "freeTier",
authType: "none",
serviceKinds: [
"webSearch"
],
noAuth: true,
searchConfig: {
baseUrl: "http://localhost:8888/search",
method: "GET",
authType: "none",
authHeader: "none",
costPerQuery: 0,
freeMonthlyQuota: 999999,
searchTypes: [
"web",
"news"
],
defaultMaxResults: 5,
maxMaxResults: 50,
timeoutMs: 10000,
cacheTTLMs: 180000
}
};
+35
View File
@@ -0,0 +1,35 @@
export default {
id: "serper",
alias: "serper",
display: {
name: "Serper",
icon: "search",
color: "#4F46E5",
textIcon: "SP",
website: "https://serper.dev",
notice: {
apiKeyUrl: "https://serper.dev/api-key"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"webSearch"
],
searchConfig: {
baseUrl: "https://google.serper.dev",
method: "POST",
authType: "apikey",
authHeader: "x-api-key",
costPerQuery: 0.001,
freeMonthlyQuota: 2500,
searchTypes: [
"web",
"news"
],
defaultMaxResults: 5,
maxMaxResults: 100,
timeoutMs: 10000,
cacheTTLMs: 300000
}
};
+50
View File
@@ -0,0 +1,50 @@
export default {
id: "tavily",
alias: "tavily",
display: {
name: "Tavily",
icon: "search",
color: "#5B21B6",
textIcon: "TV",
website: "https://tavily.com",
notice: {
apiKeyUrl: "https://app.tavily.com/home"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"webSearch",
"webFetch"
],
searchConfig: {
baseUrl: "https://api.tavily.com/search",
method: "POST",
authType: "apikey",
authHeader: "bearer",
costPerQuery: 0.008,
freeMonthlyQuota: 1000,
searchTypes: [
"web",
"news"
],
defaultMaxResults: 5,
maxMaxResults: 20,
timeoutMs: 10000,
cacheTTLMs: 300000
},
fetchConfig: {
baseUrl: "https://api.tavily.com/extract",
method: "POST",
authType: "apikey",
authHeader: "bearer",
costPerQuery: 0.008,
freeMonthlyQuota: 1000,
formats: [
"markdown",
"text"
],
maxCharacters: 100000,
timeoutMs: 15000
}
};
+19
View File
@@ -0,0 +1,19 @@
export default {
id: "topaz",
alias: "topaz",
display: {
name: "Topaz",
icon: "image",
color: "#059669",
textIcon: "TP",
website: "https://topazlabs.com",
notice: {
apiKeyUrl: "https://topazlabs.com/account"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"image"
]
};
+30
View File
@@ -0,0 +1,30 @@
export default {
id: "tortoise",
alias: "tortoise",
display: {
name: "Tortoise TTS",
icon: "record_voice_over",
color: "#7C3AED",
textIcon: "TT",
website: "https://github.com/neonbjb/tortoise-tts"
},
category: "freeTier",
authType: "none",
serviceKinds: [
"tts"
],
noAuth: true,
ttsConfig: {
baseUrl: "http://localhost:5000/api/tts",
authType: "none",
authHeader: "none",
format: "tortoise",
models: [
{
id: "tortoise-v2",
name: "Tortoise v2"
}
]
},
hidden: true
};
+1 -1
View File
@@ -1,6 +1,6 @@
export default { export default {
id: "vertex", id: "vertex",
priority: 140, priority: 40,
alias: "vertex", alias: "vertex",
aliases: [ aliases: [
"vx", "vx",
+1 -1
View File
@@ -12,7 +12,7 @@ export default {
apiKeyUrl: "https://console.x.ai", apiKeyUrl: "https://console.x.ai",
}, },
}, },
category: "apikey", category: "oauth",
authModes: [ authModes: [
"oauth", "oauth",
"apikey", "apikey",
+35
View File
@@ -0,0 +1,35 @@
export default {
id: "youcom",
alias: "youcom",
display: {
name: "You.com Search",
icon: "search",
color: "#7C3AED",
textIcon: "YC",
website: "https://you.com",
notice: {
apiKeyUrl: "https://api.you.com"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"webSearch"
],
searchConfig: {
baseUrl: "https://ydc-index.io/v1/search",
method: "GET",
authType: "apikey",
authHeader: "x-api-key",
costPerQuery: 0.005,
freeMonthlyQuota: 0,
searchTypes: [
"web",
"news"
],
defaultMaxResults: 5,
maxMaxResults: 100,
timeoutMs: 10000,
cacheTTLMs: 300000
}
};
+2 -2
View File
@@ -3,7 +3,7 @@ import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingS
import { ROLE, CLAUDE_BLOCK } from "../schema/index.js"; import { ROLE, CLAUDE_BLOCK } from "../schema/index.js";
import { adjustMaxTokens } from "./maxTokens.js"; import { adjustMaxTokens } from "./maxTokens.js";
import { applyCloaking } from "../../utils/claudeCloaking.js"; import { applyCloaking } from "../../utils/claudeCloaking.js";
import { deriveSessionId } from "../../utils/sessionManager.js"; import { resolveSessionId } from "../../utils/sessionManager.js";
import { PROVIDERS } from "../../providers/index.js"; import { PROVIDERS } from "../../providers/index.js";
// Check if message has valid non-empty content // Check if message has valid non-empty content
@@ -252,7 +252,7 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne
// Apply cloaking for OAuth tokens (billing header + fake user ID) // Apply cloaking for OAuth tokens (billing header + fake user ID)
// session_id in user_id must match X-Claude-Code-Session-Id for fingerprint consistency // session_id in user_id must match X-Claude-Code-Session-Id for fingerprint consistency
if ((provider === "claude" || provider?.startsWith("anthropic-compatible")) && apiKey) { if ((provider === "claude" || provider?.startsWith("anthropic-compatible")) && apiKey) {
const sessionId = connectionId ? deriveSessionId(connectionId) : null; const sessionId = resolveSessionId({ body, connectionId, scope: "claude" });
body = applyCloaking(body, apiKey, sessionId); body = applyCloaking(body, apiKey, sessionId);
} }
+32 -36
View File
@@ -7,15 +7,16 @@ import { normalizeThinkingConfig } from "../services/provider.js";
import { AntigravityExecutor } from "../executors/antigravity.js"; import { AntigravityExecutor } from "../executors/antigravity.js";
import { PROVIDERS } from "../providers/index.js"; import { PROVIDERS } from "../providers/index.js";
// Registry for translators // Registry for translators. Lazy-init guards against circular-import order:
const requestRegistry = new Map(); // translator modules call register() (side-effect) before this module's body runs.
const responseRegistry = new Map(); // var (not let): hoisted as undefined so register() can run during circular import (no TDZ).
var requestRegistry;
// Track initialization state var responseRegistry;
let initialized = false;
// Register translator // Register translator
export function register(from, to, requestFn, responseFn) { export function register(from, to, requestFn, responseFn) {
requestRegistry ??= new Map();
responseRegistry ??= new Map();
const key = `${from}:${to}`; const key = `${from}:${to}`;
if (requestFn) { if (requestFn) {
requestRegistry.set(key, requestFn); requestRegistry.set(key, requestFn);
@@ -25,35 +26,8 @@ export function register(from, to, requestFn, responseFn) {
} }
} }
// Lazy load translators (called once on first use) // No-op: translators self-register via the static imports at the bottom of this file.
function ensureInitialized() { function ensureInitialized() {}
if (initialized) return;
initialized = true;
// Request translators - sync require pattern for bundler
require("./request/claude-to-openai.js");
require("./request/openai-to-claude.js");
require("./request/gemini-to-openai.js");
require("./request/openai-to-gemini.js");
require("./request/openai-to-vertex.js");
require("./request/antigravity-to-openai.js");
require("./request/openai-responses.js");
require("./request/openai-to-kiro.js");
require("./request/openai-to-cursor.js");
require("./request/openai-to-ollama.js");
require("./request/openai-to-commandcode.js");
// Response translators
require("./response/claude-to-openai.js");
require("./response/openai-to-claude.js");
require("./response/gemini-to-openai.js");
require("./response/openai-to-antigravity.js");
require("./response/openai-responses.js");
require("./response/kiro-to-openai.js");
require("./response/cursor-to-openai.js");
require("./response/ollama-to-openai.js");
require("./response/commandcode-to-openai.js");
}
// Strip specific content types from messages (explicit opt-in via strip[] in PROVIDER_MODELS) // Strip specific content types from messages (explicit opt-in via strip[] in PROVIDER_MODELS)
function stripContentTypes(body, stripList = []) { function stripContentTypes(body, stripList = []) {
@@ -246,7 +220,29 @@ export function initState(sourceFormat) {
return base; return base;
} }
// Initialize all translators (kept for backward compatibility) // Kept for backward compatibility; translators are already registered at import time.
export function initTranslators() { export function initTranslators() {
ensureInitialized(); ensureInitialized();
} }
// Static side-effect imports: each module calls register() at load (works in ESM + bundler).
import "./request/claude-to-openai.js";
import "./request/openai-to-claude.js";
import "./request/gemini-to-openai.js";
import "./request/openai-to-gemini.js";
import "./request/openai-to-vertex.js";
import "./request/antigravity-to-openai.js";
import "./request/openai-responses.js";
import "./request/openai-to-kiro.js";
import "./request/openai-to-cursor.js";
import "./request/openai-to-ollama.js";
import "./request/openai-to-commandcode.js";
import "./response/claude-to-openai.js";
import "./response/openai-to-claude.js";
import "./response/gemini-to-openai.js";
import "./response/openai-to-antigravity.js";
import "./response/openai-responses.js";
import "./response/kiro-to-openai.js";
import "./response/cursor-to-openai.js";
import "./response/ollama-to-openai.js";
import "./response/commandcode-to-openai.js";
@@ -160,7 +160,8 @@ function convertContent(content) {
// Function call // Function call
if (part.functionCall) { if (part.functionCall) {
toolCalls.push({ toolCalls.push({
id: part.functionCall.id || `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, // Deterministic id from name so the matching functionResponse pairs correctly.
id: part.functionCall.id || `call_${part.functionCall.name}`,
type: OPENAI_BLOCK.FUNCTION, type: OPENAI_BLOCK.FUNCTION,
function: { function: {
name: part.functionCall.name, name: part.functionCall.name,
@@ -173,7 +174,7 @@ function convertContent(content) {
if (part.functionResponse) { if (part.functionResponse) {
toolResults.push({ toolResults.push({
role: ROLE.TOOL, role: ROLE.TOOL,
tool_call_id: part.functionResponse.id || part.functionResponse.name, tool_call_id: part.functionResponse.id || `call_${part.functionResponse.name}`,
content: JSON.stringify(part.functionResponse.response?.result || part.functionResponse.response || {}) content: JSON.stringify(part.functionResponse.response?.result || part.functionResponse.response || {})
}); });
} }
@@ -97,8 +97,10 @@ function convertGeminiContent(content) {
} }
if (part.functionCall) { if (part.functionCall) {
// Gemini lacks a native call id; derive a deterministic one from the name so the
// matching functionResponse maps to the same tool_call_id (providers require pairing).
toolCalls.push({ toolCalls.push({
id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, id: part.functionCall.id || `call_${part.functionCall.name}`,
type: OPENAI_BLOCK.FUNCTION, type: OPENAI_BLOCK.FUNCTION,
function: { function: {
name: part.functionCall.name, name: part.functionCall.name,
@@ -110,7 +112,7 @@ function convertGeminiContent(content) {
if (part.functionResponse) { if (part.functionResponse) {
return { return {
role: ROLE.TOOL, role: ROLE.TOOL,
tool_call_id: part.functionResponse.id || part.functionResponse.name, tool_call_id: part.functionResponse.id || `call_${part.functionResponse.name}`,
content: JSON.stringify(part.functionResponse.response?.result || part.functionResponse.response || {}) content: JSON.stringify(part.functionResponse.response?.result || part.functionResponse.response || {})
}; };
} }
@@ -177,6 +177,12 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
} }
// Cleanup Responses API specific fields // Cleanup Responses API specific fields
// Map Responses-only max_output_tokens to Chat max_tokens (avoid leaking unknown field upstream)
if (result.max_output_tokens !== undefined) {
if (result.max_tokens === undefined) result.max_tokens = result.max_output_tokens;
delete result.max_output_tokens;
}
delete result.input; delete result.input;
delete result.instructions; delete result.instructions;
delete result.include; delete result.include;
@@ -5,6 +5,7 @@
import { register } from "../index.js"; import { register } from "../index.js";
import { FORMATS } from "../formats.js"; import { FORMATS } from "../formats.js";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { resolveSessionId } from "../../utils/sessionManager.js";
import { import {
resolveKiroModel, resolveKiroModel,
isThinkingEnabled, isThinkingEnabled,
@@ -546,7 +547,7 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
const payload = { const payload = {
conversationState: { conversationState: {
chatTriggerType: "MANUAL", chatTriggerType: "MANUAL",
conversationId: uuidv4(), conversationId: resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId: credentials?.connectionId, scope: "kiro" }),
currentMessage: { currentMessage: {
userInputMessage: { userInputMessage: {
content: finalContent, content: finalContent,
+122
View File
@@ -79,4 +79,126 @@ export function generateBinaryStyleId() {
*/ */
export function clearSessionStore() { export function clearSessionStore() {
runtimeSessionStore.clear(); runtimeSessionStore.clear();
assistantSessionStore.clear();
} }
// Conversation-stable session store: Key = hash(scope+assistant text), Value = { sessionId, lastUsed }
const assistantSessionStore = new Map();
const ASSISTANT_MIN_LEN = 50;
const ASSISTANT_CAP_LEN = 200;
const MAX_ASSISTANT_SESSIONS = 5000;
// Client headers/body fields that carry an upstream session id (priority order)
const SESSION_HEADER_KEYS = ["x-session-id", "session_id", "x-amp-thread-id", "x-client-request-id"];
const CLAUDE_CODE_SESSION_RE = /_session_([a-f0-9-]+)$/;
function sha16(text) {
return crypto.createHash("sha256").update(text).digest("hex").slice(0, 16);
}
// Normalize a session id candidate (trim, length cap)
function normalizeSessionId(value) {
if (typeof value !== "string") return null;
const v = value.trim();
if (!v || v.length > 256) return null;
return v;
}
// Extract Claude Code session id from metadata.user_id (_session_{uuid} | JSON {session_id})
function extractClaudeCodeSession(userId) {
if (typeof userId !== "string" || !userId) return null;
const m = userId.match(CLAUDE_CODE_SESSION_RE);
if (m) return m[1];
if (userId[0] === "{") {
try { return normalizeSessionId(JSON.parse(userId)?.session_id); } catch { /* noop */ }
}
return null;
}
// Lowercase-key lookup for raw client headers
function headerValue(headers, key) {
if (!headers || typeof headers !== "object") return null;
return normalizeSessionId(headers[key] ?? headers[key.toLowerCase()]);
}
// Read client-provided session id from headers/body (no generation)
function extractClientSessionId(headers, body) {
const claude = extractClaudeCodeSession(body?.metadata?.user_id);
if (claude) return `claude:${claude}`;
for (const key of SESSION_HEADER_KEYS) {
const v = headerValue(headers, key);
if (v) return v;
}
const fromBody =
normalizeSessionId(body?.prompt_cache_key) ||
normalizeSessionId(body?.session_id) ||
normalizeSessionId(body?.conversation_id) ||
normalizeSessionId(body?.metadata?.user_id);
return fromBody || null;
}
// Accumulate assistant text from OpenAI/Responses-style input/messages (cap-limited)
function accumulateAssistantText(body) {
const items = Array.isArray(body?.input) ? body.input
: Array.isArray(body?.messages) ? body.messages : null;
if (!items) return "";
let text = "";
for (const item of items) {
if (item?.role !== "assistant") continue;
if (typeof item.content === "string") text += item.content;
else if (Array.isArray(item.content)) {
for (const c of item.content) text += c?.text || c?.output || "";
}
if (text.length >= ASSISTANT_CAP_LEN) break;
}
return text;
}
// Stable session id keyed on accumulated assistant text (avoids collision on identical first user prompt)
function assistantTextSessionId(scope, body) {
const text = accumulateAssistantText(body);
if (text.length < ASSISTANT_MIN_LEN) return null;
const hash = sha16(`${scope}:${text.slice(0, ASSISTANT_CAP_LEN)}`);
const existing = assistantSessionStore.get(hash);
if (existing) {
existing.lastUsed = Date.now();
return existing.sessionId;
}
if (assistantSessionStore.size >= MAX_ASSISTANT_SESSIONS) {
assistantSessionStore.delete(assistantSessionStore.keys().next().value);
}
const sessionId = generateBinaryStyleId();
assistantSessionStore.set(hash, { sessionId, lastUsed: Date.now() });
return sessionId;
}
/**
* Resolve a conversation-stable session id (generalizes Codex resolveCacheSessionId).
* Priority: client session → accumulated-assistant-text hash → workspaceId → per-connection.
*
* @param {object} opts
* @param {object} [opts.headers] - Raw client request headers (lowercase keys)
* @param {object} [opts.body] - Parsed request body
* @param {string} [opts.connectionId] - Connection identifier (fallback scope)
* @param {string} [opts.workspaceId] - Provider workspace id (account-wide fallback)
* @param {string} [opts.scope] - Provider scope to isolate cache keys across providers
* @returns {string} A stable session id
*/
export function resolveSessionId({ headers, body, connectionId, workspaceId, scope = "" } = {}) {
const client = extractClientSessionId(headers, body);
if (client) return client;
const fromAssistant = assistantTextSessionId(`${scope}:${connectionId || ""}`, body);
if (fromAssistant) return fromAssistant;
const ws = normalizeSessionId(workspaceId);
if (ws) return ws;
return deriveSessionId(connectionId);
}
// Cleanup expired assistant-session entries
const assistantCleanup = setInterval(() => {
const now = Date.now();
for (const [key, entry] of assistantSessionStore) {
if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) assistantSessionStore.delete(key);
}
}, MEMORY_CONFIG.sessionCleanupIntervalMs);
if (assistantCleanup.unref) assistantCleanup.unref();
+16 -14
View File
@@ -284,24 +284,26 @@ export default function ProvidersPage() {
const freeEntries = Object.entries(FREE_PROVIDERS).filter( const freeEntries = Object.entries(FREE_PROVIDERS).filter(
([, info]) => !info.hidden && matchSearch(info.name), ([, info]) => !info.hidden && matchSearch(info.name),
); );
const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS) const freeTierEntries = sortByPriority(
.filter(([, info]) => !info.hidden && matchSearch(info.name)) Object.entries(FREE_TIER_PROVIDERS).filter(
.sort(([, a], [, b]) => { ([, info]) => !info.hidden && matchSearch(info.name),
// hasFree providers first, then by priority ),
const fa = a.hasFree ? 0 : 1; "freeTier",
const fb = b.hasFree ? 0 : 1; );
if (fa !== fb) return fa - fb; // API Key: connected providers first, then alphabetical by name
return (a.priority ?? 999) - (b.priority ?? 999); const apikeyEntries = Object.entries(APIKEY_PROVIDERS)
}); .filter(
const apikeyEntries = sortByPriority(
Object.entries(APIKEY_PROVIDERS).filter(
([, info]) => ([, info]) =>
!info.hidden && !info.hidden &&
(info.serviceKinds ?? ["llm"]).includes("llm") && (info.serviceKinds ?? ["llm"]).includes("llm") &&
matchSearch(info.name), matchSearch(info.name),
), )
"apikey", .sort(([ka, a], [kb, b]) => {
); const ca = getProviderStats(ka, "apikey").total > 0 ? 0 : 1;
const cb = getProviderStats(kb, "apikey").total > 0 ? 0 : 1;
if (ca !== cb) return ca - cb;
return (a.name || "").localeCompare(b.name || "");
});
const isApikeySearching = !!searchQuery.trim(); const isApikeySearching = !!searchQuery.trim();
const visibleApikeyEntries = const visibleApikeyEntries =
isApikeySearching || showAllApikey isApikeySearching || showAllApikey
+1 -1
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getPricing, updatePricing, resetPricing, resetAllPricing } from "@/lib/localDb.js"; import { getPricing, updatePricing, resetPricing, resetAllPricing } from "@/lib/localDb.js";
import { getDefaultPricing } from "@/shared/constants/pricing.js"; import { getDefaultPricing } from "open-sse/providers/pricing.js";
/** /**
* GET /api/pricing * GET /api/pricing
+2 -2
View File
@@ -20,7 +20,7 @@ export async function getPricing() {
if (cache.value && cache.expiresAt > now) return cache.value; if (cache.value && cache.expiresAt > now) return cache.value;
const userPricing = await getUserPricing(); const userPricing = await getUserPricing();
const { PROVIDER_PRICING } = await import("@/shared/constants/pricing.js"); const { PROVIDER_PRICING } = await import("open-sse/providers/pricing.js");
const merged = {}; const merged = {};
for (const [provider, models] of Object.entries(PROVIDER_PRICING)) { for (const [provider, models] of Object.entries(PROVIDER_PRICING)) {
@@ -52,7 +52,7 @@ export async function getPricingForModel(provider, model) {
if (!model) return null; if (!model) return null;
const userPricing = await getUserPricing(); const userPricing = await getUserPricing();
if (provider && userPricing[provider]?.[model]) return userPricing[provider][model]; if (provider && userPricing[provider]?.[model]) return userPricing[provider][model];
const { getPricingForModel: resolveConst } = await import("@/shared/constants/pricing.js"); const { getPricingForModel: resolveConst } = await import("open-sse/providers/pricing.js");
return resolveConst(provider, model); return resolveConst(provider, model);
} }
+1 -1
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { getDefaultPricing, formatCost } from "@/shared/constants/pricing.js"; import { getDefaultPricing, formatCost } from "open-sse/providers/pricing.js";
export default function PricingModal({ isOpen, onClose, onSave }) { export default function PricingModal({ isOpen, onClose, onSave }) {
const [pricingData, setPricingData] = useState({}); const [pricingData, setPricingData] = useState({});
+1
View File
@@ -19,6 +19,7 @@ function buildProviderEntry(r) {
...(r.display || {}), ...(r.display || {}),
id: r.id, id: r.id,
alias: r.uiAlias || r.alias, alias: r.uiAlias || r.alias,
...(r.hidden ? { hidden: true } : {}),
...mediaFields, ...mediaFields,
...(r.priority !== undefined ? { priority: r.priority } : {}), ...(r.priority !== undefined ? { priority: r.priority } : {}),
...(r.hasFree ? { hasFree: true } : {}), ...(r.hasFree ? { hasFree: true } : {}),
+4 -230
View File
@@ -1,238 +1,12 @@
// UI display config — registry providers derive from registry.display. // UI display config — all providers derive from registry.display.
// Non-registry providers (media-only: tts, stt, search, fetch) kept hardcoded here.
import REGISTRY from "open-sse/providers/registry/index.js"; import REGISTRY from "open-sse/providers/registry/index.js";
export const RISK_NOTICE = "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk."; export const RISK_NOTICE = "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.";
// Non-registry media-only providers display config
const MEDIA_ONLY_DISPLAY = {
"elevenlabs": {
"name": "ElevenLabs",
"icon": "record_voice_over",
"color": "#6C47FF",
"textIcon": "EL",
"website": "https://elevenlabs.io",
"notice": {
"apiKeyUrl": "https://elevenlabs.io/app/settings/api-keys"
}
},
"cartesia": {
"name": "Cartesia",
"icon": "spatial_audio",
"color": "#FF4F8B",
"textIcon": "CA",
"website": "https://cartesia.ai",
"notice": {
"apiKeyUrl": "https://play.cartesia.ai/keys"
},
"hidden": true
},
"playht": {
"name": "PlayHT",
"icon": "play_circle",
"color": "#00B4D8",
"textIcon": "PH",
"website": "https://play.ht",
"notice": {
"apiKeyUrl": "https://play.ht/studio/api-access"
},
"hidden": true
},
"local-device": {
"name": "Local Device",
"icon": "speaker",
"color": "#64748B",
"textIcon": "LD",
"mediaPriority": 5
},
"google-tts": {
"name": "Google TTS",
"icon": "record_voice_over",
"color": "#4285F4",
"textIcon": "GT",
"mediaPriority": 5
},
"edge-tts": {
"name": "Edge TTS",
"icon": "record_voice_over",
"color": "#0078D4",
"textIcon": "ET",
"mediaPriority": 5
},
"coqui": {
"name": "Coqui TTS",
"icon": "record_voice_over",
"color": "#10B981",
"textIcon": "CQ",
"website": "https://github.com/coqui-ai/TTS",
"hidden": true
},
"tortoise": {
"name": "Tortoise TTS",
"icon": "record_voice_over",
"color": "#7C3AED",
"textIcon": "TT",
"website": "https://github.com/neonbjb/tortoise-tts",
"hidden": true
},
"inworld": {
"name": "Inworld TTS",
"icon": "record_voice_over",
"color": "#FF6B6B",
"textIcon": "IW",
"website": "https://inworld.ai",
"notice": {
"text": "Free tier: 40 minutes/month TTS. Paid: TTS-1.5 Mini $0.01/min ($15/1M chars), TTS-1.5 Max $0.025/min ($30/1M chars). 270+ voices, 15 languages.",
"apiKeyUrl": "https://platform.inworld.ai/api-keys"
}
},
"aws-polly": {
"name": "AWS Polly",
"icon": "record_voice_over",
"color": "#FF9900",
"textIcon": "PL",
"website": "https://aws.amazon.com/polly/",
"notice": {
"text": "Use AWS Secret Access Key as API key; set providerSpecificData.accessKeyId and optional region.",
"apiKeyUrl": "https://console.aws.amazon.com/iam/home#/security_credentials"
}
},
"jina-ai": {
"name": "Jina AI",
"icon": "blur_on",
"color": "#2563EB",
"textIcon": "JA",
"website": "https://jina.ai",
"notice": {
"text": "10M free tokens on signup (non-commercial), no credit card required.",
"apiKeyUrl": "https://jina.ai/?sui=apikey"
}
},
"jina-reader": {
"name": "Jina Reader",
"icon": "menu_book",
"color": "#000000",
"textIcon": "JR",
"website": "https://jina.ai/reader",
"notice": {
"apiKeyUrl": "https://jina.ai/?sui=apikey"
}
},
"tavily": {
"name": "Tavily",
"icon": "search",
"color": "#5B21B6",
"textIcon": "TV",
"website": "https://tavily.com",
"notice": {
"apiKeyUrl": "https://app.tavily.com/home"
}
},
"brave-search": {
"name": "Brave Search",
"icon": "travel_explore",
"color": "#FB542B",
"textIcon": "BR",
"website": "https://brave.com/search/api",
"notice": {
"apiKeyUrl": "https://api-dashboard.search.brave.com/app/keys"
}
},
"serper": {
"name": "Serper",
"icon": "search",
"color": "#4F46E5",
"textIcon": "SP",
"website": "https://serper.dev",
"notice": {
"apiKeyUrl": "https://serper.dev/api-key"
}
},
"exa": {
"name": "Exa",
"icon": "manage_search",
"color": "#2563EB",
"textIcon": "EX",
"website": "https://exa.ai",
"notice": {
"apiKeyUrl": "https://dashboard.exa.ai/api-keys"
}
},
"searxng": {
"name": "SearXNG",
"icon": "saved_search",
"color": "#3B82F6",
"textIcon": "SX",
"website": "https://docs.searxng.org"
},
"google-pse": {
"name": "Google PSE",
"icon": "search",
"color": "#4285F4",
"textIcon": "GP",
"website": "https://programmablesearchengine.google.com",
"notice": {
"apiKeyUrl": "https://programmablesearchengine.google.com/controlpanel/create"
}
},
"linkup": {
"name": "Linkup",
"icon": "link",
"color": "#0EA5E9",
"textIcon": "LK",
"website": "https://linkup.so",
"notice": {
"apiKeyUrl": "https://app.linkup.so/api-keys"
}
},
"searchapi": {
"name": "SearchAPI",
"icon": "search",
"color": "#0EA5A4",
"textIcon": "SA",
"website": "https://www.searchapi.io",
"notice": {
"apiKeyUrl": "https://www.searchapi.io/dashboard"
}
},
"youcom": {
"name": "You.com Search",
"icon": "search",
"color": "#7C3AED",
"textIcon": "YC",
"website": "https://you.com",
"notice": {
"apiKeyUrl": "https://api.you.com"
}
},
"firecrawl": {
"name": "Firecrawl",
"icon": "local_fire_department",
"color": "#F59E0B",
"textIcon": "FC",
"website": "https://firecrawl.dev",
"notice": {
"apiKeyUrl": "https://www.firecrawl.dev/app/api-keys"
}
},
"topaz": {
"name": "Topaz",
"icon": "image",
"color": "#059669",
"textIcon": "TP",
"website": "https://topazlabs.com",
"notice": {
"apiKeyUrl": "https://topazlabs.com/account"
}
},
};
// Resolve "RISK_NOTICE" token → real notice text (registry stores token to avoid import cycle) // Resolve "RISK_NOTICE" token → real notice text (registry stores token to avoid import cycle)
const resolveDisplay = (d) => const resolveDisplay = (d) =>
d.deprecationNotice === "RISK_NOTICE" ? { ...d, deprecationNotice: RISK_NOTICE } : d; d.deprecationNotice === "RISK_NOTICE" ? { ...d, deprecationNotice: RISK_NOTICE } : d;
// Merge: registry providers take precedence export const PROVIDER_DISPLAY = Object.fromEntries(
export const PROVIDER_DISPLAY = { REGISTRY.filter((r) => r.display).map((r) => [r.id, resolveDisplay(r.display)]),
...MEDIA_ONLY_DISPLAY, );
...Object.fromEntries(REGISTRY.filter(r => r.display).map(r => [r.id, resolveDisplay(r.display)])),
};
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -27,7 +27,7 @@ describe("Antigravity → OpenAI", () => {
// antigravity-to-openai.js:167 — functionCall without id gets a random Date.now() id // antigravity-to-openai.js:167 — functionCall without id gets a random Date.now() id
// KNOWN BUG: unstable id breaks matching with its functionResponse // KNOWN BUG: unstable id breaks matching with its functionResponse
it.fails("functionCall without id keeps a stable matchable id", () => { it("functionCall without id keeps a stable matchable id", () => {
const out = AG2O({ const out = AG2O({
contents: [ contents: [
{ role: "model", parts: [{ functionCall: { name: "search", args: { q: "x" } } }] }, { role: "model", parts: [{ functionCall: { name: "search", args: { q: "x" } } }] },
@@ -0,0 +1,293 @@
// REAL matrix test: every active provider in DB x every inbound client format x 4 scenarios.
// Goal: maximize translation-path coverage to surface real bugs (system, multimodal image,
// tool-call/tool-result, reasoning) across all source formats.
//
// RUN_REAL=1 npx vitest run --config tests/vitest.config.js tests/translator/real/all-formats.real.test.js
// RUN_REAL=1 REAL_PROVIDERS=gemini,kiro,codex npx vitest run ... (optional filter)
//
// Skips (console.warn + pass) when: no credential/model, auth/quota status (401/402/403/429),
// or the model rejects a capability (e.g. image on a non-vision model).
import { describe, it, expect } from "vitest";
import { getProviderCredentials } from "../../../src/sse/services/auth.js";
import { checkAndRefreshToken } from "../../../src/sse/services/tokenRefresh.js";
import { handleChatCore } from "../../../open-sse/handlers/chatCore.js";
import { getModelsByProviderId } from "../../../open-sse/config/providerModels.js";
const RUN_REAL = process.env.RUN_REAL === "1";
const TIMEOUT_MS = 90000;
const CRED_ISSUE = [401, 402, 403, 429];
// Account/plan/capability rejections -> skip (not a translate bug). Kept specific to avoid masking real bugs.
const SKIP_MSG_RE = /image|multimodal|vision|modality|unsupported|not support|reasoning_effort|deprecated|temperature|subscription|valid.*plan|embedding|quota|insufficient|model not found|context length|organization policy|disallowed|allowedmodels|failed_precondition/i;
const PROVIDER_FILTER = (process.env.REAL_PROVIDERS || "")
.split(",").map((s) => s.trim()).filter(Boolean);
// Tiny 1x1 transparent PNG (data URI body + raw base64) for multimodal scenarios.
const PNG_B64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
const PNG_DATA_URI = `data:image/png;base64,${PNG_B64}`;
// Pick first chat LLM, excluding non-chat kinds (embedding/image/tts/stt/...).
const NON_CHAT_KINDS = new Set(["embedding", "image", "imageToText", "tts", "stt", "video", "music", "webSearch"]);
function firstLlmModel(providerId) {
const models = getModelsByProviderId(providerId);
const llm = models.find((m) => {
const kind = m.kind || m.type || "llm";
return kind === "llm" || (!NON_CHAT_KINDS.has(kind) && kind === "llm");
}) || models.find((m) => !NON_CHAT_KINDS.has(m.kind || m.type || "llm"));
return llm?.id || null;
}
async function drainSSE(response) {
if (!response?.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let out = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
out += decoder.decode(value, { stream: true });
}
return out;
}
async function prepare(providerId) {
const model = firstLlmModel(providerId);
if (!model) return null;
const credentials = await getProviderCredentials(providerId, new Set(), model);
if (!credentials || credentials.allRateLimited) return null;
const refreshed = await checkAndRefreshToken(providerId, credentials);
return { model, credentials, refreshed };
}
// Run one request. Returns { raw } | "skip" | throws (real translate/runtime bug).
async function runChat(providerId, prep, body, sourceFormatOverride) {
const result = await handleChatCore({
body: { ...body, model: `${providerId}/${prep.model}` },
modelInfo: { provider: providerId, model: prep.model },
credentials: prep.refreshed,
connectionId: prep.credentials.connectionId,
sourceFormatOverride,
});
if (!result.success) {
const status = Number(result.status);
if (CRED_ISSUE.includes(status)) return "skip";
// Upstream 5xx and 406 are provider-side issues, not translate bugs.
if (status >= 500 || status === 406) return "skip";
// Account/plan/capability rejection (e.g. non-vision model + image) is not a translate bug.
if (status === 400 && SKIP_MSG_RE.test(String(result.error || ""))) return "skip";
throw new Error(`${providerId} [${result.status}]: ${result.error}`);
}
return { raw: await drainSSE(result.response) };
}
// SSE validity marker per inbound format (response is re-encoded back to source format).
const SSE_MARKER = {
openai: /chat\.completion\.chunk|"delta"|\[DONE\]/,
"openai-responses": /response\.|"type"\s*:\s*"response|\[DONE\]/,
claude: /event:\s*\w|"type"\s*:\s*"(message_start|content_block|message_delta)"/,
gemini: /"candidates"|"content"|data:/,
"gemini-cli": /"candidates"|"content"|data:/,
antigravity: /"candidates"|"content"|data:/,
};
// ---- Body builders: per format x scenario (full, spec-correct shapes) ----
const COMMON = { temperature: 0.3, top_p: 0.9, max_tokens: 256 };
// Reasoning models often reject custom temperature (must be default/1) -> omit sampling.
const REASON_TOK = { max_tokens: 1024 };
// OpenAI Chat Completions
const openaiBody = {
basic: () => ({
...COMMON, stream: true, stream_options: { include_usage: true },
messages: [
{ role: "system", content: "You are concise." },
{ role: "user", content: "Reply with the single word: hi" },
],
}),
multimodal: () => ({
...COMMON, stream: true,
messages: [
{ role: "system", content: "Describe images briefly." },
{ role: "user", content: [
{ type: "text", text: "What color dominates this image? One word." },
{ type: "image_url", image_url: { url: PNG_DATA_URI } },
] },
],
}),
tools: () => ({
...COMMON, stream: true, tool_choice: "auto",
tools: [{ type: "function", function: { name: "get_weather", description: "Get weather", parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] } } }],
messages: [
{ role: "user", content: "Weather in Paris?" },
{ role: "assistant", content: "", tool_calls: [{ id: "call_1", type: "function", function: { name: "get_weather", arguments: '{"city":"Paris"}' } }] },
{ role: "tool", tool_call_id: "call_1", content: '{"temp":"20C"}' },
{ role: "user", content: "Summarize in one short sentence." },
],
}),
reasoning: () => ({
...REASON_TOK, stream: true, reasoning_effort: "low",
messages: [{ role: "user", content: "What is 17 + 26? Reply with just the number." }],
}),
};
// OpenAI Responses API
const responsesBody = {
basic: () => ({
...COMMON, stream: true, max_output_tokens: 256,
instructions: "You are concise.",
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "Reply with the single word: hi" }] }],
}),
multimodal: () => ({
...COMMON, stream: true, max_output_tokens: 256,
instructions: "Describe images briefly.",
input: [{ type: "message", role: "user", content: [
{ type: "input_text", text: "What color dominates? One word." },
{ type: "input_image", image_url: PNG_DATA_URI },
] }],
}),
tools: () => ({
...COMMON, stream: true,
tools: [{ type: "function", name: "get_weather", description: "Get weather", parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] } }],
input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "Weather in Paris?" }] },
{ type: "function_call", call_id: "call_1", name: "get_weather", arguments: '{"city":"Paris"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"temp":"20C"}' },
{ type: "message", role: "user", content: [{ type: "input_text", text: "Summarize in one short sentence." }] },
],
}),
reasoning: () => ({
...REASON_TOK, stream: true, max_output_tokens: 1024, reasoning: { effort: "low" },
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "What is 17 + 26? Just the number." }] }],
}),
};
// Anthropic Messages (Claude)
const claudeBody = {
basic: () => ({
...COMMON, stream: true,
system: [{ type: "text", text: "You are concise." }],
messages: [{ role: "user", content: "Reply with the single word: hi" }],
}),
multimodal: () => ({
...COMMON, stream: true,
system: [{ type: "text", text: "Describe images briefly." }],
messages: [{ role: "user", content: [
{ type: "text", text: "What color dominates? One word." },
{ type: "image", source: { type: "base64", media_type: "image/png", data: PNG_B64 } },
] }],
}),
tools: () => ({
...COMMON, stream: true,
tools: [{ name: "get_weather", description: "Get weather", input_schema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] } }],
messages: [
{ role: "user", content: "Weather in Paris?" },
{ role: "assistant", content: [{ type: "tool_use", id: "toolu_1", name: "get_weather", input: { city: "Paris" } }] },
{ role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_1", content: '{"temp":"20C"}' }] },
{ role: "user", content: "Summarize in one short sentence." },
],
}),
reasoning: () => ({
...REASON_TOK, stream: true, thinking: { type: "enabled", budget_tokens: 1024 },
messages: [{ role: "user", content: "What is 17 + 26? Just the number." }],
}),
};
// Gemini generateContent
const geminiBody = {
basic: () => ({
systemInstruction: { parts: [{ text: "You are concise." }] },
contents: [{ role: "user", parts: [{ text: "Reply with the single word: hi" }] }],
generationConfig: { maxOutputTokens: 256, temperature: 0.3, topP: 0.9 },
}),
multimodal: () => ({
systemInstruction: { parts: [{ text: "Describe images briefly." }] },
contents: [{ role: "user", parts: [
{ text: "What color dominates? One word." },
{ inlineData: { mimeType: "image/png", data: PNG_B64 } },
] }],
generationConfig: { maxOutputTokens: 256 },
}),
tools: () => ({
tools: [{ functionDeclarations: [{ name: "get_weather", description: "Get weather", parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] } }] }],
contents: [
{ role: "user", parts: [{ text: "Weather in Paris?" }] },
{ role: "model", parts: [{ functionCall: { name: "get_weather", args: { city: "Paris" } } }] },
{ role: "user", parts: [{ functionResponse: { name: "get_weather", response: { temp: "20C" } } }] },
{ role: "user", parts: [{ text: "Summarize in one short sentence." }] },
],
generationConfig: { maxOutputTokens: 256 },
}),
reasoning: () => ({
contents: [{ role: "user", parts: [{ text: "What is 17 + 26? Just the number." }] }],
generationConfig: { maxOutputTokens: 1024, thinkingConfig: { thinkingBudget: 512, includeThoughts: true } },
}),
};
// Antigravity = Gemini body wrapped in { request, userAgent }.
const wrapAntigravity = (fn) => () => ({ request: fn(), userAgent: "antigravity" });
const antigravityBody = {
basic: wrapAntigravity(geminiBody.basic),
multimodal: wrapAntigravity(geminiBody.multimodal),
tools: wrapAntigravity(geminiBody.tools),
reasoning: wrapAntigravity(geminiBody.reasoning),
};
const BUILDERS = {
openai: openaiBody,
"openai-responses": responsesBody,
claude: claudeBody,
gemini: geminiBody,
"gemini-cli": geminiBody,
antigravity: antigravityBody,
};
const FORMATS = Object.keys(BUILDERS);
const SCENARIOS = ["basic", "multimodal", "tools", "reasoning"];
// Read active providers from DB at module-eval time (one test per provider/format/scenario).
function targetProviders() {
try {
const Database = require("better-sqlite3");
const os = require("os");
const path = require("path");
const dbPath = process.env.DATA_DIR
? path.join(process.env.DATA_DIR, "db", "data.sqlite")
: path.join(os.homedir(), ".9router", "db", "data.sqlite");
const db = new Database(dbPath, { readonly: true });
const rows = db.prepare("SELECT DISTINCT provider FROM providerConnections WHERE isActive = 1").all();
db.close();
let list = rows.map((r) => r.provider).sort();
if (PROVIDER_FILTER.length) list = list.filter((p) => PROVIDER_FILTER.includes(p));
return list;
} catch {
return [];
}
}
describe.skipIf(!RUN_REAL)("REAL all-formats matrix", () => {
const providers = RUN_REAL ? targetProviders() : [];
it("has active providers in DB", () => {
expect(providers.length).toBeGreaterThan(0);
});
for (const providerId of providers) {
for (const fmt of FORMATS) {
for (const scn of SCENARIOS) {
it.concurrent(`${providerId} | ${fmt} | ${scn}`, async () => {
const prep = await prepare(providerId);
if (!prep) { console.warn(`[skip] ${providerId}: no cred/model`); return expect(true).toBe(true); }
const body = BUILDERS[fmt][scn]();
const out = await runChat(providerId, prep, body, fmt);
if (out === "skip") { console.warn(`[skip] ${providerId} ${fmt}/${scn}: cred/quota/capability`); return expect(true).toBe(true); }
expect(out.raw.length, `${providerId} ${fmt}/${scn}: empty SSE`).toBeGreaterThan(0);
expect(SSE_MARKER[fmt].test(out.raw), `${providerId} ${fmt}/${scn}: invalid SSE shape`).toBe(true);
}, TIMEOUT_MS);
}
}
}
});
@@ -0,0 +1,168 @@
// B2: REAL behavior assertions for the risky provider-specific cases.
// Unlike smoke (only "doesn't crash"), each test asserts concrete OUTPUT.
// Gated by RUN_REAL=1; any provider lacking creds/model or returning an auth/quota
// status (401/402/403/429) is skipped (console.warn + pass).
//
// RUN_REAL=1 npx vitest run --config tests/vitest.config.js tests/translator/real/provider-cases.real.test.js
import { describe, it, expect } from "vitest";
import { getProviderCredentials } from "../../../src/sse/services/auth.js";
import { checkAndRefreshToken } from "../../../src/sse/services/tokenRefresh.js";
import { handleChatCore } from "../../../open-sse/handlers/chatCore.js";
import { getModelsByProviderId } from "../../../open-sse/config/providerModels.js";
const RUN_REAL = process.env.RUN_REAL === "1";
const TIMEOUT_MS = 90000;
const CRED_ISSUE = [401, 402, 403, 429];
// Pick the first plain llm model for a provider.
function firstLlmModel(providerId) {
const models = getModelsByProviderId(providerId);
const llm = models.find((m) => (m.type || "llm") === "llm");
return llm?.id || null;
}
async function drainSSE(response) {
if (!response?.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let out = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
out += decoder.decode(value, { stream: true });
}
return out;
}
// Resolve creds+model for a provider, or null when unavailable (caller skips).
async function prepare(providerId) {
const model = firstLlmModel(providerId);
if (!model) {
console.warn(`[skip] ${providerId}: no llm model`);
return null;
}
const credentials = await getProviderCredentials(providerId, new Set(), model);
if (!credentials || credentials.allRateLimited) {
console.warn(`[skip] ${providerId}: no usable credential`);
return null;
}
const refreshed = await checkAndRefreshToken(providerId, credentials);
return { model, credentials, refreshed };
}
// Run handleChatCore + drain; returns { raw } or null if cred/quota issue (caller skips).
async function runChat(providerId, prep, body) {
const result = await handleChatCore({
body: { model: `${providerId}/${prep.model}`, ...body },
modelInfo: { provider: providerId, model: prep.model },
credentials: prep.refreshed,
connectionId: prep.credentials.connectionId,
});
if (!result.success) {
if (CRED_ISSUE.includes(Number(result.status))) {
console.warn(`[skip] ${providerId}: ${result.status} (credential/quota)`);
return null;
}
throw new Error(`${providerId} failed: ${result.status} ${result.error}`);
}
return { raw: await drainSSE(result.response) };
}
describe.skipIf(!RUN_REAL)("REAL provider behavior cases", () => {
// Case #1: Gemini normal prompt -> finish_reason "stop".
it("gemini: finish_reason stop", async () => {
const prep = await prepare("gemini");
if (!prep) return expect(true).toBe(true);
// Generous max_tokens so reasoning models (gemini-3 pro) don't hit "length" first.
const out = await runChat("gemini", prep, {
stream: true,
max_tokens: 2048,
messages: [{ role: "user", content: "Reply with the single word: hi" }],
});
if (!out) return expect(true).toBe(true);
expect(/"finish_reason"\s*:\s*"stop"/.test(out.raw), "no stop finish_reason").toBe(true);
}, TIMEOUT_MS);
// Case #4: Kiro tool turn -> tool_calls finish_reason + tool_calls delta.
it("kiro: tool turn -> tool_calls", async () => {
const prep = await prepare("kiro");
if (!prep) return expect(true).toBe(true);
const out = await runChat("kiro", prep, {
stream: true,
max_tokens: 128,
tool_choice: "auto",
tools: [{
type: "function",
function: {
name: "get_weather",
description: "Get the current weather for a city",
parameters: {
type: "object",
properties: { city: { type: "string", description: "City name" } },
required: ["city"],
},
},
}],
messages: [{ role: "user", content: "What's the weather in Paris? Use the get_weather tool." }],
});
if (!out) return expect(true).toBe(true);
expect(/"finish_reason"\s*:\s*"tool_calls"/.test(out.raw), "no tool_calls finish_reason").toBe(true);
expect(/"tool_calls"/.test(out.raw), "no tool_calls delta").toBe(true);
}, TIMEOUT_MS);
// Case #3: Ollama tiny max_tokens + long prompt -> finish_reason "length".
it("ollama: max_tokens -> length", async () => {
const prep = await prepare("ollama");
if (!prep) return expect(true).toBe(true);
const out = await runChat("ollama", prep, {
stream: true,
max_tokens: 4,
messages: [{ role: "user", content: "Write a long detailed essay about the history of computing." }],
});
if (!out) return expect(true).toBe(true);
// length is model-dependent; if the model stopped on its own, skip rather than fail.
if (!/"finish_reason"\s*:\s*"length"/.test(out.raw)) {
console.warn("[skip] ollama: model did not hit length (output shorter than max_tokens)");
return expect(true).toBe(true);
}
expect(/"finish_reason"\s*:\s*"length"/.test(out.raw)).toBe(true);
}, TIMEOUT_MS);
// Case #4/#5: Codex multi-turn -> session stickiness (prompt-cache hit on 2nd turn).
it("codex: session stickiness (cached_tokens on 2nd turn)", async () => {
const prep = await prepare("codex");
if (!prep) return expect(true).toBe(true);
const longContext = "The capital of France is Paris. ".repeat(40);
const messages = [
{ role: "user", content: longContext },
{ role: "assistant", content: "Understood. I have noted that context." },
{ role: "user", content: "Reply with the single word: ok" },
];
const body = { stream: true, max_tokens: 32, messages };
const first = await runChat("codex", prep, body);
if (!first) return expect(true).toBe(true);
const second = await runChat("codex", prep, body);
if (!second) return expect(true).toBe(true);
// 2nd identical-context turn should hit prompt cache when session is sticky.
const m = second.raw.match(/"cached_tokens"\s*:\s*(\d+)/);
if (!m) {
console.warn("[skip] codex: no cached_tokens in usage (provider may not report)");
return expect(true).toBe(true);
}
expect(Number(m[1]), "cached_tokens not > 0 on 2nd turn").toBeGreaterThan(0);
}, TIMEOUT_MS);
// Case #1/#2: Antigravity normal prompt -> valid SSE response.
it("antigravity: responds OK", async () => {
const prep = await prepare("antigravity");
if (!prep) return expect(true).toBe(true);
const out = await runChat("antigravity", prep, {
stream: true,
max_tokens: 32,
messages: [{ role: "user", content: "Reply with the single word: hi" }],
});
if (!out) return expect(true).toBe(true);
expect(out.raw.length, "empty response").toBeGreaterThan(0);
expect(/data:|finish_reason|"delta"|"content"|event:/.test(out.raw), "not SSE").toBe(true);
}, TIMEOUT_MS);
});
+48
View File
@@ -0,0 +1,48 @@
// A5 (cases #7/#9/#10): lock hardcode->config no-op values.
import { describe, it, expect } from "vitest";
import {
OPENAI_COMPAT_BASE,
ANTHROPIC_COMPAT_BASE,
ANTHROPIC_API_VERSION,
} from "../../open-sse/providers/shared.js";
import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../open-sse/config/runtimeConfig.js";
import mimoFree from "../../open-sse/providers/registry/mimo-free.js";
import opencode from "../../open-sse/providers/registry/opencode.js";
import antigravity from "../../open-sse/providers/registry/antigravity.js";
describe("compat base URLs / version", () => {
it("OPENAI_COMPAT_BASE", () => {
expect(OPENAI_COMPAT_BASE).toBe("https://api.openai.com/v1");
});
it("ANTHROPIC_COMPAT_BASE", () => {
expect(ANTHROPIC_COMPAT_BASE).toBe("https://api.anthropic.com/v1");
});
it("ANTHROPIC_API_VERSION", () => {
expect(ANTHROPIC_API_VERSION).toBe("2023-06-01");
});
});
describe("default token limits", () => {
it("max/min", () => {
expect(DEFAULT_MAX_TOKENS).toBe(64000);
expect(DEFAULT_MIN_TOKENS).toBe(32000);
});
});
describe("provider baseUrl const (full path, no trailing slash)", () => {
it("mimo-free full path", () => {
expect(mimoFree.transport.baseUrl).toBe("https://api.xiaomimimo.com/api/free-ai/openai/chat");
});
it("opencode no trailing slash", () => {
expect(opencode.transport.baseUrl).toBe("https://opencode.ai");
});
});
describe("antigravity retry (intentional change: 429=6, 503=3)", () => {
it("429 attempts = 6", () => {
expect(antigravity.transport.retry["429"].attempts).toBe(6);
});
it("503 attempts = 3", () => {
expect(antigravity.transport.retry["503"].attempts).toBe(3);
});
});
+83
View File
@@ -0,0 +1,83 @@
// A1: locks toOpenAIFinish/fromOpenAIFinish behavior changes vs open-sse.old.
import { describe, it, expect } from "vitest";
import { toOpenAIFinish, fromOpenAIFinish } from "../../open-sse/translator/concerns/finishReason.js";
import { OPENAI_FINISH, CLAUDE_STOP, GEMINI_FINISH } from "../../open-sse/translator/schema/finishReasons.js";
describe("toOpenAIFinish - gemini", () => {
it.each([
["SAFETY", "content_filter"],
["RECITATION", "content_filter"],
["BLOCKLIST", "content_filter"],
["PROHIBITED_CONTENT", "content_filter"],
["OTHER", "stop"],
["UNKNOWN_XYZ", "stop"],
["STOP", "stop"],
["MAX_TOKENS", "length"],
])("%s -> %s", (input, expected) => {
expect(toOpenAIFinish(input, "gemini")).toBe(expected);
});
});
describe("toOpenAIFinish - ollama", () => {
it.each([
["length", "length"],
["max_tokens", "length"],
["tool_calls", "tool_calls"],
["unknown_xyz", "stop"],
])("%s -> %s", (input, expected) => {
expect(toOpenAIFinish(input, "ollama")).toBe(expected);
});
});
describe("toOpenAIFinish - kiro", () => {
it("tool_use -> tool_calls", () => {
expect(toOpenAIFinish("tool_use", "kiro")).toBe("tool_calls");
});
});
describe("toOpenAIFinish - claude", () => {
it.each([
["end_turn", "stop"],
["max_tokens", "length"],
["tool_use", "tool_calls"],
])("%s -> %s", (input, expected) => {
expect(toOpenAIFinish(input, "claude")).toBe(expected);
});
});
describe("toOpenAIFinish - commandcode", () => {
it("tool-calls -> tool_calls", () => {
expect(toOpenAIFinish("tool-calls", "commandcode")).toBe("tool_calls");
});
it("unknown passthrough", () => {
expect(toOpenAIFinish("xyz", "commandcode")).toBe("xyz");
});
});
describe("fromOpenAIFinish round-trip - claude", () => {
it("tool_calls -> tool_use", () => {
expect(fromOpenAIFinish("tool_calls", "claude")).toBe("tool_use");
});
it("length -> max_tokens", () => {
expect(fromOpenAIFinish("length", "claude")).toBe("max_tokens");
});
});
describe("enum literals (catch drift)", () => {
it("OPENAI_FINISH literals", () => {
expect(OPENAI_FINISH.STOP).toBe("stop");
expect(OPENAI_FINISH.LENGTH).toBe("length");
expect(OPENAI_FINISH.TOOL_CALLS).toBe("tool_calls");
expect(OPENAI_FINISH.CONTENT_FILTER).toBe("content_filter");
});
it("CLAUDE_STOP literals", () => {
expect(CLAUDE_STOP.END_TURN).toBe("end_turn");
expect(CLAUDE_STOP.MAX_TOKENS).toBe("max_tokens");
expect(CLAUDE_STOP.TOOL_USE).toBe("tool_use");
});
it("GEMINI_FINISH literals", () => {
expect(GEMINI_FINISH.STOP).toBe("STOP");
expect(GEMINI_FINISH.MAX_TOKENS).toBe("MAX_TOKENS");
expect(GEMINI_FINISH.SAFETY).toBe("SAFETY");
});
});
@@ -0,0 +1,30 @@
// A4 (case #10): malformed tool_calls args must not throw -> safeParseJSON returns {}.
import { describe, it, expect } from "vitest";
import { openaiToOllamaRequest } from "../../open-sse/translator/request/openai-to-ollama.js";
function reqWith(args) {
return {
messages: [
{
role: "assistant",
content: "",
tool_calls: [{ id: "c1", type: "function", function: { name: "get_weather", arguments: args } }],
},
],
};
}
describe("openaiToOllamaRequest - tool_calls arguments parsing", () => {
it("malformed JSON args -> {} (no throw)", () => {
let out;
expect(() => {
out = openaiToOllamaRequest("m", reqWith("{invalid json"), true);
}).not.toThrow();
expect(out.messages[0].tool_calls[0].function.arguments).toEqual({});
});
it("valid JSON args -> parsed object", () => {
const out = openaiToOllamaRequest("m", reqWith('{"a":1}'), true);
expect(out.messages[0].tool_calls[0].function.arguments).toEqual({ a: 1 });
});
});
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { MODEL_PRICING } from "../../src/shared/constants/pricing.js"; import { MODEL_PRICING } from "../../open-sse/providers/pricing.js";
describe("MiniMax-M3 pricing", () => { describe("MiniMax-M3 pricing", () => {
it("includes MiniMax-M3 in MODEL_PRICING", () => { it("includes MiniMax-M3 in MODEL_PRICING", () => {
+49
View File
@@ -0,0 +1,49 @@
// A2: locks resolveSessionId priority/stickiness (codex/kiro/antigravity centralization).
import { describe, it, expect, beforeEach } from "vitest";
import { resolveSessionId, deriveSessionId, clearSessionStore } from "../../open-sse/utils/sessionManager.js";
// Assistant text must exceed ASSISTANT_MIN_LEN (50) to trigger sticky hash path.
const longAssistant = "x".repeat(80);
const bodyWithAssistant = { messages: [{ role: "assistant", content: longAssistant }] };
beforeEach(() => clearSessionStore());
describe("resolveSessionId", () => {
it("stickiness: same body+connectionId+scope -> same id", () => {
const opts = { body: bodyWithAssistant, connectionId: "conn1", scope: "codex" };
expect(resolveSessionId(opts)).toBe(resolveSessionId(opts));
});
it("different connectionId -> different id", () => {
const a = resolveSessionId({ body: bodyWithAssistant, connectionId: "connA", scope: "codex" });
const b = resolveSessionId({ body: bodyWithAssistant, connectionId: "connB", scope: "codex" });
expect(a).not.toBe(b);
});
it("different scope -> different id", () => {
const a = resolveSessionId({ body: bodyWithAssistant, connectionId: "conn1", scope: "codex" });
const b = resolveSessionId({ body: bodyWithAssistant, connectionId: "conn1", scope: "kiro" });
expect(a).not.toBe(b);
});
it("fallback: empty body+no header+no workspaceId -> deriveSessionId(connectionId)", () => {
const got = resolveSessionId({ body: {}, connectionId: "connFallback" });
expect(got).toBe(deriveSessionId("connFallback"));
});
it("client override: x-session-id header wins, skips later steps", () => {
const got = resolveSessionId({
headers: { "x-session-id": "client-sess-123" },
body: bodyWithAssistant,
connectionId: "conn1",
workspaceId: "ws1",
scope: "codex",
});
expect(got).toBe("client-sess-123");
});
it("workspaceId path: empty body + workspaceId set -> normalized workspaceId", () => {
const got = resolveSessionId({ body: {}, connectionId: "conn1", workspaceId: "ws-abc" });
expect(got).toBe("ws-abc");
});
});
+69
View File
@@ -0,0 +1,69 @@
// A3: locks toOpenAIUsage per-provider token math (claude/gemini/kiro/ollama/commandcode).
import { describe, it, expect } from "vitest";
import { toOpenAIUsage } from "../../open-sse/translator/concerns/usage.js";
describe("toOpenAIUsage", () => {
it("claude: folds cache read+create into prompt, exposes details", () => {
const u = toOpenAIUsage(
{ input_tokens: 100, output_tokens: 20, cache_read_input_tokens: 30, cache_creation_input_tokens: 10 },
"claude"
);
expect(u.prompt_tokens).toBe(140);
expect(u.completion_tokens).toBe(20);
expect(u.total_tokens).toBe(160);
expect(u.prompt_tokens_details.cached_tokens).toBe(30);
expect(u.prompt_tokens_details.cache_creation_tokens).toBe(10);
});
it("claude: no cache -> no prompt_tokens_details", () => {
const u = toOpenAIUsage({ input_tokens: 50, output_tokens: 5 }, "claude");
expect(u.prompt_tokens).toBe(50);
expect(u.prompt_tokens_details).toBeUndefined();
});
it("gemini: full fields, completion = candidates + thoughts", () => {
const u = toOpenAIUsage(
{ promptTokenCount: 100, candidatesTokenCount: 40, thoughtsTokenCount: 10, totalTokenCount: 150 },
"gemini"
);
expect(u.prompt_tokens).toBe(100);
expect(u.completion_tokens).toBe(50);
expect(u.total_tokens).toBe(150);
expect(u.completion_tokens_details.reasoning_tokens).toBe(10);
});
it("gemini fallback: candidates=0 -> derive from total - prompt - thoughts", () => {
const u = toOpenAIUsage(
{ promptTokenCount: 100, candidatesTokenCount: 0, thoughtsTokenCount: 10, totalTokenCount: 150 },
"gemini"
);
// candidates derived = 150 - 100 - 10 = 40 ; completion = 40 + 10
expect(u.completion_tokens).toBe(50);
});
it("kiro: input/output straight", () => {
const u = toOpenAIUsage({ inputTokens: 12, outputTokens: 3 }, "kiro");
expect(u.prompt_tokens).toBe(12);
expect(u.completion_tokens).toBe(3);
expect(u.total_tokens).toBe(15);
});
it("ollama: prompt_eval_count/eval_count", () => {
const u = toOpenAIUsage({ prompt_eval_count: 7, eval_count: 4 }, "ollama");
expect(u.prompt_tokens).toBe(7);
expect(u.completion_tokens).toBe(4);
expect(u.total_tokens).toBe(11);
});
it("commandcode: keeps totalTokens fallback", () => {
const u = toOpenAIUsage({ inputTokens: 8, outputTokens: 2, totalTokens: 99 }, "commandcode");
expect(u.prompt_tokens).toBe(8);
expect(u.completion_tokens).toBe(2);
expect(u.total_tokens).toBe(99);
});
it("unknown kind / null raw -> null", () => {
expect(toOpenAIUsage({}, "nope")).toBeNull();
expect(toOpenAIUsage(null, "claude")).toBeNull();
});
});