fix claude oauth and cc-compatible regressions

This commit is contained in:
R.D.
2026-04-02 20:54:19 -04:00
parent 9a681a27ad
commit bcc374eb31
10 changed files with 698 additions and 48 deletions

View File

@@ -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;
}

View File

@@ -794,11 +794,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) {

View File

@@ -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();

View File

@@ -18,6 +18,7 @@ import { getAllModerationModels } from "@omniroute/open-sse/config/moderationReg
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 { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels";
const FALLBACK_ALIAS_TO_PROVIDER = {
ag: "antigravity",
@@ -485,6 +486,45 @@ 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.contextLength === "number" ? model.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;

View File

@@ -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,
}),
});

View File

@@ -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] = {

View 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
);
});

View File

@@ -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;

View 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");
});

View File

@@ -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;
}
});