diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 4318a5a1..07b71d2c 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -125,6 +125,7 @@ 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"; @@ -254,6 +255,7 @@ export default [ p120, p121, p122, + p125, p126, p127, p129, diff --git a/open-sse/providers/registry/tokenharbor.js b/open-sse/providers/registry/tokenharbor.js new file mode 100644 index 00000000..2ef04347 --- /dev/null +++ b/open-sse/providers/registry/tokenharbor.js @@ -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, +}; diff --git a/public/providers/tokenharbor.png b/public/providers/tokenharbor.png new file mode 100644 index 00000000..7a766a64 Binary files /dev/null and b/public/providers/tokenharbor.png differ diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index 4b5f9f82..a2e09501 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -302,9 +302,8 @@ const PROVIDER_MODELS_CONFIG = { nvidia: createOpenAIModelsConfig("https://integrate.api.nvidia.com/v1/models"), assemblyai: createOpenAIModelsConfig("https://api.assemblyai.com/v1/models"), "vercel-ai-gateway": createOpenAIModelsConfig("https://ai-gateway.vercel.sh/v1/models"), - // OpenAI-compatible aggregators. All standard Bearer + OpenAI shape, so they - // reuse createOpenAIModelsConfig; Kira and Dahl serve their catalogue - // publicly, the rest need the connection API key. + // OpenAI-compatible aggregators. + tokenharbor: createOpenAIModelsConfig("https://tokenharbor.ai/v1/models"), dahl: createOpenAIModelsConfig("https://inference.dahl.global/v1/models"), atria: createOpenAIModelsConfig("https://api.atria-asi.ai/v1/models"), agnes: createOpenAIModelsConfig("https://apihub.agnes-ai.com/v1/models"), diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index 90e19d83..92873ccc 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -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); return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; } - // OpenAI-compatible aggregators. All standard Bearer + OpenAI shape, so the - // same /v1/models probe applies to each. + case "tokenharbor": case "dahl": case "atria": case "agnes": diff --git a/tests/__baseline__/alias-baseline.json b/tests/__baseline__/alias-baseline.json index 547f0c0e..80d3af8a 100644 --- a/tests/__baseline__/alias-baseline.json +++ b/tests/__baseline__/alias-baseline.json @@ -196,6 +196,7 @@ "siliconflow": "siliconflow", "tencent": "hunyuan", "together": "together", + "tokenharbor": "tokenharbor", "tokenrouter": "tokenrouter", "venice": "venice", "vercel-ai-gateway": "vercel-ai-gateway", @@ -299,6 +300,7 @@ "siliconflow", "stability-ai", "together", + "tokenharbor", "tokenrouter", "venice", "vertex", diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index 85a3ee85..8c12ef73 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -1105,5 +1105,13 @@ "baseUrl": "https://api.b.ai/v1/chat/completions", "validateUrl": "https://api.b.ai/v1/models", "format": "openai" + }, + "tokenharbor": { + "baseUrl": "https://tokenharbor.ai/v1/chat/completions", + "validateUrl": "https://tokenharbor.ai/v1/models", + "retry": { + "429": 2 + }, + "format": "openai" } } \ No newline at end of file diff --git a/tests/unit/tokenharbor-provider.test.js b/tests/unit/tokenharbor-provider.test.js new file mode 100644 index 00000000..2f4af4fa --- /dev/null +++ b/tests/unit/tokenharbor-provider.test.js @@ -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); + }); +});