@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.
@@ -0,0 +1,384 @@
1
+ // Translation between the Anthropic Messages API (what Claude Code speaks)
2
+ // and GitHub Copilot's OpenAI-style chat/completions API.
3
+ //
4
+ // Content classification lives in blocks.ts; wire types in wire.ts;
5
+ // streaming response state lives in stream.ts.
6
+
7
+ import type {
8
+ CacheControl,
9
+ AnthropicMessage,
10
+ AnthropicRequest,
11
+ OpenAITextPart,
12
+ OpenAIImagePart,
13
+ OpenAIMessage,
14
+ OpenAIRequest,
15
+ OpenAIUsage,
16
+ OpenAIChoice,
17
+ OpenAIResponse,
18
+ AnthropicContentBlock,
19
+ AnthropicResponse,
20
+ } from "./wire"
21
+ import {
22
+ type ImageBlock,
23
+ type ToolResultBlock,
24
+ type ToolUseBlock,
25
+ classifyContent,
26
+ } from "./blocks"
27
+
28
+ function marked(blocks: unknown): boolean {
29
+ return (
30
+ Array.isArray(blocks) &&
31
+ blocks.some(
32
+ (b) => b && typeof b === "object" && (b as CacheControl).cache_control,
33
+ )
34
+ )
35
+ }
36
+
37
+ // Copilot's OpenAI-compatible surface spells the prompt-cache marker
38
+ // `copilot_cache_control` rather than the standard `cache_control`.
39
+ const COPILOT_CACHE = { type: "ephemeral" } as const
40
+
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.
44
+ export function effortFor(
45
+ payload: AnthropicRequest,
46
+ allowed: string[] | null | undefined,
47
+ ): string | undefined {
48
+ const effort = payload.output_config?.effort
49
+ if (!effort || !allowed || !allowed.includes(effort)) return undefined
50
+ return effort
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Request direction: Anthropic -> OpenAI
55
+ // ---------------------------------------------------------------------------
56
+
57
+ // Ids clco advertised to Claude Code, mapped back to the upstream slug they
58
+ // stand for. Populated from the live /models response after discovery, so the
59
+ // table always matches what the picker actually offered. Kept here rather
60
+ // than read from token.ts to avoid a cycle.
61
+ let modelAliases = new Map<string, string>()
62
+
63
+ export function setModelAliases(aliases: Map<string, string>): void {
64
+ modelAliases = aliases
65
+ }
66
+
67
+ // Copilot slugs are dot-form ("claude-sonnet-4.5"). Claude Code may echo back
68
+ // an advertised alias, dash-form, a date suffix, or a bracket suffix like
69
+ // [1m]; normalize all. The alias table wins over the pattern rules because it
70
+ // is derived from the upstream's own list — the patterns are only a fallback
71
+ // for ids discovery never saw (and they miss families like fable entirely).
72
+ export function normalizeModel(model: string): string {
73
+ let m = model.replace(/\[[^\]]*\]$/, "")
74
+ m = m.replace(/-\d{8}$/, "")
75
+ const alias = modelAliases.get(m)
76
+ if (alias) return alias
77
+ m = m.replace(
78
+ /^claude-(opus|sonnet|haiku)-(\d+)-(\d+)$/,
79
+ "claude-$1-$2.$3",
80
+ )
81
+ m = m.replace(/^claude-(\d+)-(\d+)-(sonnet|haiku|opus)$/, "claude-$1.$2-$3")
82
+ return m
83
+ }
84
+
85
+ function debugWarn(message: string): void {
86
+ if (process.env.CLCO_DEBUG) console.error("[clco:debug]", message)
87
+ }
88
+
89
+ function toImagePart(block: ImageBlock): OpenAIImagePart {
90
+ return {
91
+ type: "image_url",
92
+ image_url: {
93
+ url: `data:${block.source.media_type};base64,${block.source.data}`,
94
+ },
95
+ }
96
+ }
97
+
98
+ // Tool messages must carry string content (OpenAI schema); images are moved
99
+ // into the adjacent user message instead, otherwise one image tool_result
100
+ // would poison every subsequent request in the session.
101
+ function toolResultText(result: ToolResultBlock): {
102
+ text: string
103
+ images: OpenAIImagePart[]
104
+ } {
105
+ const images: OpenAIImagePart[] = []
106
+ let text: string
107
+ if (typeof result.content === "string") {
108
+ text = result.content
109
+ } else if (Array.isArray(result.content)) {
110
+ const texts: string[] = []
111
+ for (const view of classifyContent(result.content)) {
112
+ if (view.kind === "text") texts.push(view.text)
113
+ else if (view.kind === "image") images.push(toImagePart(view.block))
114
+ // A tool_result nesting a tool_use or a document is not something the
115
+ // protocol produces, but saying so beats dropping it.
116
+ else if (view.kind === "unsupported") {
117
+ texts.push(view.text)
118
+ debugWarn(`tool_result carried a ${view.blockType} block`)
119
+ }
120
+ }
121
+ text = texts.filter(Boolean).join("\n\n")
122
+ } else {
123
+ text = ""
124
+ }
125
+ if (result.is_error) text = `[error] ${text}`
126
+ if (images.length > 0) {
127
+ text =
128
+ (text ? `${text}\n\n` : "") +
129
+ `[${images.length} image(s) - attached to the next user message]`
130
+ }
131
+ return { text, images }
132
+ }
133
+
134
+ function translateUserMessage(message: AnthropicMessage): OpenAIMessage[] {
135
+ if (typeof message.content === "string") {
136
+ return [{ role: "user", content: message.content }]
137
+ }
138
+ const out: OpenAIMessage[] = []
139
+ const toolResults = message.content.filter(
140
+ (b): b is ToolResultBlock => b.type === "tool_result",
141
+ )
142
+ const rest = message.content.filter((b) => b.type !== "tool_result")
143
+
144
+ // Protocol order: tool_use -> tool_result -> user.
145
+ const images: OpenAIImagePart[] = []
146
+ for (const result of toolResults) {
147
+ const { text, images: resultImages } = toolResultText(result)
148
+ images.push(...resultImages)
149
+ out.push({ role: "tool", tool_call_id: result.tool_use_id, content: text })
150
+ }
151
+
152
+ const restTexts: string[] = []
153
+ const restImages: OpenAIImagePart[] = []
154
+ for (const view of classifyContent(rest)) {
155
+ if (view.kind === "text") restTexts.push(view.text)
156
+ else if (view.kind === "image") restImages.push(toImagePart(view.block))
157
+ // Never silence. A PDF attachment used to translate to nothing at all, so
158
+ // the model answered about a document it had never seen.
159
+ else if (view.kind === "unsupported") {
160
+ restTexts.push(view.text)
161
+ debugWarn(`dropped a ${view.blockType} block from a user message`)
162
+ }
163
+ }
164
+
165
+ if (images.length > 0 || restImages.length > 0) {
166
+ const parts: Array<OpenAITextPart | OpenAIImagePart> = [
167
+ ...images,
168
+ ...restImages,
169
+ ]
170
+ const text = restTexts.join("\n\n")
171
+ if (text) parts.push({ type: "text", text })
172
+ out.push({ role: "user", content: parts })
173
+ } else if (rest.length > 0) {
174
+ out.push({ role: "user", content: restTexts.filter(Boolean).join("\n\n") })
175
+ }
176
+ return out
177
+ }
178
+
179
+ function translateAssistantMessage(message: AnthropicMessage): OpenAIMessage[] {
180
+ if (typeof message.content === "string") {
181
+ return [{ role: "assistant", content: message.content }]
182
+ }
183
+ const toolUses = message.content.filter(
184
+ (b): b is ToolUseBlock => b.type === "tool_use",
185
+ )
186
+ // OpenAI has no thinking blocks; the classifier folds them into text (they
187
+ // are usually empty here anyway because CLAUDE_CODE_DISABLE_THINKING is set).
188
+ const text = classifyContent(message.content)
189
+ .map((view) => {
190
+ if (view.kind === "text") return view.text
191
+ if (view.kind === "unsupported") {
192
+ debugWarn(`dropped a ${view.blockType} block from an assistant message`)
193
+ return view.text
194
+ }
195
+ return ""
196
+ })
197
+ .filter(Boolean)
198
+ .join("\n\n")
199
+
200
+ if (toolUses.length > 0) {
201
+ return [
202
+ {
203
+ role: "assistant",
204
+ content: text || null,
205
+ tool_calls: toolUses.map((use) => ({
206
+ id: use.id,
207
+ type: "function" as const,
208
+ function: {
209
+ name: use.name,
210
+ arguments: JSON.stringify(use.input),
211
+ },
212
+ })),
213
+ },
214
+ ]
215
+ }
216
+ return [{ role: "assistant", content: text }]
217
+ }
218
+
219
+ function translateSystem(
220
+ system: AnthropicRequest["system"],
221
+ ): OpenAIMessage[] {
222
+ if (!system) return []
223
+ const text =
224
+ typeof system === "string"
225
+ ? system
226
+ : system.map((b) => b.text).join("\n\n")
227
+ return [
228
+ {
229
+ role: "system",
230
+ content: text,
231
+ ...(marked(system) && { copilot_cache_control: COPILOT_CACHE }),
232
+ },
233
+ ]
234
+ }
235
+
236
+ function translateTools(
237
+ tools: AnthropicRequest["tools"],
238
+ ): OpenAIRequest["tools"] {
239
+ if (!tools || tools.length === 0) return undefined
240
+ return tools.map((tool) => ({
241
+ type: "function" as const,
242
+ function: {
243
+ name: tool.name,
244
+ description: tool.description,
245
+ parameters: tool.input_schema,
246
+ },
247
+ }))
248
+ }
249
+
250
+ function translateToolChoice(
251
+ choice: AnthropicRequest["tool_choice"],
252
+ ): OpenAIRequest["tool_choice"] {
253
+ if (!choice) return undefined
254
+ switch (choice.type) {
255
+ case "auto":
256
+ return "auto"
257
+ case "any":
258
+ return "required"
259
+ case "none":
260
+ return "none"
261
+ case "tool":
262
+ return choice.name
263
+ ? { type: "function", function: { name: choice.name } }
264
+ : undefined
265
+ default:
266
+ return undefined
267
+ }
268
+ }
269
+
270
+ export function translateRequest(
271
+ payload: AnthropicRequest,
272
+ allowedEfforts?: string[] | null,
273
+ ): OpenAIRequest {
274
+ const effort = effortFor(payload, allowedEfforts)
275
+ return {
276
+ model: normalizeModel(payload.model),
277
+ ...(effort && { reasoning_effort: effort }),
278
+ messages: [
279
+ ...translateSystem(payload.system),
280
+ ...payload.messages.flatMap((message) => {
281
+ const out =
282
+ message.role === "user"
283
+ ? translateUserMessage(message)
284
+ : translateAssistantMessage(message)
285
+ // Carry a cache breakpoint onto the last message this turn produced.
286
+ const last = out[out.length - 1]
287
+ if (last && marked(message.content)) {
288
+ last.copilot_cache_control = COPILOT_CACHE
289
+ }
290
+ return out
291
+ }),
292
+ ],
293
+ max_tokens: payload.max_tokens,
294
+ stop: payload.stop_sequences?.length ? payload.stop_sequences : null,
295
+ stream: payload.stream,
296
+ stream_options: payload.stream ? { include_usage: true } : undefined,
297
+ temperature: payload.temperature,
298
+ top_p: payload.top_p,
299
+ user: payload.metadata?.user_id ?? null,
300
+ tools: translateTools(payload.tools),
301
+ tool_choice: translateToolChoice(payload.tool_choice),
302
+ }
303
+ }
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // Response direction: OpenAI -> Anthropic (non-streaming)
307
+ // ---------------------------------------------------------------------------
308
+
309
+ export function mapStopReason(
310
+ finish: OpenAIChoice["finish_reason"],
311
+ ): AnthropicResponse["stop_reason"] {
312
+ if (finish === null) return null
313
+ switch (finish) {
314
+ case "stop":
315
+ return "end_turn"
316
+ case "length":
317
+ return "max_tokens"
318
+ case "tool_calls":
319
+ return "tool_use"
320
+ case "content_filter":
321
+ return "end_turn"
322
+ }
323
+ }
324
+
325
+ export function usageFromOpenAI(usage: OpenAIUsage | undefined) {
326
+ const cached = usage?.prompt_tokens_details?.cached_tokens
327
+ return {
328
+ input_tokens: Math.max(
329
+ 0,
330
+ (usage?.prompt_tokens ?? 0) - (cached ?? 0),
331
+ ),
332
+ output_tokens: usage?.completion_tokens ?? 0,
333
+ ...(cached !== undefined && { cache_read_input_tokens: cached }),
334
+ }
335
+ }
336
+
337
+ export function translateResponse(upstream: OpenAIResponse): AnthropicResponse {
338
+ const choice = upstream.choices?.[0]
339
+ const content: AnthropicContentBlock[] = []
340
+
341
+ const messageContent = choice?.message?.content
342
+ if (typeof messageContent === "string" && messageContent.length > 0) {
343
+ content.push({ type: "text", text: messageContent })
344
+ } else if (Array.isArray(messageContent)) {
345
+ for (const part of messageContent) {
346
+ if (part.type === "text") content.push({ type: "text", text: part.text })
347
+ }
348
+ }
349
+ for (const call of choice?.message?.tool_calls ?? []) {
350
+ let input: Record<string, unknown> = {}
351
+ try {
352
+ input = JSON.parse(call.function.arguments || "{}") as Record<
353
+ string,
354
+ unknown
355
+ >
356
+ } catch {
357
+ // Malformed arguments: fall back to empty input rather than failing.
358
+ }
359
+ content.push({
360
+ type: "tool_use",
361
+ id: call.id,
362
+ name: call.function.name,
363
+ input,
364
+ })
365
+ }
366
+ if (content.length === 0) content.push({ type: "text", text: "" })
367
+
368
+ let stopReason = mapStopReason(choice?.finish_reason ?? null)
369
+ // "tool_use" with no tool_use block is not a valid Anthropic response.
370
+ if (stopReason === "tool_use" && !content.some((b) => b.type === "tool_use")) {
371
+ stopReason = "end_turn"
372
+ }
373
+
374
+ return {
375
+ id: upstream.id,
376
+ type: "message",
377
+ role: "assistant",
378
+ model: upstream.model,
379
+ content,
380
+ stop_reason: stopReason,
381
+ stop_sequence: null,
382
+ usage: usageFromOpenAI(upstream.usage),
383
+ }
384
+ }
package/src/wire.ts ADDED
@@ -0,0 +1,149 @@
1
+ // Wire shapes shared by the request and response dialects.
2
+ import type { ContentBlock, ToolUseBlock } from "./blocks"
3
+
4
+ export interface CacheControl {
5
+ cache_control?: { type?: string } | null
6
+ }
7
+
8
+ export interface AnthropicTextBlock extends CacheControl {
9
+ type: "text"
10
+ text: string
11
+ }
12
+
13
+ export interface AnthropicMessage {
14
+ role: "user" | "assistant"
15
+ content: string | ContentBlock[]
16
+ }
17
+
18
+ export interface AnthropicTool {
19
+ name: string
20
+ description?: string
21
+ input_schema: Record<string, unknown>
22
+ }
23
+
24
+ export interface AnthropicRequest {
25
+ model: string
26
+ max_tokens: number
27
+ messages: AnthropicMessage[]
28
+ system?: string | Array<AnthropicTextBlock>
29
+ tools?: AnthropicTool[]
30
+ tool_choice?: { type: "auto" | "any" | "tool" | "none"; name?: string }
31
+ stream?: boolean
32
+ temperature?: number
33
+ top_p?: number
34
+ stop_sequences?: string[]
35
+ metadata?: { user_id?: string }
36
+ thinking?: unknown
37
+ /** Claude Code's /effort setting rides here (gateway protocol). */
38
+ output_config?: { effort?: string }
39
+ }
40
+
41
+ export interface OpenAITextPart {
42
+ type: "text"
43
+ text: string
44
+ }
45
+
46
+ export interface OpenAIImagePart {
47
+ type: "image_url"
48
+ image_url: { url: string }
49
+ }
50
+
51
+ export type OpenAIContent = string | Array<OpenAITextPart | OpenAIImagePart> | null
52
+
53
+ export interface OpenAIToolCall {
54
+ id: string
55
+ type: "function"
56
+ function: { name: string; arguments: string }
57
+ }
58
+
59
+ export interface OpenAIMessage {
60
+ role: "system" | "user" | "assistant" | "tool"
61
+ content: OpenAIContent
62
+ tool_calls?: OpenAIToolCall[]
63
+ tool_call_id?: string
64
+ copilot_cache_control?: { type: "ephemeral" }
65
+ }
66
+
67
+ export interface OpenAIRequest {
68
+ model: string
69
+ messages: OpenAIMessage[]
70
+ max_tokens?: number
71
+ stop?: string[] | null
72
+ stream?: boolean
73
+ stream_options?: { include_usage: boolean }
74
+ reasoning_effort?: string
75
+ temperature?: number
76
+ top_p?: number
77
+ user?: string | null
78
+ tools?: Array<{
79
+ type: "function"
80
+ function: {
81
+ name: string
82
+ description?: string
83
+ parameters: Record<string, unknown>
84
+ }
85
+ }> | null
86
+ tool_choice?:
87
+ | "none"
88
+ | "auto"
89
+ | "required"
90
+ | { type: "function"; function: { name: string } }
91
+ | null
92
+ }
93
+
94
+ export interface OpenAIUsage {
95
+ prompt_tokens?: number
96
+ completion_tokens?: number
97
+ prompt_tokens_details?: { cached_tokens?: number }
98
+ }
99
+
100
+ export interface OpenAIChoice {
101
+ index: number
102
+ finish_reason: "stop" | "length" | "tool_calls" | "content_filter" | null
103
+ message?: { role: "assistant"; content: OpenAIContent; tool_calls?: OpenAIToolCall[] }
104
+ delta?: {
105
+ role?: string
106
+ content?: string | null
107
+ tool_calls?: Array<{
108
+ index: number
109
+ id?: string
110
+ function?: { name?: string; arguments?: string }
111
+ }>
112
+ }
113
+ }
114
+
115
+ export interface OpenAIResponse {
116
+ id: string
117
+ model: string
118
+ choices?: OpenAIChoice[]
119
+ usage?: OpenAIUsage
120
+ error?: { message?: string; code?: string | number }
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Anthropic response types
125
+ // ---------------------------------------------------------------------------
126
+
127
+ export type AnthropicContentBlock =
128
+ | AnthropicTextBlock
129
+ | ToolUseBlock
130
+
131
+ export interface AnthropicResponse {
132
+ id: string
133
+ type: "message"
134
+ role: "assistant"
135
+ model: string
136
+ content: AnthropicContentBlock[]
137
+ stop_reason: "end_turn" | "max_tokens" | "tool_use" | null
138
+ stop_sequence: null
139
+ usage: {
140
+ input_tokens: number
141
+ output_tokens: number
142
+ cache_read_input_tokens?: number
143
+ }
144
+ }
145
+
146
+ export interface StreamEventData {
147
+ event: string
148
+ data: Record<string, unknown>
149
+ }
package/uninstall.sh ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env bash
2
+ # clco uninstaller.
3
+ # curl -fsSL https://raw.githubusercontent.com/semanticist21/clco/main/uninstall.sh | bash
4
+ # ... --full : also remove config and the GitHub token (~/.config/clco)
5
+ set -euo pipefail
6
+
7
+ CLCO_DIR="${CLCO_DIR:-$HOME/.local/share/clco}"
8
+ BIN_DIR="${BIN_DIR:-$HOME/.local/bin}"
9
+
10
+ log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
11
+ fail() { printf '\033[1;31mError:\033[0m %s\n' "$*" >&2; exit 1; }
12
+
13
+ removed=0
14
+ if [ -f "$BIN_DIR/clco" ]; then rm -f "$BIN_DIR/clco"; log "Removed: $BIN_DIR/clco"; removed=1; fi
15
+ if [ -d "$CLCO_DIR" ]; then
16
+ if [ ! -d "$CLCO_DIR/.git" ]; then
17
+ fail "$CLCO_DIR is not a clco install (git repo) - check it yourself before deleting"
18
+ fi
19
+ rm -rf "$CLCO_DIR"; log "Removed: $CLCO_DIR"; removed=1
20
+ fi
21
+ if [ -d "$CLCO_DIR.previous" ]; then
22
+ [ -d "$CLCO_DIR.previous/.git" ] \
23
+ || fail "$CLCO_DIR.previous is not a clco rollback install - check it yourself before deleting"
24
+ rm -rf "$CLCO_DIR.previous"; log "Removed: $CLCO_DIR.previous"; removed=1
25
+ fi
26
+ if [ -d "$CLCO_DIR.update.lock" ]; then
27
+ rmdir "$CLCO_DIR.update.lock" \
28
+ || fail "$CLCO_DIR.update.lock is not empty - verify no update is running before removing it"
29
+ log "Removed stale update lock: $CLCO_DIR.update.lock"; removed=1
30
+ fi
31
+
32
+ if [ "${1:-}" = "--full" ]; then
33
+ if [ -d "$HOME/.config/clco" ]; then
34
+ rm -rf "$HOME/.config/clco"
35
+ log "Removed: ~/.config/clco (including the GitHub token)"
36
+ fi
37
+ else
38
+ [ -d "$HOME/.config/clco" ] && log "Kept config: ~/.config/clco (including the GitHub token) - remove it with: $0 --full"
39
+ fi
40
+
41
+ [ "$removed" = "1" ] || [ "${1:-}" = "--full" ] || log "Nothing to remove"
42
+ log "Done"