mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
feat: add orbit provider
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
@@ -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 || {};
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user