From 737b1f4d0a461f3f7935fe579cc0f0a8354af259 Mon Sep 17 00:00:00 2001 From: Nikan Wystaf Date: Sat, 26 Sep 2026 00:27:12 +0330 Subject: [PATCH] feat(providers): add Token Harbor provider --- open-sse/providers/registry/index.js | 2 + open-sse/providers/registry/tokenharbor.js | 49 ++++++++++++ public/providers/tokenharbor.png | Bin 0 -> 4964 bytes src/app/api/providers/[id]/models/route.js | 5 +- src/app/api/providers/[id]/test/testUtils.js | 3 +- tests/__baseline__/alias-baseline.json | 2 + tests/__baseline__/providers-baseline.json | 8 ++ tests/unit/tokenharbor-provider.test.js | 79 +++++++++++++++++++ 8 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 open-sse/providers/registry/tokenharbor.js create mode 100644 public/providers/tokenharbor.png create mode 100644 tests/unit/tokenharbor-provider.test.js 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 0000000000000000000000000000000000000000..7a766a646dd2f9146e278e078d4363cf8d32a1a0 GIT binary patch literal 4964 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 literal 0 HcmV?d00001 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); + }); +});