@semanticist14/clco 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -246,7 +246,9 @@ curl -fsSL https://raw.githubusercontent.com/semanticist21/clco/main/uninstall.s
246
246
  Responses API where a model requires it (the GPT-5.x family). SSE
247
247
  streaming, tool calling, images and parallel tool calls work on both.
248
248
 
249
- A rejected native attempt falls back to the translated path. Only an
249
+ A rejected native attempt falls back to the translated path, except a
250
+ context overflow, which comes back as Anthropic's own error so Claude Code
251
+ can compact. Only an
250
252
  unsupported model/endpoint rejection (including HTTP 404) is remembered;
251
253
  other request errors leave native routing available on the next turn.
252
254
  Route changes are recorded in the adapter log (`CLCO_DEBUG=1` in run mode).
@@ -278,8 +280,9 @@ CLCO_UPSTREAM=http://127.0.0.1:9099 bun run scripts/mock-upstream.ts
278
280
  personal use.
279
281
  - Extended thinking works on the native route only; it stays disabled on
280
282
  translated routes. `stop_sequences` are not enforced on Responses-API models.
281
- - `/effort` is forwarded when the selected model declares that level, and
282
- dropped otherwise — rows in `/model` say which do not support it.
283
+ - `/effort` maps to the nearest level the selected model declares (ties go
284
+ up; kimi's low/high/max turns `medium` into `high`) and is dropped for models
285
+ with none — rows in `/model` say which.
283
286
  - Catalog-known Claude rows recalculate their context handling when `/model`
284
287
  switches. Non-catalog rows use a conservative session ceiling because
285
288
  Claude Code cannot inspect their upstream context limit; this can compact
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semanticist14/clco",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Run Claude Code on a GitHub Copilot subscription",
package/src/auth.ts CHANGED
@@ -110,6 +110,7 @@ export async function runDeviceFlow(): Promise<{ token: string; login?: string }
110
110
  try {
111
111
  const user = await copilotFetch(`${GITHUB_API_BASE_URL}/user`, {
112
112
  headers: githubRequestHeaders(token),
113
+ signal: AbortSignal.timeout(15_000),
113
114
  })
114
115
  if (user.ok) login = ((await user.json()) as { login?: string }).login
115
116
  } catch {
package/src/claudehome.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  writeFile,
27
27
  } from "node:fs/promises"
28
28
  import { homedir } from "node:os"
29
+ import { isDeepStrictEqual } from "node:util"
29
30
  import { join } from "node:path"
30
31
 
31
32
  /** Written by claude during a session — must stay private to clco. */
@@ -46,6 +47,7 @@ const PRIVATE_ENTRIES = new Set([
46
47
  * user's file every launch discarded whatever claude had persisted in the
47
48
  * private one.
48
49
  */
50
+
49
51
  /**
50
52
  * clco owns routing and auth: ANTHROPIC_BASE_URL and the bearer travel in
51
53
  * the --settings tier. A provider env block in the user's own settings.json
@@ -75,20 +77,43 @@ function denyServerWebSearch(parsed: Record<string, unknown>): void {
75
77
  parsed.permissions = permissions
76
78
  }
77
79
 
78
- async function writeSettings(real: string, home: string): Promise<void> {
79
- const target = join(home, "settings.json")
80
- const source = await readFile(join(real, "settings.json"), "utf8").catch(
81
- () => null,
82
- )
83
- const existing = await readFile(target, "utf8").catch(() => null)
84
- const raw = source ?? existing
85
- if (raw === null) return
80
+ // What clco last derived from the user's settings.json. A three-way merge
81
+ // against it tells the keys claude persisted during a clco session (kept)
82
+ // from the ones the user changed in their own file since (taken).
83
+ const SETTINGS_BASE = ".clco-settings-base.json"
84
+
85
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
86
+ return typeof v === "object" && v !== null && !Array.isArray(v)
87
+ }
88
+
89
+ /** Objects merge key by key, lists element by element; on a scalar clash clco's side wins. */
90
+ export function merge3(base: unknown, mine: unknown, theirs: unknown): unknown {
91
+ if (isPlainObject(mine) && isPlainObject(theirs)) {
92
+ const b = isPlainObject(base) ? base : {}
93
+ const out: Record<string, unknown> = {}
94
+ for (const key of new Set([...Object.keys(mine), ...Object.keys(theirs)])) {
95
+ const v = merge3(b[key], mine[key], theirs[key])
96
+ if (v !== undefined) out[key] = v
97
+ }
98
+ return out
99
+ }
100
+ if (isDeepStrictEqual(mine, base)) return theirs
101
+ if (isDeepStrictEqual(theirs, base)) return mine
102
+ if (Array.isArray(mine) && Array.isArray(theirs) && Array.isArray(base)) {
103
+ // Both edited a list (permissions.allow, hooks): keep each side's
104
+ // additions and removals rather than one side's whole list.
105
+ const has = (list: unknown[], x: unknown) => list.some((y) => isDeepStrictEqual(x, y))
106
+ return [
107
+ ...theirs.filter((x) => !has(base, x) || has(mine, x)),
108
+ ...mine.filter((x) => !has(base, x) && !has(theirs, x)),
109
+ ]
110
+ }
111
+ return mine
112
+ }
113
+
114
+ function parseSettings(raw: string): Record<string, unknown> {
86
115
  try {
87
- const parsed = JSON.parse(raw) as Record<string, unknown>
88
- delete parsed.model
89
- stripRoutingEnv(parsed)
90
- denyServerWebSearch(parsed)
91
- await atomicWrite(target, JSON.stringify(parsed, null, 2) + "\n")
116
+ return JSON.parse(raw) as Record<string, unknown>
92
117
  } catch {
93
118
  // claude tolerates comments where JSON.parse does not. A line-based regex
94
119
  // was worse than useless here: deleting the last key's line left a
@@ -96,25 +121,57 @@ async function writeSettings(real: string, home: string): Promise<void> {
96
121
  // nested in other objects, and it missed the key when it shared a line.
97
122
  // Strip comments, parse, and re-emit — losing the comments is a smaller
98
123
  // loss than losing the settings.
124
+ return JSON.parse(stripJsonComments(raw)) as Record<string, unknown>
125
+ }
126
+ }
127
+
128
+ function sanitizeSettings(parsed: Record<string, unknown>): Record<string, unknown> {
129
+ delete parsed.model
130
+ stripRoutingEnv(parsed)
131
+ denyServerWebSearch(parsed)
132
+ return parsed
133
+ }
134
+
135
+ async function writeSettings(real: string, home: string): Promise<void> {
136
+ const target = join(home, "settings.json")
137
+ const basePath = join(home, SETTINGS_BASE)
138
+ const source = await readFile(join(real, "settings.json"), "utf8").catch(
139
+ () => null,
140
+ )
141
+ const existing = await readFile(target, "utf8").catch(() => null)
142
+ if (source === null && existing === null) return
143
+ let theirs: Record<string, unknown>
144
+ try {
145
+ theirs = sanitizeSettings(parseSettings(source ?? existing!))
146
+ } catch (err) {
147
+ // Neither form parses: keep the previous private file rather than
148
+ // replace it with something broken, and say so - a stale `model` here
149
+ // is exactly what this function exists to prevent.
150
+ const first = existing === null
151
+ console.error(
152
+ `[clco] could not parse ${join(real, "settings.json")} (${(err as Error).message}) - ` +
153
+ (first
154
+ ? "this session starts with NO settings: no env, no hooks, no permissions."
155
+ : "clco's copy was left as it was."),
156
+ )
157
+ return
158
+ }
159
+ let merged = theirs
160
+ if (source !== null && existing !== null) {
99
161
  try {
100
- const parsed = JSON.parse(stripJsonComments(raw)) as Record<string, unknown>
101
- delete parsed.model
102
- stripRoutingEnv(parsed)
103
- denyServerWebSearch(parsed)
104
- await atomicWrite(target, JSON.stringify(parsed, null, 2) + "\n")
105
- } catch (err) {
106
- // Neither form parses: keep the previous private file rather than
107
- // replace it with something broken, and say so - a stale `model` here
108
- // is exactly what this function exists to prevent.
109
- const first = existing === null
110
- console.error(
111
- `[clco] could not parse ${join(real, "settings.json")} (${(err as Error).message}) - ` +
112
- (first
113
- ? "this session starts with NO settings: no env, no hooks, no permissions."
114
- : "clco's copy was left as it was."),
115
- )
162
+ const mine = parseSettings(existing)
163
+ const baseRaw = await readFile(basePath, "utf8").catch(() => null)
164
+ // No snapshot (first launch on this version): earlier versions rebuilt
165
+ // the private copy from the user's file every launch, so let it win.
166
+ const base = baseRaw === null ? mine : JSON.parse(baseRaw)
167
+ merged = sanitizeSettings(merge3(base, mine, theirs) as Record<string, unknown>)
168
+ } catch {
169
+ // An unreadable private copy has nothing worth keeping.
116
170
  }
117
171
  }
172
+ await atomicWrite(target, JSON.stringify(merged, null, 2) + "\n")
173
+ // A briefly missing user file keeps the old snapshot for when it returns.
174
+ if (source !== null) await atomicWrite(basePath, JSON.stringify(theirs, null, 2) + "\n")
118
175
  }
119
176
 
120
177
  /** Comments only — string contents are left alone. */
@@ -181,6 +238,27 @@ async function atomicWrite(path: string, data: string): Promise<void> {
181
238
  }
182
239
  }
183
240
 
241
+ const PROJECT_MCP_KEYS = [
242
+ "mcpServers",
243
+ "enabledMcpjsonServers",
244
+ "disabledMcpjsonServers",
245
+ "enableAllProjectMcpServers",
246
+ ]
247
+ // The real file's per-project MCP keys as last synced. Plain claude writes
248
+ // empty defaults for every project, so "absent/empty there" cannot mean
249
+ // "removed": only a change since this snapshot propagates.
250
+ const CLAUDE_JSON_BASE = ".clco-claude-json-base.json"
251
+
252
+ function projectMcp(json: Record<string, unknown>): Record<string, Record<string, unknown>> {
253
+ const out: Record<string, Record<string, unknown>> = {}
254
+ for (const [path, entry] of Object.entries((json.projects ?? {}) as Record<string, Record<string, unknown>>)) {
255
+ const keys: Record<string, unknown> = {}
256
+ for (const key of PROJECT_MCP_KEYS) if (entry?.[key] !== undefined) keys[key] = entry[key]
257
+ out[path] = keys
258
+ }
259
+ return out
260
+ }
261
+
184
262
  /**
185
263
  * `.claude.json` carries trust decisions, MCP server definitions (with their
186
264
  * env secrets) and project history. Seeded once so the first session inherits
@@ -191,30 +269,46 @@ async function atomicWrite(path: string, data: string): Promise<void> {
191
269
  */
192
270
  async function syncClaudeState(userHome: string, home: string): Promise<void> {
193
271
  const target = join(home, ".claude.json")
272
+ const basePath = join(home, CLAUDE_JSON_BASE)
194
273
  const source = await readFile(join(userHome, ".claude.json"), "utf8").catch(
195
274
  () => null,
196
275
  )
197
276
  if (source === null) return
198
277
  const existing = await readFile(target, "utf8").catch(() => null)
199
- if (existing === null) {
200
- await copyFile(join(userHome, ".claude.json"), target).catch(() => {})
201
- return
202
- }
203
278
  try {
204
279
  const real = JSON.parse(source) as Record<string, unknown>
205
- const mine = JSON.parse(existing) as Record<string, unknown>
206
- mine.mcpServers = real.mcpServers
207
- const realProjects = (real.projects ?? {}) as Record<string, { hasTrustDialogAccepted?: boolean }>
208
- const myProjects = (mine.projects ?? {}) as Record<string, { hasTrustDialogAccepted?: boolean }>
209
- for (const [path, entry] of Object.entries(myProjects)) {
210
- // Absent means "no opinion", not "withdrawn". clco sessions run against
211
- // the private config dir, so a project only ever used through clco is
212
- // never recorded in the real file at all — forcing false there re-asked
213
- // the trust question on every single launch.
214
- const trusted = realProjects[path]?.hasTrustDialogAccepted
215
- if (trusted !== undefined) entry.hasTrustDialogAccepted = trusted
280
+ if (existing === null) {
281
+ await copyFile(join(userHome, ".claude.json"), target)
282
+ } else {
283
+ const mine = JSON.parse(existing) as Record<string, unknown>
284
+ mine.mcpServers = real.mcpServers
285
+ const realProjects = (real.projects ?? {}) as Record<string, Record<string, unknown>>
286
+ const myProjects = (mine.projects ?? {}) as Record<string, Record<string, unknown>>
287
+ const baseRaw = await readFile(basePath, "utf8").catch(() => null)
288
+ // No snapshot (first launch on this version): earlier versions never
289
+ // synced these keys, so keep what clco has until the real file changes.
290
+ const base = baseRaw === null ? projectMcp(real) : JSON.parse(baseRaw)
291
+ if (Object.keys(realProjects).length > 0) mine.projects = myProjects
292
+ for (const [path, theirs] of Object.entries(realProjects)) {
293
+ const entry = (myProjects[path] ??= {})
294
+ // Local-scope MCP servers (the `claude mcp add` default) and the
295
+ // .mcp.json approvals live per project; a removal must reach clco.
296
+ for (const key of PROJECT_MCP_KEYS) {
297
+ const v = merge3(base[path]?.[key], entry[key], theirs[key])
298
+ if (v === undefined) delete entry[key]
299
+ else entry[key] = v
300
+ }
301
+ // Absent means "no opinion", not "withdrawn". clco sessions run against
302
+ // the private config dir, so a project only ever used through clco is
303
+ // never recorded in the real file at all — forcing false there re-asked
304
+ // the trust question on every single launch.
305
+ if (theirs.hasTrustDialogAccepted !== undefined) {
306
+ entry.hasTrustDialogAccepted = theirs.hasTrustDialogAccepted
307
+ }
308
+ }
309
+ await atomicWrite(target, JSON.stringify(mine, null, 2) + "\n")
216
310
  }
217
- await atomicWrite(target, JSON.stringify(mine, null, 2) + "\n")
311
+ await atomicWrite(basePath, JSON.stringify(projectMcp(real)) + "\n")
218
312
  } catch {
219
313
  // Unparseable on either side: leave what is there rather than lose it.
220
314
  }
@@ -274,8 +368,20 @@ export async function prepareClaudeHome(
274
368
  // Trusting an existing link without checking left ones pointing at a
275
369
  // previous $HOME pointing there forever.
276
370
  if ((await readlink(link).catch(() => null)) === target) continue
371
+ await rm(link, { force: true })
372
+ await symlink(target, link)
373
+ continue
374
+ }
375
+ if (existing) {
376
+ // A real entry means claude created it here before ~/.claude had one
377
+ // (clco used before plain claude). Folding it in automatically races
378
+ // running sessions and concurrent launches, so leave it and say so.
379
+ console.error(
380
+ `[clco] ${link} is clco's own copy, so ~/.claude/${name} is not shared yet.` +
381
+ ` Merge it into ${target} and delete it to share.`,
382
+ )
383
+ continue
277
384
  }
278
- if (existing) await rm(link, { recursive: true, force: true })
279
385
  await symlink(target, link)
280
386
  } catch {
281
387
  // One unlinkable entry should not sink the session.
package/src/cli.ts CHANGED
@@ -851,7 +851,7 @@ async function main(): Promise<void> {
851
851
  // user passes their own --mcp-config, and claiming success there would be
852
852
  // the same trap as registering a server with no extension.
853
853
  const injected = setupClaudeArgs(setup, args.overrides, args.claudeArgs, browserExtension, searchAgent)
854
- const registered = injected.includes("--mcp-config")
854
+ const registered = injected.some((a) => a.startsWith("--mcp-config="))
855
855
  const browserLine = startupLine(
856
856
  browserEnabled,
857
857
  browserExtension,
@@ -869,7 +869,7 @@ async function main(): Promise<void> {
869
869
  console.error(
870
870
  webEnabled && !searchAgent
871
871
  ? "+ web: unavailable (Copilot exposed no search-agent model)"
872
- : webEnabled && injected.includes("--mcp-config")
872
+ : webEnabled && registered
873
873
  ? `+ web: search via ${searchAgent} planner + live local execution`
874
874
  : webEnabled
875
875
  ? "+ web: enabled in setup, but not registered because --mcp-config was supplied"
package/src/config.ts CHANGED
@@ -8,7 +8,6 @@ import {
8
8
  readFile,
9
9
  rename,
10
10
  rm,
11
- unlink,
12
11
  writeFile,
13
12
  } from "node:fs/promises"
14
13
  import { homedir } from "node:os"
@@ -72,18 +71,7 @@ export async function loadAuth(): Promise<AuthStore | null> {
72
71
  export async function saveAuth(auth: AuthStore): Promise<void> {
73
72
  await mkdir(authDir(), { recursive: true })
74
73
  await chmod(authDir(), 0o700).catch(() => {})
75
- const tmp = join(authDir(), `.auth.${process.pid}.tmp`)
76
- const handle = await openExclusive(tmp)
77
- try {
78
- await handle.writeFile(JSON.stringify(auth, null, 2) + "\n")
79
- await handle.close()
80
- await rename(tmp, authPath())
81
- await chmod(authPath(), 0o600).catch(() => {})
82
- } catch (err) {
83
- await handle.close().catch(() => {})
84
- await unlink(tmp).catch(() => {})
85
- throw err
86
- }
74
+ await writeSecret(authPath(), JSON.stringify(auth, null, 2) + "\n")
87
75
  }
88
76
 
89
77
  // "wx": fail if the temp file already exists (never truncate through a
@@ -121,10 +109,17 @@ function prefsPath(): string {
121
109
 
122
110
  export async function loadPrefs(): Promise<Prefs> {
123
111
  await migrateFromClcopilot()
112
+ const raw = await readFile(prefsPath(), "utf8").catch((err) => {
113
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null
114
+ throw err
115
+ })
116
+ if (raw === null) return {}
124
117
  try {
125
- return JSON.parse(await readFile(prefsPath(), "utf8")) as Prefs
126
- } catch {
127
- return {}
118
+ return JSON.parse(raw) as Prefs
119
+ } catch (err) {
120
+ // Treating it as empty let the next save overwrite the setup answers and
121
+ // the stored extension token with defaults.
122
+ throw new Error(`${prefsPath()} is not valid JSON (${(err as Error).message}) - fix or delete it`)
128
123
  }
129
124
  }
130
125
 
@@ -137,15 +132,19 @@ export async function loadPrefs(): Promise<Prefs> {
137
132
  */
138
133
  async function writeSecret(path: string, data: string): Promise<void> {
139
134
  const tmp = `${path}.tmp.${process.pid}`
135
+ // A crash can leave one behind, and "wx" would then refuse it forever.
140
136
  await rm(tmp, { force: true }).catch(() => {})
141
137
  const handle = await openExclusive(tmp)
142
138
  try {
143
139
  await handle.writeFile(data)
144
- } finally {
145
140
  await handle.close()
141
+ await chmod(tmp, 0o600).catch(() => {})
142
+ await rename(tmp, path)
143
+ } catch (err) {
144
+ await handle.close().catch(() => {})
145
+ await rm(tmp, { force: true }).catch(() => {})
146
+ throw err
146
147
  }
147
- await chmod(tmp, 0o600).catch(() => {})
148
- await rename(tmp, path)
149
148
  }
150
149
 
151
150
  export async function savePrefs(prefs: Prefs): Promise<void> {
package/src/responses.ts CHANGED
@@ -166,7 +166,9 @@ export function toResponsesRequest(
166
166
  description: t.description,
167
167
  parameters: t.input_schema,
168
168
  })),
169
- tool_choice: pinnedMissing
169
+ tool_choice: servable.length === 0
170
+ ? undefined
171
+ : pinnedMissing
170
172
  ? ("auto" as const)
171
173
  : choice?.type === "auto"
172
174
  ? ("auto" as const)
package/src/server.ts CHANGED
@@ -146,6 +146,9 @@ function detectVision(payload: OpenAIRequest): boolean {
146
146
  }
147
147
 
148
148
  const routes = new DialectRouter()
149
+ // Anthropic's wording, OpenAI's, and Copilot's own pre-check.
150
+ const CONTEXT_OVERFLOW =
151
+ /prompt is too long|context[ _](?:length|window)|maximum context|prompt token count of \d+ exceeds the limit|max_prompt_tokens_exceeded/i
149
152
 
150
153
  type ChatResult =
151
154
  | { ok: true; res: Response; dialect: Dialect }
@@ -225,9 +228,13 @@ async function copilotChat(
225
228
  // evidence changes future routing. Quota, auth and rate-limit failures
226
229
  // are not shape problems, so they surface
227
230
  // as-is rather than burning a second upstream call.
231
+ // A context overflow is the request's own fault, not the route's: the
232
+ // translated retry would only swap Anthropic's "prompt is too long" (the
233
+ // text Claude Code's reactive compaction keys on) for another error.
228
234
  if (
229
235
  dialect === "native" &&
230
- [400, 404, 415, 422].includes(res.status)
236
+ [400, 404, 415, 422].includes(res.status) &&
237
+ !CONTEXT_OVERFLOW.test(text)
231
238
  ) {
232
239
  debug("native /v1/messages rejected:", res.status, text.slice(0, 300))
233
240
  const remembered = routes.rejectNative(payload.model, res.status, text)
@@ -246,7 +253,16 @@ async function copilotChat(
246
253
  dialect = "responses"
247
254
  continue
248
255
  }
249
- return { ok: false, status: res.status, message: friendlyUpstreamError(text) }
256
+ const message = friendlyUpstreamError(text)
257
+ return {
258
+ ok: false,
259
+ status: res.status,
260
+ // Claude Code starts reactive compaction on Anthropic's phrase; give
261
+ // every dialect's overflow that phrase.
262
+ message: CONTEXT_OVERFLOW.test(text) && !/prompt is too long/i.test(message)
263
+ ? `prompt is too long: ${message}`
264
+ : message,
265
+ }
250
266
  }
251
267
  return { ok: false, status: 502, message: "upstream retry exhausted" }
252
268
  }
@@ -414,7 +430,7 @@ async function handleMessages(
414
430
  }
415
431
  const stream = payload.stream === true
416
432
  logLine(
417
- `[${timestamp()}] POST /v1/messages model=${payload.model} stream=${stream}`,
433
+ `[${timestamp()}] POST /v1/messages model=${payload.model} stream=${stream} effort=${payload.output_config?.effort ?? "-"}`,
418
434
  )
419
435
 
420
436
  const info = modelInfo(normalizeModel(payload.model))
package/src/setup.ts CHANGED
@@ -166,7 +166,9 @@ export function setupClaudeArgs(
166
166
  const webRequested = setup.web === true && overrides.web !== false
167
167
  if ((browserRequested || webRequested) && !has("--mcp-config")) {
168
168
  const config = combinedMcpConfig(browserExtension, webRequested, searchAgent)
169
- if (config) out.push("--mcp-config", config)
169
+ // One argv element: claude's --mcp-config is variadic, so a separate value
170
+ // would also swallow a positional prompt that follows (`clco -- "ask"`).
171
+ if (config) out.push(`--mcp-config=${config}`)
170
172
  }
171
173
  return out
172
174
  }
package/src/token.ts CHANGED
@@ -59,7 +59,15 @@ async function fetchCopilotToken(): Promise<string> {
59
59
  // Short metadata call — never let a hung connection stall startup.
60
60
  signal: AbortSignal.timeout(10_000),
61
61
  })
62
- if (res.status === 401 || res.status === 403) {
62
+ if (res.status === 403) {
63
+ // A valid token refused Copilot (no seat, org policy, SSO): re-auth would
64
+ // only fetch another token that hits the same wall.
65
+ const detail = await res.text().catch(() => "")
66
+ throw new Error(
67
+ `Copilot access denied: HTTP 403 (check your Copilot seat or org policy)${detail ? ` - ${detail.slice(0, 300)}` : ""}`,
68
+ )
69
+ }
70
+ if (res.status === 401) {
63
71
  // The stored GitHub token is revoked/expired — drop it so the next
64
72
  // ensureGithubToken can pick up a fresh one after re-auth.
65
73
  githubToken = null
package/src/translate.ts CHANGED
@@ -39,15 +39,34 @@ function marked(blocks: unknown): boolean {
39
39
  const COPILOT_CACHE = { type: "ephemeral" } as const
40
40
 
41
41
  // Copilot declares the reasoning_effort values each model accepts in its
42
- // /models capabilities. Send the effort only when the model claims it —
43
- // anything else is dropped rather than risking a 400.
42
+ // /models capabilities, and they rarely match Claude Code's tiers one to one
43
+ // (kimi offers low/high/max, gpt-5-mini tops out at high). A tier the model
44
+ // lacks maps to its nearest declared one, ties going up, so /effort still
45
+ // moves the dial instead of silently falling back to the upstream default.
46
+ const EFFORT_ORDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
47
+
44
48
  export function effortFor(
45
49
  payload: AnthropicRequest,
46
50
  allowed: string[] | null | undefined,
47
51
  ): string | undefined {
48
52
  const effort = payload.output_config?.effort
49
- if (!effort || !allowed || !allowed.includes(effort)) return undefined
50
- return effort
53
+ if (!effort || !allowed || allowed.length === 0) return undefined
54
+ if (allowed.includes(effort)) return effort
55
+ const want = EFFORT_ORDER.indexOf(effort)
56
+ if (want < 0) return undefined
57
+ let best: string | undefined
58
+ let bestDistance = Infinity
59
+ for (const value of allowed) {
60
+ // "none" turns reasoning off; never pick it for a tier that asked for some.
61
+ const rank = value === "none" ? -1 : EFFORT_ORDER.indexOf(value)
62
+ if (rank < 0) continue
63
+ const distance = Math.abs(rank - want)
64
+ if (distance < bestDistance || (distance === bestDistance && rank > want)) {
65
+ best = value
66
+ bestDistance = distance
67
+ }
68
+ }
69
+ return best
51
70
  }
52
71
 
53
72
  // ---------------------------------------------------------------------------
@@ -236,8 +255,11 @@ function translateSystem(
236
255
  function translateTools(
237
256
  tools: AnthropicRequest["tools"],
238
257
  ): OpenAIRequest["tools"] {
239
- if (!tools || tools.length === 0) return undefined
240
- return tools.map((tool) => ({
258
+ // Claude's server-side tools (web_search, ...) carry no input_schema and
259
+ // cannot run as client functions; drop them like the Responses path does.
260
+ const servable = tools?.filter((tool) => tool.input_schema)
261
+ if (!servable || servable.length === 0) return undefined
262
+ return servable.map((tool) => ({
241
263
  type: "function" as const,
242
264
  function: {
243
265
  name: tool.name,
@@ -276,6 +298,7 @@ export function translateRequest(
276
298
  allowedEfforts?: string[] | null,
277
299
  ): OpenAIRequest {
278
300
  const effort = effortFor(payload, allowedEfforts)
301
+ const tools = translateTools(payload.tools)
279
302
  return {
280
303
  model: normalizeModel(payload.model),
281
304
  ...(effort && { reasoning_effort: effort }),
@@ -301,15 +324,11 @@ export function translateRequest(
301
324
  temperature: payload.temperature,
302
325
  top_p: payload.top_p,
303
326
  user: payload.metadata?.user_id ?? null,
304
- tools: translateTools(payload.tools),
305
- tool_choice: translateToolChoice(
306
- payload.tool_choice,
307
- new Set(
308
- (payload.tools ?? [])
309
- .filter((tool) => tool.input_schema)
310
- .map((tool) => tool.name),
311
- ),
312
- ),
327
+ tools,
328
+ // A tool_choice with no tools left is a 400 on OpenAI-style endpoints.
329
+ tool_choice: tools
330
+ ? translateToolChoice(payload.tool_choice, new Set(tools.map((t) => t.function.name)))
331
+ : undefined,
313
332
  }
314
333
  }
315
334