Skip to content

Commit 5c6e4c6

Browse files
committed
1.fix claude code 2.0.28 warmup request consume premium request, forcing small model if no tools are used
2.add bun idleTimeout = 0 3.feat: Compatible with Claude code JSONL file usage error scenarios, delay closeBlockIfOpen and map responses api to anthropic support tool_use and fix spelling errors 4.feat: add configuration management with extra prompt handling and ensure config file creation
1 parent 619d482 commit 5c6e4c6

8 files changed

Lines changed: 130 additions & 64 deletions

File tree

src/lib/config.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import consola from "consola"
2+
import fs from "node:fs"
3+
4+
import { PATHS } from "./paths"
5+
6+
export interface AppConfig {
7+
extraPrompts?: Record<string, string>
8+
smallModel?: string
9+
}
10+
11+
const defaultConfig: AppConfig = {
12+
extraPrompts: {
13+
"gpt-5-codex": `
14+
## Tool use
15+
- You have access to many tools. If a tool exists to perform a specific task, you MUST use that tool instead of running a terminal command to perform that task.
16+
### Bash tool
17+
When using the Bash tool, follow these rules:
18+
- always run_in_background set to false, unless you are running a long-running command (e.g., a server or a watch command).
19+
### BashOutput tool
20+
When using the BashOutput tool, follow these rules:
21+
- Only Bash Tool run_in_background set to true, Use BashOutput to read the output later
22+
### TodoWrite tool
23+
When using the TodoWrite tool, follow these rules:
24+
- Skip using the TodoWrite tool for tasks with three or fewer steps.
25+
- Do not make single-step todo lists.
26+
- When you made a todo, update it after having performed one of the sub-tasks that you shared on the todo list.
27+
## Special user requests
28+
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as 'date'), you should do so.
29+
`,
30+
},
31+
smallModel: "gpt-5-mini",
32+
}
33+
34+
let cachedConfig: AppConfig | null = null
35+
36+
function ensureConfigFile(): void {
37+
try {
38+
fs.accessSync(PATHS.CONFIG_PATH, fs.constants.R_OK | fs.constants.W_OK)
39+
} catch {
40+
fs.writeFileSync(
41+
PATHS.CONFIG_PATH,
42+
`${JSON.stringify(defaultConfig, null, 2)}\n`,
43+
"utf8",
44+
)
45+
try {
46+
fs.chmodSync(PATHS.CONFIG_PATH, 0o600)
47+
} catch {
48+
return
49+
}
50+
}
51+
}
52+
53+
function readConfigFromDisk(): AppConfig {
54+
ensureConfigFile()
55+
try {
56+
const raw = fs.readFileSync(PATHS.CONFIG_PATH, "utf8")
57+
if (!raw.trim()) {
58+
fs.writeFileSync(
59+
PATHS.CONFIG_PATH,
60+
`${JSON.stringify(defaultConfig, null, 2)}\n`,
61+
"utf8",
62+
)
63+
return defaultConfig
64+
}
65+
return JSON.parse(raw) as AppConfig
66+
} catch (error) {
67+
consola.error("Failed to read config file, using default config", error)
68+
return defaultConfig
69+
}
70+
}
71+
72+
export function getConfig(): AppConfig {
73+
if (!cachedConfig) {
74+
cachedConfig = readConfigFromDisk()
75+
}
76+
return cachedConfig
77+
}
78+
79+
export function getExtraPromptForModel(model: string): string {
80+
const config = getConfig()
81+
return config.extraPrompts?.[model] ?? ""
82+
}
83+
84+
export function getSmallModel(): string {
85+
const config = getConfig()
86+
return config.smallModel ?? "gpt-5-mini"
87+
}

src/lib/paths.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@ import path from "node:path"
55
const APP_DIR = path.join(os.homedir(), ".local", "share", "copilot-api")
66

77
const GITHUB_TOKEN_PATH = path.join(APP_DIR, "github_token")
8+
const CONFIG_PATH = path.join(APP_DIR, "config.json")
89

910
export const PATHS = {
1011
APP_DIR,
1112
GITHUB_TOKEN_PATH,
13+
CONFIG_PATH,
1214
}
1315

1416
export async function ensurePaths(): Promise<void> {
1517
await fs.mkdir(PATHS.APP_DIR, { recursive: true })
1618
await ensureFile(PATHS.GITHUB_TOKEN_PATH)
19+
await ensureFile(PATHS.CONFIG_PATH)
1720
}
1821

1922
async function ensureFile(filePath: string): Promise<void> {

src/routes/messages/handler.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import consola from "consola"
44
import { streamSSE } from "hono/streaming"
55

66
import { awaitApproval } from "~/lib/approval"
7+
import { getSmallModel } from "~/lib/config"
78
import { checkRateLimit } from "~/lib/rate-limit"
89
import { state } from "~/lib/state"
910
import {
@@ -42,6 +43,11 @@ export async function handleCompletion(c: Context) {
4243
const anthropicPayload = await c.req.json<AnthropicMessagesPayload>()
4344
consola.debug("Anthropic request payload:", JSON.stringify(anthropicPayload))
4445

46+
// fix claude code 2.0.28 warmup request consume premium request, forcing small model if no tools are used
47+
if (!anthropicPayload.tools || anthropicPayload.tools.length === 0) {
48+
anthropicPayload.model = getSmallModel()
49+
}
50+
4551
const useResponsesApi = shouldUseResponsesApi(anthropicPayload.model)
4652

4753
if (state.manualApprove) {

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

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,6 @@ const handleOutputItemDone = (
176176
state.blockHasDelta.add(blockIndex)
177177
}
178178

179-
closeBlockIfOpen(state, blockIndex, events)
180-
181179
return events
182180
}
183181

@@ -232,7 +230,6 @@ const handleFunctionCallArgumentsDone = (
232230
state.blockHasDelta.add(blockIndex)
233231
}
234232

235-
closeBlockIfOpen(state, blockIndex, events)
236233
state.functionCallStateByOutputIndex.delete(outputIndex)
237234
return events
238235
}
@@ -340,8 +337,6 @@ const handleOutputTextDone = (
340337
})
341338
}
342339

343-
closeBlockIfOpen(state, blockIndex, events)
344-
345340
return events
346341
}
347342

@@ -421,9 +416,7 @@ const messageStart = (
421416
usage: {
422417
input_tokens: inputTokens,
423418
output_tokens: 0,
424-
...(inputCachedTokens !== undefined && {
425-
cache_creation_input_tokens: inputCachedTokens,
426-
}),
419+
cache_read_input_tokens: inputCachedTokens ?? 0,
427420
},
428421
},
429422
},
@@ -449,6 +442,7 @@ const openTextBlockIfNeeded = (
449442
}
450443

451444
if (!state.openBlocks.has(blockIndex)) {
445+
closeOpenBlocks(state, events)
452446
events.push({
453447
type: "content_block_start",
454448
index: blockIndex,
@@ -480,6 +474,7 @@ const openThinkingBlockIfNeeded = (
480474
}
481475

482476
if (!state.openBlocks.has(blockIndex)) {
477+
closeOpenBlocks(state, events)
483478
events.push({
484479
type: "content_block_start",
485480
index: blockIndex,
@@ -508,13 +503,20 @@ const closeBlockIfOpen = (
508503
state.blockHasDelta.delete(blockIndex)
509504
}
510505

511-
const closeAllOpenBlocks = (
506+
const closeOpenBlocks = (
512507
state: ResponsesStreamState,
513508
events: Array<AnthropicStreamEventData>,
514509
) => {
515510
for (const blockIndex of state.openBlocks) {
516511
closeBlockIfOpen(state, blockIndex, events)
517512
}
513+
}
514+
515+
const closeAllOpenBlocks = (
516+
state: ResponsesStreamState,
517+
events: Array<AnthropicStreamEventData>,
518+
) => {
519+
closeOpenBlocks(state, events)
518520

519521
state.functionCallStateByOutputIndex.clear()
520522
}
@@ -562,6 +564,7 @@ const openFunctionCallBlock = (
562564
const { blockIndex } = functionCallState
563565

564566
if (!state.openBlocks.has(blockIndex)) {
567+
closeOpenBlocks(state, events)
565568
events.push({
566569
type: "content_block_start",
567570
index: blockIndex,

src/routes/messages/responses-translation.ts

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import consola from "consola"
22

3+
import { getExtraPromptForModel } from "~/lib/config"
34
import {
45
type ResponsesPayload,
56
type ResponseInputContent,
@@ -60,8 +61,8 @@ export const translateAnthropicMessagesToResponsesPayload = (
6061
const responsesPayload: ResponsesPayload = {
6162
model: payload.model,
6263
input,
63-
instructions: translateSystemPrompt(payload.system),
64-
temperature: payload.temperature ?? null,
64+
instructions: translateSystemPrompt(payload.system, payload.model),
65+
temperature: 1, // reasoning high temperature fixed to 1
6566
top_p: payload.top_p ?? null,
6667
max_output_tokens: payload.max_tokens,
6768
tools: translatedTools,
@@ -277,36 +278,22 @@ const createFunctionCallOutput = (
277278

278279
const translateSystemPrompt = (
279280
system: string | Array<AnthropicTextBlock> | undefined,
281+
model: string,
280282
): string | null => {
281283
if (!system) {
282284
return null
283285
}
284286

285-
const toolUsePrompt = `
286-
## Tool use
287-
- You have access to many tools. If a tool exists to perform a specific task, you MUST use that tool instead of running a terminal command to perform that task.
288-
### Bash tool
289-
When using the Bash tool, follow these rules:
290-
- always run_in_background set to false, unless you are running a long-running command (e.g., a server or a watch command).
291-
### BashOutput tool
292-
When using the BashOutput tool, follow these rules:
293-
- Only Bash Tool run_in_background set to true, Use BashOutput to read the output later
294-
### TodoWrite tool
295-
When using the TodoWrite tool, follow these rules:
296-
- Skip using the TodoWrite tool for tasks with three or fewer steps.
297-
- Do not make single-step todo lists.
298-
- When you made a todo, update it after having performed one of the sub-tasks that you shared on the todo list.
299-
## Special user requests
300-
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as ''date''), you should do so.`
287+
const extraPrompt = getExtraPromptForModel(model)
301288

302289
if (typeof system === "string") {
303-
return system + toolUsePrompt
290+
return system + extraPrompt
304291
}
305292

306293
const text = system
307294
.map((block, index) => {
308295
if (index === 0) {
309-
return block.text + toolUsePrompt
296+
return block.text + extraPrompt
310297
}
311298
return block.text
312299
})
@@ -548,6 +535,9 @@ const mapResponsesStopReason = (
548535
const { status, incomplete_details: incompleteDetails } = response
549536

550537
if (status === "completed") {
538+
if (response.output.some((item) => item.type === "function_call")) {
539+
return "tool_use"
540+
}
551541
return "end_turn"
552542
}
553543

src/routes/responses/handler.ts

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -52,29 +52,13 @@ export const handleResponses = async (c: Context) => {
5252
if (isStreamingRequested(payload) && isAsyncIterable(response)) {
5353
consola.debug("Forwarding native Responses stream")
5454
return streamSSE(c, async (stream) => {
55-
const pingInterval = setInterval(async () => {
56-
try {
57-
await stream.writeSSE({
58-
event: "ping",
59-
data: JSON.stringify({ timestamp: Date.now() }),
60-
})
61-
} catch (error) {
62-
consola.warn("Failed to send ping:", error)
63-
clearInterval(pingInterval)
64-
}
65-
}, 3000)
66-
67-
try {
68-
for await (const chunk of response) {
69-
consola.debug("Responses stream chunk:", JSON.stringify(chunk))
70-
await stream.writeSSE({
71-
id: (chunk as { id?: string }).id,
72-
event: (chunk as { event?: string }).event,
73-
data: (chunk as { data?: string }).data ?? "",
74-
})
75-
}
76-
} finally {
77-
clearInterval(pingInterval)
55+
for await (const chunk of response) {
56+
consola.debug("Responses stream chunk:", JSON.stringify(chunk))
57+
await stream.writeSSE({
58+
id: (chunk as { id?: string }).id,
59+
event: (chunk as { event?: string }).event,
60+
data: (chunk as { data?: string }).data ?? "",
61+
})
7862
}
7963
})
8064
}

src/start.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ export async function runServer(options: RunServerOptions): Promise<void> {
111111
serve({
112112
fetch: server.fetch as ServerHandler,
113113
port: options.port,
114+
bun: {
115+
idleTimeout: 0,
116+
},
114117
})
115118
}
116119

tests/responses-stream-translation.test.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,7 @@ describe("translateResponsesStreamEvent tool calls", () => {
9696
partial_json: "[]}",
9797
})
9898

99-
const blockStop = events.find(
100-
(event) => event.type === "content_block_stop",
101-
)
102-
expect(blockStop).toBeDefined()
103-
104-
expect(state.openBlocks.size).toBe(0)
99+
expect(state.openBlocks.size).toBe(1)
105100
expect(state.functionCallStateByOutputIndex.size).toBe(0)
106101
})
107102

@@ -139,12 +134,7 @@ describe("translateResponsesStreamEvent tool calls", () => {
139134
'{"todos":[{"content":"Review src/routes/responses/translation.ts"}]}',
140135
})
141136

142-
const blockStop = events.find(
143-
(event) => event.type === "content_block_stop",
144-
)
145-
expect(blockStop).toBeDefined()
146-
147-
expect(state.openBlocks.size).toBe(0)
137+
expect(state.openBlocks.size).toBe(1)
148138
expect(state.functionCallStateByOutputIndex.size).toBe(0)
149139
})
150140
})

0 commit comments

Comments
 (0)