auto-model-router 0.4.7 → 0.4.8
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/.omp-plugin/marketplace.json +2 -2
- package/README.md +45 -14
- package/opencode-plugin/auto-model-router.ts +151 -0
- package/opencode-plugin/opencode-plugin.d.ts +39 -0
- package/package.json +1 -1
- package/src/server/http.ts +22 -7
- package/src/util/sse.ts +1 -1
- package/src/wire/openai/responses.ts +365 -0
- package/src/wire/openai/sink.ts +19 -2
- package/src/wire/types.ts +1 -1
- package/test/fixtures/harness/aider.json +47 -0
- package/test/fixtures/harness/codex-responses.json +507 -0
- package/test/fixtures/harness/opencode.json +338 -0
- package/test/harness-requests.test.ts +55 -24
- package/test/wire-responses.test.ts +174 -0
- package/test/wire-sink.test.ts +5 -0
- package/tools/capture-proxy.ts +79 -0
- package/tsconfig.all.json +1 -1
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
{
|
|
2
|
+
"harness": "opencode",
|
|
3
|
+
"capturedAtMs": 1788816092608,
|
|
4
|
+
"headers": {
|
|
5
|
+
"content-type": "application/json",
|
|
6
|
+
"user-agent": "opencode/1.18.16 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14",
|
|
7
|
+
"x-omp-harness": "opencode"
|
|
8
|
+
},
|
|
9
|
+
"body": {
|
|
10
|
+
"model": "auto",
|
|
11
|
+
"max_tokens": 32000,
|
|
12
|
+
"messages": [
|
|
13
|
+
{
|
|
14
|
+
"role": "system",
|
|
15
|
+
"content": "You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.\n\nIMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.\n\nIf the user \u00e2\u20ac\u00a6[10797 chars]"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"role": "user",
|
|
19
|
+
"content": "\"Reply with the single word ok\""
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"tools": [
|
|
23
|
+
{
|
|
24
|
+
"type": "function",
|
|
25
|
+
"function": {
|
|
26
|
+
"name": "bash",
|
|
27
|
+
"description": "Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.\n\nBe aware: OS: win32, Shell: bash\n\nAll commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd <directory> && <command>` patterns - use `workdir` instead.\n\nUse `C:\\User\u00e2\u20ac\u00a6[4657 chars]",
|
|
28
|
+
"parameters": {
|
|
29
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
30
|
+
"type": "object",
|
|
31
|
+
"properties": {
|
|
32
|
+
"command": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "The command to execute"
|
|
35
|
+
},
|
|
36
|
+
"timeout": {
|
|
37
|
+
"minimum": -9007199254740991,
|
|
38
|
+
"exclusiveMinimum": 0,
|
|
39
|
+
"type": "integer",
|
|
40
|
+
"maximum": 9007199254740991,
|
|
41
|
+
"description": "Optional timeout in milliseconds"
|
|
42
|
+
},
|
|
43
|
+
"workdir": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands."
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"required": [
|
|
49
|
+
"command"
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"type": "function",
|
|
56
|
+
"function": {
|
|
57
|
+
"name": "edit",
|
|
58
|
+
"description": "Performs exact string replacements in files. \n\nUsage:\n- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. \n- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + co\u00e2\u20ac\u00a6[1369 chars]",
|
|
59
|
+
"parameters": {
|
|
60
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
61
|
+
"type": "object",
|
|
62
|
+
"properties": {
|
|
63
|
+
"filePath": {
|
|
64
|
+
"type": "string",
|
|
65
|
+
"description": "The absolute path to the file to modify"
|
|
66
|
+
},
|
|
67
|
+
"oldString": {
|
|
68
|
+
"type": "string",
|
|
69
|
+
"description": "The text to replace"
|
|
70
|
+
},
|
|
71
|
+
"newString": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"description": "The text to replace it with (must be different from oldString)"
|
|
74
|
+
},
|
|
75
|
+
"replaceAll": {
|
|
76
|
+
"type": "boolean",
|
|
77
|
+
"description": "Replace all occurrences of oldString (default false)"
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"required": [
|
|
81
|
+
"filePath",
|
|
82
|
+
"oldString",
|
|
83
|
+
"newString"
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"type": "function",
|
|
90
|
+
"function": {
|
|
91
|
+
"name": "glob",
|
|
92
|
+
"description": "- Fast file pattern matching tool that works with any codebase size\n- Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\"\n- Returns matching file paths\n- Use this tool when you need to find files by name patterns\n- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead\n- You have the capability to call multiple tools in a sin\u00e2\u20ac\u00a6[517 chars]",
|
|
93
|
+
"parameters": {
|
|
94
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
95
|
+
"type": "object",
|
|
96
|
+
"properties": {
|
|
97
|
+
"pattern": {
|
|
98
|
+
"type": "string",
|
|
99
|
+
"description": "The glob pattern to match files against"
|
|
100
|
+
},
|
|
101
|
+
"path": {
|
|
102
|
+
"type": "string",
|
|
103
|
+
"description": "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - simply omit it for the default behavior. Must be a valid directory path if provided."
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
"required": [
|
|
107
|
+
"pattern"
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"type": "function",
|
|
114
|
+
"function": {
|
|
115
|
+
"name": "grep",
|
|
116
|
+
"description": "- Fast content search tool that works with any codebase size\n- Searches file contents using regular expressions\n- Supports full regex syntax (eg. \"log.*Error\", \"function\\s+\\w+\", etc.)\n- Filter files by pattern with the include parameter (eg. \"*.js\", \"*.{ts,tsx}\")\n- Returns file paths and line numbers with matching lines\n- Use this tool when you need to find files containing specific patterns\n- If \u00e2\u20ac\u00a6[657 chars]",
|
|
117
|
+
"parameters": {
|
|
118
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
119
|
+
"type": "object",
|
|
120
|
+
"properties": {
|
|
121
|
+
"pattern": {
|
|
122
|
+
"type": "string",
|
|
123
|
+
"description": "The regex pattern to search for in file contents"
|
|
124
|
+
},
|
|
125
|
+
"path": {
|
|
126
|
+
"type": "string",
|
|
127
|
+
"description": "The directory to search in. Defaults to the current working directory."
|
|
128
|
+
},
|
|
129
|
+
"include": {
|
|
130
|
+
"type": "string",
|
|
131
|
+
"description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")"
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
"required": [
|
|
135
|
+
"pattern"
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"type": "function",
|
|
142
|
+
"function": {
|
|
143
|
+
"name": "read",
|
|
144
|
+
"description": "Read a file or directory from the local filesystem. If the path does not exist, an error is returned.\n\nUsage:\n- The filePath parameter should be an absolute path.\n- By default, this tool returns up to 2000 lines from the start of the file.\n- The offset parameter is the line number to start from (1-indexed).\n- To read later sections, call this tool again with a larger offset.\n- Use the grep tool to\u00e2\u20ac\u00a6[1158 chars]",
|
|
145
|
+
"parameters": {
|
|
146
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
147
|
+
"type": "object",
|
|
148
|
+
"properties": {
|
|
149
|
+
"filePath": {
|
|
150
|
+
"type": "string",
|
|
151
|
+
"description": "The absolute path to the file or directory to read"
|
|
152
|
+
},
|
|
153
|
+
"offset": {
|
|
154
|
+
"minimum": 0,
|
|
155
|
+
"type": "integer",
|
|
156
|
+
"maximum": 9007199254740991,
|
|
157
|
+
"description": "The line number to start reading from (1-indexed)"
|
|
158
|
+
},
|
|
159
|
+
"limit": {
|
|
160
|
+
"minimum": 0,
|
|
161
|
+
"type": "integer",
|
|
162
|
+
"maximum": 9007199254740991,
|
|
163
|
+
"description": "The maximum number of lines to read (defaults to 2000)"
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
"required": [
|
|
167
|
+
"filePath"
|
|
168
|
+
]
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"type": "function",
|
|
174
|
+
"function": {
|
|
175
|
+
"name": "skill",
|
|
176
|
+
"description": "Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.\n\nUse this tool to inject the skill's instructions and resources into current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc in the same directory as the skill.\n\nThe skill name must match one of the skills listed in your system prompt.\n",
|
|
177
|
+
"parameters": {
|
|
178
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
179
|
+
"type": "object",
|
|
180
|
+
"properties": {
|
|
181
|
+
"name": {
|
|
182
|
+
"type": "string",
|
|
183
|
+
"description": "The name of the skill from available_skills"
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
"required": [
|
|
187
|
+
"name"
|
|
188
|
+
]
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
"type": "function",
|
|
194
|
+
"function": {
|
|
195
|
+
"name": "task",
|
|
196
|
+
"description": "Launch a new agent to handle complex, multistep tasks autonomously.\n\nWhen using the Task tool, you must specify a subagent_type parameter to select which agent type to use.\n\nWhen NOT to use the Task tool:\n- If you want to read a specific file path, use the Read or Glob tool instead of the Task tool, to find the match more quickly\n- If you are searching for a specific class definition like \"class F\u00e2\u20ac\u00a6[3019 chars]",
|
|
197
|
+
"parameters": {
|
|
198
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
199
|
+
"type": "object",
|
|
200
|
+
"properties": {
|
|
201
|
+
"description": {
|
|
202
|
+
"type": "string",
|
|
203
|
+
"description": "A short (3-5 words) description of the task"
|
|
204
|
+
},
|
|
205
|
+
"prompt": {
|
|
206
|
+
"type": "string",
|
|
207
|
+
"description": "The task for the agent to perform"
|
|
208
|
+
},
|
|
209
|
+
"subagent_type": {
|
|
210
|
+
"type": "string",
|
|
211
|
+
"description": "The type of specialized agent to use for this task"
|
|
212
|
+
},
|
|
213
|
+
"task_id": {
|
|
214
|
+
"type": "string",
|
|
215
|
+
"description": "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)"
|
|
216
|
+
},
|
|
217
|
+
"command": {
|
|
218
|
+
"type": "string",
|
|
219
|
+
"description": "The command that triggered this task"
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
"required": [
|
|
223
|
+
"description",
|
|
224
|
+
"prompt",
|
|
225
|
+
"subagent_type"
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"type": "function",
|
|
232
|
+
"function": {
|
|
233
|
+
"name": "todowrite",
|
|
234
|
+
"description": "Create and maintain a structured task list for the current coding session. Tracks progress, organizes multi-step work, and surfaces status to the user.\n\n## When to use\nUse proactively when:\n- The task requires 3+ distinct steps or actions (not just 3 tool calls for a single conceptual step)\n- The work is non-trivial and benefits from planning\n- The user provides multiple tasks (numbered or comma-s\u00e2\u20ac\u00a6[2012 chars]",
|
|
235
|
+
"parameters": {
|
|
236
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
237
|
+
"type": "object",
|
|
238
|
+
"properties": {
|
|
239
|
+
"todos": {
|
|
240
|
+
"type": "array",
|
|
241
|
+
"items": {
|
|
242
|
+
"type": "object",
|
|
243
|
+
"properties": {
|
|
244
|
+
"content": {
|
|
245
|
+
"type": "string",
|
|
246
|
+
"description": "Brief description of the task"
|
|
247
|
+
},
|
|
248
|
+
"status": {
|
|
249
|
+
"type": "string",
|
|
250
|
+
"description": "Current status of the task: pending, in_progress, completed, cancelled"
|
|
251
|
+
},
|
|
252
|
+
"priority": {
|
|
253
|
+
"type": "string",
|
|
254
|
+
"description": "Priority level of the task: high, medium, low"
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
"required": [
|
|
258
|
+
"content",
|
|
259
|
+
"status",
|
|
260
|
+
"priority"
|
|
261
|
+
]
|
|
262
|
+
},
|
|
263
|
+
"description": "The updated todo list"
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
"required": [
|
|
267
|
+
"todos"
|
|
268
|
+
]
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
"type": "function",
|
|
274
|
+
"function": {
|
|
275
|
+
"name": "webfetch",
|
|
276
|
+
"description": "- Fetches content from a specified URL\n- Takes a URL and optional format as input\n- Fetches the URL content, converts to requested format (markdown by default)\n- Returns the content in the specified format\n- Use this tool when you need to retrieve and analyze web content\n\nUsage notes:\n - IMPORTANT: if another tool is present that offers better web fetching capabilities, is more targeted to the ta\u00e2\u20ac\u00a6[750 chars]",
|
|
277
|
+
"parameters": {
|
|
278
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
279
|
+
"type": "object",
|
|
280
|
+
"properties": {
|
|
281
|
+
"url": {
|
|
282
|
+
"type": "string",
|
|
283
|
+
"description": "The URL to fetch content from"
|
|
284
|
+
},
|
|
285
|
+
"format": {
|
|
286
|
+
"type": "string",
|
|
287
|
+
"enum": [
|
|
288
|
+
"text",
|
|
289
|
+
"markdown",
|
|
290
|
+
"html"
|
|
291
|
+
],
|
|
292
|
+
"description": "The format to return the content in (text, markdown, or html). Defaults to markdown.",
|
|
293
|
+
"default": "markdown"
|
|
294
|
+
},
|
|
295
|
+
"timeout": {
|
|
296
|
+
"type": "number",
|
|
297
|
+
"description": "Optional timeout in seconds (max 120)"
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
"required": [
|
|
301
|
+
"url"
|
|
302
|
+
]
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
"type": "function",
|
|
308
|
+
"function": {
|
|
309
|
+
"name": "write",
|
|
310
|
+
"description": "Writes a file to the local filesystem.\n\nUsage:\n- This tool will overwrite the existing file if there is one at the provided path.\n- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first.\n- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.\n- NEVER proacti\u00e2\u20ac\u00a6[623 chars]",
|
|
311
|
+
"parameters": {
|
|
312
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
313
|
+
"type": "object",
|
|
314
|
+
"properties": {
|
|
315
|
+
"content": {
|
|
316
|
+
"type": "string",
|
|
317
|
+
"description": "The content to write to the file"
|
|
318
|
+
},
|
|
319
|
+
"filePath": {
|
|
320
|
+
"type": "string",
|
|
321
|
+
"description": "The absolute path to the file to write (must be absolute, not relative)"
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
"required": [
|
|
325
|
+
"content",
|
|
326
|
+
"filePath"
|
|
327
|
+
]
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
],
|
|
332
|
+
"tool_choice": "auto",
|
|
333
|
+
"stream": true,
|
|
334
|
+
"stream_options": {
|
|
335
|
+
"include_usage": true
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
@@ -5,13 +5,16 @@ import { canonicalTool, digestApplies } from "../src/server/digest.ts";
|
|
|
5
5
|
import { OPENAI_ONLY_PARAMS, parseChatRequest } from "../src/wire/openai/request.ts";
|
|
6
6
|
import type { UpstreamMutations } from "../src/wire/types.ts";
|
|
7
7
|
import { parsePolicy, shouldSend } from "../omp-extension/digest-logic.ts";
|
|
8
|
+
import { RESPONSES_ONLY_PARAMS, parseResponsesRequest } from "../src/wire/openai/responses.ts";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
|
-
* Request shapes
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* Request shapes other harnesses send: the router must parse them, keep their
|
|
12
|
+
* tool calls and headers, and drop the OpenAI-platform-only parameters before
|
|
13
|
+
* dispatch. The first block holds representative bodies for harnesses that
|
|
14
|
+
* could not be run here (Cline/Roo/Kilo) or that illustrate a header set; the
|
|
15
|
+
* second block parses requests actually captured with tools/capture-proxy.ts
|
|
16
|
+
* (Aider, OpenCode, Codex). A harness release that changes its shape belongs
|
|
17
|
+
* here as a refreshed capture.
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
20
|
const MUT: UpstreamMutations = { slug: "x/y", fallbacks: [], sessionId: "s", cacheBreakpointMessageIndices: [], reasoning: undefined, maxTokens: undefined, stripAssistantReasoning: false };
|
|
@@ -19,25 +22,6 @@ const MUT: UpstreamMutations = { slug: "x/y", fallbacks: [], sessionId: "s", cac
|
|
|
19
22
|
const TOOL = (name: string) => ({ type: "function", function: { name, description: name, parameters: { type: "object", properties: { path: { type: "string" } } } } });
|
|
20
23
|
|
|
21
24
|
const HARNESSES: Record<string, { headers: Record<string, string>; body: Record<string, unknown>; toolCall?: string }> = {
|
|
22
|
-
codex: {
|
|
23
|
-
headers: { "X-Omp-Harness": "codex" },
|
|
24
|
-
body: {
|
|
25
|
-
model: "auto",
|
|
26
|
-
messages: [
|
|
27
|
-
{ role: "developer", content: "You are Codex." },
|
|
28
|
-
{ role: "user", content: "fix the failing test" },
|
|
29
|
-
{ role: "assistant", content: null, tool_calls: [{ id: "call_1", type: "function", function: { name: "shell", arguments: '{"command":["cat","x.ts"]}' } }] },
|
|
30
|
-
{ role: "tool", tool_call_id: "call_1", content: "export const x = 1;" },
|
|
31
|
-
],
|
|
32
|
-
tools: [TOOL("shell"), TOOL("apply_patch")],
|
|
33
|
-
stream: true,
|
|
34
|
-
store: false,
|
|
35
|
-
prompt_cache_key: "session-abc",
|
|
36
|
-
reasoning_effort: "medium",
|
|
37
|
-
parallel_tool_calls: false,
|
|
38
|
-
},
|
|
39
|
-
toolCall: "shell",
|
|
40
|
-
},
|
|
41
25
|
aider: {
|
|
42
26
|
headers: { "X-Omp-Harness": "aider" },
|
|
43
27
|
body: { model: "auto", messages: [{ role: "system", content: "Act as an expert software developer." }, { role: "user", content: "add a retry helper" }], stream: true, temperature: 0, extra_body: {} },
|
|
@@ -119,6 +103,53 @@ describe("config-only harness request shapes", () => {
|
|
|
119
103
|
});
|
|
120
104
|
});
|
|
121
105
|
|
|
106
|
+
/** A request a harness actually sent, captured with tools/capture-proxy.ts (bodies trimmed, ids scrubbed). */
|
|
107
|
+
async function fixture(name: string): Promise<{ headers: Record<string, string>; body: Record<string, unknown> }> {
|
|
108
|
+
return (await Bun.file(`${import.meta.dir}/fixtures/harness/${name}.json`).json()) as { headers: Record<string, string>; body: Record<string, unknown> };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe("captured harness requests", () => {
|
|
112
|
+
test("aider 0.86: plain chat completions, no tools, no harness header", async () => {
|
|
113
|
+
const f = await fixture("aider");
|
|
114
|
+
const req = parseChatRequest(structuredClone(f.body), new Headers(f.headers));
|
|
115
|
+
expect(f.headers["user-agent"]).toContain("OpenAI/Python");
|
|
116
|
+
expect(req.harnessId).toBe("");
|
|
117
|
+
expect(req.stream).toBe(true);
|
|
118
|
+
expect(req.tools).toHaveLength(0);
|
|
119
|
+
expect(req.messages[0]?.role).toBe("system");
|
|
120
|
+
expect(req.renderUpstreamBody(MUT).model).toBe("x/y");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("opencode 1.18: chat completions with tools, stream options and the harness header", async () => {
|
|
124
|
+
const f = await fixture("opencode");
|
|
125
|
+
const req = parseChatRequest(structuredClone(f.body), new Headers(f.headers));
|
|
126
|
+
expect(req.harnessId).toBe("opencode");
|
|
127
|
+
expect(req.tools.length).toBeGreaterThan(5);
|
|
128
|
+
expect(req.tools.map((t) => t.name)).toContain("read");
|
|
129
|
+
const out = req.renderUpstreamBody(MUT);
|
|
130
|
+
expect("stream_options" in out).toBe(false);
|
|
131
|
+
expect(out.max_tokens).toBe(f.body.max_tokens);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("codex 0.153: Responses API body translates to a routed chat request", async () => {
|
|
135
|
+
const f = await fixture("codex-responses");
|
|
136
|
+
const req = parseResponsesRequest(structuredClone(f.body), new Headers(f.headers));
|
|
137
|
+
expect(req.protocol).toBe("openai-responses");
|
|
138
|
+
expect(req.harnessId).toBe("codex");
|
|
139
|
+
expect(req.requestedModel).toBe("auto");
|
|
140
|
+
expect(req.stream).toBe(true);
|
|
141
|
+
// instructions became the system message; the input items follow in order.
|
|
142
|
+
expect(req.messages.map((m) => m.role)).toEqual(["system", "developer", "user", "user"]);
|
|
143
|
+
expect(req.messages[0]?.text).toContain("coding agent running in the Codex CLI");
|
|
144
|
+
expect(req.tools.map((t) => t.name)).toContain("exec_command");
|
|
145
|
+
const out = req.renderUpstreamBody(MUT);
|
|
146
|
+
for (const key of RESPONSES_ONLY_PARAMS) expect(key in out).toBe(false);
|
|
147
|
+
expect(Array.isArray(out.messages)).toBe(true);
|
|
148
|
+
expect((out.tools as { function: { name: string } }[])[0]?.function.name).toBe("exec_command");
|
|
149
|
+
expect(out.parallel_tool_calls).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
122
153
|
describe("digest tool aliases across harnesses", () => {
|
|
123
154
|
const d = { ...DEFAULT_CONFIG.digest, enabled: true, minBytes: 10, maxBytes: 10_000 };
|
|
124
155
|
test("harness spellings map onto the canonical tools list on both sides", () => {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { WireErrorException } from "../src/wire/openai/errors.ts";
|
|
4
|
+
import { createResponsesBufferedSink, createResponsesStreamingSink, parseResponsesRequest, responsesToChatBody } from "../src/wire/openai/responses.ts";
|
|
5
|
+
import type { StreamEvent, TurnSummary, UpstreamChunk } from "../src/wire/types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The Responses API wire: request translation into the chat shape the router
|
|
9
|
+
* routes on, and rendering of the upstream chat stream as Responses events.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const HEADERS = new Headers({ "X-Omp-Harness": "codex" });
|
|
13
|
+
|
|
14
|
+
describe("responsesToChatBody", () => {
|
|
15
|
+
test("instructions, messages, function calls and their outputs become chat messages", () => {
|
|
16
|
+
const chat = responsesToChatBody({
|
|
17
|
+
model: "auto",
|
|
18
|
+
instructions: "Be terse.",
|
|
19
|
+
input: [
|
|
20
|
+
{ type: "message", role: "user", content: [{ type: "input_text", text: "list files" }] },
|
|
21
|
+
{ type: "function_call", call_id: "call_1", name: "shell", arguments: '{"cmd":"ls"}' },
|
|
22
|
+
{ type: "function_call", call_id: "call_2", name: "shell", arguments: '{"cmd":"pwd"}' },
|
|
23
|
+
{ type: "function_call_output", call_id: "call_1", output: "a.ts b.ts" },
|
|
24
|
+
{ type: "function_call_output", call_id: "call_2", output: [{ type: "input_text", text: "/repo" }] },
|
|
25
|
+
{ type: "reasoning", summary: [] },
|
|
26
|
+
{ role: "user", content: "and now?" },
|
|
27
|
+
],
|
|
28
|
+
tools: [{ type: "function", name: "shell", description: "run", parameters: { type: "object" }, strict: false }, { type: "web_search" }],
|
|
29
|
+
tool_choice: { type: "function", name: "shell" },
|
|
30
|
+
max_output_tokens: 512,
|
|
31
|
+
reasoning: { effort: "low", summary: "auto" },
|
|
32
|
+
store: false,
|
|
33
|
+
include: ["reasoning.encrypted_content"],
|
|
34
|
+
prompt_cache_key: "k",
|
|
35
|
+
stream: true,
|
|
36
|
+
parallel_tool_calls: true,
|
|
37
|
+
});
|
|
38
|
+
expect(chat.messages).toEqual([
|
|
39
|
+
{ role: "system", content: "Be terse." },
|
|
40
|
+
{ role: "user", content: "list files" },
|
|
41
|
+
{
|
|
42
|
+
role: "assistant",
|
|
43
|
+
content: null,
|
|
44
|
+
tool_calls: [
|
|
45
|
+
{ id: "call_1", type: "function", function: { name: "shell", arguments: '{"cmd":"ls"}' } },
|
|
46
|
+
{ id: "call_2", type: "function", function: { name: "shell", arguments: '{"cmd":"pwd"}' } },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
{ role: "tool", tool_call_id: "call_1", content: "a.ts b.ts" },
|
|
50
|
+
{ role: "tool", tool_call_id: "call_2", content: "/repo" },
|
|
51
|
+
{ role: "user", content: "and now?" },
|
|
52
|
+
]);
|
|
53
|
+
expect(chat.tools).toEqual([{ type: "function", function: { name: "shell", description: "run", parameters: { type: "object" } } }]);
|
|
54
|
+
expect(chat.tool_choice).toEqual({ type: "function", function: { name: "shell" } });
|
|
55
|
+
expect(chat.max_tokens).toBe(512);
|
|
56
|
+
expect(chat.reasoning).toEqual({ effort: "low" });
|
|
57
|
+
expect(chat.stream).toBe(true);
|
|
58
|
+
expect(chat.parallel_tool_calls).toBe(true);
|
|
59
|
+
for (const k of ["instructions", "input", "store", "include", "prompt_cache_key", "max_output_tokens"]) expect(k in chat).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a string input is one user message; images survive; previous_response_id is refused", () => {
|
|
63
|
+
expect(responsesToChatBody({ model: "auto", input: "hi" }).messages).toEqual([{ role: "user", content: "hi" }]);
|
|
64
|
+
const withImage = responsesToChatBody({ model: "auto", input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "what is this" }, { type: "input_image", image_url: "data:image/png;base64,AAAA" }] }] });
|
|
65
|
+
expect(withImage.messages).toEqual([{ role: "user", content: [{ type: "text", text: "what is this" }, { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }] }]);
|
|
66
|
+
expect(() => responsesToChatBody({ model: "auto", input: "x", previous_response_id: "resp_1" })).toThrow(WireErrorException);
|
|
67
|
+
expect(() => responsesToChatBody({ model: "auto", input: [] })).toThrow(WireErrorException);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("parseResponsesRequest yields a routed request tagged with the wire", () => {
|
|
71
|
+
const req = parseResponsesRequest({ model: "auto-model-router/auto-cheap", input: "hello", stream: false }, HEADERS);
|
|
72
|
+
expect(req.protocol).toBe("openai-responses");
|
|
73
|
+
expect(req.requestedModel).toBe("auto-cheap");
|
|
74
|
+
expect(req.harnessId).toBe("codex");
|
|
75
|
+
expect(req.messages).toHaveLength(1);
|
|
76
|
+
expect(req.stream).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const SUMMARY: TurnSummary = {
|
|
81
|
+
servedSlug: "openai/gpt-5.5",
|
|
82
|
+
tier: "simple",
|
|
83
|
+
attempts: 1,
|
|
84
|
+
predictedUsd: 0.001,
|
|
85
|
+
reportedUsd: 0.0012,
|
|
86
|
+
usage: { promptTokens: 120, cachedTokens: 100, cacheWriteTokens: 0, completionTokens: 9, reasoningTokens: 2, images: 0 },
|
|
87
|
+
reasons: [],
|
|
88
|
+
escalated: false,
|
|
89
|
+
};
|
|
90
|
+
const chunk = (...events: StreamEvent[]): UpstreamChunk => ({ raw: { id: "gen-1", object: "chat.completion.chunk", model: "openai/gpt-5.5" }, events });
|
|
91
|
+
|
|
92
|
+
function parseEvents(text: string): { event: string; data: Record<string, unknown> }[] {
|
|
93
|
+
return text
|
|
94
|
+
.split("\n\n")
|
|
95
|
+
.filter((f) => f.startsWith("event: "))
|
|
96
|
+
.map((f) => {
|
|
97
|
+
const [eventLine = "", ...rest] = f.split("\n");
|
|
98
|
+
const dataLine = rest.find((l) => l.startsWith("data: ")) ?? "data: {}";
|
|
99
|
+
return { event: eventLine.slice("event: ".length), data: JSON.parse(dataLine.slice("data: ".length)) as Record<string, unknown> };
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe("createResponsesStreamingSink", () => {
|
|
104
|
+
test("renders text then a function call as the standard event sequence, ending in response.completed with usage", async () => {
|
|
105
|
+
const { sink, response } = createResponsesStreamingSink("auto");
|
|
106
|
+
expect(response.headers.get("content-type")).toBe("text/event-stream");
|
|
107
|
+
sink.chunk(chunk({ type: "start", servedSlug: "openai/gpt-5.5", generationId: "gen-1" }));
|
|
108
|
+
sink.chunk(chunk({ type: "text", delta: "Run" }));
|
|
109
|
+
sink.chunk(chunk({ type: "text", delta: "ning" }));
|
|
110
|
+
sink.chunk(chunk({ type: "tool_call", index: 0, id: "call_9", name: "shell", argsDelta: '{"cmd":' }));
|
|
111
|
+
sink.chunk(chunk({ type: "tool_call", index: 0, argsDelta: '"ls"}' }));
|
|
112
|
+
sink.chunk(chunk({ type: "finish", reason: "tool_calls" }, { type: "usage", usage: SUMMARY.usage, reportedCostUsd: 0.0012 }));
|
|
113
|
+
sink.finish(SUMMARY);
|
|
114
|
+
const text = await response.text();
|
|
115
|
+
const events = parseEvents(text);
|
|
116
|
+
expect(events.map((e) => e.event)).toEqual([
|
|
117
|
+
"response.created",
|
|
118
|
+
"response.in_progress",
|
|
119
|
+
"response.output_item.added",
|
|
120
|
+
"response.content_part.added",
|
|
121
|
+
"response.output_text.delta",
|
|
122
|
+
"response.output_text.delta",
|
|
123
|
+
"response.output_text.done",
|
|
124
|
+
"response.content_part.done",
|
|
125
|
+
"response.output_item.done",
|
|
126
|
+
"response.output_item.added",
|
|
127
|
+
"response.function_call_arguments.delta",
|
|
128
|
+
"response.function_call_arguments.delta",
|
|
129
|
+
"response.function_call_arguments.done",
|
|
130
|
+
"response.output_item.done",
|
|
131
|
+
"response.completed",
|
|
132
|
+
]);
|
|
133
|
+
// Every frame names its type and carries a rising sequence number.
|
|
134
|
+
events.forEach((e, i) => {
|
|
135
|
+
expect(e.data.type).toBe(e.event);
|
|
136
|
+
expect(e.data.sequence_number).toBe(i);
|
|
137
|
+
});
|
|
138
|
+
const completed = events.at(-1)!.data;
|
|
139
|
+
const resp = completed.response as { status: string; model: string; output: Record<string, unknown>[]; usage: Record<string, unknown> };
|
|
140
|
+
expect(resp.status).toBe("completed");
|
|
141
|
+
expect(resp.model).toBe("auto");
|
|
142
|
+
expect(resp.output).toHaveLength(2);
|
|
143
|
+
expect(resp.output[0]).toMatchObject({ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Running" }] });
|
|
144
|
+
expect(resp.output[1]).toMatchObject({ type: "function_call", call_id: "call_9", name: "shell", arguments: '{"cmd":"ls"}', status: "completed" });
|
|
145
|
+
expect(resp.usage).toEqual({ input_tokens: 120, input_tokens_details: { cached_tokens: 100 }, output_tokens: 9, output_tokens_details: { reasoning_tokens: 2 }, total_tokens: 129 });
|
|
146
|
+
expect(completed.x_auto_model_router).toEqual({ model: "openai/gpt-5.5", tier: "simple", cost_usd: 0.0012, attempts: 1 });
|
|
147
|
+
// The Responses stream has no [DONE] sentinel.
|
|
148
|
+
expect(text).not.toContain("[DONE]");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("an error becomes an error event and response.failed", async () => {
|
|
152
|
+
const { sink, response } = createResponsesStreamingSink("auto");
|
|
153
|
+
sink.error({ status: 502, code: "upstream_error", message: "boom" });
|
|
154
|
+
const events = parseEvents(await response.text());
|
|
155
|
+
expect(events.map((e) => e.event)).toEqual(["error", "response.failed"]);
|
|
156
|
+
expect(events[0]!.data).toMatchObject({ code: "upstream_error", message: "boom" });
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("createResponsesBufferedSink", () => {
|
|
161
|
+
test("aggregates the stream into one Response object with the routing headers", async () => {
|
|
162
|
+
const { sink, response } = createResponsesBufferedSink("auto");
|
|
163
|
+
sink.chunk(chunk({ type: "start", servedSlug: "openai/gpt-5.5", generationId: "gen-1" }, { type: "text", delta: "4" }, { type: "finish", reason: "stop" }));
|
|
164
|
+
sink.finish(SUMMARY);
|
|
165
|
+
const res = await response;
|
|
166
|
+
expect(res.status).toBe(200);
|
|
167
|
+
expect(res.headers.get("x-auto-model-router-model")).toBe("openai/gpt-5.5");
|
|
168
|
+
const body = (await res.json()) as { object: string; status: string; output: { type: string; content: { text: string }[] }[]; usage: { total_tokens: number } };
|
|
169
|
+
expect(body.object).toBe("response");
|
|
170
|
+
expect(body.status).toBe("completed");
|
|
171
|
+
expect(body.output[0]?.content[0]?.text).toBe("4");
|
|
172
|
+
expect(body.usage.total_tokens).toBe(129);
|
|
173
|
+
});
|
|
174
|
+
});
|
package/test/wire-sink.test.ts
CHANGED
|
@@ -53,6 +53,11 @@ describe("createStreamingSink", () => {
|
|
|
53
53
|
// Headers flushed with the first chunk, so the summary arrives as the
|
|
54
54
|
// final x_auto_model_router frame before [DONE].
|
|
55
55
|
const last = frames[frames.length - 1] as Record<string, unknown>;
|
|
56
|
+
// A well-formed chunk with no choices, so strict SSE clients (OpenCode's AI SDK) accept it.
|
|
57
|
+
expect(last.object).toBe("chat.completion.chunk");
|
|
58
|
+
expect(last.choices).toEqual([]);
|
|
59
|
+
expect(last.id).toBe("gen-1");
|
|
60
|
+
expect(last.model).toBe("auto");
|
|
56
61
|
expect(last.x_auto_model_router).toEqual({
|
|
57
62
|
model: "openai/gpt-5.5",
|
|
58
63
|
tier: "simple",
|