diff --git a/CHANGELOG.md b/CHANGELOG.md index acd62261ca..2a3dba18b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index 42abe949a7..5399ae0901 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -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; } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index c536220dcb..ca0d493c49 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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)` ); diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index baff923d87..de5de80369 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -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 | null; normalizedBody?: Record | null; + claudeBody?: Record | 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[] | 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[]) + : 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> } => + !!message && message.content.length > 0 + ) + : []; + + const merged: Array<{ role: "user" | "assistant"; content: Array> }> = []; + + 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> | undefined; + cwd: string; + now: Date; +}) { + const customSystemBlocks = + Array.isArray(systemBlocks) && systemBlocks.length > 0 + ? systemBlocks + : extractCustomSystemBlocks(messages); const dateText = formatDate(now); const blocks: Array> = [ @@ -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 => !!tool); + .filter((tool): tool is Record => !!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, + 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 => !!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 => !!message); +} + +function normalizeClaudeToolInput(tools: unknown) { + if (!Array.isArray(tools)) return []; + return tools + .map((tool) => readRecord(cloneValue(tool))) + .filter((tool): tool is Record => !!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 => !!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> }> +) { + 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>) { + for (const block of content) { + delete block.cache_control; + } +} + +function markLastContentCacheControl(content: Array>, 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>) { + for (let i = tools.length - 1; i >= 0; i--) { + if (!tools[i].defer_loading) { + return i; + } + } + return -1; +} + +function cloneValue(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(); diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 2cd0e8465b..c39df2f274 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -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; diff --git a/src/lib/oauth/providers/claude.ts b/src/lib/oauth/providers/claude.ts index 6f3f3eb560..9be6e75938 100644 --- a/src/lib/oauth/providers/claude.ts +++ b/src/lib/oauth/providers/claude.ts @@ -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, }), }); diff --git a/src/shared/components/ModelSelectModal.tsx b/src/shared/components/ModelSelectModal.tsx index 95bf930435..f4ff8a1579 100644 --- a/src/shared/components/ModelSelectModal.tsx +++ b/src/shared/components/ModelSelectModal.tsx @@ -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] = { diff --git a/tests/unit/cc-compatible-model-catalog.test.mjs b/tests/unit/cc-compatible-model-catalog.test.mjs new file mode 100644 index 0000000000..2664bb5b39 --- /dev/null +++ b/tests/unit/cc-compatible-model-catalog.test.mjs @@ -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 + ); +}); diff --git a/tests/unit/cc-compatible-provider.test.mjs b/tests/unit/cc-compatible-provider.test.mjs index ddae9a8640..022668a1e9 100644 --- a/tests/unit/cc-compatible-provider.test.mjs +++ b/tests/unit/cc-compatible-provider.test.mjs @@ -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; diff --git a/tests/unit/claude-oauth-provider.test.mjs b/tests/unit/claude-oauth-provider.test.mjs new file mode 100644 index 0000000000..479a14429c --- /dev/null +++ b/tests/unit/claude-oauth-provider.test.mjs @@ -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"); +}); diff --git a/tests/unit/t27-github-copilot-response-format.test.mjs b/tests/unit/t27-github-copilot-response-format.test.mjs index 618413584f..83945d9be8 100644 --- a/tests/unit/t27-github-copilot-response-format.test.mjs +++ b/tests/unit/t27-github-copilot-response-format.test.mjs @@ -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; + } +});