Merge origin/master (v0.5.91) into gitea/new_feature
Resolve conflicts: - streamingHandler.js: adopt upstreamResponseHeaders while keeping 0-token detail row avoidance - capabilities.js: preserve user-asserted caps and globalThis slots without local caching of catalogSource - AddCustomModelModal.js & providers/[id]/page.js: wire STT transport marker with custom model edits/assertions - models/custom/route.js & aliasRepo.js: persist custom model transport and invalidate user caps - usageRepo.js: key byApiKey live stats by full API key and keep tail in maskApiKey - UsageStats.js: lazy load charts dynamically
This commit is contained in:
@@ -430,21 +430,29 @@ const TRUST_UPSTREAM_VISION = new Set(["openrouter"]);
|
||||
*
|
||||
* @param {string[]} comboModels
|
||||
* @param {Object|null} [comboLookup] optional map of combo name → models array for nested resolution
|
||||
* @param {Function|null} [resolveCaps] optional (fullId) → caps override. The synced model
|
||||
* catalog is server-only (it reads a file), so a browser-side resolution cannot see the
|
||||
* limits it supplies and silently falls back to the generic patterns below. Callers that
|
||||
* have the server's answer (/api/models, via useModelCaps) pass it here; it is merged over
|
||||
* the local tables, so fields it does not carry (tools, pdf, audio/video, thinking*) survive.
|
||||
* @param {number} [_depth] internal recursion depth guard
|
||||
* @returns {object|null} full capabilities object, or null for empty input
|
||||
*/
|
||||
export function aggregateComboCapabilities(comboModels, comboLookup = null, _depth = 0) {
|
||||
export function aggregateComboCapabilities(comboModels, comboLookup = null, resolveCaps = null, _depth = 0) {
|
||||
if (!comboModels?.length || _depth > 6) return null;
|
||||
const allCaps = comboModels.map((fullId) => {
|
||||
// Nested combo: bare name (no slash) that exists in the lookup — recurse
|
||||
if (!fullId.includes("/") && comboLookup?.[fullId]) {
|
||||
return aggregateComboCapabilities(comboLookup[fullId], comboLookup, _depth + 1)
|
||||
return aggregateComboCapabilities(comboLookup[fullId], comboLookup, resolveCaps, _depth + 1)
|
||||
?? resolveCaps?.(fullId)
|
||||
?? getCapabilitiesForModel(null, fullId);
|
||||
}
|
||||
const slash = fullId.indexOf("/");
|
||||
const provider = slash === -1 ? null : fullId.slice(0, slash);
|
||||
const model = slash === -1 ? fullId : fullId.slice(slash + 1);
|
||||
return getCapabilitiesForModel(provider, model);
|
||||
const local = getCapabilitiesForModel(provider, model);
|
||||
const override = resolveCaps?.(fullId);
|
||||
return override ? { ...local, ...override } : local;
|
||||
});
|
||||
const first = allCaps[0];
|
||||
return {
|
||||
@@ -482,7 +490,9 @@ const MODALITY_KEYS = ["vision", "pdf", "audioInput", "videoInput"];
|
||||
// handlers (silently: the setters still "succeed"). The slots therefore live on
|
||||
// globalThis, which IS shared across server bundles in the same process.
|
||||
// Same reason the browser bundle is safe: it never calls a setter, so the slots
|
||||
// stay empty and every consumer below short-circuits.
|
||||
// stay empty and every consumer below short-circuits. Every read goes through
|
||||
// globalThis: caching it locally would keep a reader alive in other copies after
|
||||
// setCatalogSource(null).
|
||||
let catalogSource = null;
|
||||
const SOURCE_SLOTS = (globalThis.__9R_CAPABILITY_SOURCES ||= {
|
||||
catalog: null, // { getModalities, getLimits } — synced models.dev catalog
|
||||
@@ -496,15 +506,13 @@ const SOURCE_SLOTS = (globalThis.__9R_CAPABILITY_SOURCES ||= {
|
||||
*/
|
||||
export function setCatalogSource(source) {
|
||||
catalogSource = source || null;
|
||||
SOURCE_SLOTS.catalog = source || null;
|
||||
if (SOURCE_SLOTS) SOURCE_SLOTS.catalog = source || null;
|
||||
if (typeof globalThis !== "undefined") globalThis.__9rCatalogSource = source || null;
|
||||
}
|
||||
|
||||
function getCatalogSource() {
|
||||
if (catalogSource) return catalogSource;
|
||||
if (SOURCE_SLOTS.catalog) return (catalogSource = SOURCE_SLOTS.catalog);
|
||||
if (typeof globalThis === "undefined") return null;
|
||||
return (catalogSource = globalThis.__9rCatalogSource || null);
|
||||
if (typeof globalThis === "undefined") return catalogSource;
|
||||
return SOURCE_SLOTS?.catalog || globalThis.__9rCatalogSource || null;
|
||||
}
|
||||
|
||||
// Capabilities the user asserted per provider+model (dashboard "Add/Edit Model"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { FORMATS } from "../../translator/formats.js";
|
||||
|
||||
// Codex auto-generates a "-review" variant for each llm model (review quota family)
|
||||
export const CODEX_REVIEW_SUFFIX = "-review";
|
||||
|
||||
@@ -25,3 +27,20 @@ export function isMuseSparkModel(modelId) {
|
||||
const base = clean.includes("/") ? clean.split("/").pop() : clean;
|
||||
return /^muse[-_]?spark(?:$|[-_:.\s])/i.test(base);
|
||||
}
|
||||
|
||||
// Endpoint families for OpenCode models outside the curated registry (modelsFetcher /
|
||||
// passthrough ids) — regex keeps auto-fetched models on the right endpoint:
|
||||
// /responses (gpt/grok/muse-spark), /messages (minimax/qwen), /chat/completions (rest).
|
||||
// Curated registry entries always win; this is the unknown-id fallback only.
|
||||
const OPENCODE_FAMILIES = [
|
||||
{ match: /^(grok|gpt|muse[-_]?spark)/i, supportedFormats: [FORMATS.OPENAI_RESPONSES], targetFormat: FORMATS.OPENAI_RESPONSES },
|
||||
{ match: /^deepseek-v4-(pro|flash)/, supportedFormats: [FORMATS.OPENAI, FORMATS.CLAUDE, FORMATS.OPENAI_RESPONSES] },
|
||||
{ match: /^(minimax|qwen)/, supportedFormats: [FORMATS.OPENAI, FORMATS.CLAUDE] },
|
||||
{ match: /^claude-/i, supportedFormats: [FORMATS.CLAUDE] },
|
||||
];
|
||||
|
||||
export function opencodeFamilyFormats(modelId) {
|
||||
if (!modelId || typeof modelId !== "string") return null;
|
||||
const base = modelId.replace(/\([^()]+\)\s*$/, "").trim();
|
||||
return OPENCODE_FAMILIES.find((f) => f.match.test(base)) || null;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,28 @@
|
||||
//
|
||||
// Fallback order (first match wins):
|
||||
// 1. PROVIDER_PRICING[provider][model] — provider-specific override
|
||||
// 2. MODEL_PRICING[model] — canonical model price (provider-agnostic)
|
||||
// 3. PATTERN_PRICING — glob pattern match (e.g. "codex-*")
|
||||
// 2. FREE_MODEL_NAMESPACES — upstream bills these at $0
|
||||
// 3. MODEL_PRICING[model] — canonical model price (provider-agnostic)
|
||||
// 4. PATTERN_PRICING — glob pattern match (e.g. "codex-*")
|
||||
|
||||
/**
|
||||
* Namespaces upstream meters at $0. A free model must never inherit a paid
|
||||
* rate: the vendor-prefix strip in getPricingForModel() would turn
|
||||
* "cline-free/deepseek-v4.1-flash" into "deepseek-v4.1-flash" and match
|
||||
* MODEL_PRICING, so the namespace is checked before both fallbacks.
|
||||
*/
|
||||
export const FREE_MODEL_NAMESPACES = ["cline-free/"];
|
||||
|
||||
export const ZERO_PRICING = {
|
||||
input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0,
|
||||
};
|
||||
|
||||
/** True when the model id sits in a namespace upstream bills at $0. */
|
||||
export function isFreeModel(model) {
|
||||
if (!model) return false;
|
||||
const lower = String(model).toLowerCase();
|
||||
return FREE_MODEL_NAMESPACES.some((ns) => lower.startsWith(ns));
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical model pricing — provider-agnostic.
|
||||
@@ -361,10 +381,11 @@ export function matchPattern(pattern, model) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve pricing for a model using the 3-step fallback chain:
|
||||
* Resolve pricing for a model using the 4-step fallback chain:
|
||||
* 1. PROVIDER_PRICING[provider][model]
|
||||
* 2. MODEL_PRICING[model]
|
||||
* 3. PATTERN_PRICING (glob match)
|
||||
* 2. free namespace (upstream bills $0)
|
||||
* 3. MODEL_PRICING[model]
|
||||
* 4. PATTERN_PRICING (glob match)
|
||||
*
|
||||
* @param {string} provider
|
||||
* @param {string} model
|
||||
@@ -378,12 +399,15 @@ export function getPricingForModel(provider, model) {
|
||||
return PROVIDER_PRICING[provider][model];
|
||||
}
|
||||
|
||||
// 2. Canonical model pricing (strip vendor prefix if needed: "deepseek/deepseek-chat" → "deepseek-chat")
|
||||
// 2. Free namespaces bill $0 regardless of the model name behind them.
|
||||
if (isFreeModel(model)) return ZERO_PRICING;
|
||||
|
||||
// 3. Canonical model pricing (strip vendor prefix if needed: "deepseek/deepseek-chat" → "deepseek-chat")
|
||||
const baseModel = model.includes("/") ? model.split("/").pop() : model;
|
||||
if (MODEL_PRICING[baseModel]) return MODEL_PRICING[baseModel];
|
||||
if (MODEL_PRICING[model]) return MODEL_PRICING[model];
|
||||
|
||||
// 3. Pattern match
|
||||
// 4. Pattern match
|
||||
for (const { pattern, pricing } of PATTERN_PRICING) {
|
||||
if (matchPattern(pattern, baseModel) || matchPattern(pattern, model)) {
|
||||
return pricing;
|
||||
|
||||
29
open-sse/providers/registry/agnes.js
Normal file
29
open-sse/providers/registry/agnes.js
Normal file
@@ -0,0 +1,29 @@
|
||||
export default {
|
||||
id: "agnes",
|
||||
priority: 120,
|
||||
alias: "agnes",
|
||||
aliases: [
|
||||
"agnes-ai",
|
||||
],
|
||||
uiAlias: "agnes",
|
||||
display: {
|
||||
name: "Agnes AI",
|
||||
icon: "auto_awesome",
|
||||
color: "#7C3AED",
|
||||
textIcon: "AG",
|
||||
website: "https://agnes-ai.com",
|
||||
notice: {
|
||||
text: "OpenAI-compatible gateway from Agnes AI, offering free API credits on sign-up. Accepts a bearer token or an x-api-key header.",
|
||||
apiKeyUrl: "https://platform.agnes-ai.com",
|
||||
},
|
||||
},
|
||||
category: "freeTier",
|
||||
authType: "apikey",
|
||||
transport: {
|
||||
baseUrl: "https://apihub.agnes-ai.com/v1/chat/completions",
|
||||
validateUrl: "https://apihub.agnes-ai.com/v1/models",
|
||||
},
|
||||
// No model ids could be verified without a key, so discovery is left to the
|
||||
// live endpoint and any id is accepted through passthroughModels.
|
||||
passthroughModels: true,
|
||||
};
|
||||
32
open-sse/providers/registry/atria.js
Normal file
32
open-sse/providers/registry/atria.js
Normal file
@@ -0,0 +1,32 @@
|
||||
export default {
|
||||
id: "atria",
|
||||
priority: 120,
|
||||
alias: "atria",
|
||||
aliases: [
|
||||
"atria-asi",
|
||||
],
|
||||
uiAlias: "atria",
|
||||
display: {
|
||||
name: "Atria Dawn",
|
||||
icon: "flare",
|
||||
color: "#C2410C",
|
||||
textIcon: "AD",
|
||||
website: "https://atria-asi.ai",
|
||||
notice: {
|
||||
text: "OpenAI-compatible endpoint from Atria Dawn (AtomInnoLab). Currently a research preview offering a single text model, Atria-Dawn-Preview.",
|
||||
apiKeyUrl: "https://api.atria-asi.ai/dashboard",
|
||||
},
|
||||
},
|
||||
category: "apikey",
|
||||
authType: "apikey",
|
||||
transport: {
|
||||
baseUrl: "https://api.atria-asi.ai/v1/chat/completions",
|
||||
validateUrl: "https://api.atria-asi.ai/v1/models",
|
||||
},
|
||||
// Docs pin the model field to one case-sensitive id. Text-only for now: the
|
||||
// service ships a hook that blocks image/PDF input, so no vision is claimed.
|
||||
models: [
|
||||
{ id: "Atria-Dawn-Preview", name: "Atria Dawn Preview" },
|
||||
],
|
||||
passthroughModels: true,
|
||||
};
|
||||
30
open-sse/providers/registry/bai.js
Normal file
30
open-sse/providers/registry/bai.js
Normal file
@@ -0,0 +1,30 @@
|
||||
export default {
|
||||
id: "bai",
|
||||
priority: 120,
|
||||
alias: "bai",
|
||||
aliases: [
|
||||
"b-ai",
|
||||
],
|
||||
uiAlias: "bai",
|
||||
display: {
|
||||
name: "B.AI",
|
||||
icon: "account_balance",
|
||||
color: "#0369A1",
|
||||
textIcon: "BA",
|
||||
website: "https://b.ai",
|
||||
notice: {
|
||||
text: "OpenAI-compatible gateway with one of the larger catalogues here. Accepts a bearer token or an x-api-key header. Model ids are fetched live from the provider.",
|
||||
apiKeyUrl: "https://b.ai",
|
||||
},
|
||||
},
|
||||
category: "apikey",
|
||||
authType: "apikey",
|
||||
transport: {
|
||||
baseUrl: "https://api.b.ai/v1/chat/completions",
|
||||
validateUrl: "https://api.b.ai/v1/models",
|
||||
},
|
||||
// No ids hardcoded: the catalogue is large and rotates, so the live endpoint
|
||||
// is the source of truth and any id is accepted via passthroughModels.
|
||||
modelsFetcher: { url: "https://api.b.ai/v1/models", type: "openai" },
|
||||
passthroughModels: true,
|
||||
};
|
||||
@@ -54,6 +54,8 @@ export default {
|
||||
oauthUrl: "https://api.anthropic.com/api/oauth/usage",
|
||||
orgUrl: "https://api.anthropic.com/v1/organizations/{org_id}/usage",
|
||||
settingsUrl: "https://api.anthropic.com/v1/settings",
|
||||
profileUrl: "https://api.anthropic.com/api/oauth/profile",
|
||||
resetUrl: "https://api.anthropic.com/api/organizations/{org_id}/reset_rate_limits",
|
||||
},
|
||||
},
|
||||
models: [
|
||||
|
||||
@@ -2,7 +2,8 @@ import { withCodexReviewModels } from "../models/helpers.js";
|
||||
|
||||
// Codex CLI version seen by OpenAI's backend — single source for the Version /
|
||||
// User-Agent identity headers. Bump when the installed codex CLI is upgraded.
|
||||
const CODEX_CLI_VERSION = "0.154.0";
|
||||
const CODEX_CLI_VERSION = "0.155.0";
|
||||
const GPT_6_LITE_THINKING_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
export default {
|
||||
id: "codex",
|
||||
@@ -42,6 +43,7 @@ export default {
|
||||
headers: {
|
||||
originator: "codex_cli_rs",
|
||||
"User-Agent": `codex_cli_rs/${CODEX_CLI_VERSION}`,
|
||||
version: CODEX_CLI_VERSION,
|
||||
},
|
||||
usage: {
|
||||
url: "https://chatgpt.com/backend-api/wham/usage",
|
||||
@@ -51,6 +53,8 @@ export default {
|
||||
},
|
||||
models: [
|
||||
{ id: "gpt-6-astra", name: "GPT 6.0 Astra" },
|
||||
{ id: "gpt-6-sol", name: "GPT 6.0 Sol", responsesLite: true, thinkingLevels: GPT_6_LITE_THINKING_LEVELS },
|
||||
{ id: "gpt-6-luna", name: "GPT 6.0 Luna", responsesLite: true, thinkingLevels: GPT_6_LITE_THINKING_LEVELS },
|
||||
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol" },
|
||||
{ id: "gpt-5.6-sol-review", name: "GPT 5.6 Sol Review", upstreamModelId: "gpt-5.6-sol", quotaFamily: "review" },
|
||||
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra" },
|
||||
|
||||
35
open-sse/providers/registry/dahl.js
Normal file
35
open-sse/providers/registry/dahl.js
Normal file
@@ -0,0 +1,35 @@
|
||||
export default {
|
||||
id: "dahl",
|
||||
priority: 120,
|
||||
alias: "dahl",
|
||||
aliases: [
|
||||
"dahl-inference",
|
||||
],
|
||||
uiAlias: "dahl",
|
||||
display: {
|
||||
name: "Dahl Inference",
|
||||
icon: "hub",
|
||||
color: "#1E40AF",
|
||||
textIcon: "DH",
|
||||
website: "https://dahl.global",
|
||||
notice: {
|
||||
text: "OpenAI-compatible Gonka inference node. Small, fixed catalogue (GLM-5.3-Flash, DeepSeek-V4-Flash, MiniMax-M2.7) at a flat per-token rate.",
|
||||
apiKeyUrl: "https://dahl.global/dashboard",
|
||||
},
|
||||
},
|
||||
category: "apikey",
|
||||
authType: "apikey",
|
||||
transport: {
|
||||
baseUrl: "https://inference.dahl.global/v1/chat/completions",
|
||||
validateUrl: "https://inference.dahl.global/v1/models",
|
||||
},
|
||||
// The live catalogue is public (no auth), so modelsFetcher works without a key
|
||||
// and the ids below are a convenience seed rather than an exhaustive list.
|
||||
models: [
|
||||
{ id: "zai-org/GLM-5.3-Flash", name: "GLM-5.3 Flash" },
|
||||
{ id: "deepseek-ai/DeepSeek-V4-Flash-0731", name: "DeepSeek V4 Flash 0731" },
|
||||
{ id: "MiniMaxAI/MiniMax-M2.7", name: "MiniMax M2.7" },
|
||||
],
|
||||
modelsFetcher: { url: "https://inference.dahl.global/v1/models", type: "openai" },
|
||||
passthroughModels: true,
|
||||
};
|
||||
@@ -58,6 +58,7 @@ export default {
|
||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", params: ["language","prompt"], kind: "stt" },
|
||||
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite (Cheapest)", params: ["language","prompt"], kind: "stt" },
|
||||
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", params: ["language","prompt"], kind: "stt" },
|
||||
{ id: "gemini-2.5-flash-native-audio-preview-09-17", name: "Gemini Live Transcription (Realtime)", params: ["language","prompt","system_instruction","setup_timeout_ms","turn_timeout_ms"], kind: "stt", transport: "gemini-live" },
|
||||
{ id: "gemini-3.1-flash-tts-preview", name: "Gemini 3.1 Flash TTS", kind: "tts" },
|
||||
{ id: "gemini-2.5-flash-preview-tts", name: "Gemini 2.5 Flash TTS", kind: "tts" },
|
||||
{ id: "gemini-2.5-pro-preview-tts", name: "Gemini 2.5 Pro TTS", kind: "tts" },
|
||||
|
||||
@@ -125,6 +125,11 @@ import p119 from "./selfhosted-embedding.js";
|
||||
import p120 from "./fish-audio.js";
|
||||
import p121 from "./alitp-intl.js";
|
||||
import p122 from "./xquik.js";
|
||||
import p125 from "./tokenharbor.js";
|
||||
import p126 from "./dahl.js";
|
||||
import p127 from "./atria.js";
|
||||
import p129 from "./agnes.js";
|
||||
import p130 from "./bai.js";
|
||||
export default [
|
||||
p0,
|
||||
p1,
|
||||
@@ -250,4 +255,9 @@ export default [
|
||||
p120,
|
||||
p121,
|
||||
p122,
|
||||
p125,
|
||||
p126,
|
||||
p127,
|
||||
p129,
|
||||
p130,
|
||||
];
|
||||
|
||||
@@ -35,37 +35,56 @@ export default {
|
||||
],
|
||||
// supportedFormats follow the endpoint table in https://opencode.ai/docs/go/
|
||||
models: [
|
||||
{ id: "deepseek-flash", name: "DeepSeek V4.1 Flash", supportedFormats: ["openai"] },
|
||||
{ id: "deepseek-flash", name: "DeepSeek Flash", supportedFormats: ["openai"] },
|
||||
{ id: "glm-5.3-flash", name: "GLM 5.3 Flash (Vision)", supportedFormats: ["openai"] },
|
||||
{ id: "glm-5.3", name: "GLM 5.3", supportedFormats: ["openai"] },
|
||||
{ id: "glm-5.2", name: "GLM 5.2", supportedFormats: ["openai"] },
|
||||
{ id: "glm-5.1", name: "GLM 5.1", supportedFormats: ["openai"] },
|
||||
{ id: "glm-5", name: "GLM 5", supportedFormats: ["openai"] },
|
||||
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code", supportedFormats: ["openai"] },
|
||||
{ id: "kimi-k2.6", name: "Kimi K2.6", supportedFormats: ["openai"] },
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5", supportedFormats: ["openai"] },
|
||||
{ id: "kimi-k3", name: "Kimi K3", supportedFormats: ["openai"] },
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai", "claude", "openai-responses"] },
|
||||
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai", "claude", "openai-responses"] },
|
||||
{ id: "deepseek-v4-flash-vision-exp", name: "DeepSeek V4 Flash Vision (Exp)", supportedFormats: ["openai", "claude", "openai-responses"] },
|
||||
{ id: "deepseek-v4.1-flash", name: "DeepSeek V4.1 Flash", supportedFormats: ["openai", "claude", "openai-responses"] },
|
||||
{ id: "longcat-2.0", name: "LongCat 2.0", supportedFormats: ["openai"] },
|
||||
{ id: "mimo-v2.6-flash", name: "MiMo V2.6 Flash", supportedFormats: ["openai"] },
|
||||
{ id: "mimo-v2.6-pro", name: "MiMo V2.6 Pro", supportedFormats: ["openai"] },
|
||||
{ id: "mimo-v2.5", name: "MiMo V2.5", supportedFormats: ["openai"] },
|
||||
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", supportedFormats: ["openai"] },
|
||||
{ id: "mimo-v2-pro", name: "MiMo V2 Pro", supportedFormats: ["openai"] },
|
||||
{ id: "mimo-v2-omni", name: "MiMo V2 Omni", supportedFormats: ["openai"] },
|
||||
{ id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "space-bunny-free", name: "Space Bunny Free", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "qwen3.8-max", name: "Qwen 3.8 Max", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "qwen3.8-flash", name: "Qwen 3.8 Flash", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "qwen3.7-max", name: "Qwen 3.7 Max", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "qwen3.7-plus", name: "Qwen 3.7 Plus", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "qwen3.5-plus", name: "Qwen 3.5 Plus", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "hy4-preview", name: "Hy4 Preview", supportedFormats: ["openai"] },
|
||||
{ id: "hy3", name: "Hy3", supportedFormats: ["openai"] },
|
||||
{ id: "hy3-preview", name: "Hy3 Preview", supportedFormats: ["openai"] },
|
||||
// In /zen/go/v1/models but absent from the docs endpoint table — chat lane is the fallback guess
|
||||
{ id: "omen-alpha", name: "Omen Alpha", supportedFormats: ["openai"] },
|
||||
// Served by /zen/go/v1/responses only — the responses-only entry forces chatCore
|
||||
// past the sourceFormat-matched transports into translation (see chatCore guard).
|
||||
{ id: "grok-4.7", name: "Grok 4.7", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||
{ id: "grok-4.6", name: "Grok 4.6", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||
{ id: "grok-4.5", name: "Grok 4.5", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||
{ id: "gpt-6-luna", name: "GPT 6 Luna", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||
{ id: "muse-spark-1.2-contributor", name: "Muse Spark 1.2 Contributor", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||
{ id: "muse-spark-1.3-contributor", name: "Muse Spark 1.3 Contributor", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||
],
|
||||
// Live catalogue; ids outside this curated list get their endpoint lane from the
|
||||
// family regex in providers/models/helpers.js (opencodeFamilyFormats).
|
||||
modelsFetcher: { url: "https://opencode.ai/zen/go/v1/models", type: "opencode-go" },
|
||||
passthroughModels: true,
|
||||
features: {
|
||||
usage: true,
|
||||
usageApikey: true,
|
||||
|
||||
49
open-sse/providers/registry/tokenharbor.js
Normal file
49
open-sse/providers/registry/tokenharbor.js
Normal file
@@ -0,0 +1,49 @@
|
||||
export default {
|
||||
id: "tokenharbor",
|
||||
priority: 120,
|
||||
alias: "tokenharbor",
|
||||
aliases: [
|
||||
"th",
|
||||
"thh",
|
||||
],
|
||||
uiAlias: "tokenharbor",
|
||||
display: {
|
||||
name: "Token Harbor",
|
||||
icon: "anchor",
|
||||
color: "#0F766E",
|
||||
textIcon: "TH",
|
||||
website: "https://tokenharbor.ai",
|
||||
notice: {
|
||||
text: "OpenAI-compatible aggregator. One API key reaches every model, billed per-token from a prepaid wallet. Model ids are bare (e.g. claude-opus-5.5, gpt-6-astra, deepseek-v4.1-flash:free) and are fetched live from the provider.",
|
||||
apiKeyUrl: "https://tokenharbor.ai/dashboard",
|
||||
},
|
||||
},
|
||||
category: "apikey",
|
||||
authType: "apikey",
|
||||
transport: {
|
||||
// OpenAI-compatible. `format` is left at the shared "openai" default and
|
||||
// `thinkingFormat` is deliberately NOT declared: Token Harbor forwards
|
||||
// requests verbatim, so each model must resolve its own thinking wire
|
||||
// format through providers/capabilities.js. Setting a provider-wide value
|
||||
// would force one format (e.g. claude-adaptive) onto every model.
|
||||
baseUrl: "https://tokenharbor.ai/v1/chat/completions",
|
||||
validateUrl: "https://tokenharbor.ai/v1/models",
|
||||
retry: {
|
||||
429: 2,
|
||||
},
|
||||
},
|
||||
// Curated seed; the live catalogue is fetched via modelsFetcher and any other
|
||||
// id is accepted via passthroughModels. Their catalogue rotates (the :free set
|
||||
// in particular), so this stays deliberately small and is only the offline
|
||||
// fallback. Ids are bare — Token Harbor does not prefix them by upstream vendor.
|
||||
models: [
|
||||
{ id: "claude-opus-5.5", name: "Claude Opus 5.5" },
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
|
||||
{ id: "gpt-6-astra", name: "GPT-6 Astra" },
|
||||
{ id: "gpt-6-sol", name: "GPT-6 Sol" },
|
||||
{ id: "deepseek-v4.1-flash:free", name: "DeepSeek V4.1 Flash (Free)" },
|
||||
{ id: "grok-4.7", name: "Grok 4.7" },
|
||||
],
|
||||
modelsFetcher: { url: "https://tokenharbor.ai/v1/models", type: "openai" },
|
||||
passthroughModels: true,
|
||||
};
|
||||
@@ -77,6 +77,11 @@ export function selectAnthropicBeta(model = "", body = null) {
|
||||
return flags.join(",");
|
||||
}
|
||||
|
||||
export function mergeAnthropicBeta(...values) {
|
||||
const flags = values.flatMap((v) => (typeof v === "string" ? v.split(",") : [])).map((f) => f.trim()).filter(Boolean);
|
||||
return [...new Set(flags)].join(",");
|
||||
}
|
||||
|
||||
// Shared baseUrls
|
||||
export const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/v1/messages";
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { getCapabilitiesForModel } from "./capabilities.js";
|
||||
import { matchPattern } from "./pricing.js";
|
||||
import { resolveKiroEffortPath } from "../config/kiroConstants.js";
|
||||
import { getProviderModels } from "../config/providerModels.js";
|
||||
|
||||
// Shared level sets (deduped) — verified against provider docs + wire in thinkingUnified.applyFormat.
|
||||
const L = {
|
||||
@@ -42,6 +43,8 @@ const PATTERN_THINKING = [
|
||||
{ provider: "codex", pattern: "*gpt-5.6-luna*", levels: CODEX_GPT_5_6_LEVELS },
|
||||
{ pattern: "*codex*", levels: ["low", "medium", "high", "xhigh"] }, // codex cannot disable thinking
|
||||
{ pattern: "*mimo*v2.6*", levels: ["none", "low", "medium", "high", "xhigh"] },
|
||||
// mimo-v2.5-pro on opencode-go rejects reasoning_effort "max" (probed live); v2.5 accepts it.
|
||||
{ pattern: "*mimo*v2.5-pro*", levels: ["none", "low", "medium", "high", "xhigh"] },
|
||||
// DeepSeek v4.* (Alibaba MaaS, probed live): effort low|medium|high|xhigh|max
|
||||
// all 200 via output_config.effort; "none" is a 400 on the anthropic route
|
||||
// (disable thinking instead). none kept for the picker = disable.
|
||||
@@ -73,10 +76,14 @@ export function getThinkingLevels(provider, model) {
|
||||
if (provider === "kiro" && resolveKiroEffortPath(model) === null) return null;
|
||||
const caps = getCapabilitiesForModel(provider, model);
|
||||
if (!caps.reasoning) return null;
|
||||
const baseId = String(model || "").replace(/\([^()]+\)\s*$/, "");
|
||||
const modelLevels = provider === "codex"
|
||||
? getProviderModels("cx").find((entry) => entry.id === baseId)?.thinkingLevels
|
||||
: null;
|
||||
const hit = PATTERN_THINKING.find((entry) =>
|
||||
(!entry.provider || entry.provider === provider) && matchPattern(entry.pattern, model)
|
||||
);
|
||||
let levels = hit?.levels || FORMAT_LEVELS[caps.thinkingFormat] || L.base;
|
||||
let levels = modelLevels || hit?.levels || FORMAT_LEVELS[caps.thinkingFormat] || L.base;
|
||||
if (caps.thinkingCanDisable === false) levels = levels.filter((l) => l !== "none");
|
||||
return levels;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user