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)
|
# v0.5.35 (2026-07-16)
|
||||||
|
|
||||||
## Features
|
## 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
|
- **xAI**: Grok Imagine video generation (`/v1/videos`) + CLI
|
||||||
- **CLI tools**: Grok Build setup — writes `[model.9router]` to `~/.grok/config.toml`
|
- **CLI tools**: Grok Build setup — writes `[model.9router]` to `~/.grok/config.toml`
|
||||||
- **GitHub Copilot**: route Claude models through Copilot's native `/v1/messages`
|
- **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 p97 from "./xiaomi-mimo.js";
|
||||||
import p98 from "./xiaomi-tokenplan.js";
|
import p98 from "./xiaomi-tokenplan.js";
|
||||||
import p99 from "./youcom.js";
|
import p99 from "./youcom.js";
|
||||||
|
import p100 from "./orbit-provider.js";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
p0,
|
p0,
|
||||||
@@ -200,5 +201,6 @@ export default [
|
|||||||
p96,
|
p96,
|
||||||
p97,
|
p97,
|
||||||
p98,
|
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,
|
KIMCHI_CONFIG,
|
||||||
} from "@/lib/oauth/constants/oauth";
|
} from "@/lib/oauth/constants/oauth";
|
||||||
import { buildClineHeaders } from "@/shared/utils/clineAuth";
|
import { buildClineHeaders } from "@/shared/utils/clineAuth";
|
||||||
|
import { validateConfiguredClaudeApiKey } from "@/lib/providers/apiKeyValidation";
|
||||||
|
|
||||||
// OAuth provider test endpoints
|
// OAuth provider test endpoints
|
||||||
const OAUTH_TEST_CONFIG = {
|
const OAUTH_TEST_CONFIG = {
|
||||||
@@ -526,6 +527,13 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const configuredClaudeResult = await validateConfiguredClaudeApiKey(
|
||||||
|
connection.provider,
|
||||||
|
connection.apiKey,
|
||||||
|
(url, options) => fetchWithConnectionProxy(url, options, effectiveProxy),
|
||||||
|
);
|
||||||
|
if (configuredClaudeResult !== null) return configuredClaudeResult;
|
||||||
|
|
||||||
switch (connection.provider) {
|
switch (connection.provider) {
|
||||||
case "cloudflare-ai": {
|
case "cloudflare-ai": {
|
||||||
const psd = connection.providerSpecificData || {};
|
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 { openaiToCommandCodeRequest } from "open-sse/translator/request/openai-to-commandcode.js";
|
||||||
import { normalizeProviderId } from "@/lib/providerNormalization";
|
import { normalizeProviderId } from "@/lib/providerNormalization";
|
||||||
import { requireProviderAdministrator } from "@/lib/providers/connectionAccess";
|
import { requireProviderAdministrator } from "@/lib/providers/connectionAccess";
|
||||||
|
import { validateConfiguredClaudeApiKey } from "@/lib/providers/apiKeyValidation";
|
||||||
|
|
||||||
// Probe a webSearch/webFetch provider using its searchConfig/fetchConfig.
|
// Probe a webSearch/webFetch provider using its searchConfig/fetchConfig.
|
||||||
// Returns true if API key is accepted (status !== 401 && !== 403).
|
// 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) {
|
switch (provider) {
|
||||||
case "openai":
|
case "openai":
|
||||||
const openaiRes = await fetch("https://api.openai.com/v1/models", {
|
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