mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat(logging): add raw HTTP logging parity for providers
This commit is contained in:
parent
0ae6ba7aa8
commit
a54ba5a86e
12 changed files with 452 additions and 116 deletions
|
|
@ -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" }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ?? ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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 : ""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue