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 00000000..dc58142b Binary files /dev/null and b/public/providers/orbit-provider.png differ 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" }); + }); +});