|
1 | 1 | import { randomUUID } from "node:crypto"; |
2 | 2 | import { readdirSync } from "node:fs"; |
3 | 3 | import type { AdapterEvent, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTool, OcxUsage } from "../types"; |
4 | | -import { namespacedToolName } from "../types"; |
5 | | -import type { AdapterRequest, ProviderAdapter } from "./base"; |
| 4 | +import { isAllowedToolChoice, namespacedToolName, toolAllowedByChoice } from "../types"; |
| 5 | +import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base"; |
6 | 6 | import type { TranslatorBudget } from "../lib/translator-budget"; |
| 7 | +import { readBoundedResponseBody } from "../lib/bounded-body"; |
| 8 | +import { configuredReasoningEfforts } from "../reasoning-effort"; |
| 9 | +import { commandCodeReasoningEfforts, refreshCommandCodeReasoningEfforts } from "../providers/command-code-efforts"; |
| 10 | +import { identifyRoutedModel } from "./identity"; |
| 11 | +import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; |
7 | 12 |
|
8 | 13 | // Retain the short ids emitted by the first local integration. New requests use the live catalog's |
9 | 14 | // provider-native IDs directly; this map is compatibility-only and is not a model fallback list. |
@@ -50,19 +55,50 @@ function wireMessages(messages: OcxMessage[]): Array<Record<string, unknown>> { |
50 | 55 | return out; |
51 | 56 | } |
52 | 57 |
|
53 | | -function wireTools(tools: OcxTool[] | undefined): Array<Record<string, unknown>> { |
54 | | - return (tools ?? []).map(tool => ({ |
| 58 | +function visibleTools(parsed: OcxParsedRequest): OcxTool[] { |
| 59 | + const choice = parsed.options.toolChoice; |
| 60 | + if (choice === "none") return []; |
| 61 | + const tools = parsed.context.tools ?? []; |
| 62 | + if (isAllowedToolChoice(choice)) { |
| 63 | + const allowed = new Set(choice.allowedTools); |
| 64 | + return tools.filter(tool => toolAllowedByChoice(tool, allowed)); |
| 65 | + } |
| 66 | + if (choice && typeof choice !== "string") { |
| 67 | + return tools.filter(tool => tool.name === choice.name || namespacedToolName(tool.namespace, tool.name) === choice.name); |
| 68 | + } |
| 69 | + return tools; |
| 70 | +} |
| 71 | + |
| 72 | +function toolChoiceInstruction(parsed: OcxParsedRequest): string | undefined { |
| 73 | + const choice = parsed.options.toolChoice; |
| 74 | + if (choice === "required" || (isAllowedToolChoice(choice) && choice.mode === "required")) { |
| 75 | + return "Tool choice is required for this turn. Make at least one call from the advertised tool catalog before answering."; |
| 76 | + } |
| 77 | + if (choice && typeof choice !== "string" && !isAllowedToolChoice(choice)) { |
| 78 | + return `Tool choice is required for this turn. Call the advertised tool named ${namespacedToolName(undefined, choice.name)} before answering.`; |
| 79 | + } |
| 80 | + return undefined; |
| 81 | +} |
| 82 | + |
| 83 | +function wireTools(tools: OcxTool[]): Array<Record<string, unknown>> { |
| 84 | + return tools.map(tool => ({ |
55 | 85 | name: namespacedToolName(tool.namespace, tool.name), |
56 | 86 | description: tool.description, |
57 | 87 | input_schema: tool.parameters, |
58 | 88 | })); |
59 | 89 | } |
60 | 90 |
|
61 | | -function commandCodeConfig(): Record<string, unknown> { |
| 91 | +function currentWorkingDirectory(): string | undefined { |
| 92 | + try { return process.cwd(); } catch { return undefined; } |
| 93 | +} |
| 94 | + |
| 95 | +function commandCodeConfig(cwd: string | undefined): Record<string, unknown> { |
62 | 96 | let structure: string[] = []; |
63 | | - try { structure = readdirSync(process.cwd()).filter(name => !name.startsWith(".")); } catch { /* cwd may disappear */ } |
| 97 | + if (cwd) { |
| 98 | + try { structure = readdirSync(cwd).filter(name => !name.startsWith(".")); } catch { /* workspace metadata is optional */ } |
| 99 | + } |
64 | 100 | return { |
65 | | - workingDir: process.cwd(), |
| 101 | + ...(cwd ? { workingDir: cwd } : {}), |
66 | 102 | date: new Date().toISOString().slice(0, 10), |
67 | 103 | environment: process.platform, |
68 | 104 | structure, |
@@ -99,74 +135,179 @@ function eventError(value: unknown): string { |
99 | 135 | return "Command Code stream error"; |
100 | 136 | } |
101 | 137 |
|
102 | | -async function*ndjson(response: Response): AsyncGenerator<Record<string, unknown>> { |
| 138 | +async function*ndjson(response: Response, budget: TranslatorBudget): AsyncGenerator<Record<string, unknown>> { |
103 | 139 | if (!response.body) throw new Error("Command Code response body missing"); |
104 | 140 | const reader = response.body.getReader(); |
105 | 141 | const decoder = new TextDecoder(); |
| 142 | + const encoder = new TextEncoder(); |
106 | 143 | let buffer = ""; |
107 | | - for (;;) { |
108 | | - const { value, done } = await reader.read(); |
109 | | - buffer += decoder.decode(value, { stream: !done }); |
110 | | - let newline = buffer.indexOf("\n"); |
111 | | - while (newline >= 0) { |
112 | | - const line = buffer.slice(0, newline).trim(); buffer = buffer.slice(newline + 1); |
113 | | - if (line) { try { yield JSON.parse(line) as Record<string, unknown>; } catch { /* ignore non-events */ } } |
114 | | - newline = buffer.indexOf("\n"); |
| 144 | + let bufferBytes = 0; |
| 145 | + try { |
| 146 | + for (;;) { |
| 147 | + const { value, done } = await reader.read(); |
| 148 | + const next = buffer + decoder.decode(value, { stream: !done }); |
| 149 | + const nextBytes = encoder.encode(next).byteLength; |
| 150 | + const reservation = budget.reserveTransient(nextBytes, { kind: "live_transient" }); |
| 151 | + buffer = next; |
| 152 | + reservation.commitRetained(); |
| 153 | + budget.releaseRetained(bufferBytes, { kind: "live_transient" }); |
| 154 | + bufferBytes = nextBytes; |
| 155 | + let newline = buffer.indexOf("\n"); |
| 156 | + while (newline >= 0) { |
| 157 | + const line = buffer.slice(0, newline).trim(); buffer = buffer.slice(newline + 1); |
| 158 | + if (line) { try { yield JSON.parse(line) as Record<string, unknown>; } catch { /* ignore non-events */ } } |
| 159 | + newline = buffer.indexOf("\n"); |
| 160 | + } |
| 161 | + const residualBytes = encoder.encode(buffer).byteLength; |
| 162 | + const residualReservation = budget.reserveTransient(residualBytes, { kind: "live_transient" }); |
| 163 | + residualReservation.commitRetained(); |
| 164 | + budget.releaseRetained(bufferBytes, { kind: "live_transient" }); |
| 165 | + bufferBytes = residualBytes; |
| 166 | + if (done) break; |
115 | 167 | } |
116 | | - if (done) break; |
| 168 | + const final = buffer.trim(); |
| 169 | + if (final) { try { yield JSON.parse(final) as Record<string, unknown>; } catch { /* ignore */ } } |
| 170 | + } finally { |
| 171 | + budget.releaseRetained(bufferBytes, { kind: "live_transient" }); |
| 172 | + reader.releaseLock(); |
117 | 173 | } |
118 | | - const final = buffer.trim(); |
119 | | - if (final) { try { yield JSON.parse(final) as Record<string, unknown>; } catch { /* ignore */ } } |
| 174 | +} |
| 175 | + |
| 176 | +function isReasoningEffortRejection(status: number, payload: string): boolean { |
| 177 | + return (status === 400 || status === 422) && /reasoning[_ -]?effort|unsupported effort|invalid effort/i.test(payload); |
| 178 | +} |
| 179 | + |
| 180 | +function requestWithoutReasoningEffort(request: AdapterRequest): AdapterRequest | undefined { |
| 181 | + try { |
| 182 | + const body = JSON.parse(request.body) as { params?: Record<string, unknown> }; |
| 183 | + if (!body.params?.reasoning_effort) return undefined; |
| 184 | + delete body.params.reasoning_effort; |
| 185 | + return { ...request, body: JSON.stringify(body), reasoningLog: undefined }; |
| 186 | + } catch { |
| 187 | + return undefined; |
| 188 | + } |
| 189 | +} |
| 190 | + |
| 191 | +async function fetchCommandCode(request: AdapterRequest, ctx: AdapterFetchContext | undefined, executor: typeof globalThis.fetch): Promise<Response> { |
| 192 | + const timeout = new AbortController(); |
| 193 | + const timer = setTimeout(() => timeout.abort(new DOMException("Timeout elapsed", "TimeoutError")), ctx?.timeoutMs ?? 200_000); |
| 194 | + const callerSignal = ctx?.abortSignal ?? new AbortController().signal; |
| 195 | + try { |
| 196 | + return await executor(request.url, { |
| 197 | + method: request.method, |
| 198 | + headers: request.headers, |
| 199 | + body: request.body, |
| 200 | + redirect: "manual", |
| 201 | + signal: AbortSignal.any([callerSignal, timeout.signal]), |
| 202 | + }); |
| 203 | + } finally { |
| 204 | + clearTimeout(timer); |
| 205 | + } |
| 206 | +} |
| 207 | + |
| 208 | +function supportedCommandCodeEffort(provider: OcxProviderConfig, modelId: string, requested: string | undefined): string | undefined { |
| 209 | + if (!requested || requested === "none") return undefined; |
| 210 | + const supported = commandCodeReasoningEfforts(modelId) ?? configuredReasoningEfforts(provider, modelId); |
| 211 | + if (!supported) return undefined; |
| 212 | + // Command Code's official profiles describe xhigh as the CLI label that maps to |
| 213 | + // the wire value `max`; preserve that mapping without advertising a synthetic tier. |
| 214 | + const wire = requested === "xhigh" && supported.includes("max") ? "max" : requested; |
| 215 | + return supported.includes(wire) ? wire : undefined; |
120 | 216 | } |
121 | 217 |
|
122 | 218 | export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderAdapter { |
| 219 | + const executor = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch; |
123 | 220 | return { |
124 | 221 | name: "command-code", |
125 | 222 | buildRequest(parsed: OcxParsedRequest): AdapterRequest { |
126 | 223 | if (!provider.apiKey) throw new Error("Command Code credential missing — run ocx login command-code"); |
127 | | - const system = parsed.context.systemPrompt?.join("\n\n") ?? ""; |
| 224 | + const cwd = currentWorkingDirectory(); |
| 225 | + const tools = visibleTools(parsed); |
| 226 | + const toolNudge = buildNonOpenAIToolCatalogNudgeForTools(tools, parsed.options.toolChoice); |
| 227 | + const choiceInstruction = toolChoiceInstruction(parsed); |
| 228 | + const system = identifyRoutedModel([ |
| 229 | + ...(parsed.context.systemPrompt ?? []), |
| 230 | + ...(toolNudge ? [toolNudge] : []), |
| 231 | + ...(choiceInstruction ? [choiceInstruction] : []), |
| 232 | + ].join("\n\n"), parsed.modelId); |
| 233 | + const reasoningEffort = supportedCommandCodeEffort(provider, parsed.modelId, parsed.options.reasoning); |
128 | 234 | const body = { |
129 | | - config: commandCodeConfig(), memory: null, taste: null, skills: null, |
| 235 | + config: commandCodeConfig(cwd), memory: null, taste: null, skills: null, |
130 | 236 | permissionMode: "standard", mode: "agent", |
131 | 237 | params: { |
132 | 238 | model: COMMAND_CODE_MODEL_ALIASES[parsed.modelId] ?? parsed.modelId, |
133 | 239 | messages: wireMessages(parsed.context.messages), |
134 | | - tools: wireTools(parsed.context.tools), |
| 240 | + tools: wireTools(tools), |
135 | 241 | system, |
136 | 242 | max_tokens: parsed.options.maxOutputTokens ?? provider.defaultMaxOutputTokens ?? 64_000, |
137 | 243 | stream: true, |
138 | 244 | ...(parsed.options.temperature !== undefined ? { temperature: parsed.options.temperature } : {}), |
139 | | - ...(parsed.options.reasoning && parsed.options.reasoning !== "none" ? { reasoning_effort: parsed.options.reasoning } : {}), |
| 245 | + ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), |
140 | 246 | }, |
141 | 247 | }; |
| 248 | + const headers: Record<string, string> = { |
| 249 | + Authorization: `Bearer ${provider.apiKey}`, |
| 250 | + "Content-Type": "application/json", |
| 251 | + "User-Agent": "cli", |
| 252 | + "x-command-code-version": "1.12.0", |
| 253 | + "x-cli-environment": "production", |
| 254 | + "x-taste-learning": "false", |
| 255 | + "x-co-flag": "false", |
| 256 | + "x-session-id": randomUUID(), |
| 257 | + }; |
| 258 | + if (cwd) headers["x-project-slug"] = cwd.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase(); |
142 | 259 | return { |
143 | 260 | url: `${provider.baseUrl.replace(/\/$/, "")}/alpha/generate`, method: "POST", |
144 | | - headers: { |
145 | | - Authorization: `Bearer ${provider.apiKey}`, |
146 | | - "Content-Type": "application/json", |
147 | | - "User-Agent": "cli", |
148 | | - "x-command-code-version": "1.12.0", |
149 | | - "x-cli-environment": "production", |
150 | | - "x-project-slug": process.cwd().replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase(), |
151 | | - "x-taste-learning": "false", |
152 | | - "x-co-flag": "false", |
153 | | - "x-session-id": randomUUID(), |
154 | | - }, |
| 261 | + headers, |
155 | 262 | body: JSON.stringify(body), |
| 263 | + ...(reasoningEffort ? { reasoningLog: { effectiveEffort: reasoningEffort, wireField: "reasoning_effort" as const, wireValue: reasoningEffort } } : {}), |
156 | 264 | }; |
157 | 265 | }, |
158 | | - async *parseStream(response: Response, _budget: TranslatorBudget): AsyncGenerator<AdapterEvent> { |
159 | | - for await (const event of ndjson(response)) { |
| 266 | + async fetchResponse(request: AdapterRequest, ctx?: AdapterFetchContext): Promise<Response> { |
| 267 | + const response = await fetchCommandCode(request, ctx, executor); |
| 268 | + if (response.ok) return response; |
| 269 | + const currentEffort = (() => { |
| 270 | + try { return (JSON.parse(request.body) as { params?: { reasoning_effort?: unknown } }).params?.reasoning_effort; } catch { return undefined; } |
| 271 | + })(); |
| 272 | + if (typeof currentEffort !== "string") return response; |
| 273 | + let body = ""; |
| 274 | + try { |
| 275 | + const observed = await readBoundedResponseBody(response.clone(), { signal: ctx?.abortSignal, maxBytes: 8 * 1024 }); |
| 276 | + if (!observed.displaySafe) return response; |
| 277 | + body = observed.text; |
| 278 | + } catch { return response; } |
| 279 | + if (!isReasoningEffortRejection(response.status, body)) return response; |
| 280 | + const modelId = (() => { |
| 281 | + try { return (JSON.parse(request.body) as { params?: { model?: unknown } }).params?.model; } catch { return undefined; } |
| 282 | + })(); |
| 283 | + if (typeof modelId !== "string") return response; |
| 284 | + const refreshed = await refreshCommandCodeReasoningEfforts(modelId, executor); |
| 285 | + if (!refreshed || refreshed.includes(currentEffort)) return response; |
| 286 | + const retry = requestWithoutReasoningEffort(request); |
| 287 | + if (!retry) return response; |
| 288 | + try { void response.body?.cancel(); } catch { /* already closed */ } |
| 289 | + return fetchCommandCode(retry, ctx, executor); |
| 290 | + }, |
| 291 | + async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> { |
| 292 | + for await (const event of ndjson(response, budget)) { |
160 | 293 | switch (event.type) { |
161 | 294 | case "text-delta": if (typeof event.text === "string") yield { type: "text_delta", text: event.text }; break; |
162 | 295 | case "reasoning-delta": if (typeof event.text === "string") yield { type: "thinking_delta", thinking: event.text }; break; |
163 | 296 | case "tool-call": { |
164 | 297 | const id = typeof event.toolCallId === "string" ? event.toolCallId : randomUUID(); |
165 | 298 | const name = typeof event.toolName === "string" ? event.toolName : "tool"; |
166 | 299 | const input = event.input ?? event.args ?? {}; |
| 300 | + const argumentsText = typeof input === "string" ? input : JSON.stringify(input); |
167 | 301 | yield { type: "tool_call_start", id, name }; |
168 | | - yield { type: "tool_call_delta", arguments: typeof input === "string" ? input : JSON.stringify(input) }; |
169 | | - yield { type: "tool_call_end" }; |
| 302 | + budget.openCall(id); |
| 303 | + try { |
| 304 | + const reservation = budget.reserveTransient(new TextEncoder().encode(argumentsText).byteLength, { kind: "tool_args", callId: id }); |
| 305 | + reservation.commitRetained(); |
| 306 | + yield { type: "tool_call_delta", arguments: argumentsText }; |
| 307 | + yield { type: "tool_call_end" }; |
| 308 | + } finally { |
| 309 | + budget.closeCall(id); |
| 310 | + } |
170 | 311 | break; |
171 | 312 | } |
172 | 313 | case "finish": yield { type: "done", usage: usage(event.totalUsage), stopReason: typeof event.rawFinishReason === "string" ? event.rawFinishReason : undefined }; break; |
|
0 commit comments