Skip to content

Commit 9be0eb6

Browse files
committed
feat: implement Copilot context cache control and prepare payload for chat completions
1 parent 0d46c7d commit 9be0eb6

4 files changed

Lines changed: 263 additions & 1 deletion

File tree

src/routes/messages/api-flows.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ import {
3636
createChatCompletions,
3737
type ChatCompletionChunk,
3838
type ChatCompletionResponse,
39+
type ChatCompletionsPayload,
40+
type Message,
3941
} from "~/services/copilot/create-chat-completions"
4042
import { createMessages } from "~/services/copilot/create-messages"
4143
import {
@@ -59,6 +61,12 @@ import {
5961
translateChunkToAnthropicEvents,
6062
} from "./stream-translation"
6163

64+
const COPILOT_CONTEXT_CACHE_SYSTEM_MARKER_LIMIT = 2
65+
const COPILOT_CONTEXT_CACHE_NON_SYSTEM_MARKER_LIMIT = 2
66+
const COPILOT_CONTEXT_CACHE_CONTROL = {
67+
type: "ephemeral",
68+
} as const
69+
6270
export interface FlowBaseOptions {
6371
logger: ConsolaInstance
6472
subagentMarker?: SubagentMarker | null
@@ -83,6 +91,7 @@ export const handleWithChatCompletions = async (
8391
) => {
8492
const { logger, subagentMarker, requestId, sessionId, compactType } = options
8593
const openAIPayload = translateToOpenAI(anthropicPayload)
94+
prepareCopilotChatCompletionsPayload(openAIPayload)
8695
const recordUsage = createCopilotUsageRecorder({
8796
endpoint: "chat_completions",
8897
fallbackSessionId: sessionId,
@@ -339,6 +348,58 @@ export const handleWithMessagesApi = async (
339348
return c.json(response)
340349
}
341350

351+
export const prepareCopilotChatCompletionsPayload = (
352+
payload: ChatCompletionsPayload,
353+
): void => {
354+
applyCopilotContextCache(payload)
355+
}
356+
357+
const applyCopilotContextCache = (payload: ChatCompletionsPayload): void => {
358+
const messageIndexes = selectCopilotContextCacheMessageIndexes(
359+
payload.messages,
360+
)
361+
for (const messageIndex of messageIndexes) {
362+
const message = payload.messages[messageIndex]
363+
message.copilot_cache_control = { ...COPILOT_CONTEXT_CACHE_CONTROL }
364+
}
365+
}
366+
367+
const selectCopilotContextCacheMessageIndexes = (
368+
messages: Array<Message>,
369+
): Array<number> => {
370+
const systemIndexes = messages
371+
.flatMap((message, index) =>
372+
message.role === "system" && isCopilotContextCacheEligible(message) ?
373+
[index]
374+
: [],
375+
)
376+
.slice(0, COPILOT_CONTEXT_CACHE_SYSTEM_MARKER_LIMIT)
377+
const reverseNonSystemIndexes = messages
378+
.flatMap((message, index) =>
379+
message.role !== "system" && isCopilotContextCacheEligible(message) ?
380+
[index]
381+
: [],
382+
)
383+
.reverse()
384+
.slice(0, COPILOT_CONTEXT_CACHE_NON_SYSTEM_MARKER_LIMIT)
385+
386+
return uniqueIndexes([...systemIndexes, ...reverseNonSystemIndexes]).sort(
387+
(a, b) => a - b,
388+
)
389+
}
390+
391+
const isCopilotContextCacheEligible = (message: Message): boolean => {
392+
if (typeof message.content === "string") {
393+
return message.content.length > 0
394+
}
395+
396+
return Array.isArray(message.content) && message.content.length > 0
397+
}
398+
399+
const uniqueIndexes = (indexes: Array<number>): Array<number> => [
400+
...new Set(indexes),
401+
]
402+
342403
const isNonStreaming = (
343404
response: Awaited<ReturnType<typeof createChatCompletions>>,
344405
): response is ChatCompletionResponse => Object.hasOwn(response, "choices")

src/routes/messages/non-stream-translation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ function mapContent(
427427
}
428428
}
429429
if (contentParts.length === 0) {
430-
return null
430+
return ""
431431
}
432432
return contentParts
433433
}

src/services/copilot/create-chat-completions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ export interface Message {
217217
reasoning_content?: string | null
218218
reasoning_text?: string | null
219219
reasoning_opaque?: string | null
220+
copilot_cache_control?: CopilotCacheControl
220221
}
221222

222223
export interface ToolCall {
@@ -234,6 +235,10 @@ export interface CacheControl {
234235
type: "ephemeral"
235236
}
236237

238+
export interface CopilotCacheControl {
239+
type: "ephemeral"
240+
}
241+
237242
export interface TextPart {
238243
type: "text"
239244
text: string

tests/messages-api-flows.test.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { beforeEach, expect, mock, test } from "bun:test"
2+
3+
import type { AnthropicMessagesPayload } from "../src/routes/messages/anthropic-types"
4+
import type {
5+
ChatCompletionResponse,
6+
ChatCompletionsPayload,
7+
} from "../src/services/copilot/create-chat-completions"
8+
9+
const actualChatCompletionsModule = await import(
10+
"../src/services/copilot/create-chat-completions"
11+
)
12+
13+
let capturedPayload: ChatCompletionsPayload | null = null
14+
15+
const createChatCompletions = mock(
16+
(payload: ChatCompletionsPayload): Promise<ChatCompletionResponse> => {
17+
capturedPayload = payload
18+
return Promise.resolve({
19+
id: "chatcmpl-test",
20+
object: "chat.completion",
21+
created: 0,
22+
model: payload.model,
23+
choices: [
24+
{
25+
index: 0,
26+
message: {
27+
role: "assistant",
28+
content: "ok",
29+
},
30+
logprobs: null,
31+
finish_reason: "stop",
32+
},
33+
],
34+
})
35+
},
36+
)
37+
38+
await mock.module("~/services/copilot/create-chat-completions", () => ({
39+
...actualChatCompletionsModule,
40+
createChatCompletions,
41+
}))
42+
43+
const { handleWithChatCompletions, prepareCopilotChatCompletionsPayload } =
44+
await import("../src/routes/messages/api-flows")
45+
46+
const logger = {
47+
debug: () => {},
48+
warn: () => {},
49+
error: () => {},
50+
} as unknown as Parameters<typeof handleWithChatCompletions>[2]["logger"]
51+
52+
const createContext = () =>
53+
({
54+
json: (body: unknown) => Response.json(body),
55+
}) as Parameters<typeof handleWithChatCompletions>[0]
56+
57+
beforeEach(() => {
58+
capturedPayload = null
59+
createChatCompletions.mockClear()
60+
})
61+
62+
test("messages Chat Completions flow adds Copilot cache control to system and latest two non-system messages", async () => {
63+
const payload: AnthropicMessagesPayload = {
64+
model: "gpt-test",
65+
max_tokens: 128,
66+
system: [
67+
{
68+
type: "text",
69+
text: "system prompt",
70+
},
71+
],
72+
messages: [
73+
{ role: "user", content: "first user" },
74+
{
75+
role: "user",
76+
content: [
77+
{
78+
type: "text",
79+
text: "second user",
80+
},
81+
],
82+
},
83+
{ role: "assistant", content: "older answer" },
84+
{ role: "user", content: "latest user" },
85+
{ role: "assistant", content: "latest answer" },
86+
],
87+
}
88+
89+
const response = await handleWithChatCompletions(createContext(), payload, {
90+
logger,
91+
requestId: "request-1",
92+
})
93+
94+
expect(response.status).toBe(200)
95+
expect(createChatCompletions).toHaveBeenCalledTimes(1)
96+
expect(capturedPayload?.messages).toEqual([
97+
{
98+
role: "system",
99+
content: "system prompt",
100+
copilot_cache_control: {
101+
type: "ephemeral",
102+
},
103+
},
104+
{
105+
role: "user",
106+
content: "first user",
107+
},
108+
{
109+
role: "user",
110+
content: [
111+
{
112+
type: "text",
113+
text: "second user",
114+
},
115+
],
116+
},
117+
{
118+
role: "assistant",
119+
content: "older answer",
120+
},
121+
{
122+
role: "user",
123+
content: "latest user",
124+
copilot_cache_control: {
125+
type: "ephemeral",
126+
},
127+
},
128+
{
129+
role: "assistant",
130+
content: "latest answer",
131+
copilot_cache_control: {
132+
type: "ephemeral",
133+
},
134+
},
135+
])
136+
})
137+
138+
test("Copilot Chat Completions payload preparation marks two system and latest two non-system messages", () => {
139+
const payload: ChatCompletionsPayload = {
140+
model: "gpt-test",
141+
messages: [
142+
{ role: "system", content: "system one" },
143+
{ role: "system", content: "system two" },
144+
{ role: "system", content: "system three" },
145+
{ role: "user", content: "older user" },
146+
{ role: "assistant", content: "older assistant" },
147+
{ role: "user", content: "latest user" },
148+
{ role: "assistant", content: "latest assistant" },
149+
],
150+
}
151+
152+
prepareCopilotChatCompletionsPayload(payload)
153+
154+
expect(payload.messages).toEqual([
155+
{
156+
role: "system",
157+
content: "system one",
158+
copilot_cache_control: {
159+
type: "ephemeral",
160+
},
161+
},
162+
{
163+
role: "system",
164+
content: "system two",
165+
copilot_cache_control: {
166+
type: "ephemeral",
167+
},
168+
},
169+
{
170+
role: "system",
171+
content: "system three",
172+
},
173+
{
174+
role: "user",
175+
content: "older user",
176+
},
177+
{
178+
role: "assistant",
179+
content: "older assistant",
180+
},
181+
{
182+
role: "user",
183+
content: "latest user",
184+
copilot_cache_control: {
185+
type: "ephemeral",
186+
},
187+
},
188+
{
189+
role: "assistant",
190+
content: "latest assistant",
191+
copilot_cache_control: {
192+
type: "ephemeral",
193+
},
194+
},
195+
])
196+
})

0 commit comments

Comments
 (0)