@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 +6 -3
- package/package.json +1 -1
- package/src/auth.ts +1 -0
- package/src/claudehome.ts +152 -46
- package/src/cli.ts +2 -2
- package/src/config.ts +18 -19
- package/src/responses.ts +3 -1
- package/src/server.ts +19 -3
- package/src/setup.ts +3 -1
- package/src/token.ts +9 -1
- package/src/translate.ts +34 -15
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
|
|
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`
|
|
282
|
-
|
|
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
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
//
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
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(
|
|
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.
|
|
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 &&
|
|
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
|
-
|
|
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(
|
|
126
|
-
} catch {
|
|
127
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
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
|
|
43
|
-
//
|
|
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 ||
|
|
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
|
-
|
|
240
|
-
|
|
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
|
|
305
|
-
tool_choice
|
|
306
|
-
|
|
307
|
-
new Set(
|
|
308
|
-
|
|
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
|
|