Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions src/api/providers/__tests__/openai-native-usage.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
import { describe, it, expect, beforeEach } from "vitest"
import { OpenAiNativeHandler } from "../openai-native"
import { OpenAiServiceTier, openAiNativeModels } from "@roo-code/types"
import { asyncStreamFrom, collectStream } from "../../../test-utils/stream"

// The streaming entry point reaches the SDK client, so stub the client surface the
// handler actually calls. Without this the real SDK would attempt a network request.
const mockResponsesCreate = vitest.fn()

vitest.mock("openai", () => ({
__esModule: true,
default: vitest.fn().mockImplementation(function () {
return {
responses: {
create: mockResponsesCreate,
},
}
}),
}))

describe("OpenAiNativeHandler - normalizeUsage", () => {
let handler: OpenAiNativeHandler
Expand Down Expand Up @@ -440,6 +456,97 @@ describe("OpenAiNativeHandler - normalizeUsage", () => {
})
})

describe("OpenAiNativeHandler - prompt cache key", () => {
let handler: OpenAiNativeHandler

beforeEach(() => {
handler = new OpenAiNativeHandler({
openAiNativeApiKey: "test-key",
})
})

const buildRequestBody = (modelId: string, taskId?: string) => {
handler["options"].apiModelId = modelId
const model = handler.getModel()
const metadata = taskId === undefined ? undefined : { taskId }
return handler["buildRequestBody"](model, [], "", model.verbosity, undefined, metadata)
}

it("should send the task id as the body prompt_cache_key", () => {
const body = buildRequestBody("gpt-5.1", "task-abc")
expect(body.prompt_cache_key).toBe("task-abc")
})

it("should keep the key stable across turns of the same task", () => {
const firstTurn = buildRequestBody("gpt-5.1", "task-stable")
const secondTurn = buildRequestBody("gpt-5.1", "task-stable")
expect(firstTurn.prompt_cache_key).toBe("task-stable")
expect(secondTurn.prompt_cache_key).toBe(firstTurn.prompt_cache_key)
})

it("should give distinct keys to distinct tasks", () => {
const first = buildRequestBody("gpt-5.1", "task-one")
const second = buildRequestBody("gpt-5.1", "task-two")
expect(first.prompt_cache_key).toBe("task-one")
expect(second.prompt_cache_key).toBe("task-two")
expect(first.prompt_cache_key).not.toBe(second.prompt_cache_key)
})

it("should omit the key when no task id is supplied", () => {
// No task-scoped identity means no accumulating prefix, so the field is omitted
// rather than filled with a per-call random value that would override default routing.
const body = buildRequestBody("gpt-5.1")
expect(body).not.toHaveProperty("prompt_cache_key")
})

it("should omit the key when the model does not support prompt caching", () => {
const modelId = "codex-mini-latest"
expect(openAiNativeModels[modelId as keyof typeof openAiNativeModels].supportsPromptCache).toBe(false)

const body = buildRequestBody(modelId, "task-abc")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
expect(body.prompt_cache_key).toBeUndefined()
})

it("should omit the key for a non-caching model through the streaming entry point", async () => {
// The builder-level test above cannot see the streaming path: createMessage forwards
// metadata into buildRequestBody, so a key added anywhere along that path would still
// pass it. Drive the public entry point with a task id to pin the model gate there too.
const modelId = "codex-mini-latest"
expect(openAiNativeModels[modelId as keyof typeof openAiNativeModels].supportsPromptCache).toBe(false)

mockResponsesCreate.mockClear()
mockResponsesCreate.mockResolvedValue(asyncStreamFrom([]))

const streamingHandler = new OpenAiNativeHandler({
openAiNativeApiKey: "test-key",
apiModelId: modelId,
})

await collectStream(
streamingHandler.createMessage("You are a helpful assistant.", [{ role: "user", content: "Hello!" }], {
taskId: "task-abc",
}),
)

expect(mockResponsesCreate).toHaveBeenCalledTimes(1)
const [body] = mockResponsesCreate.mock.calls[0]
expect(body).not.toHaveProperty("prompt_cache_key")
})

it("should clamp an over-length task id to OpenAI's 64-character limit", () => {
const longTaskId = "t".repeat(80)
const body = buildRequestBody("gpt-5.1", longTaskId)
expect(body.prompt_cache_key).toHaveLength(64)
expect(body.prompt_cache_key).toBe(longTaskId.slice(0, 64))
})

it("should keep prompt_cache_retention alongside the key for 24h models", () => {
const body = buildRequestBody("gpt-5.1", "task-abc")
expect(body.prompt_cache_key).toBe("task-abc")
expect(body.prompt_cache_retention).toBe("24h")
})
})

describe("cost calculation", () => {
it.each([
{ tier: OpenAiServiceTier.Default, expectedCost: 4.525 },
Expand Down
114 changes: 114 additions & 0 deletions src/api/providers/__tests__/openai-native.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,25 @@ describe("OpenAiNativeHandler", () => {

expect(result).toBe("")
})

it("should omit the prompt_cache_key because no task-scoped identity exists", async () => {
mockResponsesCreate.mockResolvedValue({ output: [] })

await handler.completePrompt("Test prompt")

const [request] = mockResponsesCreate.mock.calls[0]
expect(request).not.toHaveProperty("prompt_cache_key")
})

it("should omit the prompt_cache_key when the model does not support prompt caching", async () => {
mockResponsesCreate.mockResolvedValue({ output: [] })
handler = new OpenAiNativeHandler({ ...mockOptions, apiModelId: "codex-mini-latest" })

await handler.completePrompt("Test prompt")

const [request] = mockResponsesCreate.mock.calls[0]
expect(request).not.toHaveProperty("prompt_cache_key")
})
})

describe("getModel", () => {
Expand Down Expand Up @@ -1446,6 +1465,101 @@ describe("OpenAiNativeHandler", () => {
expect(callBody.previous_response_id).toBeUndefined()
})

it("sends a task-scoped prompt_cache_key in the body and no session_id header", async () => {
const mockFetch = vitest.fn().mockResolvedValue({
ok: true,
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"))
controller.close()
},
}),
})
global.fetch = mockFetch as typeof fetch
// Force the hand-built SSE path so the wire body and headers are inspectable.
mockResponsesCreate.mockRejectedValue(new Error("SDK not available"))

const cacheHandler = new OpenAiNativeHandler({ ...mockOptions, apiModelId: "gpt-5.1" })

for (const taskId of ["task-cache-one", "task-cache-one", "task-cache-two"]) {
for await (const _chunk of cacheHandler.createMessage(systemPrompt, messages, { taskId })) {
// consume
}
}

const bodies = mockFetch.mock.calls.map(([, init]) => JSON.parse(init.body))
expect(bodies).toHaveLength(3)
expect(bodies[0].prompt_cache_key).toBe("task-cache-one")
// Same task, second turn: the key must not drift, or the growing prefix loses affinity.
expect(bodies[1].prompt_cache_key).toBe(bodies[0].prompt_cache_key)
// A different task must not share the bucket.
expect(bodies[2].prompt_cache_key).toBe("task-cache-two")
expect(bodies[2].prompt_cache_key).not.toBe(bodies[0].prompt_cache_key)

// The retention control is untouched by this change.
expect(bodies[0].prompt_cache_retention).toBe("24h")

// The affinity header is gone: OpenAI ignores it for caching and hardened
// gateways reject underscore header names outright.
for (const [, init] of mockFetch.mock.calls) {
expect(init.headers).not.toHaveProperty("session_id")
}
})

it("sends the task-scoped prompt_cache_key through the SDK path", async () => {
mockResponsesCreate.mockResolvedValue(asyncStreamFrom([]))
;(OpenAI as unknown as ReturnType<typeof vitest.fn>).mockClear()

const cacheHandler = new OpenAiNativeHandler({ ...mockOptions, apiModelId: "gpt-5.1" })

// The second turn of the same task carries a longer conversation. Reusing one
// messages array would let a key derived from the conversation pass, so the two
// calls must differ in input while the key holds.
const followUp: Anthropic.Messages.MessageParam[] = [
...messages,
{ role: "assistant", content: "Hello! How can I help?" },
{ role: "user", content: "Tell me about prompt caching." },
]
const turns: Array<{ taskId: string; messages: Anthropic.Messages.MessageParam[] }> = [
{ taskId: "sdk-task-one", messages },
{ taskId: "sdk-task-one", messages: followUp },
{ taskId: "sdk-task-two", messages },
]

for (const turn of turns) {
await collectStream(cacheHandler.createMessage(systemPrompt, turn.messages, { taskId: turn.taskId }))
}

const bodies = mockResponsesCreate.mock.calls.map(([body]) => body)
expect(bodies[0].prompt_cache_key).toBe("sdk-task-one")
expect(bodies[1].input).not.toEqual(bodies[0].input)
expect(bodies[1].prompt_cache_key).toBe(bodies[0].prompt_cache_key)
expect(bodies[2].prompt_cache_key).toBe("sdk-task-two")
expect(bodies[0].prompt_cache_retention).toBe("24h")
expect(mockResponsesCreate.mock.calls[0][1].headers).not.toHaveProperty("session_id")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

// Per-request headers are only half the surface: the SDK constructor also takes
// defaultHeaders, and a restored affinity header there would ship on every request
// while the assertion above still passed. Cache affinity is a body field, so neither
// the header name nor the body field name may appear in the constructor's defaults.
const constructorArgs = (OpenAI as unknown as ReturnType<typeof vitest.fn>).mock.calls[0][0]
expect(constructorArgs.defaultHeaders).not.toHaveProperty("session_id")
expect(constructorArgs.defaultHeaders).not.toHaveProperty("prompt_cache_key")
})

it("omits the prompt_cache_key through createMessage when no metadata is supplied", async () => {
mockResponsesCreate.mockResolvedValue(asyncStreamFrom([]))

const cacheHandler = new OpenAiNativeHandler({ ...mockOptions, apiModelId: "gpt-5.1" })

// No metadata at all: the streaming entry point must not synthesize a fallback
// task id, or the field would replace OpenAI's default routing for no affinity.
await collectStream(cacheHandler.createMessage(systemPrompt, messages))

const [body] = mockResponsesCreate.mock.calls[0]
expect(body).not.toHaveProperty("prompt_cache_key")
})

it("should provide helpful error messages for different error codes", async () => {
const testCases = [
{ status: 400, expectedMessage: "Invalid request to Responses API" },
Expand Down
Loading
Loading