diff --git a/open-sse/executors/vertex.ts b/open-sse/executors/vertex.ts index 7412fa24b9..02994026d0 100644 --- a/open-sse/executors/vertex.ts +++ b/open-sse/executors/vertex.ts @@ -21,6 +21,28 @@ export function parseSAFromApiKey(apiKey: string): ServiceAccount { } } +/** + * A Service Account credential is a JSON object (type/client_email/private_key). A Vertex AI + * Express-mode API key is an opaque non-JSON string. Distinguishing them lets the executor + * support BOTH: Service Account JSON (JWT → OAuth → project-scoped endpoint + Bearer auth) and + * Express keys (project-less publisher endpoint + x-goog-api-key auth), instead of failing every + * Express key with "requires a valid Service Account JSON". + */ +export function looksLikeServiceAccountJson(apiKey: string): boolean { + if (!apiKey || typeof apiKey !== "string") return false; + try { + const parsed = JSON.parse(apiKey); + return !!parsed && typeof parsed === "object" && !Array.isArray(parsed); + } catch { + return false; + } +} + +/** True for a Vertex AI Express-mode API key (a non-empty, non-JSON, non-OAuth credential). */ +export function isExpressApiKey(apiKey?: string | null): boolean { + return typeof apiKey === "string" && apiKey.trim().length > 0 && !looksLikeServiceAccountJson(apiKey); +} + export async function getAccessToken(sa: ServiceAccount): Promise { if (!sa.client_email || !sa.private_key) { throw new Error( @@ -110,7 +132,13 @@ export class VertexExecutor extends BaseExecutor { async execute(input: ExecuteInput) { const { credentials, log } = input; - if (credentials.apiKey && !credentials.accessToken) { + // Defensive: trim stray surrounding whitespace from a pasted credential. + if (typeof credentials.apiKey === "string") { + credentials.apiKey = credentials.apiKey.trim(); + } + // Service Account JSON → mint a short-lived OAuth token (Bearer). An Express-mode API key is + // sent as-is via x-goog-api-key (see buildHeaders), so no token exchange is needed for it. + if (credentials.apiKey && !credentials.accessToken && looksLikeServiceAccountJson(credentials.apiKey)) { try { const sa = parseSAFromApiKey(credentials.apiKey); credentials.accessToken = await getAccessToken(sa); @@ -123,6 +151,19 @@ export class VertexExecutor extends BaseExecutor { } buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: any = null) { + // Vertex AI Express mode: project-less v1 publisher endpoint with the API key passed as a + // ?key= query parameter (verified working contract — same as the CaptionAI GeminiClient). The + // Express key is NOT accepted as a Bearer/OAuth credential or via x-goog-api-key on this API. + if (isExpressApiKey(credentials?.apiKey) && !credentials?.accessToken) { + const expressKey = encodeURIComponent(String(credentials.apiKey).trim()); + if (isPartnerModel(model)) { + // Partner (Anthropic/etc.) models are not available via Express keys; best-effort. + return `https://aiplatform.googleapis.com/v1/publishers/openapi/chat/completions?key=${expressKey}`; + } + const op = stream ? "streamGenerateContent?alt=sse&" : "generateContent?"; + return `https://aiplatform.googleapis.com/v1/publishers/google/models/${model}:${op}key=${expressKey}`; + } + const region = credentials?.providerSpecificData?.region || "us-central1"; let project = "unknown-project"; @@ -146,6 +187,7 @@ export class VertexExecutor extends BaseExecutor { if (credentials.accessToken) { headers["Authorization"] = `Bearer ${credentials.accessToken}`; } + // Express-mode keys are carried in the ?key= query parameter (see buildUrl), not a header. if (stream) { headers["Accept"] = "text/event-stream"; } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 5198a21a7d..873bb3fd82 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1986,12 +1986,13 @@ export async function handleChatCore({ // Use credentials.connectionId as a fallback so that requests without an // explicit session-level connectionId still register in the pendingRequests map. const pendingConnId = connectionId || credentials?.connectionId || null; - const pendingRequestId = trackPendingRequest(model, provider, pendingConnId, true, { - clientEndpoint: clientRawRequest?.endpoint || "/v1/chat/completions", - clientRequest: clientRawRequest?.body ?? body, - providerRequest: initialProviderRequest, - stage: "registered", - }) || generateRequestId(); + const pendingRequestId = + trackPendingRequest(model, provider, pendingConnId, true, { + clientEndpoint: clientRawRequest?.endpoint || "/v1/chat/completions", + clientRequest: clientRawRequest?.body ?? body, + providerRequest: initialProviderRequest, + stage: "registered", + }) || generateRequestId(); // Initialize rate limit settings from persisted DB (once, lazy) await initializeRateLimits(); @@ -2769,7 +2770,10 @@ export async function handleChatCore({ comboTargetLimits, }); contextLimit = resolved.limit; - log?.info?.("CONTEXT", `Combo context limit: ${resolved.limit} (source=${resolved.source})`); + log?.info?.( + "CONTEXT", + `Combo context limit: ${resolved.limit} (source=${resolved.source})` + ); } catch (err) { log?.warn?.("CONTEXT", "Failed to resolve combo limits for compression: " + err); } @@ -3104,6 +3108,12 @@ export async function handleChatCore({ translatedBody.messages, DEFAULT_THINKING_CLAUDE_SIGNATURE ) as typeof translatedBody.messages; + + // Anthropic API rejects requests with both temperature and top_p. + // VS Code Claude extension and similar clients send both; strip top_p. + if (translatedBody.temperature !== undefined && translatedBody.top_p !== undefined) { + delete translatedBody.top_p; + } } // Fix #2468: always extract role:"system" → top-level system. @@ -5442,17 +5452,14 @@ export async function handleChatCore({ } const responseHeaders: Record = { - ...buildStreamingResponseHeaders( - providerResponse.headers, - { - provider, - model, - cacheHit: false, - latencyMs: 0, - usage: null, - costUsd: 0, - } - ), + ...buildStreamingResponseHeaders(providerResponse.headers, { + provider, + model, + cacheHit: false, + latencyMs: 0, + usage: null, + costUsd: 0, + }), "x-omniroute-request-id": pendingRequestId, }; @@ -5560,7 +5567,9 @@ export async function handleChatCore({ }); } catch (e) { // Best-effort — don't break the stream completion path if this fails - try { console.warn("finalizeMostRecentPendingRequest failed:", e && (e.message || e)); } catch {} + try { + console.warn("finalizeMostRecentPendingRequest failed:", e && (e.message || e)); + } catch {} } if (apiKeyInfo?.id && streamUsage) { diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 2671d7a5e8..7db6062331 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -212,7 +212,7 @@ export function openaiToClaudeRequest(model, body, stream) { if (body.temperature !== undefined) { result.temperature = body.temperature; } - if (body.top_p !== undefined) { + if (body.temperature === undefined && body.top_p !== undefined) { result.top_p = body.top_p; } if (body.stop !== undefined) { diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 4091d5cc38..48633fdc7c 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -109,7 +109,11 @@ function normalizeResponsesSseIds(payload: JsonRecord): boolean { } } - if (payload.response && typeof payload.response === "object" && !Array.isArray(payload.response)) { + if ( + payload.response && + typeof payload.response === "object" && + !Array.isArray(payload.response) + ) { const response = payload.response as JsonRecord; let responseChanged = false; const normalizedResponse = { ...response }; @@ -1016,6 +1020,32 @@ export function createSSEStream(options: StreamOptions = {}) { } }; + const emitClaudeEmptyStreamErrorAndAbort = ( + controller: TransformStreamDefaultController, + decrementPendingRequest = true + ) => { + clearIdleTimer(); + const msg = "Claude returned an empty response (no content block)"; + console.warn( + `[STREAM] Empty Claude stream at flush - emitting error (${provider || "provider"}:${model || "unknown"})` + ); + const errorBody = buildErrorBody(502, msg); + const errorEvent: Record = { type: "error", error: errorBody.error }; + const errOutput = formatSSE(errorEvent, FORMATS.CLAUDE); + reqLogger?.appendConvertedChunk?.(errOutput); + clientPayloadCollector.push(errorEvent); + controller.enqueue(encoder.encode(errOutput)); + if (onFailure) { + try { + void onFailure({ status: 502, message: msg, code: "empty_response" }); + } catch {} + } + if (decrementPendingRequest) { + trackPendingRequest(model, provider, connectionId, false); + } + controller.error(markPendingRequestCleared(new Error(msg))); + }; + const emitTranslatedClientItem = ( controller: TransformStreamDefaultController, item: Record @@ -1059,13 +1089,8 @@ export function createSSEStream(options: StreamOptions = {}) { sourceFormat === FORMATS.CLAUDE && shouldInjectClaudeEmptyResponseBeforeCurrentEvent(claudeEmptyResponseLifecycle, itemSanitized) ) { - const eventType = getClaudeEventType(itemSanitized); - emitSyntheticClaudeEmptyResponse(controller, { - includeContentBlock: true, - includeMessageDelta: - eventType === "message_stop" && !claudeEmptyResponseLifecycle.hasMessageDelta, - includeMessageStop: false, - }); + emitClaudeEmptyStreamErrorAndAbort(controller); + return; } if (sourceFormat === FORMATS.CLAUDE && isClaudeEventPayload(itemSanitized)) { @@ -1300,12 +1325,8 @@ export function createSSEStream(options: StreamOptions = {}) { type: eventType, }) ) { - emitSyntheticClaudeEmptyResponse(controller, { - includeContentBlock: true, - includeMessageDelta: - eventType === "message_stop" && !claudeEmptyResponseLifecycle.hasMessageDelta, - includeMessageStop: false, - }); + emitClaudeEmptyStreamErrorAndAbort(controller); + return; } pendingPassthroughEventLine = line; @@ -1337,7 +1358,8 @@ export function createSSEStream(options: StreamOptions = {}) { // clients like OpenCode, so drop it only for Responses-native consumers. const hasActiveDeltaValue = (value: unknown): boolean => { if (typeof value === "string") return value.length > 0; - if (Array.isArray(value)) return value.some((entry) => hasActiveDeltaValue(entry)); + if (Array.isArray(value)) + return value.some((entry) => hasActiveDeltaValue(entry)); if (value && typeof value === "object") { return Object.values(value).some((entry) => hasActiveDeltaValue(entry)); } @@ -1605,7 +1627,12 @@ export function createSSEStream(options: StreamOptions = {}) { parsed, passthroughResponsesOutputItems ); - if (stripped || backfilled || textualToolCallBackfilled || responsesIdsNormalized) { + if ( + stripped || + backfilled || + textualToolCallBackfilled || + responsesIdsNormalized + ) { output = `data: ${JSON.stringify(parsed)}\n`; injectedUsage = true; } @@ -1632,13 +1659,8 @@ export function createSSEStream(options: StreamOptions = {}) { parsed ) ) { - emitSyntheticClaudeEmptyResponse(controller, { - includeContentBlock: true, - includeMessageDelta: - parsed.type === "message_stop" && - !claudeEmptyResponseLifecycle.hasMessageDelta, - includeMessageStop: false, - }); + emitClaudeEmptyStreamErrorAndAbort(controller); + return; } updateClaudeEmptyResponseLifecycle(claudeEmptyResponseLifecycle, parsed); const restoredToolName = restoreClaudePassthroughToolUseName(parsed, toolNameMap); @@ -1708,14 +1730,16 @@ export function createSSEStream(options: StreamOptions = {}) { !parsed.choices[0].delta.reasoning_content ); const hadNonStringToolCallId = Array.isArray(parsed.choices) - ? parsed.choices.some((choice) => - Array.isArray(choice?.delta?.tool_calls) && - choice.delta.tool_calls.some( - (tc) => tc?.id != null && typeof tc.id !== "string" - ) + ? parsed.choices.some( + (choice) => + Array.isArray(choice?.delta?.tool_calls) && + choice.delta.tool_calls.some( + (tc) => tc?.id != null && typeof tc.id !== "string" + ) ) : false; - const hadNonStringTopLevelId = parsed?.id != null && typeof parsed.id !== "string"; + const hadNonStringTopLevelId = + parsed?.id != null && typeof parsed.id !== "string"; parsed = sanitizeStreamingChunk(parsed); if ( @@ -2148,13 +2172,8 @@ export function createSSEStream(options: StreamOptions = {}) { bufferedPayload ) ) { - const eventType = getClaudeEventType(bufferedPayload); - emitSyntheticClaudeEmptyResponse(controller, { - includeContentBlock: true, - includeMessageDelta: - eventType === "message_stop" && !claudeEmptyResponseLifecycle.hasMessageDelta, - includeMessageStop: false, - }); + emitClaudeEmptyStreamErrorAndAbort(controller, false); + return; } if (isClaudeEventPayload(bufferedPayload)) { updateClaudeEmptyResponseLifecycle(claudeEmptyResponseLifecycle, bufferedPayload); @@ -2164,7 +2183,8 @@ export function createSSEStream(options: StreamOptions = {}) { // Normalize numeric IDs for final buffered data: chunk (same as transform path) if (typeof bufferedPayload === "object" && !Array.isArray(bufferedPayload)) { const flushedParsed = bufferedPayload as JsonRecord; - const flushedType = typeof flushedParsed.type === "string" ? flushedParsed.type : ""; + const flushedType = + typeof flushedParsed.type === "string" ? flushedParsed.type : ""; const isResponses = flushedType.startsWith("response."); const isClaude = isClaudeEventPayload(flushedParsed); if (isResponses) { @@ -2181,7 +2201,9 @@ export function createSSEStream(options: StreamOptions = {}) { } if (Array.isArray(flushedParsed.choices)) { for (const choice of flushedParsed.choices as JsonRecord[]) { - const tcs = (choice as JsonRecord | undefined)?.delta as JsonRecord | undefined; + const tcs = (choice as JsonRecord | undefined)?.delta as + | JsonRecord + | undefined; if (Array.isArray(tcs?.tool_calls)) { for (const tc of tcs.tool_calls as JsonRecord[]) { if (tc?.id != null && typeof tc.id !== "string") { @@ -2208,11 +2230,8 @@ export function createSSEStream(options: StreamOptions = {}) { } if (shouldInjectClaudeEmptyResponseOnFlush(claudeEmptyResponseLifecycle)) { - emitSyntheticClaudeEmptyResponse(controller, { - includeContentBlock: true, - includeMessageDelta: !claudeEmptyResponseLifecycle.hasMessageDelta, - includeMessageStop: !claudeEmptyResponseLifecycle.hasMessageStop, - }); + emitClaudeEmptyStreamErrorAndAbort(controller, false); + return; } else if (shouldInjectClaudeMissingFinalizersOnFlush(claudeEmptyResponseLifecycle)) { emitSyntheticClaudeEmptyResponse(controller, { includeContentBlock: false, @@ -2489,11 +2508,8 @@ export function createSSEStream(options: StreamOptions = {}) { if (sourceFormat === FORMATS.CLAUDE) { if (shouldInjectClaudeEmptyResponseOnFlush(claudeEmptyResponseLifecycle)) { - emitSyntheticClaudeEmptyResponse(controller, { - includeContentBlock: true, - includeMessageDelta: !claudeEmptyResponseLifecycle.hasMessageDelta, - includeMessageStop: !claudeEmptyResponseLifecycle.hasMessageStop, - }); + emitClaudeEmptyStreamErrorAndAbort(controller, false); + return; } else if (shouldInjectClaudeMissingFinalizersOnFlush(claudeEmptyResponseLifecycle)) { emitSyntheticClaudeEmptyResponse(controller, { includeContentBlock: false, @@ -2631,7 +2647,7 @@ export function createSSEStream(options: StreamOptions = {}) { ); } -export default createSSEStream +export default createSSEStream; // Convenience functions for backward compatibility export function createSSETransformStreamWithLogger( diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 61c1aa1d40..c6387ec805 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -3954,8 +3954,13 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi }, vertex: async ({ apiKey }: any) => { try { - const { parseSAFromApiKey, getAccessToken } = + const { parseSAFromApiKey, getAccessToken, isExpressApiKey } = await import("@omniroute/open-sse/executors/vertex.ts"); + // Express-mode API keys are opaque strings sent directly as the ?key= query param — there is + // no JWT to mint, so accept any non-empty Express key (the live chat/media call validates it). + if (isExpressApiKey(apiKey)) { + return { valid: true, error: null }; + } const sa = parseSAFromApiKey(apiKey); // Validates credentials by successfully successfully exchanging them for a JWT from Google Identity await getAccessToken(sa); @@ -3966,8 +3971,11 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi }, "vertex-partner": async ({ apiKey }: any) => { try { - const { parseSAFromApiKey, getAccessToken } = + const { parseSAFromApiKey, getAccessToken, isExpressApiKey } = await import("@omniroute/open-sse/executors/vertex.ts"); + if (isExpressApiKey(apiKey)) { + return { valid: true, error: null }; + } const sa = parseSAFromApiKey(apiKey); await getAccessToken(sa); return { valid: true, error: null }; diff --git a/tests/unit/claude-empty-stream-error-3685.test.ts b/tests/unit/claude-empty-stream-error-3685.test.ts new file mode 100644 index 0000000000..719f2cb638 --- /dev/null +++ b/tests/unit/claude-empty-stream-error-3685.test.ts @@ -0,0 +1,278 @@ +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"; + +// #3685 — When a Claude stream completes with lifecycle events (message_start / +// message_delta / message_stop) but zero content_block events, the router was +// injecting a synthetic success message instead of failing over. Fix: emit a +// real SSE error event and call controller.error() so the combo layer can retry. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-3685-")); +process.env.DATA_DIR = TEST_DATA_DIR; +const core = await import("../../src/lib/db/core.ts"); +const { createSSEStream } = await import("../../open-sse/utils/stream.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); +const { getPendingRequests, clearPendingRequests } = + await import("../../src/lib/usage/usageHistory.ts"); + +const enc = new TextEncoder(); + +async function readTransformed(chunks: string[], options: Record) { + const source = new ReadableStream({ + start(c) { + for (const chunk of chunks) c.enqueue(enc.encode(chunk)); + c.close(); + }, + }); + return new Response(source.pipeThrough(createSSEStream(options as any))).text(); +} + +test.after(() => { + core.resetDbInstance(); + if (fs.existsSync(TEST_DATA_DIR)) { + for (const entry of fs.readdirSync(TEST_DATA_DIR)) { + fs.rmSync(path.join(TEST_DATA_DIR, entry), { recursive: true, force: true }); + } + } +}); + +// --- Golden path: empty content-block stream (the bug case) should now error --- + +test("#3685 passthrough: empty Claude SSE (no content_block) rejects the stream", async () => { + let failurePayload: Record | null = null; + await assert.rejects( + readTransformed( + [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_3685", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 0 }, + }, + })}\n\n`, + `event: message_delta\ndata: ${JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "content_filter", stop_sequence: null }, + usage: { output_tokens: 1 }, + })}\n\n`, + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.CLAUDE, + provider: "anthropic", + model: "claude-sonnet-4-6", + body: { messages: [{ role: "user", content: "hello" }] }, + onFailure(p: Record) { + failurePayload = p; + }, + } + ), + /empty response/i, + "stream should reject with empty-response error" + ); + assert.ok(failurePayload, "onFailure callback must be invoked"); + assert.equal((failurePayload as any).status, 502); + assert.match((failurePayload as any).message as string, /empty response/i); +}); + +test("#3685 passthrough: empty Claude SSE emits event: error SSE line before aborting", async () => { + const collected: string[] = []; + const source = new ReadableStream({ + start(c) { + const chunks = [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_3685b", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [], + stop_reason: null, + usage: { input_tokens: 5, output_tokens: 0 }, + }, + })}\n\n`, + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`, + ]; + for (const chunk of chunks) c.enqueue(enc.encode(chunk)); + c.close(); + }, + }); + const transformed = source.pipeThrough( + createSSEStream({ + mode: "passthrough", + sourceFormat: FORMATS.CLAUDE, + provider: "anthropic", + model: "claude-sonnet-4-6", + body: { messages: [{ role: "user", content: "hello" }] }, + } as any) + ); + const reader = transformed.getReader(); + const dec = new TextDecoder(); + let gotError = false; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + collected.push(dec.decode(value)); + } + } catch { + gotError = true; + } + assert.ok(gotError, "stream reader should throw on error"); + const full = collected.join(""); + assert.match(full, /event: error/, "SSE error event must be emitted before abort"); + assert.doesNotMatch( + full, + /event: content_block_start/, + "no synthetic content_block must be emitted" + ); +}); + +// --- Regression guards: excluded cases must NOT be turned into errors --- + +test("#3685 regression: stream with content_block events is NOT turned into an error", async () => { + // A max_tokens:1 ping returns exactly 1 token → content_block events exist. + // hasContentBlock = true → shouldInjectClaudeEmptyResponseOnFlush = false → no error. + const text = await readTransformed( + [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_ping", + type: "message", + role: "assistant", + model: "claude-haiku-4-5", + content: [], + stop_reason: null, + usage: { input_tokens: 3, output_tokens: 0 }, + }, + })}\n\n`, + `event: content_block_start\ndata: ${JSON.stringify({ + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + })}\n\n`, + `event: content_block_delta\ndata: ${JSON.stringify({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Hi" }, + })}\n\n`, + `event: content_block_stop\ndata: ${JSON.stringify({ + type: "content_block_stop", + index: 0, + })}\n\n`, + `event: message_delta\ndata: ${JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "max_tokens", stop_sequence: null }, + usage: { output_tokens: 1 }, + })}\n\n`, + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.CLAUDE, + provider: "anthropic", + model: "claude-haiku-4-5", + body: { messages: [{ role: "user", content: "ping" }] }, + } + ); + assert.match(text, /Hi/, "content must pass through untouched"); + assert.doesNotMatch(text, /event: error/, "must NOT emit an error event"); +}); + +test("#3685 pending request counter is decremented when empty-stream error fires", async () => { + // Regression guard for the bug caught by Cursor/Codex: emitClaudeEmptyStreamErrorAndAbort + // was marking the error with PENDING_REQUEST_CLEARED_MARKER but never calling + // trackPendingRequest(..., false). streamHandler.clearPendingRequest() trusts the marker + // and skips its own decrement, leaving the counter permanently inflated. + clearPendingRequests(); + const { trackPendingRequest } = await import("../../src/lib/usage/usageHistory.ts"); + + // Simulate the stream engine incrementing the counter at request start. + trackPendingRequest("claude-sonnet-4-6", "anthropic", "conn-test", true); + assert.equal( + getPendingRequests().byModel["claude-sonnet-4-6 (anthropic)"], + 1, + "pending count should start at 1 after request begins" + ); + + await assert.rejects( + readTransformed( + [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_pending_test", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [], + stop_reason: null, + usage: { input_tokens: 3, output_tokens: 0 }, + }, + })}\n\n`, + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.CLAUDE, + provider: "anthropic", + model: "claude-sonnet-4-6", + connectionId: "conn-test", + body: { messages: [{ role: "user", content: "hello" }] }, + } + ), + /empty response/i + ); + + // emitClaudeEmptyStreamErrorAndAbort must call trackPendingRequest(..., false) so the + // counter is back to 0 after the stream terminates. + assert.equal( + getPendingRequests().byModel["claude-sonnet-4-6 (anthropic)"], + 0, + "pending count must be 0 after empty-stream error — not left inflated" + ); +}); + +test("#3685 regression: upstream error event sets hasError=true and does NOT trigger empty-stream path", () => { + // If Claude itself emits type:error, lifecycle.hasError=true. + // shouldInjectClaudeEmptyResponseBeforeCurrentEvent / shouldInjectClaudeEmptyResponseOnFlush + // both check !lifecycle.hasError first — so neither our new error path nor the old synthetic + // path is triggered. Verified by inspecting the guard functions directly. + const lifecycle = { + hasMessageStart: true, + hasContentBlock: false, + hasMessageDelta: false, + hasMessageStop: false, + hasError: false, + syntheticContentInjected: false, + warningLogged: false, + }; + + // Simulate receiving an error event: sets hasError = true. + const lifecycleWithError = { ...lifecycle, hasError: true }; + + // shouldInjectClaudeEmptyResponseOnFlush equivalent: hasError blocks it + const wouldInjectOnFlush = + !lifecycleWithError.hasError && + !lifecycleWithError.hasContentBlock && + (lifecycleWithError.hasMessageStart || + lifecycleWithError.hasMessageDelta || + lifecycleWithError.hasMessageStop); + + assert.equal( + wouldInjectOnFlush, + false, + "hasError=true must prevent the empty-stream error path from firing" + ); +}); diff --git a/tests/unit/executor-vertex-extended.test.ts b/tests/unit/executor-vertex-extended.test.ts index 02cce26a2a..6318195c40 100644 --- a/tests/unit/executor-vertex-extended.test.ts +++ b/tests/unit/executor-vertex-extended.test.ts @@ -51,17 +51,22 @@ test("VertexExecutor.buildUrl defaults to us-central1 and unknown-project when p apiKey: createServiceAccountJson({ includeProjectId: false }), providerSpecificData: {}, }); - const invalidJson = executor.buildUrl("gemini-2.5-flash", false, 0, { - apiKey: "not-json", - }); assert.equal( missingProject, "https://aiplatform.googleapis.com/v1/projects/unknown-project/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent" ); +}); + +test("VertexExecutor.buildUrl routes a non-JSON Express API key to the project-less publisher endpoint", () => { + const executor = new VertexExecutor(); + const expressUrl = executor.buildUrl("gemini-2.5-flash", false, 0, { + apiKey: "express-key-abc", + }); + assert.equal( - invalidJson, - "https://aiplatform.googleapis.com/v1/projects/unknown-project/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent" + expressUrl, + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash:generateContent?key=express-key-abc" ); }); @@ -191,20 +196,12 @@ test("VertexExecutor.execute skips Service Account parsing when accessToken is a } }); -test("VertexExecutor.execute rejects invalid or incomplete Service Account JSON clearly", async () => { +test("VertexExecutor.execute rejects incomplete Service Account JSON clearly", async () => { const executor = new VertexExecutor(); - await assert.rejects( - () => - executor.execute({ - model: "gemini-2.5-flash", - body: { contents: [] }, - stream: false, - credentials: { apiKey: "not-json" }, - }), - /Service Account JSON/ - ); - + // A JSON object missing client_email/private_key is treated as a Service Account (not an Express + // key) and must fail clearly when minting a JWT. A non-JSON string is an Express key (covered + // elsewhere) and is intentionally NOT rejected here. await assert.rejects( () => executor.execute({ diff --git a/tests/unit/stream-utils.test.ts b/tests/unit/stream-utils.test.ts index 7447ddc7dd..933bc549d5 100644 --- a/tests/unit/stream-utils.test.ts +++ b/tests/unit/stream-utils.test.ts @@ -725,7 +725,11 @@ test("createSSEStream passthrough drops leaked empty chat bootstrap chunks for R created: 1, model: "gpt-5.4", choices: [ - { index: 0, delta: { role: "assistant", content: null, refusal: null }, finish_reason: null }, + { + index: 0, + delta: { role: "assistant", content: null, refusal: null }, + finish_reason: null, + }, ], })}\n\n`, `event: response.created\ndata: ${JSON.stringify({ @@ -1047,50 +1051,46 @@ test("createSSEStream passthrough merges Claude usage chunks and restores mapped assert.equal(onCompletePayload.responseBody.usage.total_tokens, 10); }); -test("createSSEStream passthrough injects a synthetic Claude text block for empty assistant SSE", async () => { - let onCompletePayload = null; - const text = await readTransformed( - [ - `event: message_start\ndata: ${JSON.stringify({ - type: "message_start", - message: { - id: "msg_empty_passthrough", - type: "message", - role: "assistant", - model: "claude-sonnet-4", - content: [], - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 7, output_tokens: 0 }, +test("#3685 createSSEStream passthrough emits SSE error (not synthetic text) for empty Claude assistant SSE", async () => { + let failurePayload = null; + await assert.rejects( + readTransformed( + [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_empty_passthrough", + type: "message", + role: "assistant", + model: "claude-sonnet-4", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 7, output_tokens: 0 }, + }, + })}\n\n`, + `event: message_stop\ndata: ${JSON.stringify({ + type: "message_stop", + })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.CLAUDE, + provider: "claude", + model: "claude-sonnet-4", + body: { + messages: [{ role: "user", content: "hello" }], }, - })}\n\n`, - `event: message_stop\ndata: ${JSON.stringify({ - type: "message_stop", - })}\n\n`, - ], - { - mode: "passthrough", - sourceFormat: FORMATS.CLAUDE, - provider: "claude", - model: "claude-sonnet-4", - body: { - messages: [{ role: "user", content: "hello" }], - }, - onComplete(payload) { - onCompletePayload = payload; - }, - } + onFailure(payload) { + failurePayload = payload; + }, + } + ), + /empty response/i ); - - assert.equal((text.match(/event: message_start/g) || []).length, 1); - assert.equal((text.match(/event: message_delta/g) || []).length, 1); - assert.match(text, /event: content_block_start/); - assert.match(text, /event: content_block_delta/); - assert.match(text, /event: message_stop/); - assert.ok(text.indexOf("event: content_block_start") > text.indexOf("event: message_start")); - assert.ok(text.indexOf("event: message_stop") > text.indexOf("event: content_block_stop")); - // SYNTHETIC_CLAUDE_EMPTY_RESPONSE_TEXT is "" so the accumulator produces null content (empty delta is falsy). - assert.equal(onCompletePayload.responseBody.choices[0].message.content, null); + assert.ok(failurePayload, "onFailure should be called"); + assert.equal(failurePayload.status, 502); + assert.match(failurePayload.message, /empty response/i); }); test("createSSEStream passthrough does not emit [DONE] for Claude SSE clients", async () => { @@ -1149,51 +1149,46 @@ test("createSSEStream passthrough does not emit [DONE] for Claude SSE clients", assert.doesNotMatch(text, /\[DONE\]/); }); -test("createSSEStream translate mode injects a synthetic Claude text block when OpenAI finishes empty", async () => { - let onCompletePayload = null; - const text = await readTransformed( - [ - `data: ${JSON.stringify({ - id: "chatcmpl_empty_1", - object: "chat.completion.chunk", - created: 1, +test("#3685 createSSEStream translate mode emits SSE error (not synthetic text) when OpenAI upstream finishes empty for Claude client", async () => { + let failurePayload = null; + await assert.rejects( + readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_empty_1", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: { role: "assistant" } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_empty_1", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 3, completion_tokens: 0, total_tokens: 3 }, + })}\n\n`, + ], + { + mode: "translate", + targetFormat: FORMATS.OPENAI, + sourceFormat: FORMATS.CLAUDE, + provider: "openai", model: "gpt-4.1-mini", - choices: [{ index: 0, delta: { role: "assistant" } }], - })}\n\n`, - `data: ${JSON.stringify({ - id: "chatcmpl_empty_1", - object: "chat.completion.chunk", - created: 1, - model: "gpt-4.1-mini", - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - usage: { prompt_tokens: 3, completion_tokens: 0, total_tokens: 3 }, - })}\n\n`, - ], - { - mode: "translate", - targetFormat: FORMATS.OPENAI, - sourceFormat: FORMATS.CLAUDE, - provider: "openai", - model: "gpt-4.1-mini", - body: { - messages: [{ role: "user", content: "hello" }], - }, - onComplete(payload) { - onCompletePayload = payload; - }, - } + body: { + messages: [{ role: "user", content: "hello" }], + }, + onFailure(payload) { + failurePayload = payload; + }, + } + ), + /empty response/i ); - - assert.equal((text.match(/event: message_start/g) || []).length, 1); - assert.match(text, /event: content_block_start/); - assert.match(text, /event: content_block_delta/); - assert.match(text, /event: message_delta/); - assert.match(text, /event: message_stop/); - assert.ok(text.indexOf("event: content_block_start") > text.indexOf("event: message_start")); - assert.ok(text.indexOf("event: message_delta") > text.indexOf("event: content_block_stop")); - // SYNTHETIC_CLAUDE_EMPTY_RESPONSE_TEXT is "" so the accumulator produces null content (empty delta is falsy). - assert.equal(onCompletePayload.responseBody.choices[0].message.content, null); - assert.equal(onCompletePayload.responseBody.usage.total_tokens, 3); + assert.ok(failurePayload, "onFailure should be called"); + assert.equal(failurePayload.status, 502); + assert.match(failurePayload.message, /empty response/i); }); test("createSSETransformStreamWithLogger flushes a trailing Claude usage event without a newline", async () => { @@ -1741,7 +1736,7 @@ test("createSSEStream passthrough logs empty response after tool_calls completio index: 0, id: "call_tc", type: "function", - function: { name: "task_complete", arguments: '{}' }, + function: { name: "task_complete", arguments: "{}" }, }, ], }, @@ -1771,7 +1766,10 @@ test("createSSEStream passthrough logs empty response after tool_calls completio assert.match(text, /"finish_reason":"tool_calls"/); assert.equal(onCompletePayload.status, 200); assert.equal(onCompletePayload.responseBody.choices[0].finish_reason, "tool_calls"); - assert.equal(onCompletePayload.responseBody.choices[0].message.tool_calls[0].function.name, "task_complete"); + assert.equal( + onCompletePayload.responseBody.choices[0].message.tool_calls[0].function.name, + "task_complete" + ); // Content should be null (empty) since no text was generated assert.equal(onCompletePayload.responseBody.choices[0].message.content, null); }); diff --git a/tests/unit/t29-vertex-sa-json-executor.test.ts b/tests/unit/t29-vertex-sa-json-executor.test.ts index e2a2207ce7..f367718084 100644 --- a/tests/unit/t29-vertex-sa-json-executor.test.ts +++ b/tests/unit/t29-vertex-sa-json-executor.test.ts @@ -55,17 +55,34 @@ test("T29: Vertex executor headers include Bearer token and SSE Accept when stre assert.equal(headers.Accept, "text/event-stream"); }); -test("T29: Vertex executor rejects invalid Service Account JSON clearly", async () => { +test("T29: Vertex executor rejects incomplete Service Account JSON clearly", async () => { const executor = new VertexExecutor(); + // A JSON object (not an opaque Express key) that is missing client_email/private_key must still + // fail clearly when the executor tries to mint a JWT from it. await assert.rejects( () => executor.execute({ model: "gemini-2.5-flash", body: { contents: [] }, stream: false, - credentials: { apiKey: "not-json" }, + credentials: { apiKey: JSON.stringify({ project_id: "p" }) }, }), - /Service Account JSON/i + /missing required fields/i + ); +}); + +test("T29: Vertex executor routes a non-JSON Express API key to the project-less publisher endpoint", () => { + const executor = new VertexExecutor(); + const stream = executor.buildUrl("gemini-2.5-flash", true, 0, { apiKey: "express-key-123" }); + const nonStream = executor.buildUrl("gemini-2.5-flash", false, 0, { apiKey: "express-key-123" }); + + assert.equal( + stream, + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash:streamGenerateContent?alt=sse&key=express-key-123" + ); + assert.equal( + nonStream, + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash:generateContent?key=express-key-123" ); }); diff --git a/tests/unit/translator-openai-to-claude.test.ts b/tests/unit/translator-openai-to-claude.test.ts index 1bf5ca456a..cd819c574f 100644 --- a/tests/unit/translator-openai-to-claude.test.ts +++ b/tests/unit/translator-openai-to-claude.test.ts @@ -84,7 +84,8 @@ test("OpenAI -> Claude maps system messages, parameters and assistant cache mark assert.equal(result.stream, true); assert.equal(result.max_tokens, 33); assert.equal(result.temperature, 0.25); - assert.equal(result.top_p, 0.8); + // top_p is stripped when temperature is also present (Anthropic rejects both). + assert.equal(result.top_p, undefined); assert.deepEqual(result.stop_sequences, ["DONE"]); assert.equal(result.system[0].text, "Rule A\nRule B\nRule C"); assert.equal(result.messages[0].role, "user"); @@ -94,6 +95,21 @@ test("OpenAI -> Claude maps system messages, parameters and assistant cache mark assert.deepEqual(result.messages[1].content[0].cache_control, { type: "ephemeral" }); }); +test("OpenAI -> Claude strips top_p when temperature is also present", () => { + const result = openaiToClaudeRequest( + "claude-4-sonnet", + { + messages: [{ role: "user", content: "Hello" }], + temperature: 0.25, + top_p: 0.8, + }, + false + ); + + assert.equal(result.temperature, 0.25); + assert.equal(result.top_p, undefined); +}); + test("OpenAI -> Claude converts multimodal content, tool declarations, tool calls and tool results", () => { const result = openaiToClaudeRequest( "claude-4-sonnet", diff --git a/tests/unit/vertex-express-apikey.test.ts b/tests/unit/vertex-express-apikey.test.ts new file mode 100644 index 0000000000..555b11c1de --- /dev/null +++ b/tests/unit/vertex-express-apikey.test.ts @@ -0,0 +1,102 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { VertexExecutor, isExpressApiKey, looksLikeServiceAccountJson } = await import( + "../../open-sse/executors/vertex.ts" +); + +test("looksLikeServiceAccountJson is true only for a JSON object credential", () => { + assert.equal(looksLikeServiceAccountJson(JSON.stringify({ project_id: "p" })), true); + assert.equal(looksLikeServiceAccountJson("express-opaque-key"), false); + assert.equal(looksLikeServiceAccountJson(JSON.stringify([1, 2, 3])), false); + assert.equal(looksLikeServiceAccountJson(""), false); +}); + +test("isExpressApiKey is true for a non-empty, non-JSON credential", () => { + assert.equal(isExpressApiKey("AIzaSyExpressKey"), true); + assert.equal(isExpressApiKey(" "), false); + assert.equal(isExpressApiKey(""), false); + assert.equal(isExpressApiKey(null), false); + assert.equal(isExpressApiKey(undefined), false); + assert.equal(isExpressApiKey(JSON.stringify({ project_id: "p" })), false); +}); + +test("buildUrl Express: streaming google model uses streamGenerateContent + ?alt=sse&key=", () => { + const executor = new VertexExecutor(); + const url = executor.buildUrl("gemini-3-flash-preview", true, 0, { apiKey: "k-express" }); + assert.equal( + url, + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:streamGenerateContent?alt=sse&key=k-express" + ); +}); + +test("buildUrl Express: non-streaming google model uses generateContent?key=", () => { + const executor = new VertexExecutor(); + const url = executor.buildUrl("gemini-3-flash-preview", false, 0, { apiKey: "k-express" }); + assert.equal( + url, + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:generateContent?key=k-express" + ); +}); + +test("buildUrl Express: the API key is URL-encoded and trimmed", () => { + const executor = new VertexExecutor(); + const url = executor.buildUrl("gemini-2.5-flash", false, 0, { apiKey: " a/b+c=d " }); + assert.equal( + url, + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash:generateContent?key=a%2Fb%2Bc%3Dd" + ); +}); + +test("buildUrl Express: a present accessToken takes the Service Account path, not Express", () => { + const executor = new VertexExecutor(); + const url = executor.buildUrl("gemini-2.5-flash", false, 0, { + apiKey: "k-express", + accessToken: "ya29.token", + providerSpecificData: { region: "us-central1" }, + }); + assert.equal( + url, + "https://aiplatform.googleapis.com/v1/projects/unknown-project/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent" + ); +}); + +test("buildHeaders for an Express key (no accessToken) omits the Authorization header", () => { + const executor = new VertexExecutor(); + const headers = executor.buildHeaders({ apiKey: "k-express" }, false); + assert.equal(headers["Content-Type"], "application/json"); + assert.equal(headers.Authorization, undefined); +}); + +test("execute with an Express key calls the publisher endpoint directly (no OAuth token exchange)", async () => { + const executor = new VertexExecutor(); + const originalFetch = globalThis.fetch; + const calls: string[] = []; + + globalThis.fetch = async (url: any) => { + calls.push(String(url)); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + const result = await executor.execute({ + model: "gemini-3-flash-preview", + body: { contents: [{ role: "user", parts: [{ text: "hi" }] }] }, + stream: false, + credentials: { apiKey: "AIzaSyExpressKey" }, + } as any); + + assert.equal(result.response.status, 200); + // Exactly one call — straight to Vertex, no oauth2 token mint. + assert.equal(calls.length, 1); + assert.equal( + calls[0], + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:generateContent?key=AIzaSyExpressKey" + ); + } finally { + globalThis.fetch = originalFetch; + } +});