From 7ff4ebeed119471e4b3b63246abc9a91921bf60f Mon Sep 17 00:00:00 2001 From: Nezumi-2711 Date: Fri, 17 Jul 2026 16:17:50 +0700 Subject: [PATCH] feat: add orbit provider --- CHANGELOG.md | 1 + open-sse/providers/registry/index.js | 4 +- open-sse/providers/registry/orbit-provider.js | 31 +++++++ public/providers/orbit-provider.png | Bin 0 -> 2736 bytes src/app/api/providers/[id]/test/testUtils.js | 8 ++ src/app/api/providers/validate/route.js | 8 ++ src/lib/providers/apiKeyValidation.js | 56 +++++++++++ tests/unit/orbit-provider.test.js | 87 ++++++++++++++++++ 8 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 open-sse/providers/registry/orbit-provider.js create mode 100644 public/providers/orbit-provider.png create mode 100644 src/lib/providers/apiKeyValidation.js create mode 100644 tests/unit/orbit-provider.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b0a12b8..17a06f85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # v0.5.35 (2026-07-16) ## Features +- **Orbit Provider**: add Anthropic-compatible API-key routing for Claude Opus 4.6–4.8 models - **xAI**: Grok Imagine video generation (`/v1/videos`) + CLI - **CLI tools**: Grok Build setup — writes `[model.9router]` to `~/.grok/config.toml` - **GitHub Copilot**: route Claude models through Copilot's native `/v1/messages` diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index e4cf5439..17c67b84 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -99,6 +99,7 @@ import p96 from "./xai.js"; import p97 from "./xiaomi-mimo.js"; import p98 from "./xiaomi-tokenplan.js"; import p99 from "./youcom.js"; +import p100 from "./orbit-provider.js"; export default [ p0, @@ -200,5 +201,6 @@ export default [ p96, p97, p98, - p99 + p99, + p100 ]; diff --git a/open-sse/providers/registry/orbit-provider.js b/open-sse/providers/registry/orbit-provider.js new file mode 100644 index 00000000..0c7ffbfe --- /dev/null +++ b/open-sse/providers/registry/orbit-provider.js @@ -0,0 +1,31 @@ +export default { + id: "orbit-provider", + priority: 100, + alias: "orbit", + display: { + name: "Orbit Provider", + icon: "public_dns", + color: "#8B5CF6", + textIcon: "OB", + }, + category: "apikey", + authType: "apikey", + transport: { + baseUrl: "https://api.orbit-provider.com/anthropic/v1/messages", + format: "claude", + headers: { + "anthropic-version": "2023-06-01", + }, + }, + models: [ + { id: "claude-opus-4-8", name: "Claude Opus 4.8" }, + { id: "claude-opus-4-7", name: "Claude Opus 4.7" }, + { id: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 (Thinking)" }, + ], + serviceKinds: ["llm"], + thinkingConfig: { + options: ["auto", "on", "off"], + defaultMode: "auto", + }, +}; diff --git a/public/providers/orbit-provider.png b/public/providers/orbit-provider.png new file mode 100644 index 0000000000000000000000000000000000000000..dc58142bd80af59ed94e51da6a1055d81d160646 GIT binary patch literal 2736 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 BvyuP+ literal 0 HcmV?d00001 diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index a05e6f4e..f97d03c7 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -19,6 +19,7 @@ import { KIMCHI_CONFIG, } from "@/lib/oauth/constants/oauth"; import { buildClineHeaders } from "@/shared/utils/clineAuth"; +import { validateConfiguredClaudeApiKey } from "@/lib/providers/apiKeyValidation"; // OAuth provider test endpoints const OAUTH_TEST_CONFIG = { @@ -526,6 +527,13 @@ async function testApiKeyConnection(connection, effectiveProxy = null) { } try { + const configuredClaudeResult = await validateConfiguredClaudeApiKey( + connection.provider, + connection.apiKey, + (url, options) => fetchWithConnectionProxy(url, options, effectiveProxy), + ); + if (configuredClaudeResult !== null) return configuredClaudeResult; + switch (connection.provider) { case "cloudflare-ai": { const psd = connection.providerSpecificData || {}; diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js index 0d761276..2b41844f 100644 --- a/src/app/api/providers/validate/route.js +++ b/src/app/api/providers/validate/route.js @@ -6,6 +6,7 @@ import { resolveOllamaLocalHost, resolveXiaomiTokenplanBaseUrl, PROVIDERS } from import { openaiToCommandCodeRequest } from "open-sse/translator/request/openai-to-commandcode.js"; import { normalizeProviderId } from "@/lib/providerNormalization"; import { requireProviderAdministrator } from "@/lib/providers/connectionAccess"; +import { validateConfiguredClaudeApiKey } from "@/lib/providers/apiKeyValidation"; // Probe a webSearch/webFetch provider using its searchConfig/fetchConfig. // Returns true if API key is accepted (status !== 401 && !== 403). @@ -253,6 +254,13 @@ export async function POST(request) { }); } + // Registry-backed Anthropic-compatible providers share one config-driven + // validation flow instead of requiring a hardcoded switch case. + const claudeResult = await validateConfiguredClaudeApiKey(provider, apiKey); + if (claudeResult !== null) { + return NextResponse.json(claudeResult); + } + switch (provider) { case "openai": const openaiRes = await fetch("https://api.openai.com/v1/models", { diff --git a/src/lib/providers/apiKeyValidation.js b/src/lib/providers/apiKeyValidation.js new file mode 100644 index 00000000..79a579b2 --- /dev/null +++ b/src/lib/providers/apiKeyValidation.js @@ -0,0 +1,56 @@ +import { getModelsByProviderId } from "open-sse/config/providerModels.js"; +import { PROVIDERS } from "open-sse/config/providers.js"; + +function setAuthHeader(headers, auth, apiKey) { + if (!auth?.header) return false; + headers[auth.header] = auth.scheme === "bearer" ? `Bearer ${apiKey}` : apiKey; + return true; +} + +function buildClaudeValidationHeaders(config, apiKey) { + const headers = { + "Content-Type": "application/json", + ...(config.headers || {}), + }; + const auth = config.auth; + + if (auth?.combined) { + setAuthHeader(headers, auth, apiKey); + } else if (auth?.apiKey) { + setAuthHeader(headers, auth.apiKey, apiKey); + } else if (!setAuthHeader(headers, auth, apiKey)) { + headers["x-api-key"] = apiKey; + } + + return headers; +} + +/** + * Validate an API key for a registry-backed Claude-format provider. + * A non-auth error still confirms that the upstream accepted the credential. + * + * @returns {Promise<{valid: boolean, error: string|null}|null>} null when the + * provider is not backed by a Claude transport. + */ +export async function validateConfiguredClaudeApiKey(provider, apiKey, fetchImpl = fetch) { + const config = PROVIDERS[provider]; + if (!config?.baseUrl || config.format !== "claude") return null; + const model = getModelsByProviderId(provider)?.[0]?.id; + + const response = await fetchImpl(config.baseUrl, { + method: "POST", + headers: buildClaudeValidationHeaders(config, apiKey), + body: JSON.stringify({ + model, + max_tokens: 1, + messages: [{ role: "user", content: "test" }], + }), + signal: AbortSignal.timeout(10000), + }); + const valid = response.status !== 401 && response.status !== 403; + + return { + valid, + error: valid ? null : "Invalid API key", + }; +} diff --git a/tests/unit/orbit-provider.test.js b/tests/unit/orbit-provider.test.js new file mode 100644 index 00000000..5b9f5d6f --- /dev/null +++ b/tests/unit/orbit-provider.test.js @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from "vitest"; + +import { getExecutor } from "../../open-sse/executors/index.js"; +import { PROVIDERS, PROVIDER_MODELS } from "../../open-sse/providers/index.js"; +import REGISTRY from "../../open-sse/providers/registry/index.js"; +import { validateConfiguredClaudeApiKey } from "../../src/lib/providers/apiKeyValidation.js"; + +describe("Orbit Provider", () => { + const orbit = REGISTRY.find((entry) => entry.id === "orbit-provider"); + + it("registers an Anthropic-compatible API-key provider", () => { + expect(orbit).toBeDefined(); + expect(orbit).toMatchObject({ + alias: "orbit", + category: "apikey", + authType: "apikey", + serviceKinds: ["llm"], + transport: { + baseUrl: "https://api.orbit-provider.com/anthropic/v1/messages", + format: "claude", + }, + }); + }); + + it("builds the Claude transport with default x-api-key authentication", () => { + expect(PROVIDERS["orbit-provider"]).toMatchObject({ + baseUrl: "https://api.orbit-provider.com/anthropic/v1/messages", + format: "claude", + headers: { + "anthropic-version": "2023-06-01", + }, + }); + + const executor = getExecutor("orbit-provider"); + expect(executor.buildUrl("claude-opus-4-8", true)).toBe( + "https://api.orbit-provider.com/anthropic/v1/messages", + ); + expect(executor.buildHeaders({ apiKey: "orbit-test-key" })).toMatchObject({ + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + "x-api-key": "orbit-test-key", + }); + }); + + it("exposes the configured Opus models and thinking controls", () => { + expect((PROVIDER_MODELS.orbit || []).map((model) => model.id)).toEqual([ + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", + "claude-opus-4-6-thinking", + ]); + expect(orbit.thinkingConfig).toEqual({ + options: ["auto", "on", "off"], + defaultMode: "auto", + }); + }); + + it("validates its API key against the configured Claude endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValue({ status: 400 }); + + await expect( + validateConfiguredClaudeApiKey("orbit-provider", "orbit-test-key", fetchMock), + ).resolves.toEqual({ valid: true, error: null }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.orbit-provider.com/anthropic/v1/messages", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "x-api-key": "orbit-test-key", + "anthropic-version": "2023-06-01", + }), + }), + ); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ + model: "claude-opus-4-8", + max_tokens: 1, + }); + }); + + it.each([401, 403])("rejects authentication status %i", async (status) => { + const fetchMock = vi.fn().mockResolvedValue({ status }); + + await expect( + validateConfiguredClaudeApiKey("orbit-provider", "bad-key", fetchMock), + ).resolves.toEqual({ valid: false, error: "Invalid API key" }); + }); +});