@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/LICENSE +21 -0
- package/README.md +279 -0
- package/bin/clco +27 -0
- package/bun.lock +39 -0
- package/install.sh +161 -0
- package/package.json +35 -0
- package/scripts/mock-upstream.ts +129 -0
- package/scripts/write-launcher.sh +47 -0
- package/src/api.ts +90 -0
- package/src/auth.ts +130 -0
- package/src/blocks.ts +198 -0
- package/src/browsermcp.ts +430 -0
- package/src/catalog.ts +147 -0
- package/src/claudehome.ts +269 -0
- package/src/cli.ts +889 -0
- package/src/config.ts +164 -0
- package/src/responses.ts +435 -0
- package/src/route.ts +52 -0
- package/src/server.ts +641 -0
- package/src/setup.ts +218 -0
- package/src/spawn.ts +453 -0
- package/src/stream.ts +235 -0
- package/src/tls.ts +234 -0
- package/src/token.ts +298 -0
- package/src/tokens.ts +55 -0
- package/src/translate.ts +384 -0
- package/src/wire.ts +149 -0
- package/uninstall.sh +42 -0
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
|
+
}
|
package/src/responses.ts
ADDED
|
@@ -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
|
+
}
|