feat(providers): add Token Harbor provider
This commit is contained in:
@@ -125,6 +125,7 @@ import p119 from "./selfhosted-embedding.js";
|
|||||||
import p120 from "./fish-audio.js";
|
import p120 from "./fish-audio.js";
|
||||||
import p121 from "./alitp-intl.js";
|
import p121 from "./alitp-intl.js";
|
||||||
import p122 from "./xquik.js";
|
import p122 from "./xquik.js";
|
||||||
|
import p125 from "./tokenharbor.js";
|
||||||
import p126 from "./dahl.js";
|
import p126 from "./dahl.js";
|
||||||
import p127 from "./atria.js";
|
import p127 from "./atria.js";
|
||||||
import p129 from "./agnes.js";
|
import p129 from "./agnes.js";
|
||||||
@@ -254,6 +255,7 @@ export default [
|
|||||||
p120,
|
p120,
|
||||||
p121,
|
p121,
|
||||||
p122,
|
p122,
|
||||||
|
p125,
|
||||||
p126,
|
p126,
|
||||||
p127,
|
p127,
|
||||||
p129,
|
p129,
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
BIN
public/providers/tokenharbor.png
Normal file
BIN
public/providers/tokenharbor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
@@ -302,9 +302,8 @@ const PROVIDER_MODELS_CONFIG = {
|
|||||||
nvidia: createOpenAIModelsConfig("https://integrate.api.nvidia.com/v1/models"),
|
nvidia: createOpenAIModelsConfig("https://integrate.api.nvidia.com/v1/models"),
|
||||||
assemblyai: createOpenAIModelsConfig("https://api.assemblyai.com/v1/models"),
|
assemblyai: createOpenAIModelsConfig("https://api.assemblyai.com/v1/models"),
|
||||||
"vercel-ai-gateway": createOpenAIModelsConfig("https://ai-gateway.vercel.sh/v1/models"),
|
"vercel-ai-gateway": createOpenAIModelsConfig("https://ai-gateway.vercel.sh/v1/models"),
|
||||||
// OpenAI-compatible aggregators. All standard Bearer + OpenAI shape, so they
|
// OpenAI-compatible aggregators.
|
||||||
// reuse createOpenAIModelsConfig; Kira and Dahl serve their catalogue
|
tokenharbor: createOpenAIModelsConfig("https://tokenharbor.ai/v1/models"),
|
||||||
// publicly, the rest need the connection API key.
|
|
||||||
dahl: createOpenAIModelsConfig("https://inference.dahl.global/v1/models"),
|
dahl: createOpenAIModelsConfig("https://inference.dahl.global/v1/models"),
|
||||||
atria: createOpenAIModelsConfig("https://api.atria-asi.ai/v1/models"),
|
atria: createOpenAIModelsConfig("https://api.atria-asi.ai/v1/models"),
|
||||||
agnes: createOpenAIModelsConfig("https://apihub.agnes-ai.com/v1/models"),
|
agnes: createOpenAIModelsConfig("https://apihub.agnes-ai.com/v1/models"),
|
||||||
|
|||||||
@@ -696,8 +696,7 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
|
|||||||
const res = await fetchWithConnectionProxy("https://api.hyperbolic.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
const res = await fetchWithConnectionProxy("https://api.hyperbolic.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
// OpenAI-compatible aggregators. All standard Bearer + OpenAI shape, so the
|
case "tokenharbor":
|
||||||
// same /v1/models probe applies to each.
|
|
||||||
case "dahl":
|
case "dahl":
|
||||||
case "atria":
|
case "atria":
|
||||||
case "agnes":
|
case "agnes":
|
||||||
|
|||||||
@@ -196,6 +196,7 @@
|
|||||||
"siliconflow": "siliconflow",
|
"siliconflow": "siliconflow",
|
||||||
"tencent": "hunyuan",
|
"tencent": "hunyuan",
|
||||||
"together": "together",
|
"together": "together",
|
||||||
|
"tokenharbor": "tokenharbor",
|
||||||
"tokenrouter": "tokenrouter",
|
"tokenrouter": "tokenrouter",
|
||||||
"venice": "venice",
|
"venice": "venice",
|
||||||
"vercel-ai-gateway": "vercel-ai-gateway",
|
"vercel-ai-gateway": "vercel-ai-gateway",
|
||||||
@@ -299,6 +300,7 @@
|
|||||||
"siliconflow",
|
"siliconflow",
|
||||||
"stability-ai",
|
"stability-ai",
|
||||||
"together",
|
"together",
|
||||||
|
"tokenharbor",
|
||||||
"tokenrouter",
|
"tokenrouter",
|
||||||
"venice",
|
"venice",
|
||||||
"vertex",
|
"vertex",
|
||||||
|
|||||||
@@ -1105,5 +1105,13 @@
|
|||||||
"baseUrl": "https://api.b.ai/v1/chat/completions",
|
"baseUrl": "https://api.b.ai/v1/chat/completions",
|
||||||
"validateUrl": "https://api.b.ai/v1/models",
|
"validateUrl": "https://api.b.ai/v1/models",
|
||||||
"format": "openai"
|
"format": "openai"
|
||||||
|
},
|
||||||
|
"tokenharbor": {
|
||||||
|
"baseUrl": "https://tokenharbor.ai/v1/chat/completions",
|
||||||
|
"validateUrl": "https://tokenharbor.ai/v1/models",
|
||||||
|
"retry": {
|
||||||
|
"429": 2
|
||||||
|
},
|
||||||
|
"format": "openai"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
79
tests/unit/tokenharbor-provider.test.js
Normal file
79
tests/unit/tokenharbor-provider.test.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import REGISTRY from "../../open-sse/providers/registry/index.js";
|
||||||
|
import { PROVIDERS, PROVIDER_MODELS } from "../../open-sse/providers/index.js";
|
||||||
|
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
|
||||||
|
import { getExecutor } from "../../open-sse/executors/index.js";
|
||||||
|
import { DefaultExecutor } from "../../open-sse/executors/default.js";
|
||||||
|
|
||||||
|
describe("Token Harbor provider", () => {
|
||||||
|
const entry = REGISTRY.find((e) => e.id === "tokenharbor");
|
||||||
|
|
||||||
|
it("is registered as an OpenAI-compatible apikey provider", () => {
|
||||||
|
expect(entry).toBeDefined();
|
||||||
|
expect(entry.category).toBe("apikey");
|
||||||
|
expect(entry.authType).toBe("apikey");
|
||||||
|
expect(entry.alias).toBe("tokenharbor");
|
||||||
|
expect(entry.aliases).toContain("th");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("points at the verified OpenAI-compatible base URL", () => {
|
||||||
|
expect(PROVIDERS.tokenharbor.baseUrl).toBe("https://tokenharbor.ai/v1/chat/completions");
|
||||||
|
expect(PROVIDERS.tokenharbor.validateUrl).toBe("https://tokenharbor.ai/v1/models");
|
||||||
|
// transport.format defaults to "openai" via the shared provider default
|
||||||
|
expect(PROVIDERS.tokenharbor.format).toBe("openai");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares no provider-wide thinkingFormat so each model resolves its own", () => {
|
||||||
|
// Token Harbor forwards bodies verbatim. A provider-wide thinkingFormat
|
||||||
|
// would override capabilities.js and force one wire format (e.g.
|
||||||
|
// claude-adaptive) onto every model, which an OpenAI endpoint rejects.
|
||||||
|
expect(PROVIDERS.tokenharbor.thinkingFormat).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables dynamic model discovery and passthrough", () => {
|
||||||
|
expect(entry.passthroughModels).toBe(true);
|
||||||
|
expect(entry.modelsFetcher).toMatchObject({
|
||||||
|
url: "https://tokenharbor.ai/v1/models",
|
||||||
|
type: "openai",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes a small seed of bare (unprefixed) model ids", () => {
|
||||||
|
const ids = (PROVIDER_MODELS.tokenharbor || []).map((m) => m.id);
|
||||||
|
expect(ids.length).toBeGreaterThan(0);
|
||||||
|
expect(ids).toContain("claude-opus-5.5");
|
||||||
|
// Token Harbor does not prefix ids by upstream vendor
|
||||||
|
expect(ids.every((id) => !id.includes("/"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes through the shared DefaultExecutor (no custom adapter)", () => {
|
||||||
|
expect(getExecutor("tokenharbor")).toBeInstanceOf(DefaultExecutor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves per-model capabilities from the shared tables", () => {
|
||||||
|
// Bare ids must still reach the canonical family patterns.
|
||||||
|
expect(getCapabilitiesForModel("tokenharbor", "claude-opus-5.5")).toMatchObject({
|
||||||
|
vision: true,
|
||||||
|
reasoning: true,
|
||||||
|
thinkingFormat: "claude-adaptive",
|
||||||
|
});
|
||||||
|
expect(getCapabilitiesForModel("tokenharbor", "gpt-6-astra")).toMatchObject({
|
||||||
|
reasoning: true,
|
||||||
|
thinkingFormat: "openai",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not invent capabilities for an uncatalogued model", () => {
|
||||||
|
// Vision/reasoning must not be blanket-granted across the provider.
|
||||||
|
const caps = getCapabilitiesForModel("tokenharbor", "some-unknown-model-x");
|
||||||
|
expect(caps.vision).toBe(false);
|
||||||
|
expect(caps.reasoning).toBe(false);
|
||||||
|
expect(caps.thinkingFormat).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every registry id unique after adding tokenharbor", () => {
|
||||||
|
const ids = REGISTRY.map((e) => e.id);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user