mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
@@ -9,6 +9,7 @@
|
||||
*
|
||||
* Header order and body field order were captured via mitmproxy traffic analysis.
|
||||
*/
|
||||
import { isClaudeCodeCompatible } from "../services/provider.ts";
|
||||
|
||||
export interface CliFingerprint {
|
||||
/** Ordered list of header names (case-sensitive). Unlisted headers are appended. */
|
||||
@@ -75,6 +76,43 @@ export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
|
||||
],
|
||||
userAgent: "claude-code",
|
||||
},
|
||||
"claude-code-compatible": {
|
||||
headerOrder: [
|
||||
"Host",
|
||||
"Content-Type",
|
||||
"x-api-key",
|
||||
"anthropic-version",
|
||||
"anthropic-beta",
|
||||
"anthropic-dangerous-direct-browser-access",
|
||||
"x-app",
|
||||
"User-Agent",
|
||||
"X-Claude-Code-Session-Id",
|
||||
"X-Stainless-Retry-Count",
|
||||
"X-Stainless-Timeout",
|
||||
"X-Stainless-Lang",
|
||||
"X-Stainless-Package-Version",
|
||||
"X-Stainless-OS",
|
||||
"X-Stainless-Arch",
|
||||
"X-Stainless-Runtime",
|
||||
"X-Stainless-Runtime-Version",
|
||||
"Accept",
|
||||
"accept-language",
|
||||
"sec-fetch-mode",
|
||||
"accept-encoding",
|
||||
],
|
||||
bodyFieldOrder: [
|
||||
"model",
|
||||
"messages",
|
||||
"system",
|
||||
"tools",
|
||||
"metadata",
|
||||
"max_tokens",
|
||||
"thinking",
|
||||
"context_management",
|
||||
"output_config",
|
||||
"stream",
|
||||
],
|
||||
},
|
||||
github: {
|
||||
headerOrder: [
|
||||
"Host",
|
||||
@@ -243,7 +281,10 @@ export function applyFingerprint(
|
||||
headers: Record<string, string>,
|
||||
body: unknown
|
||||
): { headers: Record<string, string>; bodyString: string } {
|
||||
const fingerprint = CLI_FINGERPRINTS[provider?.toLowerCase()];
|
||||
const fingerprintKey = isClaudeCodeCompatible(provider)
|
||||
? "claude-code-compatible"
|
||||
: provider?.toLowerCase();
|
||||
const fingerprint = CLI_FINGERPRINTS[fingerprintKey];
|
||||
|
||||
if (!fingerprint) {
|
||||
return { headers, bodyString: JSON.stringify(body) };
|
||||
@@ -300,6 +341,8 @@ export function getCliCompatProviders(): string[] {
|
||||
* Reads from: 1) Runtime cache (Settings UI), 2) Environment variables.
|
||||
*/
|
||||
export function isCliCompatEnabled(provider: string): boolean {
|
||||
if (isClaudeCodeCompatible(provider)) return true;
|
||||
|
||||
const key = provider?.toLowerCase().replace(/[^a-z0-9]/g, "_");
|
||||
|
||||
// 1. Check runtime cache (set via Settings UI)
|
||||
|
||||
@@ -2,6 +2,12 @@ import { BaseExecutor } from "./base.ts";
|
||||
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts";
|
||||
import { getAccessToken } from "../services/tokenRefresh.ts";
|
||||
import { getRotatingApiKey } from "../services/apiKeyRotator.ts";
|
||||
import {
|
||||
buildClaudeCodeCompatibleHeaders,
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
joinBaseUrlAndPath,
|
||||
} from "../services/claudeCodeCompatible.ts";
|
||||
import { isClaudeCodeCompatible } from "../services/provider.ts";
|
||||
|
||||
export class DefaultExecutor extends BaseExecutor {
|
||||
constructor(provider) {
|
||||
@@ -9,6 +15,9 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
||||
void model;
|
||||
void stream;
|
||||
void urlIndex;
|
||||
if (this.provider?.startsWith?.("openai-compatible-")) {
|
||||
const psd = credentials?.providerSpecificData;
|
||||
const baseUrl = psd?.baseUrl || "https://api.openai.com/v1";
|
||||
@@ -21,8 +30,11 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
if (this.provider?.startsWith?.("anthropic-compatible-")) {
|
||||
const psd = credentials?.providerSpecificData;
|
||||
const baseUrl = psd?.baseUrl || "https://api.anthropic.com/v1";
|
||||
const normalized = baseUrl.replace(/\/$/, "");
|
||||
const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
|
||||
if (isClaudeCodeCompatible(this.provider)) {
|
||||
return joinBaseUrlAndPath(baseUrl, customPath || CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH);
|
||||
}
|
||||
const normalized = baseUrl.replace(/\/$/, "");
|
||||
return `${normalized}${customPath || "/messages"}`;
|
||||
}
|
||||
switch (this.provider) {
|
||||
@@ -70,6 +82,13 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
headers["x-api-key"] = effectiveKey || credentials.accessToken;
|
||||
break;
|
||||
default:
|
||||
if (isClaudeCodeCompatible(this.provider)) {
|
||||
return buildClaudeCodeCompatibleHeaders(
|
||||
effectiveKey || credentials.accessToken || "",
|
||||
stream,
|
||||
credentials?.providerSpecificData?.ccSessionId
|
||||
);
|
||||
}
|
||||
if (this.provider?.startsWith?.("anthropic-compatible-")) {
|
||||
if (effectiveKey) {
|
||||
headers["x-api-key"] = effectiveKey;
|
||||
|
||||
@@ -92,6 +92,11 @@ import { generateRequestId } from "@/shared/utils/requestId";
|
||||
import { normalizePayloadForLog } from "@/lib/logPayloads";
|
||||
import { injectMemory, shouldInjectMemory } from "@/lib/memory/injection";
|
||||
import { retrieveMemories } from "@/lib/memory/retrieval";
|
||||
import {
|
||||
buildClaudeCodeCompatibleRequest,
|
||||
isClaudeCodeCompatibleProvider,
|
||||
resolveClaudeCodeCompatibleSessionId,
|
||||
} from "../services/claudeCodeCompatible.ts";
|
||||
|
||||
export function shouldUseNativeCodexPassthrough({
|
||||
provider,
|
||||
@@ -705,6 +710,8 @@ export async function handleChatCore({
|
||||
// Translate request (pass reqLogger for intermediate logging)
|
||||
let translatedBody = body;
|
||||
const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE;
|
||||
const isClaudeCodeCompatible = isClaudeCodeCompatibleProvider(provider);
|
||||
let ccSessionId: string | null = null;
|
||||
|
||||
// Determine if we should preserve client-side cache_control headers
|
||||
// Fetch settings from DB to get user preference
|
||||
@@ -729,6 +736,46 @@ export async function handleChatCore({
|
||||
if (nativeCodexPassthrough) {
|
||||
translatedBody = { ...body, _nativeCodexPassthrough: true };
|
||||
log?.debug?.("FORMAT", "native codex passthrough enabled");
|
||||
} else if (isClaudeCodeCompatible) {
|
||||
let normalizedForCc = { ...body };
|
||||
|
||||
// Claude Code-compatible providers expect Anthropic Messages-shaped payloads,
|
||||
// but we extract only role/text/max_tokens/effort from an OpenAI-like view first.
|
||||
if (sourceFormat !== FORMATS.OPENAI) {
|
||||
const normalizeToolCallId = getModelNormalizeToolCallId(
|
||||
provider || "",
|
||||
model || "",
|
||||
sourceFormat
|
||||
);
|
||||
const preserveDeveloperRole = getModelPreserveOpenAIDeveloperRole(
|
||||
provider || "",
|
||||
model || "",
|
||||
sourceFormat
|
||||
);
|
||||
normalizedForCc = translateRequest(
|
||||
sourceFormat,
|
||||
FORMATS.OPENAI,
|
||||
model,
|
||||
{ ...body },
|
||||
stream,
|
||||
credentials,
|
||||
provider,
|
||||
reqLogger,
|
||||
{ normalizeToolCallId, preserveDeveloperRole, preserveCacheControl }
|
||||
);
|
||||
}
|
||||
|
||||
ccSessionId = resolveClaudeCodeCompatibleSessionId(clientRawRequest?.headers);
|
||||
translatedBody = buildClaudeCodeCompatibleRequest({
|
||||
sourceBody: body,
|
||||
normalizedBody: normalizedForCc,
|
||||
model,
|
||||
stream,
|
||||
sessionId: ccSessionId,
|
||||
cwd: process.cwd(),
|
||||
now: new Date(),
|
||||
});
|
||||
log?.debug?.("FORMAT", "claude-code-compatible bridge enabled");
|
||||
} else if (isClaudePassthrough && preserveCacheControl) {
|
||||
// Pure passthrough: when preserveCacheControl is true, forward the body
|
||||
// as-is without prior normalization. The OpenAI round-trip would strip
|
||||
@@ -965,8 +1012,21 @@ export async function handleChatCore({
|
||||
|
||||
// Get executor for this provider
|
||||
const executor = getExecutor(provider);
|
||||
const getExecutionCredentials = () =>
|
||||
nativeCodexPassthrough ? { ...credentials, requestEndpointPath: endpointPath } : credentials;
|
||||
const getExecutionCredentials = () => {
|
||||
const nextCredentials = nativeCodexPassthrough
|
||||
? { ...credentials, requestEndpointPath: endpointPath }
|
||||
: credentials;
|
||||
|
||||
if (!ccSessionId) return nextCredentials;
|
||||
|
||||
return {
|
||||
...nextCredentials,
|
||||
providerSpecificData: {
|
||||
...(nextCredentials?.providerSpecificData || {}),
|
||||
ccSessionId,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Create stream controller for disconnect detection
|
||||
const streamController = createStreamController({ onDisconnect, log, provider, model });
|
||||
|
||||
485
open-sse/services/claudeCodeCompatible.ts
Normal file
485
open-sse/services/claudeCodeCompatible.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
export const CLAUDE_CODE_COMPATIBLE_PREFIX = "anthropic-compatible-cc-";
|
||||
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH = "/messages?beta=true";
|
||||
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH = "/models";
|
||||
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS = 8092;
|
||||
export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION = "2023-06-01";
|
||||
export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA =
|
||||
"claude-code-20250219,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24";
|
||||
export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.89 (external, sdk-cli)";
|
||||
export const CLAUDE_CODE_COMPATIBLE_BILLING_HEADER =
|
||||
"x-anthropic-billing-header: cc_version=2.1.89.728; cc_entrypoint=sdk-cli; cch=00000;";
|
||||
|
||||
type HeaderLike =
|
||||
| Headers
|
||||
| Record<string, string | undefined>
|
||||
| { get?: (name: string) => string | null }
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
type MessageLike = {
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
type BuildRequestOptions = {
|
||||
sourceBody?: Record<string, unknown> | null;
|
||||
normalizedBody?: Record<string, unknown> | null;
|
||||
model: string;
|
||||
stream?: boolean;
|
||||
cwd?: string;
|
||||
now?: Date;
|
||||
sessionId?: string | null;
|
||||
};
|
||||
|
||||
export function isClaudeCodeCompatibleProvider(provider: string | null | undefined): boolean {
|
||||
return typeof provider === "string" && provider.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX);
|
||||
}
|
||||
|
||||
export function stripAnthropicMessagesSuffix(baseUrl: string | null | undefined): string {
|
||||
const normalized = String(baseUrl || "")
|
||||
.trim()
|
||||
.replace(/\/$/, "");
|
||||
if (!normalized) return "";
|
||||
return normalized.replace(/\/messages(?:\?[^#]*)?$/i, "");
|
||||
}
|
||||
|
||||
export function joinBaseUrlAndPath(baseUrl: string, path: string): string {
|
||||
const normalizedBase = stripAnthropicMessagesSuffix(baseUrl).replace(/\/$/, "");
|
||||
const normalizedPath = String(path || "").startsWith("/")
|
||||
? String(path)
|
||||
: `/${String(path || "")}`;
|
||||
return `${normalizedBase}${normalizedPath}`;
|
||||
}
|
||||
|
||||
export function buildClaudeCodeCompatibleHeaders(
|
||||
apiKey: string,
|
||||
stream = false,
|
||||
sessionId?: string | null
|
||||
): Record<string, string> {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Accept: stream ? "text/event-stream" : "application/json",
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION,
|
||||
"anthropic-beta": CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA,
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
"x-app": "cli",
|
||||
"User-Agent": CLAUDE_CODE_COMPATIBLE_USER_AGENT,
|
||||
"X-Stainless-Retry-Count": "0",
|
||||
"X-Stainless-Timeout": "300",
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-Package-Version": "0.74.0",
|
||||
"X-Stainless-OS": "MacOS",
|
||||
"X-Stainless-Arch": "arm64",
|
||||
"X-Stainless-Runtime": "node",
|
||||
"X-Stainless-Runtime-Version": "v25.8.1",
|
||||
"accept-language": "*",
|
||||
"sec-fetch-mode": "cors",
|
||||
"accept-encoding": "identity",
|
||||
...(sessionId ? { "X-Claude-Code-Session-Id": sessionId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildClaudeCodeCompatibleValidationPayload(model = "claude-sonnet-4-6") {
|
||||
const sessionId = randomUUID();
|
||||
return buildClaudeCodeCompatibleRequest({
|
||||
sourceBody: { max_tokens: 1 },
|
||||
normalizedBody: {
|
||||
messages: [{ role: "user", content: "ok" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
model,
|
||||
stream: false,
|
||||
sessionId,
|
||||
cwd: process.cwd(),
|
||||
now: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveClaudeCodeCompatibleSessionId(headers?: HeaderLike): string {
|
||||
const raw =
|
||||
getHeader(headers, "x-claude-code-session-id") ||
|
||||
getHeader(headers, "x-session-id") ||
|
||||
getHeader(headers, "x_session_id") ||
|
||||
getHeader(headers, "x-omniroute-session") ||
|
||||
null;
|
||||
|
||||
return (raw && raw.trim()) || randomUUID();
|
||||
}
|
||||
|
||||
export function buildClaudeCodeCompatibleRequest({
|
||||
sourceBody,
|
||||
normalizedBody,
|
||||
model,
|
||||
stream = false,
|
||||
cwd = process.cwd(),
|
||||
now = new Date(),
|
||||
sessionId,
|
||||
}: BuildRequestOptions) {
|
||||
const normalized = normalizedBody || {};
|
||||
const messages = Array.isArray(normalized.messages)
|
||||
? buildClaudeCodeCompatibleMessages(normalized.messages as MessageLike[])
|
||||
: [];
|
||||
const system = buildClaudeCodeCompatibleSystemBlocks(
|
||||
normalized.messages as MessageLike[],
|
||||
cwd,
|
||||
now
|
||||
);
|
||||
const resolvedSessionId = sessionId || randomUUID();
|
||||
const effort = resolveClaudeCodeCompatibleEffort(sourceBody, normalizedBody, model);
|
||||
const maxTokens = resolveClaudeCodeCompatibleMaxTokens(sourceBody, normalizedBody);
|
||||
const tools = buildClaudeCodeCompatibleTools(normalizedBody, sourceBody);
|
||||
const toolChoice =
|
||||
tools.length > 0
|
||||
? buildClaudeCodeCompatibleToolChoice(
|
||||
normalizedBody?.["tool_choice"] ?? sourceBody?.["tool_choice"]
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
model,
|
||||
messages,
|
||||
system,
|
||||
tools,
|
||||
metadata: {
|
||||
user_id: JSON.stringify({
|
||||
device_id: createHash("sha256")
|
||||
.update(String(cwd || ""))
|
||||
.digest("hex")
|
||||
.slice(0, 24),
|
||||
account_uuid: "",
|
||||
session_id: resolvedSessionId,
|
||||
}),
|
||||
},
|
||||
max_tokens: maxTokens,
|
||||
thinking: {
|
||||
type: "adaptive",
|
||||
},
|
||||
context_management: {
|
||||
edits: [
|
||||
{
|
||||
type: "clear_thinking_20251015",
|
||||
keep: "all",
|
||||
},
|
||||
],
|
||||
},
|
||||
output_config: {
|
||||
effort,
|
||||
},
|
||||
...(toolChoice ? { tool_choice: toolChoice } : {}),
|
||||
...(stream ? { stream: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveClaudeCodeCompatibleEffort(
|
||||
sourceBody?: Record<string, unknown> | null,
|
||||
normalizedBody?: Record<string, unknown> | null,
|
||||
model?: string | null
|
||||
): "low" | "medium" | "high" {
|
||||
const raw =
|
||||
readNestedString(sourceBody, ["output_config", "effort"]) ||
|
||||
readNestedString(sourceBody, ["reasoning", "effort"]) ||
|
||||
toNonEmptyString(sourceBody?.["reasoning_effort"]) ||
|
||||
readNestedString(normalizedBody, ["output_config", "effort"]) ||
|
||||
readNestedString(normalizedBody, ["reasoning", "effort"]) ||
|
||||
toNonEmptyString(normalizedBody?.["reasoning_effort"]) ||
|
||||
"";
|
||||
|
||||
const normalizedEffort = raw.toLowerCase();
|
||||
void model;
|
||||
|
||||
if (!normalizedEffort) return "high";
|
||||
if (normalizedEffort === "low") return "low";
|
||||
if (normalizedEffort === "medium") return "medium";
|
||||
if (normalizedEffort === "high") return "high";
|
||||
if (normalizedEffort === "none" || normalizedEffort === "disabled") return "low";
|
||||
if (normalizedEffort === "max" || normalizedEffort === "xhigh") {
|
||||
return "high";
|
||||
}
|
||||
return "high";
|
||||
}
|
||||
|
||||
export function resolveClaudeCodeCompatibleMaxTokens(
|
||||
sourceBody?: Record<string, unknown> | null,
|
||||
normalizedBody?: Record<string, unknown> | null
|
||||
): number {
|
||||
const candidates = [
|
||||
sourceBody?.["max_tokens"],
|
||||
sourceBody?.["max_completion_tokens"],
|
||||
sourceBody?.["max_output_tokens"],
|
||||
normalizedBody?.["max_tokens"],
|
||||
normalizedBody?.["max_completion_tokens"],
|
||||
normalizedBody?.["max_output_tokens"],
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const value = Number(candidate);
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
}
|
||||
|
||||
return CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS;
|
||||
}
|
||||
|
||||
function buildClaudeCodeCompatibleMessages(messages: MessageLike[]) {
|
||||
const converted = messages
|
||||
.map((message) => convertClaudeCodeCompatibleMessage(message))
|
||||
.filter(
|
||||
(
|
||||
message
|
||||
): message is { role: "user" | "assistant"; content: Array<Record<string, unknown>> } =>
|
||||
!!message && message.content.length > 0
|
||||
);
|
||||
|
||||
const merged: Array<{ role: "user" | "assistant"; content: Array<Record<string, unknown>> }> = [];
|
||||
|
||||
for (const message of converted) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && last.role === message.role) {
|
||||
last.content.push(...message.content);
|
||||
continue;
|
||||
}
|
||||
merged.push({ role: message.role, content: [...message.content] });
|
||||
}
|
||||
|
||||
for (let i = merged.length - 1; i >= 0; i--) {
|
||||
if (merged[i].role !== "user") continue;
|
||||
const lastBlock = merged[i].content[merged[i].content.length - 1];
|
||||
if (lastBlock) {
|
||||
lastBlock.cache_control = { type: "ephemeral" };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function buildClaudeCodeCompatibleSystemBlocks(
|
||||
messages: MessageLike[] | undefined,
|
||||
cwd: string,
|
||||
now: Date
|
||||
) {
|
||||
const customSystemBlocks = Array.isArray(messages)
|
||||
? messages
|
||||
.filter((message) => {
|
||||
const role = String(message?.role || "").toLowerCase();
|
||||
return role === "system" || role === "developer";
|
||||
})
|
||||
.map((message) => contentToText(message?.content))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const dateText = formatDate(now);
|
||||
const blocks: Array<Record<string, unknown>> = [
|
||||
{
|
||||
type: "text",
|
||||
text: CLAUDE_CODE_COMPATIBLE_BILLING_HEADER,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `You are Claude Code, Anthropic's official CLI for Claude.\n\nCWD: ${cwd}\nDate: ${dateText}`,
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
];
|
||||
|
||||
for (const systemText of customSystemBlocks) {
|
||||
blocks.push({
|
||||
type: "text",
|
||||
text: systemText,
|
||||
cache_control: { type: "ephemeral" },
|
||||
});
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function convertClaudeCodeCompatibleMessage(message: MessageLike | null | undefined) {
|
||||
const rawRole = String(message?.role || "").toLowerCase();
|
||||
const role =
|
||||
rawRole === "user"
|
||||
? "user"
|
||||
: rawRole === "assistant" || rawRole === "model"
|
||||
? "assistant"
|
||||
: null;
|
||||
|
||||
if (!role) return null;
|
||||
|
||||
const text = contentToText(message?.content);
|
||||
if (!text) return null;
|
||||
|
||||
return {
|
||||
role,
|
||||
content: [{ type: "text", text }],
|
||||
};
|
||||
}
|
||||
|
||||
function buildClaudeCodeCompatibleTools(
|
||||
normalizedBody?: Record<string, unknown> | null,
|
||||
sourceBody?: Record<string, unknown> | null
|
||||
) {
|
||||
const rawTools = Array.isArray(normalizedBody?.["tools"])
|
||||
? normalizedBody?.["tools"]
|
||||
: Array.isArray(sourceBody?.["tools"])
|
||||
? sourceBody?.["tools"]
|
||||
: [];
|
||||
|
||||
return rawTools
|
||||
.map((tool) => convertClaudeCodeCompatibleTool(tool))
|
||||
.filter((tool): tool is Record<string, unknown> => !!tool);
|
||||
}
|
||||
|
||||
function convertClaudeCodeCompatibleTool(tool: unknown) {
|
||||
const rawTool = readRecord(tool);
|
||||
if (!rawTool) return null;
|
||||
|
||||
const toolData =
|
||||
rawTool.type === "function" ? readRecord(rawTool.function) || rawTool : rawTool;
|
||||
|
||||
const name = toNonEmptyString(toolData.name);
|
||||
if (!name) return null;
|
||||
|
||||
const rawSchema =
|
||||
readRecord(toolData.parameters) ||
|
||||
readRecord(toolData.input_schema) || { type: "object", properties: {}, required: [] };
|
||||
const inputSchema =
|
||||
rawSchema.type === "object" && !readRecord(rawSchema.properties)
|
||||
? { ...rawSchema, properties: {} }
|
||||
: rawSchema;
|
||||
|
||||
const converted: Record<string, unknown> = {
|
||||
name,
|
||||
description: toNonEmptyString(toolData.description) || "",
|
||||
input_schema: inputSchema,
|
||||
};
|
||||
|
||||
if (typeof toolData.defer_loading === "boolean") {
|
||||
converted.defer_loading = toolData.defer_loading;
|
||||
}
|
||||
|
||||
return converted;
|
||||
}
|
||||
|
||||
function buildClaudeCodeCompatibleToolChoice(choice: unknown) {
|
||||
if (!choice) return null;
|
||||
|
||||
if (typeof choice === "string") {
|
||||
if (choice === "required") return { type: "any" };
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawChoice = readRecord(choice);
|
||||
if (!rawChoice) return null;
|
||||
|
||||
if (rawChoice.type === "tool") {
|
||||
const name = toNonEmptyString(rawChoice.name);
|
||||
return name ? { type: "tool", name } : null;
|
||||
}
|
||||
|
||||
if (rawChoice.type === "function") {
|
||||
const functionName =
|
||||
toNonEmptyString(readRecord(rawChoice.function)?.name) || toNonEmptyString(rawChoice.name);
|
||||
return functionName ? { type: "tool", name: functionName } : null;
|
||||
}
|
||||
|
||||
if (rawChoice.type === "required" || rawChoice.type === "any") {
|
||||
return { type: "any" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function contentToText(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part) => {
|
||||
if (!part || typeof part !== "object") return "";
|
||||
const record = part as Record<string, unknown>;
|
||||
if (record.type === "text" && typeof record.text === "string") {
|
||||
return record.text.trim();
|
||||
}
|
||||
if (typeof record.text === "string") {
|
||||
return record.text.trim();
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
if (content && typeof content === "object") {
|
||||
const record = content as Record<string, unknown>;
|
||||
if (typeof record.text === "string") return record.text.trim();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function getHeader(headers: HeaderLike, name: string): string | null {
|
||||
if (!headers) return null;
|
||||
|
||||
if (typeof (headers as Headers).get === "function") {
|
||||
return (headers as Headers).get(name);
|
||||
}
|
||||
|
||||
const record = headers as Record<string, string | undefined>;
|
||||
const target = name.toLowerCase();
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (key.toLowerCase() === target) {
|
||||
return value ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
const formatter = new Intl.DateTimeFormat("en-CA", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(date);
|
||||
const year = parts.find((part) => part.type === "year")?.value || "1970";
|
||||
const month = parts.find((part) => part.type === "month")?.value || "01";
|
||||
const day = parts.find((part) => part.type === "day")?.value || "01";
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function toNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readNestedString(
|
||||
source: Record<string, unknown> | null | undefined,
|
||||
path: string[]
|
||||
): string | null {
|
||||
let current: unknown = source;
|
||||
for (const key of path) {
|
||||
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
||||
return null;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return toNonEmptyString(current);
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
import { getRegistryEntry } from "../config/providerRegistry.ts";
|
||||
import {
|
||||
buildClaudeCodeCompatibleHeaders,
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
joinBaseUrlAndPath,
|
||||
stripAnthropicMessagesSuffix,
|
||||
} from "./claudeCodeCompatible.ts";
|
||||
|
||||
const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
|
||||
const OPENAI_COMPATIBLE_DEFAULTS = {
|
||||
@@ -7,6 +13,7 @@ const OPENAI_COMPATIBLE_DEFAULTS = {
|
||||
};
|
||||
|
||||
const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-";
|
||||
const CLAUDE_CODE_COMPATIBLE_PREFIX = "anthropic-compatible-cc-";
|
||||
const ANTHROPIC_COMPATIBLE_DEFAULTS = {
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
};
|
||||
@@ -19,6 +26,10 @@ function isAnthropicCompatible(provider) {
|
||||
return typeof provider === "string" && provider.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
|
||||
}
|
||||
|
||||
export function isClaudeCodeCompatible(provider) {
|
||||
return typeof provider === "string" && provider.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX);
|
||||
}
|
||||
|
||||
function getOpenAICompatibleType(provider) {
|
||||
if (!isOpenAICompatible(provider)) return "chat";
|
||||
return provider.includes("responses") ? "responses" : "chat";
|
||||
@@ -49,7 +60,10 @@ export function detectFormatFromEndpoint(body, endpointPath = "") {
|
||||
return "claude";
|
||||
}
|
||||
|
||||
if (/\/(?:chat\/completions|completions)(?=\/|$)/i.test(path) || /^(?:chat\/completions|completions)(?=\/|$)/i.test(path)) {
|
||||
if (
|
||||
/\/(?:chat\/completions|completions)(?=\/|$)/i.test(path) ||
|
||||
/^(?:chat\/completions|completions)(?=\/|$)/i.test(path)
|
||||
) {
|
||||
return "openai";
|
||||
}
|
||||
|
||||
@@ -190,6 +204,9 @@ export function buildProviderUrl(
|
||||
}
|
||||
if (isAnthropicCompatible(provider)) {
|
||||
const baseUrl = options?.baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl;
|
||||
if (isClaudeCodeCompatible(provider)) {
|
||||
return joinBaseUrlAndPath(baseUrl, CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH);
|
||||
}
|
||||
return buildAnthropicCompatibleUrl(baseUrl);
|
||||
}
|
||||
|
||||
@@ -220,6 +237,7 @@ export function buildProviderUrl(
|
||||
|
||||
// Build provider headers
|
||||
export function buildProviderHeaders(provider, credentials, stream = true, body = null) {
|
||||
void body;
|
||||
const config = getProviderConfig(provider);
|
||||
const entry = getRegistryEntry(provider);
|
||||
const headers = {
|
||||
@@ -229,6 +247,14 @@ export function buildProviderHeaders(provider, credentials, stream = true, body
|
||||
|
||||
// Add auth header
|
||||
// Specific override for Anthropic Compatible
|
||||
if (isClaudeCodeCompatible(provider)) {
|
||||
const token = credentials.apiKey || credentials.accessToken || "";
|
||||
return buildClaudeCodeCompatibleHeaders(
|
||||
token,
|
||||
stream,
|
||||
credentials?.providerSpecificData?.ccSessionId
|
||||
);
|
||||
}
|
||||
if (isAnthropicCompatible(provider)) {
|
||||
if (credentials.apiKey) {
|
||||
headers["x-api-key"] = credentials.apiKey;
|
||||
|
||||
@@ -111,6 +111,21 @@ export function normalizeDeveloperRole(
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeModelRole(
|
||||
messages: NormalizedMessage[] | unknown
|
||||
): NormalizedMessage[] | unknown {
|
||||
if (!Array.isArray(messages)) return messages;
|
||||
|
||||
return messages.map((msg: NormalizedMessage) => {
|
||||
if (!msg || typeof msg !== "object") return msg;
|
||||
const role = typeof msg.role === "string" ? msg.role : "";
|
||||
if (role.toLowerCase() === "model") {
|
||||
return { ...msg, role: "assistant" };
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert `system` messages to user messages for providers that don't support
|
||||
* the system role. The system content is prepended to the first user message
|
||||
@@ -196,7 +211,8 @@ export function normalizeRoles(
|
||||
): NormalizedMessage[] | unknown {
|
||||
if (!Array.isArray(messages)) return messages;
|
||||
|
||||
let result = normalizeDeveloperRole(messages, targetFormat, preserveDeveloperRole);
|
||||
let result = normalizeModelRole(messages);
|
||||
result = normalizeDeveloperRole(result, targetFormat, preserveDeveloperRole);
|
||||
result = normalizeSystemRole(result, provider, model);
|
||||
|
||||
return result;
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
getProviderAlias,
|
||||
isOpenAICompatibleProvider,
|
||||
isAnthropicCompatibleProvider,
|
||||
isClaudeCodeCompatibleProvider,
|
||||
} from "@/shared/constants/providers";
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
@@ -420,6 +421,7 @@ interface AddApiKeyModalProps {
|
||||
providerName?: string;
|
||||
isCompatible?: boolean;
|
||||
isAnthropic?: boolean;
|
||||
isCcCompatible?: boolean;
|
||||
onSave: (data: {
|
||||
name: string;
|
||||
apiKey: string;
|
||||
@@ -463,8 +465,14 @@ interface EditCompatibleNodeModalProps {
|
||||
onSave: (data: unknown) => Promise<void>;
|
||||
onClose: () => void;
|
||||
isAnthropic?: boolean;
|
||||
isCcCompatible?: boolean;
|
||||
}
|
||||
|
||||
const CC_COMPATIBLE_LABEL = "CC Compatible";
|
||||
const CC_COMPATIBLE_DETAILS_TITLE = "CC Compatible Details";
|
||||
const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/messages?beta=true";
|
||||
const CC_COMPATIBLE_DEFAULT_MODELS_PATH = "/models";
|
||||
|
||||
function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly: boolean } {
|
||||
const record =
|
||||
policy && typeof policy === "object" && !Array.isArray(policy)
|
||||
@@ -830,17 +838,33 @@ export default function ProviderDetailPage() {
|
||||
const [compatSavingModelId, setCompatSavingModelId] = useState<string | null>(null);
|
||||
const [applyingCodexAuthId, setApplyingCodexAuthId] = useState<string | null>(null);
|
||||
const [exportingCodexAuthId, setExportingCodexAuthId] = useState<string | null>(null);
|
||||
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
|
||||
const isCcCompatible = isClaudeCodeCompatibleProvider(providerId);
|
||||
const isAnthropicCompatible =
|
||||
isAnthropicCompatibleProvider(providerId) && !isClaudeCodeCompatibleProvider(providerId);
|
||||
const isCompatible = isOpenAICompatible || isAnthropicCompatible || isCcCompatible;
|
||||
const isAnthropicProtocolCompatible = isAnthropicCompatible || isCcCompatible;
|
||||
|
||||
const providerInfo = providerNode
|
||||
? {
|
||||
id: providerNode.id,
|
||||
name:
|
||||
providerNode.name ||
|
||||
(providerNode.type === "anthropic-compatible"
|
||||
? t("anthropicCompatibleName")
|
||||
: t("openaiCompatibleName")),
|
||||
color: providerNode.type === "anthropic-compatible" ? "#D97757" : "#10A37F",
|
||||
textIcon: providerNode.type === "anthropic-compatible" ? "AC" : "OC",
|
||||
(isCcCompatible
|
||||
? CC_COMPATIBLE_LABEL
|
||||
: providerNode.type === "anthropic-compatible"
|
||||
? t("anthropicCompatibleName")
|
||||
: t("openaiCompatibleName")),
|
||||
color: isCcCompatible
|
||||
? "#B45309"
|
||||
: providerNode.type === "anthropic-compatible"
|
||||
? "#D97757"
|
||||
: "#10A37F",
|
||||
textIcon: isCcCompatible
|
||||
? "CC"
|
||||
: providerNode.type === "anthropic-compatible"
|
||||
? "AC"
|
||||
: "OC",
|
||||
apiType: providerNode.apiType,
|
||||
baseUrl: providerNode.baseUrl,
|
||||
type: providerNode.type,
|
||||
@@ -851,10 +875,6 @@ export default function ProviderDetailPage() {
|
||||
const isOAuth = !!(FREE_PROVIDERS as any)[providerId] || !!(OAUTH_PROVIDERS as any)[providerId];
|
||||
const models = getModelsByProviderId(providerId);
|
||||
const providerAlias = getProviderAlias(providerId);
|
||||
|
||||
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
|
||||
const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId);
|
||||
const isCompatible = isOpenAICompatible || isAnthropicCompatible;
|
||||
const isManagedAvailableModelsProvider = isCompatible || providerId === "openrouter";
|
||||
const isSearchProvider = providerId.endsWith("-search");
|
||||
|
||||
@@ -1836,16 +1856,20 @@ export default function ProviderDetailPage() {
|
||||
const description =
|
||||
providerId === "openrouter"
|
||||
? t("openRouterAnyModelHint")
|
||||
: t("compatibleModelsDescription", {
|
||||
type: isAnthropicCompatible ? t("anthropic") : t("openai"),
|
||||
});
|
||||
: isCcCompatible
|
||||
? "CC Compatible provider models are routed through the Claude Code-compatible bridge."
|
||||
: t("compatibleModelsDescription", {
|
||||
type: isAnthropicCompatible ? t("anthropic") : t("openai"),
|
||||
});
|
||||
const inputLabel = providerId === "openrouter" ? t("modelIdFromOpenRouter") : t("modelId");
|
||||
const inputPlaceholder =
|
||||
providerId === "openrouter"
|
||||
? t("openRouterModelPlaceholder")
|
||||
: isAnthropicCompatible
|
||||
? t("anthropicCompatibleModelPlaceholder")
|
||||
: t("openaiCompatibleModelPlaceholder");
|
||||
: isCcCompatible
|
||||
? "claude-sonnet-4-6"
|
||||
: isAnthropicCompatible
|
||||
? t("anthropicCompatibleModelPlaceholder")
|
||||
: t("openaiCompatibleModelPlaceholder");
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -1866,7 +1890,7 @@ export default function ProviderDetailPage() {
|
||||
onSetAlias={handleSetAlias}
|
||||
onDeleteAlias={handleDeleteAlias}
|
||||
connections={connections}
|
||||
isAnthropic={isAnthropicCompatible}
|
||||
isAnthropic={isAnthropicProtocolCompatible}
|
||||
onImportWithProgress={handleCompatibleImportWithProgress}
|
||||
t={t}
|
||||
effectiveModelNormalize={effectiveModelNormalize}
|
||||
@@ -1997,7 +2021,7 @@ export default function ProviderDetailPage() {
|
||||
? "/providers/oai-r.png"
|
||||
: "/providers/oai-cc.png";
|
||||
}
|
||||
if (isAnthropicCompatible) {
|
||||
if (isAnthropicProtocolCompatible) {
|
||||
return "/providers/anthropic-m.png";
|
||||
}
|
||||
return `/providers/${providerInfo.id}.png`;
|
||||
@@ -2062,22 +2086,26 @@ export default function ProviderDetailPage() {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isAnthropicCompatible
|
||||
? t("anthropicCompatibleDetails")
|
||||
: t("openaiCompatibleDetails")}
|
||||
{isCcCompatible
|
||||
? CC_COMPATIBLE_DETAILS_TITLE
|
||||
: isAnthropicCompatible
|
||||
? t("anthropicCompatibleDetails")
|
||||
: t("openaiCompatibleDetails")}
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
{isAnthropicCompatible
|
||||
{isAnthropicProtocolCompatible
|
||||
? t("messagesApi")
|
||||
: providerNode.apiType === "responses"
|
||||
? t("responsesApi")
|
||||
: t("chatCompletions")}{" "}
|
||||
· {(providerNode.baseUrl || "").replace(/\/$/, "")}/
|
||||
{isAnthropicCompatible
|
||||
? t("messagesPath")
|
||||
: providerNode.apiType === "responses"
|
||||
? t("responsesPath")
|
||||
: t("chatCompletionsPath")}
|
||||
{isCcCompatible
|
||||
? (providerNode.chatPath || CC_COMPATIBLE_DEFAULT_CHAT_PATH).replace(/^\//, "")
|
||||
: isAnthropicCompatible
|
||||
? (providerNode.chatPath || "/messages").replace(/^\//, "")
|
||||
: providerNode.apiType === "responses"
|
||||
? (providerNode.chatPath || "/responses").replace(/^\//, "")
|
||||
: (providerNode.chatPath || "/chat/completions").replace(/^\//, "")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -2105,7 +2133,11 @@ export default function ProviderDetailPage() {
|
||||
if (
|
||||
!confirm(
|
||||
t("deleteCompatibleNodeConfirm", {
|
||||
type: isAnthropicCompatible ? t("anthropic") : t("openai"),
|
||||
type: isCcCompatible
|
||||
? CC_COMPATIBLE_LABEL
|
||||
: isAnthropicCompatible
|
||||
? t("anthropic")
|
||||
: t("openai"),
|
||||
})
|
||||
)
|
||||
)
|
||||
@@ -2466,7 +2498,8 @@ export default function ProviderDetailPage() {
|
||||
provider={providerId}
|
||||
providerName={providerInfo.name}
|
||||
isCompatible={isCompatible}
|
||||
isAnthropic={isAnthropicCompatible}
|
||||
isAnthropic={isAnthropicProtocolCompatible}
|
||||
isCcCompatible={isCcCompatible}
|
||||
onSave={handleSaveApiKey}
|
||||
onClose={() => setShowAddApiKeyModal(false)}
|
||||
/>
|
||||
@@ -2482,7 +2515,8 @@ export default function ProviderDetailPage() {
|
||||
node={providerNode}
|
||||
onSave={handleUpdateNode}
|
||||
onClose={() => setShowEditNodeModal(false)}
|
||||
isAnthropic={isAnthropicCompatible}
|
||||
isAnthropic={isAnthropicProtocolCompatible}
|
||||
isCcCompatible={isCcCompatible}
|
||||
/>
|
||||
)}
|
||||
{/* Batch Test Results Modal */}
|
||||
@@ -4332,6 +4366,7 @@ function AddApiKeyModal({
|
||||
providerName,
|
||||
isCompatible,
|
||||
isAnthropic,
|
||||
isCcCompatible,
|
||||
onSave,
|
||||
onClose,
|
||||
}: AddApiKeyModalProps) {
|
||||
@@ -4499,13 +4534,15 @@ function AddApiKeyModal({
|
||||
)}
|
||||
{isCompatible && (
|
||||
<p className="text-xs text-text-muted">
|
||||
{isAnthropic
|
||||
? t("validationChecksAnthropicCompatible", {
|
||||
provider: providerName || t("anthropicCompatibleName"),
|
||||
})
|
||||
: t("validationChecksOpenAiCompatible", {
|
||||
provider: providerName || t("openaiCompatibleName"),
|
||||
})}
|
||||
{isCcCompatible
|
||||
? "Validation uses the strict Claude Code-compatible bridge request for this provider."
|
||||
: isAnthropic
|
||||
? t("validationChecksAnthropicCompatible", {
|
||||
provider: providerName || t("anthropicCompatibleName"),
|
||||
})
|
||||
: t("validationChecksOpenAiCompatible", {
|
||||
provider: providerName || t("openaiCompatibleName"),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
@@ -4580,6 +4617,7 @@ AddApiKeyModal.propTypes = {
|
||||
providerName: PropTypes.string,
|
||||
isCompatible: PropTypes.bool,
|
||||
isAnthropic: PropTypes.bool,
|
||||
isCcCompatible: PropTypes.bool,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
@@ -4650,7 +4688,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
setValidationResult(null);
|
||||
setSaveError(null);
|
||||
}
|
||||
}, [connection, isBailian]);
|
||||
}, [connection, isBailian, isVertex]);
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!connection?.provider) return;
|
||||
@@ -5041,6 +5079,7 @@ function EditCompatibleNodeModal({
|
||||
onSave,
|
||||
onClose,
|
||||
isAnthropic,
|
||||
isCcCompatible,
|
||||
}: EditCompatibleNodeModalProps) {
|
||||
const t = useTranslations("providers");
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -5066,12 +5105,18 @@ function EditCompatibleNodeModal({
|
||||
baseUrl:
|
||||
node.baseUrl ||
|
||||
(isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"),
|
||||
chatPath: node.chatPath || "",
|
||||
modelsPath: node.modelsPath || "",
|
||||
chatPath: node.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""),
|
||||
modelsPath: node.modelsPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_MODELS_PATH : ""),
|
||||
});
|
||||
setShowAdvanced(!!(node.chatPath || node.modelsPath));
|
||||
setShowAdvanced(
|
||||
!!(
|
||||
node.chatPath ||
|
||||
node.modelsPath ||
|
||||
(isCcCompatible && !node.chatPath && !node.modelsPath)
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [node, isAnthropic]);
|
||||
}, [node, isAnthropic, isCcCompatible]);
|
||||
|
||||
const apiTypeOptions = [
|
||||
{ value: "chat", label: t("chatCompletions") },
|
||||
@@ -5086,8 +5131,9 @@ function EditCompatibleNodeModal({
|
||||
name: formData.name,
|
||||
prefix: formData.prefix,
|
||||
baseUrl: formData.baseUrl,
|
||||
chatPath: formData.chatPath || "",
|
||||
modelsPath: formData.modelsPath || "",
|
||||
chatPath: formData.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""),
|
||||
modelsPath:
|
||||
formData.modelsPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_MODELS_PATH : ""),
|
||||
};
|
||||
if (!isAnthropic) {
|
||||
payload.apiType = formData.apiType;
|
||||
@@ -5108,7 +5154,10 @@ function EditCompatibleNodeModal({
|
||||
baseUrl: formData.baseUrl,
|
||||
apiKey: checkKey,
|
||||
type: isAnthropic ? "anthropic-compatible" : "openai-compatible",
|
||||
modelsPath: formData.modelsPath || "",
|
||||
compatMode: isCcCompatible ? "cc" : undefined,
|
||||
chatPath: formData.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""),
|
||||
modelsPath:
|
||||
formData.modelsPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_MODELS_PATH : ""),
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -5125,25 +5174,39 @@ function EditCompatibleNodeModal({
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
title={t("editCompatibleTitle", { type: isAnthropic ? t("anthropic") : t("openai") })}
|
||||
title={
|
||||
isCcCompatible
|
||||
? CC_COMPATIBLE_DETAILS_TITLE
|
||||
: t("editCompatibleTitle", { type: isAnthropic ? t("anthropic") : t("openai") })
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label={t("nameLabel")}
|
||||
label={isCcCompatible ? "Name" : t("nameLabel")}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={t("compatibleProdPlaceholder", {
|
||||
type: isAnthropic ? t("anthropic") : t("openai"),
|
||||
})}
|
||||
hint={t("nameHint")}
|
||||
placeholder={
|
||||
isCcCompatible
|
||||
? "CC Compatible Production"
|
||||
: t("compatibleProdPlaceholder", {
|
||||
type: isAnthropic ? t("anthropic") : t("openai"),
|
||||
})
|
||||
}
|
||||
hint={isCcCompatible ? "Display name for this provider" : t("nameHint")}
|
||||
/>
|
||||
<Input
|
||||
label={t("prefixLabel")}
|
||||
label={isCcCompatible ? "Prefix" : t("prefixLabel")}
|
||||
value={formData.prefix}
|
||||
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
|
||||
placeholder={isAnthropic ? t("anthropicPrefixPlaceholder") : t("openaiPrefixPlaceholder")}
|
||||
hint={t("prefixHint")}
|
||||
placeholder={
|
||||
isCcCompatible
|
||||
? "cc"
|
||||
: isAnthropic
|
||||
? t("anthropicPrefixPlaceholder")
|
||||
: t("openaiPrefixPlaceholder")
|
||||
}
|
||||
hint={isCcCompatible ? "Used for aliases such as prefix/model-id" : t("prefixHint")}
|
||||
/>
|
||||
{!isAnthropic && (
|
||||
<Select
|
||||
@@ -5154,15 +5217,23 @@ function EditCompatibleNodeModal({
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
label={t("baseUrlLabel")}
|
||||
label={isCcCompatible ? "Base URL" : t("baseUrlLabel")}
|
||||
value={formData.baseUrl}
|
||||
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
|
||||
placeholder={
|
||||
isAnthropic ? t("anthropicBaseUrlPlaceholder") : t("openaiBaseUrlPlaceholder")
|
||||
isCcCompatible
|
||||
? "https://example.com/v1"
|
||||
: isAnthropic
|
||||
? t("anthropicBaseUrlPlaceholder")
|
||||
: t("openaiBaseUrlPlaceholder")
|
||||
}
|
||||
hint={
|
||||
isCcCompatible
|
||||
? "Base URL for the CC-compatible site. Do not include /messages."
|
||||
: t("compatibleBaseUrlHint", {
|
||||
type: isAnthropic ? t("anthropic") : t("openai"),
|
||||
})
|
||||
}
|
||||
hint={t("compatibleBaseUrlHint", {
|
||||
type: isAnthropic ? t("anthropic") : t("openai"),
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -5182,18 +5253,30 @@ function EditCompatibleNodeModal({
|
||||
{showAdvanced && (
|
||||
<div id="advanced-settings" className="flex flex-col gap-3 pl-2 border-l-2 border-border">
|
||||
<Input
|
||||
label={t("chatPathLabel")}
|
||||
label={isCcCompatible ? "Chat Path" : t("chatPathLabel")}
|
||||
value={formData.chatPath}
|
||||
onChange={(e) => setFormData({ ...formData, chatPath: e.target.value })}
|
||||
placeholder={isAnthropic ? "/messages" : t("chatPathPlaceholder")}
|
||||
hint={t("chatPathHint")}
|
||||
placeholder={
|
||||
isCcCompatible
|
||||
? CC_COMPATIBLE_DEFAULT_CHAT_PATH
|
||||
: isAnthropic
|
||||
? "/messages"
|
||||
: t("chatPathPlaceholder")
|
||||
}
|
||||
hint={
|
||||
isCcCompatible
|
||||
? "Defaults to the strict Claude Code-compatible messages path"
|
||||
: t("chatPathHint")
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label={t("modelsPathLabel")}
|
||||
label={isCcCompatible ? "Models Path" : t("modelsPathLabel")}
|
||||
value={formData.modelsPath}
|
||||
onChange={(e) => setFormData({ ...formData, modelsPath: e.target.value })}
|
||||
placeholder={t("modelsPathPlaceholder")}
|
||||
hint={t("modelsPathHint")}
|
||||
placeholder={
|
||||
isCcCompatible ? CC_COMPATIBLE_DEFAULT_MODELS_PATH : t("modelsPathPlaceholder")
|
||||
}
|
||||
hint={isCcCompatible ? "Defaults to /models" : t("modelsPathHint")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -5253,4 +5336,5 @@ EditCompatibleNodeModal.propTypes = {
|
||||
onSave: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
isAnthropic: PropTypes.bool,
|
||||
isCcCompatible: PropTypes.bool,
|
||||
};
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||
import {
|
||||
FREE_PROVIDERS,
|
||||
OPENAI_COMPATIBLE_PREFIX,
|
||||
ANTHROPIC_COMPATIBLE_PREFIX,
|
||||
isAnthropicCompatibleProvider,
|
||||
isClaudeCodeCompatibleProvider,
|
||||
isOpenAICompatibleProvider,
|
||||
} from "@/shared/constants/providers";
|
||||
import Link from "next/link";
|
||||
import { getErrorCode, getRelativeTime } from "@/shared/utils";
|
||||
@@ -26,6 +27,11 @@ import { useNotificationStore } from "@/store/notificationStore";
|
||||
import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const CC_COMPATIBLE_LABEL = "CC Compatible";
|
||||
const ADD_CC_COMPATIBLE_LABEL = "Add CC Compatible";
|
||||
const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/messages?beta=true";
|
||||
const CC_COMPATIBLE_DEFAULT_MODELS_PATH = "/models";
|
||||
|
||||
// Shared helper function to avoid code duplication between ProviderCard and ApiKeyProviderCard
|
||||
function getStatusDisplay(connected, error, errorCode, t) {
|
||||
const parts = [];
|
||||
@@ -95,10 +101,12 @@ function getConnectionErrorTag(connection) {
|
||||
export default function ProvidersPage() {
|
||||
const [connections, setConnections] = useState<any[]>([]);
|
||||
const [providerNodes, setProviderNodes] = useState<any[]>([]);
|
||||
const [ccCompatibleProviderEnabled, setCcCompatibleProviderEnabled] = useState(false);
|
||||
const [expirations, setExpirations] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false);
|
||||
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false);
|
||||
const [showAddCcCompatibleModal, setShowAddCcCompatibleModal] = useState(false);
|
||||
const [testingMode, setTestingMode] = useState<string | null>(null);
|
||||
const [testResults, setTestResults] = useState<any>(null);
|
||||
const [importingZed, setImportingZed] = useState(false);
|
||||
@@ -118,7 +126,10 @@ export default function ProvidersPage() {
|
||||
const nodesData = await nodesRes.json();
|
||||
const expirationsData = await expirationsRes.json();
|
||||
if (connectionsRes.ok) setConnections(connectionsData.connections || []);
|
||||
if (nodesRes.ok) setProviderNodes(nodesData.nodes || []);
|
||||
if (nodesRes.ok) {
|
||||
setProviderNodes(nodesData.nodes || []);
|
||||
setCcCompatibleProviderEnabled(nodesData.ccCompatibleProviderEnabled === true);
|
||||
}
|
||||
if (expirationsRes.ok && expirationsData) setExpirations(expirationsData);
|
||||
} catch (error) {
|
||||
console.log("Error fetching data:", error);
|
||||
@@ -283,7 +294,9 @@ export default function ProvidersPage() {
|
||||
}));
|
||||
|
||||
const anthropicCompatibleProviders = providerNodes
|
||||
.filter((node) => node.type === "anthropic-compatible")
|
||||
.filter(
|
||||
(node) => node.type === "anthropic-compatible" && !isClaudeCodeCompatibleProvider(node.id)
|
||||
)
|
||||
.map((node) => ({
|
||||
id: node.id,
|
||||
name: node.name || t("anthropicCompatibleName"),
|
||||
@@ -291,6 +304,17 @@ export default function ProvidersPage() {
|
||||
textIcon: "AC",
|
||||
}));
|
||||
|
||||
const ccCompatibleProviders = providerNodes
|
||||
.filter(
|
||||
(node) => node.type === "anthropic-compatible" && isClaudeCodeCompatibleProvider(node.id)
|
||||
)
|
||||
.map((node) => ({
|
||||
id: node.id,
|
||||
name: node.name || CC_COMPATIBLE_LABEL,
|
||||
color: "#B45309",
|
||||
textIcon: "CC",
|
||||
}));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
@@ -474,7 +498,9 @@ export default function ProvidersPage() {
|
||||
<span className="size-2.5 rounded-full bg-orange-500" title={t("compatibleLabel")} />
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && (
|
||||
{(compatibleProviders.length > 0 ||
|
||||
anthropicCompatibleProviders.length > 0 ||
|
||||
ccCompatibleProviders.length > 0) && (
|
||||
<button
|
||||
onClick={() => handleBatchTest("compatible")}
|
||||
disabled={!!testingMode}
|
||||
@@ -491,6 +517,16 @@ export default function ProvidersPage() {
|
||||
{testingMode === "compatible" ? t("testing") : t("testAll")}
|
||||
</button>
|
||||
)}
|
||||
{ccCompatibleProviderEnabled && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="add"
|
||||
onClick={() => setShowAddCcCompatibleModal(true)}
|
||||
>
|
||||
{ADD_CC_COMPATIBLE_LABEL}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" icon="add" onClick={() => setShowAddAnthropicCompatibleModal(true)}>
|
||||
{t("addAnthropicCompatible")}
|
||||
</Button>
|
||||
@@ -505,7 +541,9 @@ export default function ProvidersPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{compatibleProviders.length === 0 && anthropicCompatibleProviders.length === 0 ? (
|
||||
{compatibleProviders.length === 0 &&
|
||||
anthropicCompatibleProviders.length === 0 &&
|
||||
ccCompatibleProviders.length === 0 ? (
|
||||
<div className="text-center py-8 border border-dashed border-border rounded-xl">
|
||||
<span className="material-symbols-outlined text-[32px] text-text-muted mb-2">
|
||||
extension
|
||||
@@ -515,7 +553,11 @@ export default function ProvidersPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{[...compatibleProviders, ...anthropicCompatibleProviders].map((info) => (
|
||||
{[
|
||||
...compatibleProviders,
|
||||
...anthropicCompatibleProviders,
|
||||
...ccCompatibleProviders,
|
||||
].map((info) => (
|
||||
<ApiKeyProviderCard
|
||||
key={info.id}
|
||||
providerId={info.id}
|
||||
@@ -544,6 +586,16 @@ export default function ProvidersPage() {
|
||||
setShowAddAnthropicCompatibleModal(false);
|
||||
}}
|
||||
/>
|
||||
{ccCompatibleProviderEnabled && (
|
||||
<AddCcCompatibleModal
|
||||
isOpen={showAddCcCompatibleModal}
|
||||
onClose={() => setShowAddCcCompatibleModal(false)}
|
||||
onCreated={(node) => {
|
||||
setProviderNodes((prev) => [...prev, node]);
|
||||
setShowAddCcCompatibleModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Test Results Modal */}
|
||||
{testResults && (
|
||||
<div
|
||||
@@ -695,8 +747,10 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
||||
const t = useTranslations("providers");
|
||||
const tc = useTranslations("common");
|
||||
const { connected, error, errorCode, errorTime, allDisabled } = stats;
|
||||
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
|
||||
const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
|
||||
const isCompatible = isOpenAICompatibleProvider(providerId);
|
||||
const isCcCompatible = isClaudeCodeCompatibleProvider(providerId);
|
||||
const isAnthropicCompatible =
|
||||
isAnthropicCompatibleProvider(providerId) && !isClaudeCodeCompatibleProvider(providerId);
|
||||
|
||||
const dotColors = {
|
||||
free: "bg-green-500",
|
||||
@@ -717,7 +771,7 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
||||
if (isCompatible) {
|
||||
return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png";
|
||||
}
|
||||
if (isAnthropicCompatible) return "/providers/anthropic-m.png";
|
||||
if (isAnthropicCompatible || isCcCompatible) return "/providers/anthropic-m.png";
|
||||
return null; // ProviderIcon will handle it
|
||||
})();
|
||||
|
||||
@@ -781,6 +835,11 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
||||
{provider.apiType === "responses" ? t("responses") : t("chat")}
|
||||
</Badge>
|
||||
)}
|
||||
{isCcCompatible && (
|
||||
<Badge variant="default" size="sm">
|
||||
CC
|
||||
</Badge>
|
||||
)}
|
||||
{isAnthropicCompatible && (
|
||||
<Badge variant="default" size="sm">
|
||||
{t("messages")}
|
||||
@@ -1232,6 +1291,200 @@ AddAnthropicCompatibleModal.propTypes = {
|
||||
onCreated: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function AddCcCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
prefix: "",
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
chatPath: CC_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
modelsPath: CC_COMPATIBLE_DEFAULT_MODELS_PATH,
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [checkKey, setCheckKey] = useState("");
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setValidationResult(null);
|
||||
setCheckKey("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/provider-nodes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: formData.name,
|
||||
prefix: formData.prefix,
|
||||
baseUrl: formData.baseUrl,
|
||||
type: "anthropic-compatible",
|
||||
compatMode: "cc",
|
||||
chatPath: formData.chatPath || CC_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
modelsPath: formData.modelsPath || CC_COMPATIBLE_DEFAULT_MODELS_PATH,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
onCreated(data.node);
|
||||
setFormData({
|
||||
name: "",
|
||||
prefix: "",
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
chatPath: CC_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
modelsPath: CC_COMPATIBLE_DEFAULT_MODELS_PATH,
|
||||
});
|
||||
setCheckKey("");
|
||||
setValidationResult(null);
|
||||
setShowAdvanced(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error creating CC Compatible node:", error);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidate = async () => {
|
||||
setValidating(true);
|
||||
try {
|
||||
const res = await fetch("/api/provider-nodes/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
baseUrl: formData.baseUrl,
|
||||
apiKey: checkKey,
|
||||
type: "anthropic-compatible",
|
||||
compatMode: "cc",
|
||||
chatPath: formData.chatPath || CC_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
modelsPath: formData.modelsPath || CC_COMPATIBLE_DEFAULT_MODELS_PATH,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
setValidationResult(data.valid ? "success" : "failed");
|
||||
} catch {
|
||||
setValidationResult("failed");
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title={ADD_CC_COMPATIBLE_LABEL} onClose={onClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="CC Compatible Production"
|
||||
hint="Display name for this Claude Code-compatible provider"
|
||||
/>
|
||||
<Input
|
||||
label="Prefix"
|
||||
value={formData.prefix}
|
||||
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
|
||||
placeholder="cc"
|
||||
hint="Used for model aliases such as prefix/model-id"
|
||||
/>
|
||||
<Input
|
||||
label="Base URL"
|
||||
value={formData.baseUrl}
|
||||
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
|
||||
placeholder="https://example.com/v1"
|
||||
hint="Base URL for the CC-compatible site. Do not include /messages."
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
aria-expanded={showAdvanced}
|
||||
aria-controls="advanced-settings-cc"
|
||||
>
|
||||
<span
|
||||
className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
Advanced settings
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div
|
||||
id="advanced-settings-cc"
|
||||
className="flex flex-col gap-3 pl-2 border-l-2 border-border"
|
||||
>
|
||||
<Input
|
||||
label="Chat Path"
|
||||
value={formData.chatPath}
|
||||
onChange={(e) => setFormData({ ...formData, chatPath: e.target.value })}
|
||||
placeholder={CC_COMPATIBLE_DEFAULT_CHAT_PATH}
|
||||
hint="Defaults to the strict Claude Code-compatible messages path"
|
||||
/>
|
||||
<Input
|
||||
label="Models Path"
|
||||
value={formData.modelsPath}
|
||||
onChange={(e) => setFormData({ ...formData, modelsPath: e.target.value })}
|
||||
placeholder={CC_COMPATIBLE_DEFAULT_MODELS_PATH}
|
||||
hint="Defaults to /models"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
label="API Key for Check"
|
||||
type="password"
|
||||
value={checkKey}
|
||||
onChange={(e) => setCheckKey(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="pt-6">
|
||||
<Button
|
||||
onClick={handleValidate}
|
||||
disabled={!checkKey || validating || !formData.baseUrl.trim()}
|
||||
variant="secondary"
|
||||
>
|
||||
{validating ? "Checking..." : "Check"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{validationResult && (
|
||||
<Badge variant={validationResult === "success" ? "success" : "error"}>
|
||||
{validationResult === "success" ? "Valid" : "Invalid"}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
fullWidth
|
||||
disabled={
|
||||
!formData.name.trim() ||
|
||||
!formData.prefix.trim() ||
|
||||
!formData.baseUrl.trim() ||
|
||||
submitting
|
||||
}
|
||||
>
|
||||
{submitting ? "Creating..." : "Add"}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
AddCcCompatibleModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onCreated: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
// ─── Provider Test Results View (mirrors combo TestResultsView) ──────────────
|
||||
|
||||
function ProviderTestResultsView({ results }) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
CLAUDE_CODE_COMPATIBLE_PREFIX,
|
||||
OPENAI_COMPATIBLE_PREFIX,
|
||||
ANTHROPIC_COMPATIBLE_PREFIX,
|
||||
} from "@/shared/constants/providers";
|
||||
@@ -39,6 +40,8 @@ export function useProviderOptions(initialProvider = "openai") {
|
||||
const info = (AI_PROVIDERS as any)[pid as string];
|
||||
const node: any = nodeMap.get(pid);
|
||||
let label = info?.name || node?.name || pid;
|
||||
if (!info && (pid as string).startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX))
|
||||
label = node?.name || "CC Compatible";
|
||||
if (!info && (pid as string).startsWith(OPENAI_COMPATIBLE_PREFIX))
|
||||
label = node?.name || t("openaiCompatibleLabel");
|
||||
if (!info && (pid as string).startsWith(ANTHROPIC_COMPATIBLE_PREFIX))
|
||||
|
||||
@@ -59,9 +59,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
// Sanitize Base URL for Anthropic Compatible
|
||||
if (node.type === "anthropic-compatible") {
|
||||
sanitizedBaseUrl = sanitizedBaseUrl.replace(/\/$/, "");
|
||||
if (sanitizedBaseUrl.endsWith("/messages")) {
|
||||
sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -9); // remove /messages
|
||||
}
|
||||
sanitizedBaseUrl = sanitizedBaseUrl.replace(/\/messages(?:\?[^#]*)?$/i, "");
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
|
||||
@@ -3,8 +3,10 @@ import { createProviderNode, getProviderNodes } from "@/models";
|
||||
import {
|
||||
OPENAI_COMPATIBLE_PREFIX,
|
||||
ANTHROPIC_COMPATIBLE_PREFIX,
|
||||
CLAUDE_CODE_COMPATIBLE_PREFIX,
|
||||
} from "@/shared/constants/providers";
|
||||
import { generateId } from "@/shared/utils";
|
||||
import { isCcCompatibleProviderEnabled } from "@/shared/utils/featureFlags";
|
||||
import { createProviderNodeSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
@@ -16,11 +18,21 @@ const ANTHROPIC_COMPATIBLE_DEFAULTS = {
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
};
|
||||
|
||||
function sanitizeAnthropicBaseUrl(baseUrl: string) {
|
||||
return (baseUrl || "")
|
||||
.trim()
|
||||
.replace(/\/$/, "")
|
||||
.replace(/\/messages(?:\?[^#]*)?$/i, "");
|
||||
}
|
||||
|
||||
// GET /api/provider-nodes - List all provider nodes
|
||||
export async function GET() {
|
||||
try {
|
||||
const nodes = await getProviderNodes();
|
||||
return NextResponse.json({ nodes });
|
||||
return NextResponse.json({
|
||||
nodes,
|
||||
ccCompatibleProviderEnabled: isCcCompatibleProviderEnabled(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error fetching provider nodes:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch provider nodes" }, { status: 500 });
|
||||
@@ -49,7 +61,8 @@ export async function POST(request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { name, prefix, apiType, baseUrl, type, chatPath, modelsPath } = validation.data;
|
||||
const { name, prefix, apiType, baseUrl, type, compatMode, chatPath, modelsPath } =
|
||||
validation.data;
|
||||
|
||||
// Determine type
|
||||
const nodeType = type || "openai-compatible";
|
||||
@@ -69,17 +82,19 @@ export async function POST(request) {
|
||||
}
|
||||
|
||||
if (nodeType === "anthropic-compatible") {
|
||||
// Sanitize Base URL: remove trailing slash, and remove trailing /messages if user added it
|
||||
// This prevents double-appending /messages at runtime
|
||||
let sanitizedBaseUrl = (baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl)
|
||||
.trim()
|
||||
.replace(/\/$/, "");
|
||||
if (sanitizedBaseUrl.endsWith("/messages")) {
|
||||
sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -9); // remove /messages
|
||||
if (compatMode === "cc" && !isCcCompatibleProviderEnabled()) {
|
||||
return NextResponse.json({ error: "CC Compatible provider is disabled" }, { status: 403 });
|
||||
}
|
||||
|
||||
const sanitizedBaseUrl = sanitizeAnthropicBaseUrl(
|
||||
baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl
|
||||
);
|
||||
|
||||
const node = await createProviderNode({
|
||||
id: `${ANTHROPIC_COMPATIBLE_PREFIX}${generateId()}`,
|
||||
id:
|
||||
compatMode === "cc"
|
||||
? `${CLAUDE_CODE_COMPATIBLE_PREFIX}${generateId()}`
|
||||
: `${ANTHROPIC_COMPATIBLE_PREFIX}${generateId()}`,
|
||||
type: "anthropic-compatible",
|
||||
prefix: prefix.trim(),
|
||||
baseUrl: sanitizedBaseUrl,
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { validateClaudeCodeCompatibleProvider } from "@/lib/providers/validation";
|
||||
import { isCcCompatibleProviderEnabled } from "@/shared/utils/featureFlags";
|
||||
import { providerNodeValidateSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
function sanitizeAnthropicBaseUrl(baseUrl: string) {
|
||||
return (baseUrl || "")
|
||||
.trim()
|
||||
.replace(/\/$/, "")
|
||||
.replace(/\/messages(?:\?[^#]*)?$/i, "");
|
||||
}
|
||||
|
||||
// POST /api/provider-nodes/validate - Validate API key against base URL
|
||||
export async function POST(request) {
|
||||
let rawBody;
|
||||
@@ -24,16 +33,38 @@ export async function POST(request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { baseUrl, apiKey, type, modelsPath } = validation.data;
|
||||
const { baseUrl, apiKey, type, compatMode, chatPath, modelsPath } = validation.data;
|
||||
|
||||
// Anthropic Compatible Validation
|
||||
if (type === "anthropic-compatible") {
|
||||
// Robustly construct URL: remove trailing slash, and remove trailing /messages if user added it
|
||||
let normalizedBase = baseUrl.trim().replace(/\/$/, "");
|
||||
if (normalizedBase.endsWith("/messages")) {
|
||||
normalizedBase = normalizedBase.slice(0, -9); // remove /messages
|
||||
if (compatMode === "cc") {
|
||||
if (!isCcCompatibleProviderEnabled()) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "CC Compatible provider is disabled" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await validateClaudeCodeCompatibleProvider({
|
||||
apiKey,
|
||||
providerSpecificData: {
|
||||
baseUrl: sanitizeAnthropicBaseUrl(baseUrl),
|
||||
chatPath: chatPath || undefined,
|
||||
modelsPath: modelsPath || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
valid: !!result.valid,
|
||||
error: result.valid ? null : result.error || "Invalid API key",
|
||||
warning: result.warning || null,
|
||||
method: result.method || null,
|
||||
});
|
||||
}
|
||||
|
||||
// Robustly construct URL: remove trailing slash, and remove trailing /messages if user added it
|
||||
const normalizedBase = sanitizeAnthropicBaseUrl(baseUrl);
|
||||
|
||||
// Use /models endpoint for validation as many compatible providers support it (like OpenAI)
|
||||
const modelsUrl = `${normalizedBase}${modelsPath || "/models"}`;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/models";
|
||||
import { APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||
import {
|
||||
isClaudeCodeCompatibleProvider,
|
||||
isOpenAICompatibleProvider,
|
||||
isAnthropicCompatibleProvider,
|
||||
} from "@/shared/constants/providers";
|
||||
@@ -97,7 +98,14 @@ export async function POST(request: Request) {
|
||||
} else if (isAnthropicCompatibleProvider(provider)) {
|
||||
const node: any = await getProviderNodeById(provider);
|
||||
if (!node) {
|
||||
return NextResponse.json({ error: "Anthropic Compatible node not found" }, { status: 404 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: isClaudeCodeCompatibleProvider(provider)
|
||||
? "CC Compatible node not found"
|
||||
: "Anthropic Compatible node not found",
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const existingConnections = await getProviderConnections({ provider });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderNodeById } from "@/models";
|
||||
import {
|
||||
isClaudeCodeCompatibleProvider,
|
||||
isOpenAICompatibleProvider,
|
||||
isAnthropicCompatibleProvider,
|
||||
} from "@/shared/constants/providers";
|
||||
@@ -37,7 +38,11 @@ export async function POST(request) {
|
||||
if (isOpenAICompatibleProvider(provider) || isAnthropicCompatibleProvider(provider)) {
|
||||
const node: any = await getProviderNodeById(provider);
|
||||
if (!node) {
|
||||
const typeName = isOpenAICompatibleProvider(provider) ? "OpenAI" : "Anthropic";
|
||||
const typeName = isOpenAICompatibleProvider(provider)
|
||||
? "OpenAI"
|
||||
: isClaudeCodeCompatibleProvider(provider)
|
||||
? "CC"
|
||||
: "Anthropic";
|
||||
return NextResponse.json(
|
||||
{ error: `${typeName} Compatible node not found` },
|
||||
{ status: 404 }
|
||||
@@ -47,6 +52,8 @@ export async function POST(request) {
|
||||
...providerSpecificData,
|
||||
baseUrl: node.baseUrl,
|
||||
apiType: node.apiType,
|
||||
chatPath: node.chatPath,
|
||||
modelsPath: node.modelsPath,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -65,5 +65,9 @@ export function getProviderDisplayName(
|
||||
);
|
||||
if (match) return `Compatible (${match[1]})`;
|
||||
|
||||
if (/^anthropic-compatible-cc-[0-9a-f-]{10,}$/i.test(providerId)) {
|
||||
return "CC Compatible";
|
||||
}
|
||||
|
||||
return providerId;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
import {
|
||||
buildClaudeCodeCompatibleHeaders,
|
||||
buildClaudeCodeCompatibleValidationPayload,
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH,
|
||||
joinBaseUrlAndPath,
|
||||
stripAnthropicMessagesSuffix,
|
||||
} from "@omniroute/open-sse/services/claudeCodeCompatible.ts";
|
||||
import {
|
||||
isClaudeCodeCompatibleProvider,
|
||||
isAnthropicCompatibleProvider,
|
||||
isOpenAICompatibleProvider,
|
||||
} from "@/shared/constants/providers";
|
||||
@@ -11,6 +20,10 @@ function normalizeBaseUrl(baseUrl: string) {
|
||||
return (baseUrl || "").trim().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function normalizeAnthropicBaseUrl(baseUrl: string) {
|
||||
return stripAnthropicMessagesSuffix(baseUrl || "");
|
||||
}
|
||||
|
||||
function addModelsSuffix(baseUrl: string) {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
if (!normalized) return "";
|
||||
@@ -36,6 +49,9 @@ function resolveChatUrl(provider: string, baseUrl: string, providerSpecificData:
|
||||
if (!normalized) return "";
|
||||
|
||||
if (isOpenAICompatibleProvider(provider)) {
|
||||
if (providerSpecificData?.chatPath) {
|
||||
return `${normalized}${providerSpecificData.chatPath}`;
|
||||
}
|
||||
if (providerSpecificData?.apiType === "responses") {
|
||||
return `${normalized}/responses`;
|
||||
}
|
||||
@@ -496,15 +512,11 @@ async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData =
|
||||
}
|
||||
|
||||
async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
let baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl);
|
||||
let baseUrl = normalizeAnthropicBaseUrl(providerSpecificData.baseUrl);
|
||||
if (!baseUrl) {
|
||||
return { valid: false, error: "No base URL configured for Anthropic compatible provider" };
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/messages")) {
|
||||
baseUrl = baseUrl.slice(0, -9);
|
||||
}
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
@@ -514,10 +526,13 @@ async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificDat
|
||||
|
||||
// Step 1: Try GET /models
|
||||
try {
|
||||
const modelsRes = await fetch(`${baseUrl}/models`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
const modelsRes = await fetch(
|
||||
joinBaseUrlAndPath(baseUrl, providerSpecificData?.modelsPath || "/models"),
|
||||
{
|
||||
method: "GET",
|
||||
headers,
|
||||
}
|
||||
);
|
||||
|
||||
if (modelsRes.ok) {
|
||||
return { valid: true, error: null };
|
||||
@@ -533,15 +548,18 @@ async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificDat
|
||||
// Step 2: Fallback — try a minimal messages request
|
||||
const testModelId = providerSpecificData?.validationModelId || "claude-3-5-sonnet-20241022";
|
||||
try {
|
||||
const messagesRes = await fetch(`${baseUrl}/messages`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: testModelId,
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
}),
|
||||
});
|
||||
const messagesRes = await fetch(
|
||||
joinBaseUrlAndPath(baseUrl, providerSpecificData?.chatPath || "/messages"),
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: testModelId,
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (messagesRes.status === 401 || messagesRes.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
@@ -554,6 +572,80 @@ async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificDat
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateClaudeCodeCompatibleProvider({
|
||||
apiKey,
|
||||
providerSpecificData = {},
|
||||
}: any) {
|
||||
const baseUrl = normalizeAnthropicBaseUrl(providerSpecificData.baseUrl);
|
||||
if (!baseUrl) {
|
||||
return { valid: false, error: "No base URL configured for CC Compatible provider" };
|
||||
}
|
||||
|
||||
const modelsPath = providerSpecificData?.modelsPath || CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH;
|
||||
const chatPath = providerSpecificData?.chatPath || CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH;
|
||||
const defaultHeaders = buildClaudeCodeCompatibleHeaders(apiKey, false);
|
||||
|
||||
try {
|
||||
const modelsRes = await fetch(joinBaseUrlAndPath(baseUrl, modelsPath), {
|
||||
method: "GET",
|
||||
headers: defaultHeaders,
|
||||
});
|
||||
|
||||
if (modelsRes.ok) {
|
||||
return { valid: true, error: null, method: "models_endpoint" };
|
||||
}
|
||||
|
||||
if (modelsRes.status === 401 || modelsRes.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to bridge request validation.
|
||||
}
|
||||
|
||||
const payload = buildClaudeCodeCompatibleValidationPayload(
|
||||
providerSpecificData?.validationModelId || "claude-sonnet-4-6"
|
||||
);
|
||||
const sessionId = JSON.parse(payload.metadata.user_id).session_id;
|
||||
|
||||
try {
|
||||
const messagesRes = await fetch(joinBaseUrlAndPath(baseUrl, chatPath), {
|
||||
method: "POST",
|
||||
headers: buildClaudeCodeCompatibleHeaders(apiKey, false, sessionId),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (messagesRes.status === 401 || messagesRes.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (messagesRes.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "cc_bridge_request",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
|
||||
if (messagesRes.status >= 400 && messagesRes.status < 500) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "cc_bridge_request",
|
||||
warning: "Bridge request reached upstream, but the model or payload was rejected",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: messagesRes.ok,
|
||||
error: messagesRes.ok ? null : `Validation failed: ${messagesRes.status}`,
|
||||
method: "cc_bridge_request",
|
||||
};
|
||||
} catch (error: any) {
|
||||
return { valid: false, error: error.message || "Connection failed" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search provider validators (factored) ──
|
||||
|
||||
async function validateSearchProvider(
|
||||
@@ -638,6 +730,9 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
|
||||
if (isAnthropicCompatibleProvider(provider)) {
|
||||
try {
|
||||
if (isClaudeCodeCompatibleProvider(provider)) {
|
||||
return await validateClaudeCodeCompatibleProvider({ apiKey, providerSpecificData });
|
||||
}
|
||||
return await validateAnthropicCompatibleProvider({ apiKey, providerSpecificData });
|
||||
} catch (error: any) {
|
||||
return { valid: false, error: error.message || "Validation failed", unsupported: false };
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
OAUTH_PROVIDERS,
|
||||
APIKEY_PROVIDERS,
|
||||
FREE_PROVIDERS,
|
||||
CLAUDE_CODE_COMPATIBLE_PREFIX,
|
||||
OPENAI_COMPATIBLE_PREFIX,
|
||||
ANTHROPIC_COMPATIBLE_PREFIX,
|
||||
} from "@/shared/constants/providers";
|
||||
@@ -45,6 +46,17 @@ function usePageInfo(pathname: string | null): {
|
||||
};
|
||||
}
|
||||
|
||||
if (providerId.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX)) {
|
||||
return {
|
||||
title: "CC Compatible",
|
||||
description: "",
|
||||
breadcrumbs: [
|
||||
{ label: t("providers"), href: "/dashboard/providers" },
|
||||
{ label: "CC Compatible", providerId: "claude" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (providerId.startsWith(OPENAI_COMPATIBLE_PREFIX)) {
|
||||
return {
|
||||
title: t("openaiCompatible"),
|
||||
|
||||
@@ -10,7 +10,11 @@ export {
|
||||
getModelsByProviderId,
|
||||
} from "@omniroute/open-sse/config/providerModels.ts";
|
||||
|
||||
import { AI_PROVIDERS, isOpenAICompatibleProvider } from "./providers";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
isAnthropicCompatibleProvider,
|
||||
isOpenAICompatibleProvider,
|
||||
} from "./providers";
|
||||
import { PROVIDER_MODELS as MODELS } from "@omniroute/open-sse/config/providerModels.ts";
|
||||
|
||||
// Providers that accept any model (passthrough)
|
||||
@@ -23,6 +27,7 @@ const PASSTHROUGH_PROVIDERS = new Set(
|
||||
// Wrap isValidModel with passthrough providers
|
||||
export function isValidModel(aliasOrId, modelId) {
|
||||
if (isOpenAICompatibleProvider(aliasOrId)) return true;
|
||||
if (isAnthropicCompatibleProvider(aliasOrId)) return true;
|
||||
if (PASSTHROUGH_PROVIDERS.has(aliasOrId)) return true;
|
||||
const models = MODELS[aliasOrId];
|
||||
if (!models) return false;
|
||||
|
||||
@@ -601,6 +601,7 @@ export const APIKEY_PROVIDERS = {
|
||||
|
||||
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
|
||||
export const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-";
|
||||
export const CLAUDE_CODE_COMPATIBLE_PREFIX = "anthropic-compatible-cc-";
|
||||
|
||||
export function isOpenAICompatibleProvider(providerId) {
|
||||
return typeof providerId === "string" && providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
|
||||
@@ -610,6 +611,10 @@ export function isAnthropicCompatibleProvider(providerId) {
|
||||
return typeof providerId === "string" && providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
|
||||
}
|
||||
|
||||
export function isClaudeCodeCompatibleProvider(providerId) {
|
||||
return typeof providerId === "string" && providerId.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX);
|
||||
}
|
||||
|
||||
// All providers (combined)
|
||||
export const AI_PROVIDERS = { ...FREE_PROVIDERS, ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS };
|
||||
|
||||
|
||||
3
src/shared/utils/featureFlags.ts
Normal file
3
src/shared/utils/featureFlags.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function isCcCompatibleProviderEnabled() {
|
||||
return process.env.ENABLE_CC_COMPATIBLE_PROVIDER === "true";
|
||||
}
|
||||
@@ -966,6 +966,7 @@ export const createProviderNodeSchema = z
|
||||
apiType: z.enum(["chat", "responses"]).optional(),
|
||||
baseUrl: z.string().trim().min(1).optional(),
|
||||
type: z.enum(["openai-compatible", "anthropic-compatible"]).optional(),
|
||||
compatMode: z.enum(["cc"]).optional(),
|
||||
chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
||||
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
||||
})
|
||||
@@ -993,6 +994,8 @@ export const providerNodeValidateSchema = z.object({
|
||||
baseUrl: z.string().trim().min(1, "Base URL and API key required"),
|
||||
apiKey: z.string().trim().min(1, "Base URL and API key required"),
|
||||
type: z.enum(["openai-compatible", "anthropic-compatible"]).optional(),
|
||||
compatMode: z.enum(["cc"]).optional(),
|
||||
chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
||||
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
|
||||
300
tests/unit/cc-compatible-provider.test.mjs
Normal file
300
tests/unit/cc-compatible-provider.test.mjs
Normal file
@@ -0,0 +1,300 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cc-compatible-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { DefaultExecutor } = await import("../../open-sse/executors/default.ts");
|
||||
const {
|
||||
buildClaudeCodeCompatibleRequest,
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS,
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH,
|
||||
} = await import("../../open-sse/services/claudeCodeCompatible.ts");
|
||||
const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts");
|
||||
const providerNodesRoute = await import("../../src/app/api/provider-nodes/route.ts");
|
||||
const providerNodesValidateRoute =
|
||||
await import("../../src/app/api/provider-nodes/validate/route.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalFlag = process.env.ENABLE_CC_COMPATIBLE_PROVIDER;
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.afterEach(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalFlag === undefined) {
|
||||
delete process.env.ENABLE_CC_COMPATIBLE_PROVIDER;
|
||||
} else {
|
||||
process.env.ENABLE_CC_COMPATIBLE_PROVIDER = originalFlag;
|
||||
}
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalFlag === undefined) {
|
||||
delete process.env.ENABLE_CC_COMPATIBLE_PROVIDER;
|
||||
} else {
|
||||
process.env.ENABLE_CC_COMPATIBLE_PROVIDER = originalFlag;
|
||||
}
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("buildClaudeCodeCompatibleRequest keeps order/text while mapping unsupported roles", () => {
|
||||
const payload = buildClaudeCodeCompatibleRequest({
|
||||
sourceBody: {
|
||||
reasoning_effort: "xhigh",
|
||||
tool_choice: "required",
|
||||
},
|
||||
normalizedBody: {
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "lookup_weather",
|
||||
description: "Fetch weather",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
city: { type: "string" },
|
||||
},
|
||||
required: ["city"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{ role: "system", content: "sys" },
|
||||
{ role: "user", content: [{ type: "text", text: "u1" }, { type: "image_url" }] },
|
||||
{ role: "model", content: "a1" },
|
||||
{ role: "user", content: [{ type: "text", text: "u2" }, { type: "tool_result" }] },
|
||||
],
|
||||
},
|
||||
model: "claude-sonnet-4-6",
|
||||
cwd: "/tmp/work",
|
||||
now: new Date("2026-04-01T12:00:00.000Z"),
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
assert.equal(payload.max_tokens, CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS);
|
||||
assert.equal(payload.output_config.effort, "high");
|
||||
assert.deepEqual(
|
||||
payload.messages.map((message) => ({
|
||||
role: message.role,
|
||||
text: message.content.map((block) => block.text).join("\n"),
|
||||
})),
|
||||
[
|
||||
{ role: "user", text: "u1" },
|
||||
{ role: "assistant", text: "a1" },
|
||||
{ role: "user", text: "u2" },
|
||||
]
|
||||
);
|
||||
assert.deepEqual(payload.messages.at(-1).content.at(-1).cache_control, { type: "ephemeral" });
|
||||
assert.equal(payload.system.length, 4);
|
||||
assert.equal(payload.system.at(-1).text, "sys");
|
||||
assert.equal(payload.tools.length, 1);
|
||||
assert.deepEqual(payload.tools[0], {
|
||||
name: "lookup_weather",
|
||||
description: "Fetch weather",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
city: { type: "string" },
|
||||
},
|
||||
required: ["city"],
|
||||
},
|
||||
});
|
||||
assert.deepEqual(payload.tool_choice, { type: "any" });
|
||||
assert.equal(payload.context_management.edits[0].type, "clear_thinking_20251015");
|
||||
assert.equal(JSON.parse(payload.metadata.user_id).session_id, "session-1");
|
||||
});
|
||||
|
||||
test("buildClaudeCodeCompatibleRequest honors token priority fields", () => {
|
||||
const payload = buildClaudeCodeCompatibleRequest({
|
||||
sourceBody: { max_completion_tokens: 321 },
|
||||
normalizedBody: {
|
||||
max_tokens: 123,
|
||||
max_output_tokens: 456,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
},
|
||||
model: "claude-sonnet-4-6",
|
||||
sessionId: "session-2",
|
||||
});
|
||||
|
||||
assert.equal(payload.max_tokens, 321);
|
||||
assert.deepEqual(payload.tools, []);
|
||||
assert.equal(payload.tool_choice, undefined);
|
||||
});
|
||||
|
||||
test("buildClaudeCodeCompatibleRequest omits auto tool_choice while preserving tools", () => {
|
||||
const payload = buildClaudeCodeCompatibleRequest({
|
||||
sourceBody: { tool_choice: "auto" },
|
||||
normalizedBody: {
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "ping",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
model: "claude-sonnet-4-6",
|
||||
sessionId: "session-4",
|
||||
});
|
||||
|
||||
assert.equal(payload.tools.length, 1);
|
||||
assert.equal(payload.tool_choice, undefined);
|
||||
});
|
||||
|
||||
test("DefaultExecutor uses CC-compatible path and headers", () => {
|
||||
const executor = new DefaultExecutor("anthropic-compatible-cc-test");
|
||||
const credentials = {
|
||||
apiKey: "sk-test",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://proxy.example.com/v1/",
|
||||
chatPath: "",
|
||||
ccSessionId: "session-3",
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
executor.buildUrl("claude-sonnet-4-6", true, 0, credentials),
|
||||
`https://proxy.example.com/v1${CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH}`
|
||||
);
|
||||
|
||||
const headers = executor.buildHeaders(credentials, true);
|
||||
assert.equal(headers["x-api-key"], "sk-test");
|
||||
assert.equal(headers["X-Claude-Code-Session-Id"], "session-3");
|
||||
assert.equal(headers.Accept, "text/event-stream");
|
||||
assert.equal(headers.Authorization, undefined);
|
||||
});
|
||||
|
||||
test("validateProviderApiKey uses CC skeleton request after /models fallback", async () => {
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
calls.push({
|
||||
url: String(url),
|
||||
method: init.method || "GET",
|
||||
headers: init.headers,
|
||||
body: init.body ? JSON.parse(String(init.body)) : null,
|
||||
});
|
||||
|
||||
if (String(url).endsWith(CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH)) {
|
||||
return new Response(JSON.stringify({ error: "missing models" }), { status: 500 });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: "bad model" }), { status: 400 });
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "anthropic-compatible-cc-test",
|
||||
apiKey: "sk-test",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://proxy.example.com/v1/messages?beta=true",
|
||||
validationModelId: "claude-sonnet-4-6",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.method, "cc_bridge_request");
|
||||
assert.match(result.warning, /reached upstream/i);
|
||||
assert.deepEqual(
|
||||
calls.map((call) => `${call.method} ${call.url}`),
|
||||
[
|
||||
"GET https://proxy.example.com/v1/models",
|
||||
"POST https://proxy.example.com/v1/messages?beta=true",
|
||||
]
|
||||
);
|
||||
assert.equal(calls[1].body.model, "claude-sonnet-4-6");
|
||||
assert.equal(calls[1].body.messages[0].role, "user");
|
||||
assert.equal(calls[1].headers["x-api-key"], "sk-test");
|
||||
});
|
||||
|
||||
test("provider-nodes create route rejects CC mode when feature flag is disabled", async () => {
|
||||
delete process.env.ENABLE_CC_COMPATIBLE_PROVIDER;
|
||||
|
||||
const response = await providerNodesRoute.POST(
|
||||
new Request("http://localhost/api/provider-nodes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "Hidden CC",
|
||||
prefix: "cc",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
type: "anthropic-compatible",
|
||||
compatMode: "cc",
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
});
|
||||
|
||||
test("provider-nodes create route creates CC node with dedicated prefix when enabled", async () => {
|
||||
process.env.ENABLE_CC_COMPATIBLE_PROVIDER = "true";
|
||||
|
||||
const response = await providerNodesRoute.POST(
|
||||
new Request("http://localhost/api/provider-nodes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "Hidden CC",
|
||||
prefix: "cc",
|
||||
baseUrl: "https://proxy.example.com/v1/messages?beta=true",
|
||||
type: "anthropic-compatible",
|
||||
compatMode: "cc",
|
||||
chatPath: CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
modelsPath: CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
const data = await response.json();
|
||||
assert.match(data.node.id, /^anthropic-compatible-cc-/);
|
||||
assert.equal(data.node.baseUrl, "https://proxy.example.com/v1");
|
||||
assert.equal(data.node.chatPath, CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH);
|
||||
assert.equal(data.node.modelsPath, CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH);
|
||||
});
|
||||
|
||||
test("provider-nodes validate route rejects CC mode when feature flag is disabled", async () => {
|
||||
delete process.env.ENABLE_CC_COMPATIBLE_PROVIDER;
|
||||
|
||||
const response = await providerNodesValidateRoute.POST(
|
||||
new Request("http://localhost/api/provider-nodes/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
apiKey: "sk-test",
|
||||
type: "anthropic-compatible",
|
||||
compatMode: "cc",
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
});
|
||||
|
||||
test("provider-nodes list route exposes CC flag state from server env", async () => {
|
||||
process.env.ENABLE_CC_COMPATIBLE_PROVIDER = "true";
|
||||
|
||||
const response = await providerNodesRoute.GET();
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const data = await response.json();
|
||||
assert.equal(data.ccCompatibleProviderEnabled, true);
|
||||
});
|
||||
Reference in New Issue
Block a user