mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
chore(release): merge PR 937 and fix catalog models context length types
This commit is contained in:
@@ -13,6 +13,9 @@
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed Claude OAuth token refreshes failing to preserve cache contexts (#937)
|
||||
- Fixed CC-Compatible provider errors rendering cached models unreachable (#937)
|
||||
- Fixed GitHub Executor errors related to invalid context arrays (#937)
|
||||
- Fixed NPM-installed CLI tools healthcheck failures on Windows (#935)
|
||||
- Fixed payload translation dropping valid content due to invalid API fields (#927)
|
||||
- Fixed runtime crash in Node 25 regarding API key execution (#867)
|
||||
|
||||
@@ -76,7 +76,12 @@ export class GithubExecutor extends BaseExecutor {
|
||||
if (!result || !result.response?.body) return result;
|
||||
|
||||
const isStreaming = input.stream === true;
|
||||
if (isStreaming) {
|
||||
const contentType = (result.response.headers.get("content-type") || "").toLowerCase();
|
||||
if (isStreaming && result.response.ok && contentType.includes("text/event-stream")) {
|
||||
// Preserve the original response body for downstream error handling.
|
||||
const sourceResponse = result.response.clone();
|
||||
if (!sourceResponse.body) return result;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const transformStream = new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
@@ -88,10 +93,10 @@ export class GithubExecutor extends BaseExecutor {
|
||||
},
|
||||
});
|
||||
|
||||
const newResponse = new Response(result.response.body.pipeThrough(transformStream), {
|
||||
status: result.response.status,
|
||||
statusText: result.response.statusText,
|
||||
headers: result.response.headers, // Headers class carries over correctly
|
||||
const newResponse = new Response(sourceResponse.body.pipeThrough(transformStream), {
|
||||
status: sourceResponse.status,
|
||||
statusText: sourceResponse.statusText,
|
||||
headers: new Headers(sourceResponse.headers),
|
||||
});
|
||||
result.response = newResponse;
|
||||
}
|
||||
|
||||
@@ -796,11 +796,13 @@ export async function handleChatCore({
|
||||
translatedBody = buildClaudeCodeCompatibleRequest({
|
||||
sourceBody: body,
|
||||
normalizedBody: normalizedForCc,
|
||||
claudeBody: sourceFormat === FORMATS.CLAUDE ? body : null,
|
||||
model,
|
||||
stream: upstreamStream,
|
||||
sessionId: ccSessionId,
|
||||
cwd: process.cwd(),
|
||||
now: new Date(),
|
||||
preserveCacheControl,
|
||||
});
|
||||
log?.debug?.("FORMAT", "claude-code-compatible bridge enabled");
|
||||
} else if (isClaudePassthrough && preserveCacheControl) {
|
||||
@@ -1380,7 +1382,15 @@ export async function handleChatCore({
|
||||
// For providers with per-model quotas (passthrough providers, Gemini),
|
||||
// each model has independent quota. A 429 on one model must NOT lock out
|
||||
// the entire connection — other models may still have quota available.
|
||||
if (lockModelIfPerModelQuota(provider, connectionId, model, "rate_limited", retryAfterMs || COOLDOWN_MS.rateLimit)) {
|
||||
if (
|
||||
lockModelIfPerModelQuota(
|
||||
provider,
|
||||
connectionId,
|
||||
model,
|
||||
"rate_limited",
|
||||
retryAfterMs || COOLDOWN_MS.rateLimit
|
||||
)
|
||||
) {
|
||||
console.warn(
|
||||
`[provider] Node ${connectionId} model-only rate limited (${statusCode}) for ${model} - ${Math.ceil((retryAfterMs || COOLDOWN_MS.rateLimit) / 1000)}s (connection stays active)`
|
||||
);
|
||||
@@ -1401,7 +1411,15 @@ export async function handleChatCore({
|
||||
}
|
||||
} else if (errorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED) {
|
||||
// Providers with per-model quotas — lock the model only, not the connection
|
||||
if (lockModelIfPerModelQuota(provider, connectionId, model, "quota_exhausted", retryAfterMs || COOLDOWN_MS.rateLimit)) {
|
||||
if (
|
||||
lockModelIfPerModelQuota(
|
||||
provider,
|
||||
connectionId,
|
||||
model,
|
||||
"quota_exhausted",
|
||||
retryAfterMs || COOLDOWN_MS.rateLimit
|
||||
)
|
||||
) {
|
||||
console.warn(
|
||||
`[provider] Node ${connectionId} model-only quota exhausted (${statusCode}) for ${model} - ${Math.ceil((retryAfterMs || COOLDOWN_MS.rateLimit) / 1000)}s (connection stays active)`
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
import { prepareClaudeRequest } from "../translator/helpers/claudeHelper.ts";
|
||||
|
||||
export const CLAUDE_CODE_COMPATIBLE_PREFIX = "anthropic-compatible-cc-";
|
||||
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true";
|
||||
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH = "/models";
|
||||
@@ -26,11 +28,13 @@ type MessageLike = {
|
||||
type BuildRequestOptions = {
|
||||
sourceBody?: Record<string, unknown> | null;
|
||||
normalizedBody?: Record<string, unknown> | null;
|
||||
claudeBody?: Record<string, unknown> | null;
|
||||
model: string;
|
||||
stream?: boolean;
|
||||
cwd?: string;
|
||||
now?: Date;
|
||||
sessionId?: string | null;
|
||||
preserveCacheControl?: boolean;
|
||||
};
|
||||
|
||||
export function isClaudeCodeCompatibleProvider(provider: string | null | undefined): boolean {
|
||||
@@ -137,25 +141,38 @@ export function resolveClaudeCodeCompatibleSessionId(headers?: HeaderLike): stri
|
||||
export function buildClaudeCodeCompatibleRequest({
|
||||
sourceBody,
|
||||
normalizedBody,
|
||||
claudeBody,
|
||||
model,
|
||||
stream = false,
|
||||
cwd = process.cwd(),
|
||||
now = new Date(),
|
||||
sessionId,
|
||||
preserveCacheControl = false,
|
||||
}: BuildRequestOptions) {
|
||||
const normalized = normalizedBody || {};
|
||||
const messages = Array.isArray(normalized.messages)
|
||||
? buildClaudeCodeCompatibleMessages(normalized.messages as MessageLike[])
|
||||
: [];
|
||||
const system = buildClaudeCodeCompatibleSystemBlocks(
|
||||
normalized.messages as MessageLike[],
|
||||
const preparedClaudeBody = claudeBody
|
||||
? prepareClaudeCodeCompatibleBody(claudeBody, preserveCacheControl)
|
||||
: null;
|
||||
const messages = preparedClaudeBody
|
||||
? buildClaudeCodeCompatibleMessagesFromClaude(
|
||||
preparedClaudeBody.messages as MessageLike[],
|
||||
preserveCacheControl
|
||||
)
|
||||
: Array.isArray(normalized.messages)
|
||||
? buildClaudeCodeCompatibleMessages(normalized.messages as MessageLike[])
|
||||
: [];
|
||||
const system = buildClaudeCodeCompatibleSystemBlocks({
|
||||
messages: normalized.messages as MessageLike[],
|
||||
systemBlocks: preparedClaudeBody?.system as Record<string, unknown>[] | undefined,
|
||||
cwd,
|
||||
now
|
||||
);
|
||||
now,
|
||||
});
|
||||
const resolvedSessionId = sessionId || randomUUID();
|
||||
const effort = resolveClaudeCodeCompatibleEffort(sourceBody, normalizedBody, model);
|
||||
const maxTokens = resolveClaudeCodeCompatibleMaxTokens(sourceBody, normalizedBody);
|
||||
const tools = buildClaudeCodeCompatibleTools(normalizedBody, sourceBody);
|
||||
const tools = preparedClaudeBody?.tools
|
||||
? (preparedClaudeBody.tools as Record<string, unknown>[])
|
||||
: buildClaudeCodeCompatibleTools(normalizedBody, sourceBody);
|
||||
const toolChoice =
|
||||
tools.length > 0
|
||||
? buildClaudeCodeCompatibleToolChoice(
|
||||
@@ -295,32 +312,83 @@ function buildClaudeCodeCompatibleMessages(messages: MessageLike[]) {
|
||||
}
|
||||
}
|
||||
|
||||
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" };
|
||||
applyClaudeCodeCompatibleMessageCacheStrategy(merged);
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function buildClaudeCodeCompatibleMessagesFromClaude(
|
||||
messages: MessageLike[] | undefined,
|
||||
preserveCacheControl: boolean
|
||||
) {
|
||||
const converted = Array.isArray(messages)
|
||||
? messages
|
||||
.map((message) => convertClaudeCodeCompatibleClaudeMessage(message, preserveCacheControl))
|
||||
.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] });
|
||||
}
|
||||
|
||||
while (merged.length > 0 && merged[merged.length - 1].role === "assistant") {
|
||||
merged.pop();
|
||||
}
|
||||
|
||||
if (!preserveCacheControl) {
|
||||
for (const message of merged) {
|
||||
stripCacheControlFromContentBlocks(message.content);
|
||||
}
|
||||
applyClaudeCodeCompatibleMessageCacheStrategy(merged);
|
||||
}
|
||||
|
||||
if (merged.length === 0) {
|
||||
const fallbackText = converted
|
||||
.flatMap((message) => message.content)
|
||||
.map((block) => contentToText(block))
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (fallbackText) {
|
||||
return [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text", text: fallbackText, 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)
|
||||
: [];
|
||||
function buildClaudeCodeCompatibleSystemBlocks({
|
||||
messages,
|
||||
systemBlocks,
|
||||
cwd,
|
||||
now,
|
||||
}: {
|
||||
messages: MessageLike[] | undefined;
|
||||
systemBlocks?: Array<Record<string, unknown>> | undefined;
|
||||
cwd: string;
|
||||
now: Date;
|
||||
}) {
|
||||
const customSystemBlocks =
|
||||
Array.isArray(systemBlocks) && systemBlocks.length > 0
|
||||
? systemBlocks
|
||||
: extractCustomSystemBlocks(messages);
|
||||
|
||||
const dateText = formatDate(now);
|
||||
const blocks: Array<Record<string, unknown>> = [
|
||||
@@ -340,12 +408,8 @@ function buildClaudeCodeCompatibleSystemBlocks(
|
||||
},
|
||||
];
|
||||
|
||||
for (const systemText of customSystemBlocks) {
|
||||
blocks.push({
|
||||
type: "text",
|
||||
text: systemText,
|
||||
cache_control: { type: "ephemeral" },
|
||||
});
|
||||
for (const systemBlock of customSystemBlocks) {
|
||||
blocks.push(systemBlock);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
@@ -383,7 +447,14 @@ function buildClaudeCodeCompatibleTools(
|
||||
|
||||
return rawTools
|
||||
.map((tool) => convertClaudeCodeCompatibleTool(tool))
|
||||
.filter((tool): tool is Record<string, unknown> => !!tool);
|
||||
.filter((tool): tool is Record<string, unknown> => !!tool)
|
||||
.map((tool, index, tools) => {
|
||||
if (index !== findLastCacheableToolIndex(tools)) return tool;
|
||||
return {
|
||||
...tool,
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function convertClaudeCodeCompatibleTool(tool: unknown) {
|
||||
@@ -444,6 +515,190 @@ function buildClaudeCodeCompatibleToolChoice(choice: unknown) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function prepareClaudeCodeCompatibleBody(
|
||||
claudeBody: Record<string, unknown>,
|
||||
preserveCacheControl: boolean
|
||||
) {
|
||||
const prepared = prepareClaudeRequest(
|
||||
{
|
||||
system: normalizeClaudeSystemInput(claudeBody.system),
|
||||
messages: normalizeClaudeMessageInput(claudeBody.messages),
|
||||
tools: normalizeClaudeToolInput(claudeBody.tools),
|
||||
thinking: readRecord(claudeBody.thinking) || claudeBody.thinking,
|
||||
},
|
||||
CLAUDE_CODE_COMPATIBLE_PREFIX,
|
||||
preserveCacheControl
|
||||
);
|
||||
|
||||
return readRecord(prepared);
|
||||
}
|
||||
|
||||
function normalizeClaudeSystemInput(system: unknown) {
|
||||
if (typeof system === "string") {
|
||||
const text = system.trim();
|
||||
return text ? [{ type: "text", text }] : [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(system)) return [];
|
||||
return system
|
||||
.map((block) => normalizeClaudeContentBlock(block))
|
||||
.filter((block): block is Record<string, unknown> => !!block);
|
||||
}
|
||||
|
||||
function normalizeClaudeMessageInput(messages: unknown) {
|
||||
if (!Array.isArray(messages)) return [];
|
||||
return messages
|
||||
.map((message) => {
|
||||
const record = readRecord(message);
|
||||
if (!record) return null;
|
||||
|
||||
return {
|
||||
...record,
|
||||
content: normalizeClaudeContentInput(record.content),
|
||||
};
|
||||
})
|
||||
.filter((message): message is Record<string, unknown> => !!message);
|
||||
}
|
||||
|
||||
function normalizeClaudeToolInput(tools: unknown) {
|
||||
if (!Array.isArray(tools)) return [];
|
||||
return tools
|
||||
.map((tool) => readRecord(cloneValue(tool)))
|
||||
.filter((tool): tool is Record<string, unknown> => !!tool);
|
||||
}
|
||||
|
||||
function normalizeClaudeContentInput(content: unknown) {
|
||||
const blocks = normalizeClaudeContentBlocks(content);
|
||||
return blocks.length > 0 ? blocks : content;
|
||||
}
|
||||
|
||||
function normalizeClaudeContentBlocks(content: unknown) {
|
||||
if (typeof content === "string") {
|
||||
const text = content.trim();
|
||||
return text ? [{ type: "text", text }] : [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
const block = normalizeClaudeContentBlock(content);
|
||||
return block ? [block] : [];
|
||||
}
|
||||
|
||||
return content
|
||||
.map((block) => normalizeClaudeContentBlock(block))
|
||||
.filter((block): block is Record<string, unknown> => !!block);
|
||||
}
|
||||
|
||||
function normalizeClaudeContentBlock(block: unknown) {
|
||||
const record = readRecord(cloneValue(block));
|
||||
if (!record) return null;
|
||||
|
||||
if (
|
||||
record.type === "text" ||
|
||||
(typeof record.type !== "string" && typeof record.text === "string")
|
||||
) {
|
||||
const text = toNonEmptyString(record.text);
|
||||
if (!text) return null;
|
||||
return {
|
||||
...record,
|
||||
type: "text",
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
function convertClaudeCodeCompatibleClaudeMessage(
|
||||
message: MessageLike | null | undefined,
|
||||
preserveCacheControl: boolean
|
||||
) {
|
||||
const rawRole = String(message?.role || "").toLowerCase();
|
||||
const role = rawRole === "user" ? "user" : rawRole === "assistant" ? "assistant" : null;
|
||||
|
||||
if (!role) return null;
|
||||
|
||||
const content = normalizeClaudeContentBlocks(message?.content).map((block) => {
|
||||
if (preserveCacheControl) return block;
|
||||
const { cache_control, ...rest } = block;
|
||||
return rest;
|
||||
});
|
||||
if (content.length === 0) return null;
|
||||
|
||||
return {
|
||||
role,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
function extractCustomSystemBlocks(messages: MessageLike[] | undefined) {
|
||||
if (!Array.isArray(messages)) return [];
|
||||
|
||||
return messages
|
||||
.filter((message) => {
|
||||
const role = String(message?.role || "").toLowerCase();
|
||||
return role === "system" || role === "developer";
|
||||
})
|
||||
.map((message) => contentToText(message?.content))
|
||||
.filter(Boolean)
|
||||
.map((text) => ({
|
||||
type: "text",
|
||||
text,
|
||||
cache_control: { type: "ephemeral" },
|
||||
}));
|
||||
}
|
||||
|
||||
function applyClaudeCodeCompatibleMessageCacheStrategy(
|
||||
messages: Array<{ role: "user" | "assistant"; content: Array<Record<string, unknown>> }>
|
||||
) {
|
||||
const userIndexes = messages.reduce((indexes, message, index) => {
|
||||
if (message.role === "user") indexes.push(index);
|
||||
return indexes;
|
||||
}, [] as number[]);
|
||||
const hasAssistant = messages.some((message) => message.role === "assistant");
|
||||
const secondToLastUserIndex = userIndexes.length >= 2 ? userIndexes[userIndexes.length - 2] : -1;
|
||||
|
||||
if (secondToLastUserIndex >= 0) {
|
||||
markLastContentCacheControl(messages[secondToLastUserIndex].content);
|
||||
} else if (!hasAssistant && userIndexes.length > 0) {
|
||||
markLastContentCacheControl(messages[userIndexes[userIndexes.length - 1]].content);
|
||||
}
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role !== "assistant") continue;
|
||||
if (markLastContentCacheControl(messages[i].content)) break;
|
||||
}
|
||||
}
|
||||
|
||||
function stripCacheControlFromContentBlocks(content: Array<Record<string, unknown>>) {
|
||||
for (const block of content) {
|
||||
delete block.cache_control;
|
||||
}
|
||||
}
|
||||
|
||||
function markLastContentCacheControl(content: Array<Record<string, unknown>>, ttl?: string) {
|
||||
if (!Array.isArray(content) || content.length === 0) return false;
|
||||
const lastBlock = content[content.length - 1];
|
||||
if (!lastBlock) return false;
|
||||
lastBlock.cache_control = ttl ? { type: "ephemeral", ttl } : { type: "ephemeral" };
|
||||
return true;
|
||||
}
|
||||
|
||||
function findLastCacheableToolIndex(tools: Array<Record<string, unknown>>) {
|
||||
for (let i = tools.length - 1; i >= 0; i--) {
|
||||
if (!tools[i].defer_loading) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function cloneValue<T>(value: T): T {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function contentToText(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content.trim();
|
||||
|
||||
@@ -19,6 +19,7 @@ import { getAllVideoModels } from "@omniroute/open-sse/config/videoRegistry.ts";
|
||||
import { getAllMusicModels } from "@omniroute/open-sse/config/musicRegistry.ts";
|
||||
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
import { getSyncedAvailableModels } from "@/lib/db/models";
|
||||
import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels";
|
||||
|
||||
const FALLBACK_ALIAS_TO_PROVIDER = {
|
||||
ag: "antigravity",
|
||||
@@ -306,9 +307,7 @@ export async function getUnifiedModelsResponse(
|
||||
if (getModelIsHidden("gemini", sm.id)) continue;
|
||||
|
||||
// Convert supportedEndpoints to type/subtype for endpoint categorization
|
||||
const endpoints = Array.isArray(sm.supportedEndpoints)
|
||||
? sm.supportedEndpoints
|
||||
: ["chat"];
|
||||
const endpoints = Array.isArray(sm.supportedEndpoints) ? sm.supportedEndpoints : ["chat"];
|
||||
let modelType: string | undefined;
|
||||
if (endpoints.includes("embeddings")) modelType = "embedding";
|
||||
else if (endpoints.includes("images")) modelType = "image";
|
||||
@@ -551,6 +550,47 @@ export async function getUnifiedModelsResponse(
|
||||
console.log("Could not fetch custom models");
|
||||
}
|
||||
|
||||
// Add managed fallback models for compatible providers that don't import a model list.
|
||||
for (const conn of connections) {
|
||||
const providerId = typeof conn.provider === "string" ? conn.provider : null;
|
||||
if (!providerId) continue;
|
||||
if (blockedProviders.has(providerId)) continue;
|
||||
|
||||
const fallbackModels = getCompatibleFallbackModels(providerId);
|
||||
if (!Array.isArray(fallbackModels) || fallbackModels.length === 0) continue;
|
||||
|
||||
const prefix = providerIdToPrefix[providerId];
|
||||
const alias = prefix || providerIdToAlias[providerId] || providerId;
|
||||
|
||||
for (const model of fallbackModels) {
|
||||
const modelId = typeof model.id === "string" ? model.id : null;
|
||||
if (!modelId) continue;
|
||||
if (getModelIsHidden(providerId, modelId)) continue;
|
||||
|
||||
const aliasId = `${alias}/${modelId}`;
|
||||
if (models.some((m) => m.id === aliasId)) continue;
|
||||
|
||||
const visionFields =
|
||||
getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(modelId);
|
||||
const contextLength =
|
||||
typeof (model as any).contextLength === "number"
|
||||
? (model as any).contextLength
|
||||
: undefined;
|
||||
|
||||
models.push({
|
||||
id: aliasId,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: providerId,
|
||||
permission: [],
|
||||
root: modelId,
|
||||
parent: null,
|
||||
...(contextLength ? { context_length: contextLength } : {}),
|
||||
...(visionFields || {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by API key permissions if requested
|
||||
const authHeader = request.headers.get("authorization");
|
||||
let finalModels = models;
|
||||
|
||||
@@ -3,12 +3,12 @@ import { CLAUDE_CONFIG } from "../constants/oauth";
|
||||
export const claude = {
|
||||
config: CLAUDE_CONFIG,
|
||||
flowType: "authorization_code_pkce",
|
||||
buildAuthUrl: (config, _redirectUri, state, codeChallenge) => {
|
||||
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
|
||||
const params = new URLSearchParams({
|
||||
code: "true",
|
||||
client_id: config.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: config.redirectUri,
|
||||
redirect_uri: redirectUri,
|
||||
scope: config.scopes.join(" "),
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: config.codeChallengeMethod,
|
||||
@@ -16,7 +16,7 @@ export const claude = {
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, _redirectUri, codeVerifier, state) => {
|
||||
exchangeToken: async (config, code, redirectUri, codeVerifier, state) => {
|
||||
let authCode = code;
|
||||
let codeState = "";
|
||||
if (authCode.includes("#")) {
|
||||
@@ -36,7 +36,7 @@ export const claude = {
|
||||
state: codeState || state,
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.clientId,
|
||||
redirect_uri: config.redirectUri,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useMemo, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Modal from "./Modal";
|
||||
import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
|
||||
import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels";
|
||||
import {
|
||||
OAUTH_PROVIDERS,
|
||||
FREE_PROVIDERS,
|
||||
@@ -160,9 +161,24 @@ export default function ModelSelectModal({
|
||||
value: `${nodePrefix}/${fullModel.replace(`${providerId}/`, "")}`,
|
||||
}));
|
||||
|
||||
const fallbackEntries = (
|
||||
getCompatibleFallbackModels(providerId, providerCustomModels) || []
|
||||
)
|
||||
.filter((fm) => !nodeModels.some((nm) => nm.id === fm.id))
|
||||
.map((fm) => ({
|
||||
id: fm.id,
|
||||
name: fm.name || fm.id,
|
||||
value: `${nodePrefix}/${fm.id}`,
|
||||
isFallback: true,
|
||||
}));
|
||||
|
||||
// Merge custom models for custom providers
|
||||
const customEntries = providerCustomModels
|
||||
.filter((cm) => !nodeModels.some((nm) => nm.id === cm.id))
|
||||
.filter(
|
||||
(cm) =>
|
||||
!nodeModels.some((nm) => nm.id === cm.id) &&
|
||||
!fallbackEntries.some((fm) => fm.id === cm.id)
|
||||
)
|
||||
.map((cm) => ({
|
||||
id: cm.id,
|
||||
name: cm.name || cm.id,
|
||||
@@ -170,7 +186,7 @@ export default function ModelSelectModal({
|
||||
isCustom: true,
|
||||
}));
|
||||
|
||||
const allModels = [...nodeModels, ...customEntries];
|
||||
const allModels = [...nodeModels, ...fallbackEntries, ...customEntries];
|
||||
|
||||
if (allModels.length > 0) {
|
||||
groups[providerId] = {
|
||||
|
||||
67
tests/unit/cc-compatible-model-catalog.test.mjs
Normal file
67
tests/unit/cc-compatible-model-catalog.test.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
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-models-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.afterEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("v1 models exposes CC-compatible fallback models under the provider node prefix", async () => {
|
||||
await providersDb.createProviderNode({
|
||||
id: "anthropic-compatible-cc-cm",
|
||||
type: "anthropic-compatible",
|
||||
name: "Claude Max",
|
||||
prefix: "cm",
|
||||
baseUrl: "https://proxy.example.com",
|
||||
chatPath: "/v1/messages?beta=true",
|
||||
modelsPath: "/v1/models",
|
||||
});
|
||||
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "anthropic-compatible-cc-cm",
|
||||
authType: "apikey",
|
||||
name: "cm-main",
|
||||
apiKey: "sk-test",
|
||||
isActive: true,
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://proxy.example.com",
|
||||
chatPath: "/v1/messages?beta=true",
|
||||
modelsPath: "/v1/models",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await v1ModelsCatalog.getUnifiedModelsResponse(
|
||||
new Request("http://localhost/api/v1/models", { method: "GET" })
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.json();
|
||||
const ids = new Set(body.data.map((item) => item.id));
|
||||
|
||||
assert.ok(ids.has("cm/claude-opus-4-6"));
|
||||
assert.ok(ids.has("cm/claude-sonnet-4-6"));
|
||||
assert.equal(
|
||||
[...ids].some((id) => id.startsWith("anthropic-compatible-cc-cm/")),
|
||||
false
|
||||
);
|
||||
});
|
||||
@@ -104,11 +104,14 @@ test("buildClaudeCodeCompatibleRequest keeps prior role history while dropping t
|
||||
{ role: "user", text: "u2" },
|
||||
]
|
||||
);
|
||||
assert.deepEqual(payload.messages.at(-1).content.at(-1).cache_control, { type: "ephemeral" });
|
||||
assert.deepEqual(payload.messages[0].content.at(-1).cache_control, { type: "ephemeral" });
|
||||
assert.deepEqual(payload.messages[1].content.at(-1).cache_control, { type: "ephemeral" });
|
||||
assert.equal(payload.messages[2].content.at(-1).cache_control, undefined);
|
||||
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], {
|
||||
const { cache_control, ...toolWithoutCacheControl } = payload.tools[0];
|
||||
assert.deepEqual(toolWithoutCacheControl, {
|
||||
name: "lookup_weather",
|
||||
description: "Fetch weather",
|
||||
input_schema: {
|
||||
@@ -119,11 +122,70 @@ test("buildClaudeCodeCompatibleRequest keeps prior role history while dropping t
|
||||
required: ["city"],
|
||||
},
|
||||
});
|
||||
assert.deepEqual(payload.tools[0].cache_control, { type: "ephemeral", ttl: "1h" });
|
||||
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 preserves Claude cache markers when requested", () => {
|
||||
const payload = buildClaudeCodeCompatibleRequest({
|
||||
sourceBody: {
|
||||
max_tokens: 64,
|
||||
},
|
||||
normalizedBody: {
|
||||
max_tokens: 64,
|
||||
messages: [{ role: "user", content: "fallback" }],
|
||||
},
|
||||
claudeBody: {
|
||||
system: [{ type: "text", text: "sys", cache_control: { type: "ephemeral", ttl: "5m" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "u1", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "a1",
|
||||
cache_control: { type: "ephemeral", ttl: "10m" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "u2" }] },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: "lookup_weather",
|
||||
description: "Fetch weather",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
city: { type: "string" },
|
||||
},
|
||||
required: ["city"],
|
||||
},
|
||||
cache_control: { type: "ephemeral", ttl: "30m" },
|
||||
},
|
||||
],
|
||||
},
|
||||
model: "claude-sonnet-4-6",
|
||||
sessionId: "session-preserve",
|
||||
preserveCacheControl: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(payload.system.at(-1).cache_control, { type: "ephemeral", ttl: "5m" });
|
||||
assert.deepEqual(payload.messages[0].content[0].cache_control, { type: "ephemeral" });
|
||||
assert.deepEqual(payload.messages[1].content[0].cache_control, {
|
||||
type: "ephemeral",
|
||||
ttl: "10m",
|
||||
});
|
||||
assert.equal(payload.messages[2].content[0].cache_control, undefined);
|
||||
assert.deepEqual(payload.tools[0].cache_control, { type: "ephemeral", ttl: "30m" });
|
||||
});
|
||||
|
||||
test("buildClaudeCodeCompatibleRequest falls back to a user turn when the source only has assistant/model text", () => {
|
||||
const payload = buildClaudeCodeCompatibleRequest({
|
||||
sourceBody: {
|
||||
@@ -181,6 +243,7 @@ test("buildClaudeCodeCompatibleRequest omits auto tool_choice while preserving t
|
||||
});
|
||||
|
||||
assert.equal(payload.tools.length, 1);
|
||||
assert.deepEqual(payload.tools[0].cache_control, { type: "ephemeral", ttl: "1h" });
|
||||
assert.equal(payload.tool_choice, undefined);
|
||||
});
|
||||
|
||||
@@ -356,6 +419,126 @@ test("handleChatCore forces upstream streaming for CC compatible while returning
|
||||
assert.equal(payload.usage.completion_tokens, 5);
|
||||
});
|
||||
|
||||
test("handleChatCore preserves Claude cache_control when CC-compatible requests originate from Claude", 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,
|
||||
});
|
||||
|
||||
return new Response(
|
||||
[
|
||||
"event: message_start",
|
||||
'data: {"type":"message_start","message":{"id":"msg_2","type":"message","role":"assistant","model":"claude-sonnet-4-6","usage":{"input_tokens":12,"output_tokens":0}}}',
|
||||
"",
|
||||
"event: content_block_start",
|
||||
'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}',
|
||||
"",
|
||||
"event: content_block_delta",
|
||||
'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Preserved"}}',
|
||||
"",
|
||||
"event: message_delta",
|
||||
'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":3}}',
|
||||
"",
|
||||
"event: message_stop",
|
||||
'data: {"type":"message_stop"}',
|
||||
"",
|
||||
].join("\n"),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/event-stream",
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const claudeBody = {
|
||||
model: "claude-sonnet-4-6",
|
||||
max_tokens: 64,
|
||||
system: [{ type: "text", text: "system", cache_control: { type: "ephemeral", ttl: "5m" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "u1", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "a1",
|
||||
cache_control: { type: "ephemeral", ttl: "10m" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "u2" }] },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: "lookup_weather",
|
||||
description: "Fetch weather",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
city: { type: "string" },
|
||||
},
|
||||
},
|
||||
cache_control: { type: "ephemeral", ttl: "30m" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await handleChatCore({
|
||||
body: claudeBody,
|
||||
modelInfo: {
|
||||
provider: "anthropic-compatible-cc-test",
|
||||
model: "claude-sonnet-4-6",
|
||||
extendedContext: false,
|
||||
},
|
||||
credentials: {
|
||||
apiKey: "sk-test",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://proxy.example.com",
|
||||
chatPath: CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
},
|
||||
},
|
||||
clientRawRequest: {
|
||||
endpoint: "/v1/messages",
|
||||
body: claudeBody,
|
||||
headers: new Headers({ accept: "application/json" }),
|
||||
},
|
||||
userAgent: "Claude-Code/1.0.0",
|
||||
log: {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(calls[0].body.system.at(-1).cache_control, {
|
||||
type: "ephemeral",
|
||||
ttl: "5m",
|
||||
});
|
||||
assert.deepEqual(calls[0].body.messages[0].content[0].cache_control, {
|
||||
type: "ephemeral",
|
||||
});
|
||||
assert.deepEqual(calls[0].body.messages[1].content[0].cache_control, {
|
||||
type: "ephemeral",
|
||||
ttl: "10m",
|
||||
});
|
||||
assert.deepEqual(calls[0].body.tools[0].cache_control, {
|
||||
type: "ephemeral",
|
||||
ttl: "30m",
|
||||
});
|
||||
});
|
||||
|
||||
test("provider-nodes create route rejects CC mode when feature flag is disabled", async () => {
|
||||
delete process.env.ENABLE_CC_COMPATIBLE_PROVIDER;
|
||||
|
||||
|
||||
55
tests/unit/claude-oauth-provider.test.mjs
Normal file
55
tests/unit/claude-oauth-provider.test.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { claude } from "../../src/lib/oauth/providers/claude.ts";
|
||||
import { CLAUDE_CONFIG } from "../../src/lib/oauth/constants/oauth.ts";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("Claude OAuth provider uses the runtime redirectUri when building the auth URL", () => {
|
||||
const redirectUri = "http://localhost:43121/callback";
|
||||
const authUrl = claude.buildAuthUrl(CLAUDE_CONFIG, redirectUri, "state-123", "challenge-456");
|
||||
const parsed = new URL(authUrl);
|
||||
|
||||
assert.equal(parsed.searchParams.get("redirect_uri"), redirectUri);
|
||||
assert.equal(parsed.searchParams.get("state"), "state-123");
|
||||
assert.equal(parsed.searchParams.get("code_challenge"), "challenge-456");
|
||||
});
|
||||
|
||||
test("Claude OAuth provider uses the runtime redirectUri during token exchange", async () => {
|
||||
let captured = null;
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
captured = {
|
||||
url: String(url),
|
||||
method: init.method,
|
||||
headers: init.headers,
|
||||
body: JSON.parse(String(init.body)),
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify({ access_token: "token-1" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
const redirectUri = "http://localhost:43121/callback";
|
||||
await claude.exchangeToken(
|
||||
CLAUDE_CONFIG,
|
||||
"auth-code#state-from-fragment",
|
||||
redirectUri,
|
||||
"verifier-123",
|
||||
"state-from-request"
|
||||
);
|
||||
|
||||
assert.equal(captured.url, CLAUDE_CONFIG.tokenUrl);
|
||||
assert.equal(captured.method, "POST");
|
||||
assert.equal(captured.body.redirect_uri, redirectUri);
|
||||
assert.equal(captured.body.code, "auth-code");
|
||||
assert.equal(captured.body.state, "state-from-fragment");
|
||||
assert.equal(captured.body.code_verifier, "verifier-123");
|
||||
});
|
||||
@@ -82,3 +82,30 @@ test("T27: SSE [DONE] guard applies only in streaming mode", async () => {
|
||||
BaseExecutor.prototype.execute = originalExecute;
|
||||
}
|
||||
});
|
||||
|
||||
test("T27: streaming error responses keep their original body readable", async () => {
|
||||
const executor = new GithubExecutor();
|
||||
const originalExecute = BaseExecutor.prototype.execute;
|
||||
|
||||
BaseExecutor.prototype.execute = async () => ({
|
||||
response: new Response("IDE token expired: unauthorized: token expired\n", {
|
||||
status: 401,
|
||||
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||
}),
|
||||
url: "https://api.githubcopilot.com/chat/completions",
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "claude-sonnet-4.5",
|
||||
body: { messages: [] },
|
||||
stream: true,
|
||||
credentials: { accessToken: "token" },
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 401);
|
||||
assert.equal(await result.response.text(), "IDE token expired: unauthorized: token expired\n");
|
||||
} finally {
|
||||
BaseExecutor.prototype.execute = originalExecute;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user