mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
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:
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
id: "byteplus",
|
id: "byteplus",
|
||||||
priority: 150,
|
priority: 70,
|
||||||
alias: "byteplus",
|
alias: "byteplus",
|
||||||
aliases: [
|
aliases: [
|
||||||
"bpm",
|
"bpm",
|
||||||
|
|||||||
@@ -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,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,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: [
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,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: {
|
||||||
|
|||||||
@@ -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: []
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,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: {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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: []
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,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,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,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: {
|
||||||
|
|||||||
@@ -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,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,5 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
id: "mmf",
|
id: "mmf",
|
||||||
|
hidden: true,
|
||||||
priority: 200,
|
priority: 200,
|
||||||
display: {
|
display: {
|
||||||
name: "MMF",
|
name: "MMF",
|
||||||
|
|||||||
@@ -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,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,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,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,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: {
|
||||||
|
|||||||
@@ -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,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: {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -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,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
id: "vertex",
|
id: "vertex",
|
||||||
priority: 140,
|
priority: 40,
|
||||||
alias: "vertex",
|
alias: "vertex",
|
||||||
aliases: [
|
aliases: [
|
||||||
"vx",
|
"vx",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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,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({});
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user