diff --git a/src/api/core/logging/__tests__/http-interceptor.spec.ts b/src/api/core/logging/__tests__/http-interceptor.spec.ts index 02d8819048..a490521f82 100644 --- a/src/api/core/logging/__tests__/http-interceptor.spec.ts +++ b/src/api/core/logging/__tests__/http-interceptor.spec.ts @@ -2,7 +2,7 @@ * @fileoverview Tests for the HTTP interceptor logging module */ -import { createLoggingFetch } from "../http-interceptor" +import { createLoggingFetch, withScopedFetchLogging } from "../http-interceptor" import * as envConfig from "../env-config" // Mock the env-config module @@ -272,3 +272,87 @@ describe("createLoggingFetch", () => { }) }) }) + +describe("withScopedFetchLogging", () => { + let consoleSpy: ReturnType + let originalFetch: typeof fetch + + beforeEach(() => { + vi.clearAllMocks() + consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}) + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + vi.unstubAllGlobals() + }) + + it("should no-op and not touch global fetch when logging is disabled", async () => { + vi.mocked(envConfig.isLoggingEnabled).mockReturnValue(false) + const before = globalThis.fetch + await withScopedFetchLogging("TestProvider", async () => { + // nothing + }) + expect(globalThis.fetch).toBe(before) + expect(consoleSpy).not.toHaveBeenCalled() + }) + + it("should restore global fetch after callback", async () => { + vi.mocked(envConfig.isLoggingEnabled).mockReturnValue(true) + const baseFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + vi.stubGlobal("fetch", baseFetch) + const before = globalThis.fetch + + await withScopedFetchLogging("TestProvider", async () => { + await globalThis.fetch("https://api.example.com/test") + }) + + expect(globalThis.fetch).toBe(before) + expect(consoleSpy).toHaveBeenCalledWith( + "[TestProvider] RAW HTTP REQUEST", + expect.objectContaining({ url: "https://api.example.com/test" }), + ) + expect(consoleSpy).toHaveBeenCalledWith( + "[TestProvider] RAW HTTP RESPONSE", + expect.objectContaining({ status: 200 }), + ) + }) + + it("should support nesting and restore to previous scoped fetch", async () => { + vi.mocked(envConfig.isLoggingEnabled).mockReturnValue(true) + const baseFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + vi.stubGlobal("fetch", baseFetch) + const rootFetch = globalThis.fetch + + await withScopedFetchLogging("Outer", async () => { + const outerFetch = globalThis.fetch + await withScopedFetchLogging("Inner", async () => { + await globalThis.fetch("https://api.example.com/inner") + }) + expect(globalThis.fetch).toBe(outerFetch) + await globalThis.fetch("https://api.example.com/outer") + }) + + expect(globalThis.fetch).toBe(rootFetch) + // Ensure both provider labels were used + expect(consoleSpy).toHaveBeenCalledWith( + "[Inner] RAW HTTP REQUEST", + expect.objectContaining({ url: "https://api.example.com/inner" }), + ) + expect(consoleSpy).toHaveBeenCalledWith( + "[Outer] RAW HTTP REQUEST", + expect.objectContaining({ url: "https://api.example.com/outer" }), + ) + }) +}) diff --git a/src/api/core/logging/http-interceptor.ts b/src/api/core/logging/http-interceptor.ts index 4aaa5fac8f..b1e5c7db8c 100644 --- a/src/api/core/logging/http-interceptor.ts +++ b/src/api/core/logging/http-interceptor.ts @@ -10,7 +10,7 @@ import { isLoggingEnabled } from "./env-config" /** * Sanitize headers by removing sensitive data like API keys */ -function sanitizeHeaders(headers: Record): Record { +export function sanitizeHeaders(headers: Record): Record { const sensitiveKeys = ["authorization", "x-api-key", "api-key", "openai-api-key", "anthropic-api-key"] const sanitized: Record = {} @@ -90,9 +90,11 @@ function headersToObject(headers: Headers): Record { * Creates a fetch wrapper that logs raw HTTP requests and responses * * @param providerName - Name of the provider for logging context + * @param baseFetch - Optional base fetch implementation to wrap (defaults to current global fetch) * @returns A fetch function that logs requests and responses */ -export function createLoggingFetch(providerName: string): typeof fetch { + +export function createLoggingFetch(providerName: string, baseFetch: typeof fetch = globalThis.fetch): typeof fetch { return async (input: RequestInfo | URL, init?: RequestInit): Promise => { const loggingEnabled = isLoggingEnabled() const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url @@ -124,8 +126,8 @@ export function createLoggingFetch(providerName: string): typeof fetch { }) } - // Execute the actual fetch - const response = await fetch(input, init) + // Execute the actual fetch (avoid recursion if globalThis.fetch has been replaced) + const response = await baseFetch(input, init) if (loggingEnabled) { const contentType = response.headers.get("content-type") || "" @@ -164,6 +166,36 @@ export function createLoggingFetch(providerName: string): typeof fetch { } } +const scopedFetchStack: Array = [] + +/** + * Temporarily replaces globalThis.fetch with a logging fetch for the duration of the callback. + * + * Intended for SDKs that call global fetch internally and do not support injecting a custom fetch. + * + * Requirements: + * - Always restore in finally + * - Support nesting + * - No-op when logging disabled + * - Preserve typeof fetch + */ +export async function withScopedFetchLogging(providerName: string, callback: () => Promise): Promise { + if (!isLoggingEnabled()) { + return callback() + } + + const previousFetch = globalThis.fetch + scopedFetchStack.push(previousFetch) + globalThis.fetch = createLoggingFetch(providerName, previousFetch) + + try { + return await callback() + } finally { + const restoreFetch = scopedFetchStack.pop() + globalThis.fetch = restoreFetch ?? previousFetch + } +} + /** * Export a default logging fetch for convenience */ diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts index 5c0c1632b4..af3307c4ea 100644 --- a/src/api/providers/__tests__/anthropic.spec.ts +++ b/src/api/providers/__tests__/anthropic.spec.ts @@ -2,6 +2,7 @@ import { AnthropicHandler } from "../anthropic" import { ApiHandlerOptions } from "../../../shared/api" +import { createLoggingFetch } from "../../core/logging/http-interceptor" // Mock TelemetryService vitest.mock("@roo-code/telemetry", () => ({ @@ -69,6 +70,14 @@ vitest.mock("@anthropic-ai/sdk", () => { } }) +vitest.mock("../../core/logging/http-interceptor", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createLoggingFetch: vitest.fn(actual.createLoggingFetch), + } +}) + // Import after mock import { Anthropic } from "@anthropic-ai/sdk" @@ -93,6 +102,18 @@ describe("AnthropicHandler", () => { expect(handler.getModel().id).toBe(mockOptions.apiModelId) }) + it("should construct Anthropic client with injected fetch for raw HTTP logging", () => { + const handler = new AnthropicHandler(mockOptions) + expect(handler).toBeInstanceOf(AnthropicHandler) + expect(mockAnthropicConstructor).toHaveBeenCalledTimes(1) + expect(createLoggingFetch).toHaveBeenCalledWith("Anthropic") + expect(mockAnthropicConstructor.mock.calls[0]![0]!).toEqual( + expect.objectContaining({ + fetch: expect.any(Function), + }), + ) + }) + it("should initialize with undefined API key", () => { // The SDK will handle API key validation, so we just verify it initializes const handlerWithoutKey = new AnthropicHandler({ diff --git a/src/api/providers/__tests__/bedrock.spec.ts b/src/api/providers/__tests__/bedrock.spec.ts index d728fbb91e..385dfa4bf4 100644 --- a/src/api/providers/__tests__/bedrock.spec.ts +++ b/src/api/providers/__tests__/bedrock.spec.ts @@ -24,16 +24,26 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => { stream: [], }) const mockConverseStreamCommand = vi.fn() + const mockMiddlewareAdd = vi.fn() return { BedrockRuntimeClient: vi.fn().mockImplementation(() => ({ send: mockSend, + middlewareStack: { + add: mockMiddlewareAdd, + }, })), ConverseStreamCommand: mockConverseStreamCommand, ConverseCommand: vi.fn(), } }) +vi.mock("../../core/logging/env-config", () => ({ + isLoggingEnabled: vi.fn(), +})) + +import * as envConfig from "../../core/logging/env-config" + import { AwsBedrockHandler } from "../bedrock" import { ConverseStreamCommand, BedrockRuntimeClient, ConverseCommand } from "@aws-sdk/client-bedrock-runtime" import { @@ -55,6 +65,7 @@ describe("AwsBedrockHandler", () => { beforeEach(() => { // Clear all mocks before each test vi.clearAllMocks() + vi.mocked(envConfig.isLoggingEnabled).mockReturnValue(true) handler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", @@ -64,6 +75,42 @@ describe("AwsBedrockHandler", () => { }) }) + it("should attach raw HTTP logging middleware when logging is enabled", () => { + // Avoid counting middleware added by the suite-level beforeEach handler construction. + vi.clearAllMocks() + vi.mocked(envConfig.isLoggingEnabled).mockReturnValue(true) + + new AwsBedrockHandler({ + apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", + awsAccessKey: "test-access-key", + awsSecretKey: "test-secret-key", + awsRegion: "us-east-1", + }) + + const clientInstance = + vi.mocked(BedrockRuntimeClient).mock.results[vi.mocked(BedrockRuntimeClient).mock.results.length - 1]?.value + const middlewareAdd = clientInstance?.middlewareStack?.add as unknown as ReturnType + expect(middlewareAdd).toHaveBeenCalledTimes(1) + }) + + it("should not attach raw HTTP logging middleware when logging is disabled", () => { + // Avoid counting middleware added by the suite-level beforeEach handler construction. + vi.clearAllMocks() + vi.mocked(envConfig.isLoggingEnabled).mockReturnValue(false) + + new AwsBedrockHandler({ + apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", + awsAccessKey: "test-access-key", + awsSecretKey: "test-secret-key", + awsRegion: "us-east-1", + }) + + const clientInstance = + vi.mocked(BedrockRuntimeClient).mock.results[vi.mocked(BedrockRuntimeClient).mock.results.length - 1]?.value + const middlewareAdd = clientInstance?.middlewareStack?.add as unknown as ReturnType + expect(middlewareAdd).not.toHaveBeenCalled() + }) + describe("getModel", () => { it("should return the correct model info for a standard model", () => { const modelInfo = handler.getModel() diff --git a/src/api/providers/__tests__/cerebras.spec.ts b/src/api/providers/__tests__/cerebras.spec.ts index 936bd4fd3f..5cf78b75dd 100644 --- a/src/api/providers/__tests__/cerebras.spec.ts +++ b/src/api/providers/__tests__/cerebras.spec.ts @@ -20,9 +20,14 @@ vi.mock("../constants", () => ({ import { CerebrasHandler } from "../cerebras" import { cerebrasModels, type CerebrasModelId } from "@roo-code/types" +import * as envConfig from "../../core/logging/env-config" // Mock fetch globally -global.fetch = vi.fn() +vi.stubGlobal("fetch", vi.fn()) + +vi.mock("../../core/logging/env-config", () => ({ + isLoggingEnabled: vi.fn(), +})) describe("CerebrasHandler", () => { let handler: CerebrasHandler @@ -36,6 +41,14 @@ describe("CerebrasHandler", () => { handler = new CerebrasHandler(mockOptions) }) + function createEmptyReadableStream(): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.close() + }, + }) + } + describe("constructor", () => { it("should throw error when API key is missing", () => { expect(() => new CerebrasHandler({ cerebrasApiKey: "" })).toThrow("Cerebras API key is required") @@ -79,23 +92,23 @@ describe("CerebrasHandler", () => { describe("createMessage", () => { it("should make correct API request", async () => { - // Mock successful API response - const mockResponse = { - ok: true, - body: { - getReader: () => ({ - read: vi.fn().mockResolvedValueOnce({ done: true, value: new Uint8Array() }), - releaseLock: vi.fn(), - }), - }, - } - vi.mocked(fetch).mockResolvedValueOnce(mockResponse as any) + vi.mocked(envConfig.isLoggingEnabled).mockReturnValue(true) + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}) + vi.stubGlobal("fetch", vi.fn()) + // Mock successful API response as a real Response (needed by createLoggingFetch) + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + new Response(createEmptyReadableStream(), { + status: 200, + statusText: "OK", + headers: { "content-type": "text/event-stream" }, + }), + ) const generator = handler.createMessage("System prompt", []) await generator.next() // Actually start the generator to trigger the fetch call // Test that fetch was called with correct parameters - expect(fetch).toHaveBeenCalledWith( + expect(globalThis.fetch).toHaveBeenCalledWith( "https://api.cerebras.ai/v1/chat/completions", expect.objectContaining({ method: "POST", @@ -108,15 +121,22 @@ describe("CerebrasHandler", () => { }), }), ) + + expect(consoleSpy).toHaveBeenCalledWith( + "[Cerebras] RAW HTTP REQUEST", + expect.objectContaining({ url: "https://api.cerebras.ai/v1/chat/completions" }), + ) }) it("should handle API errors properly", async () => { - const mockErrorResponse = { - ok: false, - status: 400, - text: () => Promise.resolve('{"error": {"message": "Bad Request"}}'), - } - vi.mocked(fetch).mockResolvedValueOnce(mockErrorResponse as any) + vi.stubGlobal("fetch", vi.fn()) + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + new Response('{"error": {"message": "Bad Request"}}', { + status: 400, + statusText: "Bad Request", + headers: { "content-type": "application/json" }, + }), + ) const generator = handler.createMessage("System prompt", []) // Since the mock isn't working, let's just check that an error is thrown @@ -130,33 +150,37 @@ describe("CerebrasHandler", () => { }) it("should handle temperature clamping", async () => { + vi.stubGlobal("fetch", vi.fn()) const handlerWithTemp = new CerebrasHandler({ ...mockOptions, modelTemperature: 2.0, // Above Cerebras max of 1.5 }) - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - body: { getReader: () => ({ read: () => Promise.resolve({ done: true }), releaseLock: vi.fn() }) }, - } as any) + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + new Response(createEmptyReadableStream(), { + status: 200, + statusText: "OK", + headers: { "content-type": "text/event-stream" }, + }), + ) await handlerWithTemp.createMessage("test", []).next() - const requestBody = JSON.parse(vi.mocked(fetch).mock.calls[0][1]?.body as string) + const requestBody = JSON.parse(vi.mocked(globalThis.fetch).mock.calls[0][1]?.body as string) expect(requestBody.temperature).toBe(1.5) // Should be clamped }) }) describe("completePrompt", () => { it("should handle non-streaming completion", async () => { - const mockResponse = { - ok: true, - json: () => - Promise.resolve({ - choices: [{ message: { content: "Test response" } }], - }), - } - vi.mocked(fetch).mockResolvedValueOnce(mockResponse as any) + vi.stubGlobal("fetch", vi.fn()) + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ choices: [{ message: { content: "Test response" } }] }), { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + }), + ) const result = await handler.completePrompt("Test prompt") expect(result).toBe("Test response") diff --git a/src/api/providers/__tests__/gemini.spec.ts b/src/api/providers/__tests__/gemini.spec.ts index 8c2ee87a78..bd434be0d7 100644 --- a/src/api/providers/__tests__/gemini.spec.ts +++ b/src/api/providers/__tests__/gemini.spec.ts @@ -16,9 +16,14 @@ import { type ModelInfo, geminiDefaultModelId, ApiProviderError } from "@roo-cod import { t } from "i18next" import { GeminiHandler } from "../gemini" +import * as envConfig from "../../core/logging/env-config" const GEMINI_MODEL_NAME = geminiDefaultModelId +vitest.mock("../../core/logging/env-config", () => ({ + isLoggingEnabled: vitest.fn(), +})) + describe("GeminiHandler", () => { let handler: GeminiHandler @@ -69,6 +74,17 @@ describe("GeminiHandler", () => { const systemPrompt = "You are a helpful assistant" it("should handle text messages correctly", async () => { + vitest.mocked(envConfig.isLoggingEnabled).mockReturnValue(true) + const consoleSpy = vitest.spyOn(console, "log").mockImplementation(() => {}) + // NOTE: This is best-effort. We can only get raw HTTP logs if the Google GenAI SDK + // actually uses globalThis.fetch under the hood. + const baseFetch = vitest.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + vitest.stubGlobal("fetch", baseFetch) // Setup the mock implementation to return an async generator ;(handler["client"].models.generateContentStream as any).mockResolvedValue({ [Symbol.asyncIterator]: async function* () { @@ -101,6 +117,12 @@ describe("GeminiHandler", () => { }), }), ) + + // If fetch is called (SDK implementation detail), we should see RAW HTTP logs. + if (baseFetch.mock.calls.length > 0) { + expect(consoleSpy).toHaveBeenCalledWith("[Gemini] RAW HTTP REQUEST", expect.any(Object)) + expect(consoleSpy).toHaveBeenCalledWith("[Gemini] RAW HTTP RESPONSE", expect.any(Object)) + } }) it("should handle API errors", async () => { diff --git a/src/api/providers/__tests__/minimax.spec.ts b/src/api/providers/__tests__/minimax.spec.ts index 86cb5e0194..64ef5b2d3c 100644 --- a/src/api/providers/__tests__/minimax.spec.ts +++ b/src/api/providers/__tests__/minimax.spec.ts @@ -10,6 +10,8 @@ vitest.mock("vscode", () => ({ import { Anthropic } from "@anthropic-ai/sdk" +import { createLoggingFetch } from "../../core/logging/http-interceptor" + import { type MinimaxModelId, minimaxDefaultModelId, minimaxModels } from "@roo-code/types" import { MiniMaxHandler } from "../minimax" @@ -25,6 +27,14 @@ vitest.mock("@anthropic-ai/sdk", () => { } }) +vitest.mock("../../core/logging/http-interceptor", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createLoggingFetch: vitest.fn(actual.createLoggingFetch), + } +}) + describe("MiniMaxHandler", () => { let handler: MiniMaxHandler let mockCreate: any @@ -48,8 +58,10 @@ describe("MiniMaxHandler", () => { expect(Anthropic).toHaveBeenCalledWith( expect.objectContaining({ baseURL: "https://api.minimax.io/anthropic", + fetch: expect.any(Function), }), ) + expect(createLoggingFetch).toHaveBeenCalledWith("MiniMax") }) it("should convert /v1 endpoint to /anthropic endpoint", () => { diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 4c39fed883..2059b4007c 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -24,6 +24,7 @@ import { handleProviderError } from "./utils/error-handler" import { BaseProvider } from "./base-provider" import { withLogging, ApiLogger } from "../core/logging" +import { createLoggingFetch, withScopedFetchLogging } from "../core/logging/http-interceptor" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { calculateApiCostAnthropic } from "../../shared/cost" import { @@ -49,6 +50,8 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa this.client = new Anthropic({ baseURL: this.options.anthropicBaseUrl || undefined, [apiKeyFieldName]: this.options.apiKey, + // Anthropic SDK supports injecting fetch; use this for raw HTTP parity when logging enabled. + fetch: createLoggingFetch(this.providerName), }) } @@ -102,7 +105,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa const nativeToolParams = shouldIncludeNativeTools ? { tools: convertOpenAIToolsToAnthropic(metadata.tools!), - tool_choice: this.convertOpenAIToolChoice(metadata.tool_choice, metadata.parallelToolCalls), + tool_choice: convertOpenAIToolChoiceToAnthropic(metadata.tool_choice, metadata.parallelToolCalls), } : {} @@ -198,57 +201,65 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 try { - stream = await this.client.messages.create( - { - model: modelId, - max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, - temperature, - thinking, - // Setting cache breakpoint for system prompt so new tasks can reuse it. - system: [{ text: systemPrompt, type: "text", cache_control: cacheControl }], - messages: sanitizedMessages.map((message, index) => { - if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) { - return { - ...message, - content: - typeof message.content === "string" - ? [{ type: "text", text: message.content, cache_control: cacheControl }] - : message.content.map((content, contentIndex) => - contentIndex === message.content.length - 1 - ? { ...content, cache_control: cacheControl } - : content, - ), + stream = await withScopedFetchLogging(this.providerName, async () => + this.client.messages.create( + { + model: modelId, + max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, + temperature, + thinking, + // Setting cache breakpoint for system prompt so new tasks can reuse it. + system: [{ text: systemPrompt, type: "text", cache_control: cacheControl }], + messages: sanitizedMessages.map((message, index) => { + if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) { + return { + ...message, + content: + typeof message.content === "string" + ? [ + { + type: "text", + text: message.content, + cache_control: cacheControl, + }, + ] + : message.content.map((content, contentIndex) => + contentIndex === message.content.length - 1 + ? { ...content, cache_control: cacheControl } + : content, + ), + } } - } - return message - }), - stream: true, - ...nativeToolParams, - }, - (() => { - // prompt caching: https://x.com/alexalbert__/status/1823751995901272068 - // https://github.com/anthropics/anthropic-sdk-typescript?tab=readme-ov-file#default-headers - // https://github.com/anthropics/anthropic-sdk-typescript/commit/c920b77fc67bd839bfeb6716ceab9d7c9bbe7393 + return message + }), + stream: true, + ...nativeToolParams, + }, + (() => { + // prompt caching: https://x.com/alexalbert__/status/1823751995901272068 + // https://github.com/anthropics/anthropic-sdk-typescript?tab=readme-ov-file#default-headers + // https://github.com/anthropics/anthropic-sdk-typescript/commit/c920b77fc67bd839bfeb6716ceab9d7c9bbe7393 - // Then check for models that support prompt caching - switch (modelId) { - case "claude-sonnet-4-5": - case "claude-sonnet-4-20250514": - case "claude-opus-4-5-20251101": - case "claude-opus-4-1-20250805": - case "claude-opus-4-20250514": - case "claude-3-7-sonnet-20250219": - case "claude-3-5-sonnet-20241022": - case "claude-3-5-haiku-20241022": - case "claude-3-opus-20240229": - case "claude-haiku-4-5-20251001": - case "claude-3-haiku-20240307": - betas.push("prompt-caching-2024-07-31") - return { headers: { "anthropic-beta": betas.join(",") } } - default: - return undefined - } - })(), + // Then check for models that support prompt caching + switch (modelId) { + case "claude-sonnet-4-5": + case "claude-sonnet-4-20250514": + case "claude-opus-4-5-20251101": + case "claude-opus-4-1-20250805": + case "claude-opus-4-20250514": + case "claude-3-7-sonnet-20250219": + case "claude-3-5-sonnet-20241022": + case "claude-3-5-haiku-20241022": + case "claude-3-opus-20240229": + case "claude-haiku-4-5-20251001": + case "claude-3-haiku-20240307": + betas.push("prompt-caching-2024-07-31") + return { headers: { "anthropic-beta": betas.join(",") } } + default: + return undefined + } + })(), + ), ) } catch (error) { TelemetryService.instance.captureException( @@ -265,15 +276,17 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa } default: { try { - stream = (await this.client.messages.create({ - model: modelId, - max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, - temperature, - system: [{ text: systemPrompt, type: "text" }], - messages: sanitizedMessages, - stream: true, - ...nativeToolParams, - })) as any + stream = (await withScopedFetchLogging(this.providerName, async () => + this.client.messages.create({ + model: modelId, + max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, + temperature, + system: [{ text: systemPrompt, type: "text" }], + messages: sanitizedMessages, + stream: true, + ...nativeToolParams, + }), + )) as any } catch (error) { TelemetryService.instance.captureException( new ApiProviderError( @@ -462,14 +475,16 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa let message try { - message = await this.client.messages.create({ - model, - max_tokens: ANTHROPIC_DEFAULT_MAX_TOKENS, - thinking: undefined, - temperature, - messages: [{ role: "user", content: prompt }], - stream: false, - }) + message = await withScopedFetchLogging(this.providerName, async () => + this.client.messages.create({ + model, + max_tokens: ANTHROPIC_DEFAULT_MAX_TOKENS, + thinking: undefined, + temperature, + messages: [{ role: "user", content: prompt }], + stream: false, + }), + ) } catch (error) { // Check if Anthropic.APIError exists before using instanceof (may not exist in test mocks) const isAnthropicAPIError = typeof Anthropic.APIError === "function" && error instanceof Anthropic.APIError diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index e091263cfa..c9be7255bb 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -46,6 +46,8 @@ import { shouldUseReasoningBudget } from "../../shared/api" import { normalizeToolSchema } from "../../utils/json-schema" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { withLogging, ApiLogger } from "../core/logging" +import { isLoggingEnabled } from "../core/logging/env-config" +import { sanitizeHeaders } from "../core/logging/http-interceptor" /************************************************************************************ * @@ -288,6 +290,63 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH } this.client = new BedrockRuntimeClient(clientConfig) + this.addRawHttpLoggingMiddleware() + } + + private addRawHttpLoggingMiddleware(): void { + if (!isLoggingEnabled()) { + return + } + + this.client.middlewareStack.add( + (next, context) => async (args) => { + const request = args.request as + | { + protocol?: string + hostname?: string + path?: string + method?: string + headers?: Record + } + | undefined + + if (request) { + const url = + request.protocol && request.hostname + ? `${request.protocol}//${request.hostname}${request.path ?? ""}` + : undefined + + console.log(`[${this.providerName}] RAW HTTP REQUEST`, { + url, + method: request.method, + operation: context.commandName, + headers: request.headers ? sanitizeHeaders(request.headers) : {}, + body: "[unavailable]", + }) + } + + const result = await next(args) + const response = result.response as + | { + statusCode?: number + headers?: Record + body?: unknown + } + | undefined + + if (response) { + console.log(`[${this.providerName}] RAW HTTP RESPONSE`, { + status: response.statusCode, + operation: context.commandName, + headers: response.headers ? sanitizeHeaders(response.headers) : {}, + streaming: response.body != null, + }) + } + + return result + }, + { step: "finalizeRequest", name: "rooRawHttpLogging", tags: ["ROO_CODE"] }, + ) } // Helper to guess model info from custom modelId string if not in bedrockModels diff --git a/src/api/providers/cerebras.ts b/src/api/providers/cerebras.ts index 99e7c4cc3d..557d79be65 100644 --- a/src/api/providers/cerebras.ts +++ b/src/api/providers/cerebras.ts @@ -12,6 +12,7 @@ import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from ". import { BaseProvider } from "./base-provider" import { DEFAULT_HEADERS } from "./constants" import { t } from "../../i18n" +import { createLoggingFetch } from "../core/logging/http-interceptor" const CEREBRAS_BASE_URL = "https://api.cerebras.ai/v1" const CEREBRAS_DEFAULT_TEMPERATURE = 0 @@ -38,6 +39,10 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan } } + protected override get providerName(): string { + return "Cerebras" + } + getModel(): { id: CerebrasModelId; info: (typeof cerebrasModels)[CerebrasModelId] } { const modelId = this.options.apiModelId as CerebrasModelId const validModelId = modelId && this.providerModels[modelId] ? modelId : this.defaultProviderModelId @@ -128,7 +133,8 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan } try { - const response = await fetch(`${CEREBRAS_BASE_URL}/chat/completions`, { + const fetchWithLogging = createLoggingFetch(this.providerName) + const response = await fetchWithLogging(`${CEREBRAS_BASE_URL}/chat/completions`, { method: "POST", headers: { ...DEFAULT_HEADERS, @@ -290,7 +296,8 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan } try { - const response = await fetch(`${CEREBRAS_BASE_URL}/chat/completions`, { + const fetchWithLogging = createLoggingFetch(this.providerName) + const response = await fetchWithLogging(`${CEREBRAS_BASE_URL}/chat/completions`, { method: "POST", headers: { ...DEFAULT_HEADERS, diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index e672584f8f..5717562c83 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -30,6 +30,7 @@ import { handleProviderError } from "./utils/error-handler" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { BaseProvider } from "./base-provider" import { withLogging, ApiLogger } from "../core/logging" +import { withScopedFetchLogging } from "../core/logging/http-interceptor" type GeminiHandlerOptions = ApiHandlerOptions & { isVertex?: boolean @@ -231,7 +232,9 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl const params: GenerateContentParameters = { model, contents, config } try { - const result = await this.client.models.generateContentStream(params) + const result = await withScopedFetchLogging(this.providerName, async () => + this.client.models.generateContentStream(params), + ) let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined let pendingGroundingMetadata: GroundingMetadata | undefined @@ -459,7 +462,9 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl config: promptConfig, } - const result = await this.client.models.generateContent(request) + const result = await withScopedFetchLogging(this.providerName, async () => + this.client.models.generateContent(request), + ) let text = result.text ?? "" diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts index 12d7934546..ebdcd7ee19 100644 --- a/src/api/providers/minimax.ts +++ b/src/api/providers/minimax.ts @@ -14,6 +14,7 @@ import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { calculateApiCostAnthropic } from "../../shared/cost" import { convertOpenAIToolsToAnthropic } from "../../core/prompts/tools/native-tools/converters" +import { createLoggingFetch, withScopedFetchLogging } from "../core/logging/http-interceptor" /** * Converts OpenAI tool_choice to Anthropic ToolChoice format @@ -72,9 +73,14 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand this.client = new Anthropic({ baseURL, apiKey: options.minimaxApiKey, + fetch: createLoggingFetch(this.providerName), }) } + protected override get providerName(): string { + return "MiniMax" + } + async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], @@ -113,7 +119,7 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand } } - stream = await this.client.messages.create(requestParams) + stream = await withScopedFetchLogging(this.providerName, async () => this.client.messages.create(requestParams)) let inputTokens = 0 let outputTokens = 0 @@ -292,13 +298,15 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand async completePrompt(prompt: string) { const { id: model, temperature } = this.getModel() - const message = await this.client.messages.create({ - model, - max_tokens: 16_384, - temperature: temperature ?? 1.0, - messages: [{ role: "user", content: prompt }], - stream: false, - }) + const message = await withScopedFetchLogging(this.providerName, async () => + this.client.messages.create({ + model, + max_tokens: 16_384, + temperature: temperature ?? 1.0, + messages: [{ role: "user", content: prompt }], + stream: false, + }), + ) const content = message.content.find(({ type }) => type === "text") return content?.type === "text" ? content.text : ""