feat(logging): add raw HTTP logging parity for providers

This commit is contained in:
Hannes Rudolph 2025-12-18 17:33:09 -07:00 committed by daniel-lxs
parent 0ae6ba7aa8
commit a54ba5a86e
No known key found for this signature in database
GPG key ID: 21C74479048B3AA6
12 changed files with 452 additions and 116 deletions

View file

@ -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<typeof vi.spyOn>
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" }),
)
})
})

View file

@ -10,7 +10,7 @@ import { isLoggingEnabled } from "./env-config"
/**
* Sanitize headers by removing sensitive data like API keys
*/
function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
export function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
const sensitiveKeys = ["authorization", "x-api-key", "api-key", "openai-api-key", "anthropic-api-key"]
const sanitized: Record<string, string> = {}
@ -90,9 +90,11 @@ function headersToObject(headers: Headers): Record<string, string> {
* 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<Response> => {
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<typeof fetch> = []
/**
* 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<T>(providerName: string, callback: () => Promise<T>): Promise<T> {
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
*/

View file

@ -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<typeof import("../../core/logging/http-interceptor")>()
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({

View file

@ -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<typeof vi.fn>
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<typeof vi.fn>
expect(middlewareAdd).not.toHaveBeenCalled()
})
describe("getModel", () => {
it("should return the correct model info for a standard model", () => {
const modelInfo = handler.getModel()

View file

@ -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<Uint8Array> {
return new ReadableStream<Uint8Array>({
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")

View file

@ -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 () => {

View file

@ -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<typeof import("../../core/logging/http-interceptor")>()
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", () => {

View file

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

View file

@ -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<string, string>
}
| 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<string, string>
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

View file

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

View file

@ -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 ?? ""

View file

@ -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 : ""