@semanticist14/clco 0.1.0

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/src/config.ts ADDED
@@ -0,0 +1,164 @@
1
+ // Durable auth storage: ~/.config/clco/auth.json (mode 600).
2
+ // Writes go through a temp file + rename so a crash or a pre-existing
3
+ // symlink/permission can never leave the token readable or half-written.
4
+
5
+ import {
6
+ chmod,
7
+ mkdir,
8
+ readFile,
9
+ rename,
10
+ rm,
11
+ unlink,
12
+ writeFile,
13
+ } from "node:fs/promises"
14
+ import { homedir } from "node:os"
15
+ import { join } from "node:path"
16
+
17
+ export interface AuthStore {
18
+ github_token: string
19
+ login?: string
20
+ }
21
+
22
+ // Overridable so tests can exercise the real read/merge/write path without
23
+ // writing into the user's live install.
24
+ let configDir: string | null = null
25
+
26
+ export function setConfigDir(dir: string | null): void {
27
+ configDir = dir
28
+ }
29
+
30
+ function authDir(): string {
31
+ return configDir ?? join(homedir(), ".config", "clco")
32
+ }
33
+
34
+ function authPath(): string {
35
+ return join(authDir(), "auth.json")
36
+ }
37
+
38
+ // One-time migration from the pre-rename config directory.
39
+ let migrated = false
40
+ async function migrateFromClcopilot(): Promise<void> {
41
+ if (migrated) return
42
+ migrated = true
43
+ const legacyDir = join(homedir(), ".config", "clcopilot")
44
+ await mkdir(authDir(), { recursive: true, mode: 0o700 }).catch(() => {})
45
+ await chmod(authDir(), 0o700).catch(() => {})
46
+ for (const name of ["auth.json", "prefs.json"]) {
47
+ const target = join(authDir(), name)
48
+ try {
49
+ await readFile(target, "utf8")
50
+ continue // already present in the new location
51
+ } catch {
52
+ // not migrated yet
53
+ }
54
+ try {
55
+ const data = await readFile(join(legacyDir, name), "utf8")
56
+ await writeFile(target, data, { mode: 0o600 })
57
+ } catch {
58
+ // nothing to migrate
59
+ }
60
+ }
61
+ }
62
+
63
+ export async function loadAuth(): Promise<AuthStore | null> {
64
+ await migrateFromClcopilot()
65
+ try {
66
+ return JSON.parse(await readFile(authPath(), "utf8")) as AuthStore
67
+ } catch {
68
+ return null
69
+ }
70
+ }
71
+
72
+ export async function saveAuth(auth: AuthStore): Promise<void> {
73
+ await mkdir(authDir(), { recursive: true })
74
+ 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
+ }
87
+ }
88
+
89
+ // "wx": fail if the temp file already exists (never truncate through a
90
+ // symlink); creation mode 0600.
91
+ async function openExclusive(path: string) {
92
+ const { open } = await import("node:fs/promises")
93
+ return open(path, "wx", 0o600)
94
+ }
95
+
96
+ /** Startup options answered once, changed with `clco setup`. */
97
+ export interface SetupPrefs {
98
+ version: number
99
+ bypass: boolean
100
+ select: boolean
101
+ /** Register the Playwright MCP server for clco sessions. Added in v2. */
102
+ browser?: boolean
103
+ /**
104
+ * Playwright MCP's extension token. Skips the connect dialog every session.
105
+ * Kept here because prefs.json is already written 0600, alongside no other
106
+ * secret — the GitHub token lives in auth.json.
107
+ */
108
+ browserToken?: string
109
+ }
110
+
111
+ export interface Prefs {
112
+ last_model?: string
113
+ setup?: SetupPrefs
114
+ }
115
+
116
+ function prefsPath(): string {
117
+ return join(authDir(), "prefs.json")
118
+ }
119
+
120
+ export async function loadPrefs(): Promise<Prefs> {
121
+ await migrateFromClcopilot()
122
+ try {
123
+ return JSON.parse(await readFile(prefsPath(), "utf8")) as Prefs
124
+ } catch {
125
+ return {}
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Merge into the stored preferences.
131
+ *
132
+ * Every caller holds one concern — the model prompt writes `last_model`, setup
133
+ * writes `setup` — and a plain write let whichever ran last erase the other.
134
+ * Picking a model really did discard the setup answers.
135
+ */
136
+ async function writeSecret(path: string, data: string): Promise<void> {
137
+ const tmp = `${path}.tmp.${process.pid}`
138
+ await rm(tmp, { force: true }).catch(() => {})
139
+ const handle = await openExclusive(tmp)
140
+ try {
141
+ await handle.writeFile(data)
142
+ } finally {
143
+ await handle.close()
144
+ }
145
+ await chmod(tmp, 0o600).catch(() => {})
146
+ await rename(tmp, path)
147
+ }
148
+
149
+ export async function savePrefs(prefs: Prefs): Promise<void> {
150
+ await migrateFromClcopilot()
151
+ await mkdir(authDir(), { recursive: true })
152
+ const merged = { ...(await loadPrefs()), ...prefs }
153
+ // prefs now holds the Playwright extension token, so it gets the same
154
+ // treatment as auth.json: exclusive create, explicit mode, atomic rename.
155
+ // A plain write leaves an existing 0644 file at 0644 and can truncate.
156
+ await writeSecret(prefsPath(), JSON.stringify(merged, null, 2) + "\n")
157
+ }
158
+
159
+ export async function clearAuth(): Promise<void> {
160
+ // Also remove the legacy copy — otherwise migrateFromClcopilot resurrects
161
+ // the old token on the next run (logout would be a no-op).
162
+ await rm(authPath()).catch(() => {})
163
+ await rm(join(homedir(), ".config", "clcopilot", "auth.json")).catch(() => {})
164
+ }
@@ -0,0 +1,435 @@
1
+ // GitHub Copilot serves newer models (GPT-5.x "luna" family, codex) through
2
+ // the OpenAI Responses API (POST /responses) instead of /chat/completions.
3
+ // This module translates Anthropic requests into Responses requests, and
4
+ // Responses SSE events into OpenAI-style chunks so the existing
5
+ // StreamTranslator can render Anthropic events unchanged.
6
+
7
+ import { effortFor, normalizeModel } from "./translate"
8
+ import type { AnthropicRequest, OpenAIResponse } from "./wire"
9
+ import { classifyContent } from "./blocks"
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Request direction: Anthropic -> Responses
13
+ // ---------------------------------------------------------------------------
14
+
15
+ interface ResponsesTool {
16
+ type: "function"
17
+ name: string
18
+ description?: string
19
+ parameters: Record<string, unknown>
20
+ }
21
+
22
+ interface ResponsesRequest {
23
+ model: string
24
+ instructions?: string
25
+ input: Array<Record<string, unknown>>
26
+ stream?: boolean
27
+ max_output_tokens?: number
28
+ temperature?: number
29
+ top_p?: number
30
+ tools?: ResponsesTool[]
31
+ tool_choice?: "auto" | "none" | "required" | { type: "function"; name: string }
32
+ reasoning?: { effort: string }
33
+ }
34
+
35
+ export function toResponsesRequest(
36
+ payload: AnthropicRequest,
37
+ allowedEfforts?: string[] | null,
38
+ ): ResponsesRequest {
39
+ const input: Array<Record<string, unknown>> = []
40
+
41
+ for (const message of payload.messages) {
42
+ if (typeof message.content === "string") {
43
+ input.push({
44
+ role: message.role,
45
+ content: [
46
+ {
47
+ type: message.role === "assistant" ? "output_text" : "input_text",
48
+ text: message.content,
49
+ },
50
+ ],
51
+ })
52
+ continue
53
+ }
54
+
55
+ // Tool results must land in the same order as the conversation.
56
+ const rest: Array<Record<string, unknown>> = []
57
+ const pendingImages: Array<Record<string, unknown>> = []
58
+ for (const view of classifyContent(message.content)) {
59
+ if (view.kind === "tool_result") {
60
+ const block = view.block
61
+ // Flush pending user content before the tool output to preserve
62
+ // ordering (results come first in Anthropic user messages anyway).
63
+ if (rest.length > 0) {
64
+ input.push({ role: "user", content: [...rest] })
65
+ rest.length = 0
66
+ }
67
+ let text: string
68
+ const images: Array<Record<string, unknown>> = []
69
+ if (typeof block.content === "string") {
70
+ text = block.content
71
+ } else if (Array.isArray(block.content)) {
72
+ const texts: string[] = []
73
+ for (const child of classifyContent(block.content)) {
74
+ if (child.kind === "text" || child.kind === "unsupported") texts.push(child.text)
75
+ else if (child.kind === "image")
76
+ images.push({
77
+ type: "input_image",
78
+ image_url: `data:${child.block.source.media_type};base64,${child.block.source.data}`,
79
+ })
80
+ }
81
+ text = texts.join("\n\n")
82
+ } else {
83
+ text = ""
84
+ }
85
+ if (block.is_error) text = `[error] ${text}`
86
+ if (images.length > 0) {
87
+ text =
88
+ (text ? `${text}\n\n` : "") +
89
+ `[${images.length} image(s) - attached to the next user message]`
90
+ pendingImages.push(...images)
91
+ }
92
+ input.push({
93
+ type: "function_call_output",
94
+ call_id: block.tool_use_id,
95
+ output: text,
96
+ })
97
+ continue
98
+ }
99
+ if (view.kind === "text" || view.kind === "unsupported") {
100
+ rest.push({
101
+ type: message.role === "assistant" ? "output_text" : "input_text",
102
+ text: view.text,
103
+ })
104
+ } else if (view.kind === "image") {
105
+ const block = view.block
106
+ rest.push({
107
+ type: "input_image",
108
+ image_url: `data:${block.source.media_type};base64,${block.source.data}`,
109
+ })
110
+ } else if (view.kind === "tool_use") {
111
+ const block = view.block
112
+ if (rest.length > 0) {
113
+ input.push({ role: message.role, content: [...rest] })
114
+ rest.length = 0
115
+ }
116
+ input.push({
117
+ type: "function_call",
118
+ call_id: block.id,
119
+ name: block.name,
120
+ arguments: JSON.stringify(block.input),
121
+ })
122
+ } else {
123
+ const exhaustive: never = view
124
+ throw new Error(`unhandled content: ${exhaustive}`)
125
+ }
126
+ }
127
+ if (pendingImages.length > 0 || rest.length > 0) {
128
+ // Merge pending tool-result images with trailing text into ONE user
129
+ // message (mirrors the chat dialect's adjacent-user-message layout).
130
+ input.push({
131
+ role: message.role,
132
+ content: [...pendingImages, ...rest],
133
+ })
134
+ pendingImages.length = 0
135
+ }
136
+ }
137
+
138
+ const instructions = Array.isArray(payload.system)
139
+ ? payload.system.map((b) => b.text).join("\n\n")
140
+ : payload.system
141
+
142
+ const effort = effortFor(payload, allowedEfforts)
143
+
144
+ return {
145
+ model: normalizeModel(payload.model),
146
+ ...(instructions && { instructions }),
147
+ ...(effort && { reasoning: { effort } }),
148
+ input,
149
+ stream: payload.stream,
150
+ max_output_tokens: payload.max_tokens,
151
+ temperature: payload.temperature,
152
+ top_p: payload.top_p,
153
+ tools: payload.tools?.map((t) => ({
154
+ type: "function" as const,
155
+ name: t.name,
156
+ description: t.description,
157
+ parameters: t.input_schema,
158
+ })),
159
+ tool_choice:
160
+ payload.tool_choice?.type === "auto"
161
+ ? "auto"
162
+ : payload.tool_choice?.type === "none"
163
+ ? "none"
164
+ : payload.tool_choice?.type === "any"
165
+ ? "required"
166
+ : payload.tool_choice?.name
167
+ ? { type: "function", name: payload.tool_choice.name }
168
+ : undefined,
169
+ }
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Event direction: Responses SSE -> OpenAI-style chunks (for StreamTranslator)
174
+ // ---------------------------------------------------------------------------
175
+
176
+ function incompleteError(response: Record<string, unknown> | undefined): OpenAIResponse | null {
177
+ const details = response?.incomplete_details as { reason?: string } | undefined
178
+ const reason = details?.reason
179
+ // Only token exhaustion (or missing detail) maps to max_tokens. Other
180
+ // incomplete causes must not masquerade as successful text or tool calls.
181
+ if (!reason || reason === "max_output_tokens") return null
182
+ return { id: "r", model: "", error: { code: "response_incomplete", message: `upstream response incomplete: ${reason}` } }
183
+ }
184
+
185
+ export class ResponsesEventAdapter {
186
+ private toolIndexes = new Map<string, number>()
187
+ private argsAccum = new Map<string, string>()
188
+ private textAccum = new Map<string, string>()
189
+ private nextIndex = 0
190
+ private sawToolCall = false
191
+
192
+ /** Convert one Responses stream event into OpenAI-chunk shape (or null). */
193
+ pushEvent(event: Record<string, unknown>): OpenAIResponse | null {
194
+ const type = event.type as string | undefined
195
+ if (!type) return null
196
+
197
+ if (type === "response.output_text.delta") {
198
+ const delta = event.delta as string | undefined
199
+ if (!delta) return null
200
+ const key = String(event.item_id ?? "r")
201
+ this.textAccum.set(key, (this.textAccum.get(key) ?? "") + delta)
202
+ return {
203
+ id: key,
204
+ model: "",
205
+ choices: [{ index: 0, finish_reason: null, delta: { content: delta } }],
206
+ }
207
+ }
208
+
209
+ if (type === "response.output_text.done") {
210
+ // Authoritative fallback: if no delta arrived for this item, emit the
211
+ // full text so a lost stream cannot silently become an empty message.
212
+ const key = String(event.item_id ?? "r")
213
+ const full = (event.text as string | undefined) ?? ""
214
+ if (full && !(this.textAccum.get(key) ?? "")) {
215
+ return {
216
+ id: key,
217
+ model: "",
218
+ choices: [{ index: 0, finish_reason: null, delta: { content: full } }],
219
+ }
220
+ }
221
+ return null
222
+ }
223
+
224
+ if (type === "response.output_item.added") {
225
+ const item = event.item as
226
+ | { type?: string; call_id?: string; id?: string; name?: string }
227
+ | undefined
228
+ if (item?.type !== "function_call") return null
229
+ this.sawToolCall = true
230
+ const index = this.nextIndex++
231
+ const key = String(item.call_id ?? item.id ?? index)
232
+ this.toolIndexes.set(key, index)
233
+ // Arguments deltas are keyed by item_id; remember that too.
234
+ if (item.id) this.toolIndexes.set(String(item.id), index)
235
+ return {
236
+ id: String(item.call_id ?? item.id ?? "r"),
237
+ model: "",
238
+ choices: [
239
+ {
240
+ index: 0,
241
+ finish_reason: null,
242
+ delta: {
243
+ tool_calls: [
244
+ {
245
+ index,
246
+ id: item.call_id ?? item.id,
247
+ function: { name: item.name ?? "", arguments: "" },
248
+ },
249
+ ],
250
+ },
251
+ },
252
+ ],
253
+ }
254
+ }
255
+
256
+ if (type === "response.function_call_arguments.delta") {
257
+ const delta = event.delta as string | undefined
258
+ if (!delta) return null
259
+ const key = String(event.item_id ?? "")
260
+ const index = this.toolIndexes.get(key)
261
+ if (index === undefined) return null
262
+ this.argsAccum.set(key, (this.argsAccum.get(key) ?? "") + delta)
263
+ return {
264
+ id: key,
265
+ model: "",
266
+ choices: [
267
+ {
268
+ index: 0,
269
+ finish_reason: null,
270
+ delta: {
271
+ tool_calls: [{ index, function: { arguments: delta } }],
272
+ },
273
+ },
274
+ ],
275
+ }
276
+ }
277
+
278
+ if (type === "response.function_call_arguments.done") {
279
+ const key = String(event.item_id ?? "")
280
+ const index = this.toolIndexes.get(key)
281
+ if (index === undefined) return null
282
+ const full = (event.arguments as string | undefined) ?? ""
283
+ if (full && !(this.argsAccum.get(key) ?? "")) {
284
+ return {
285
+ id: key,
286
+ model: "",
287
+ choices: [
288
+ {
289
+ index: 0,
290
+ finish_reason: null,
291
+ delta: {
292
+ tool_calls: [{ index, function: { arguments: full } }],
293
+ },
294
+ },
295
+ ],
296
+ }
297
+ }
298
+ return null
299
+ }
300
+
301
+ if (type === "response.completed" || type === "response.incomplete") {
302
+ if (type === "response.incomplete") {
303
+ const error = incompleteError(event.response as Record<string, unknown> | undefined)
304
+ if (error) return error
305
+ }
306
+ const response = event.response as
307
+ | {
308
+ usage?: {
309
+ input_tokens?: number
310
+ output_tokens?: number
311
+ input_tokens_details?: { cached_tokens?: number }
312
+ }
313
+ }
314
+ | undefined
315
+ const usage = response?.usage
316
+ const cached = usage?.input_tokens_details?.cached_tokens
317
+ return {
318
+ id: "r",
319
+ model: "",
320
+ choices: [
321
+ {
322
+ index: 0,
323
+ finish_reason: type === "response.incomplete" ? "length" : this.sawToolCall ? "tool_calls" : "stop",
324
+ delta: {},
325
+ },
326
+ ],
327
+ usage: usage
328
+ ? {
329
+ prompt_tokens: usage.input_tokens,
330
+ completion_tokens: usage.output_tokens,
331
+ ...(cached !== undefined && {
332
+ prompt_tokens_details: { cached_tokens: cached },
333
+ }),
334
+ }
335
+ : undefined,
336
+ }
337
+ }
338
+
339
+ if (type === "response.failed") {
340
+ const response = event.response as
341
+ | { error?: { message?: string; code?: string } }
342
+ | undefined
343
+ return {
344
+ id: "r",
345
+ model: "",
346
+ error: {
347
+ message: response?.error?.message ?? "upstream response failed",
348
+ code: response?.error?.code,
349
+ },
350
+ } as OpenAIResponse
351
+ }
352
+
353
+ if (type === "error") {
354
+ return {
355
+ id: "r",
356
+ model: "",
357
+ error: {
358
+ message: (event.message as string | undefined) ?? "upstream stream error",
359
+ },
360
+ } as OpenAIResponse
361
+ }
362
+
363
+ return null
364
+ }
365
+ }
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // Non-streaming: Responses response JSON -> OpenAI response shape
369
+ // ---------------------------------------------------------------------------
370
+
371
+ export function responsesToOpenAIResponse(
372
+ body: Record<string, unknown>,
373
+ ): OpenAIResponse {
374
+ if (body.status === "incomplete") {
375
+ const error = incompleteError(body)
376
+ if (error) return error
377
+ }
378
+ const output = (body.output as Array<Record<string, unknown>> | undefined) ?? []
379
+ let text = ""
380
+ const toolCalls: Array<{
381
+ id: string
382
+ type: "function"
383
+ function: { name: string; arguments: string }
384
+ }> = []
385
+ for (const item of output) {
386
+ if (item.type === "message") {
387
+ const content = (item.content as Array<{ type?: string; text?: string }> | undefined) ?? []
388
+ for (const part of content) {
389
+ if (part.type === "output_text") text += part.text ?? ""
390
+ }
391
+ } else if (item.type === "function_call") {
392
+ toolCalls.push({
393
+ id: String(item.call_id ?? item.id ?? ""),
394
+ type: "function",
395
+ function: {
396
+ name: String(item.name ?? ""),
397
+ arguments: String(item.arguments ?? "{}"),
398
+ },
399
+ })
400
+ }
401
+ }
402
+ const usage = body.usage as
403
+ | {
404
+ input_tokens?: number
405
+ output_tokens?: number
406
+ input_tokens_details?: { cached_tokens?: number }
407
+ }
408
+ | undefined
409
+ return {
410
+ id: String(body.id ?? "r"),
411
+ model: String(body.model ?? ""),
412
+ choices: [
413
+ {
414
+ index: 0,
415
+ finish_reason: body.status === "incomplete" ? "length" : toolCalls.length > 0 ? "tool_calls" : "stop",
416
+ message: {
417
+ role: "assistant",
418
+ content: text || null,
419
+ ...(toolCalls.length > 0 && { tool_calls: toolCalls }),
420
+ },
421
+ },
422
+ ],
423
+ usage: usage
424
+ ? {
425
+ prompt_tokens: usage.input_tokens,
426
+ completion_tokens: usage.output_tokens,
427
+ ...(usage.input_tokens_details?.cached_tokens !== undefined && {
428
+ prompt_tokens_details: {
429
+ cached_tokens: usage.input_tokens_details.cached_tokens,
430
+ },
431
+ }),
432
+ }
433
+ : undefined,
434
+ }
435
+ }
package/src/route.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { UpstreamModel } from "./token"
2
+
3
+ export type Dialect = "native" | "chat" | "responses"
4
+
5
+ /** Evidence of an unsupported route/model, not merely a bad request. */
6
+ export function nativeModelRejected(status: number, body: string): boolean {
7
+ if (status === 404) return true
8
+ if (![400, 415, 422].includes(status)) return false
9
+ let message = body.trim()
10
+ let code: unknown
11
+ try {
12
+ const parsed = JSON.parse(body)
13
+ message = String(parsed?.error?.message ?? parsed?.message ?? "").trim()
14
+ code = parsed?.error?.code ?? parsed?.code
15
+ } catch {
16
+ // Copilot may return a plain-text rejection.
17
+ }
18
+ if (typeof code === "string" && /^(?:model|endpoint)_(?:not_supported|unsupported|not_found)$/.test(code)) return true
19
+ // Anchor on the rejected subject. "model X: unsupported parameter" must
20
+ // not accidentally become a process-lifetime model demotion.
21
+ if (/\b(?:parameter|argument|temperature|thinking|schema|moderation|content[_ -]filter)\b/i.test(message)) return false
22
+ return /^(?:the\s+)?(?:requested\s+)?(?:model|endpoint)(?:\s+(?:["'][^"']+["']|[^\s:]+))?\s+(?:is\s+)?(?:not supported|unsupported|not found|not accessible via)(?:\b|$)/i.test(message) ||
23
+ /^(?:unsupported|unknown)\s+(?:model|endpoint)(?:\s|:|$)/i.test(message)
24
+ }
25
+
26
+ /** Learned routes live for the adapter process. Callers pass the already
27
+ * normalized upstream slug; normalizing twice can strip a real dated id. */
28
+ export class DialectRouter {
29
+ private nativeRejectedModels = new Set<string>()
30
+ private responsesOnlyModels = new Set<string>()
31
+
32
+ select(model: string, info: UpstreamModel | undefined, nativeAvailable: boolean): Dialect {
33
+ if (nativeAvailable && info?.endpoints.includes("/v1/messages") && !this.nativeRejectedModels.has(model)) return "native"
34
+ return this.translated(model, info)
35
+ }
36
+
37
+ translated(model: string, info: UpstreamModel | undefined): "chat" | "responses" {
38
+ if (this.responsesOnlyModels.has(model) ||
39
+ (info?.endpoints.includes("/responses") && !info.endpoints.includes("/chat/completions"))) return "responses"
40
+ return "chat"
41
+ }
42
+
43
+ rejectNative(model: string, status: number, body: string): boolean {
44
+ if (!nativeModelRejected(status, body) || this.nativeRejectedModels.has(model)) return false
45
+ this.nativeRejectedModels.add(model)
46
+ return true
47
+ }
48
+
49
+ requireResponses(model: string): void {
50
+ this.responsesOnlyModels.add(model)
51
+ }
52
+ }