@bitkyc08/opencodex 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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +164 -0
  3. package/README.md +165 -0
  4. package/README.zh-CN.md +162 -0
  5. package/gui/README.md +73 -0
  6. package/gui/dist/assets/index-C1wlp1SM.css +1 -0
  7. package/gui/dist/assets/index-C9y3iMF1.js +9 -0
  8. package/gui/dist/favicon.png +0 -0
  9. package/gui/dist/icons.svg +24 -0
  10. package/gui/dist/index.html +15 -0
  11. package/gui/dist/logo.png +0 -0
  12. package/package.json +56 -0
  13. package/scripts/postinstall.mjs +57 -0
  14. package/src/adapters/anthropic.ts +306 -0
  15. package/src/adapters/azure.ts +31 -0
  16. package/src/adapters/base.ts +20 -0
  17. package/src/adapters/google.ts +195 -0
  18. package/src/adapters/image.ts +23 -0
  19. package/src/adapters/openai-chat.ts +265 -0
  20. package/src/adapters/openai-responses.ts +43 -0
  21. package/src/bridge.ts +296 -0
  22. package/src/cli.ts +183 -0
  23. package/src/codex-catalog.ts +318 -0
  24. package/src/codex-inject.ts +186 -0
  25. package/src/config.ts +108 -0
  26. package/src/index.ts +20 -0
  27. package/src/init.ts +163 -0
  28. package/src/model-cache.ts +42 -0
  29. package/src/oauth/anthropic.ts +151 -0
  30. package/src/oauth/callback-server.ts +249 -0
  31. package/src/oauth/index.ts +235 -0
  32. package/src/oauth/key-providers.ts +126 -0
  33. package/src/oauth/kimi.ts +160 -0
  34. package/src/oauth/local-token-detect.ts +71 -0
  35. package/src/oauth/login-cli.ts +90 -0
  36. package/src/oauth/pkce.ts +15 -0
  37. package/src/oauth/store.ts +39 -0
  38. package/src/oauth/types.ts +22 -0
  39. package/src/oauth/xai.ts +234 -0
  40. package/src/responses/parser.ts +402 -0
  41. package/src/responses/schema.ts +145 -0
  42. package/src/router.ts +86 -0
  43. package/src/server.ts +522 -0
  44. package/src/service.ts +130 -0
  45. package/src/star-prompt.ts +50 -0
  46. package/src/types.ts +228 -0
  47. package/src/update.ts +64 -0
  48. package/src/vision/describe.ts +98 -0
  49. package/src/vision/index.ts +141 -0
  50. package/src/web-search/executor.ts +75 -0
  51. package/src/web-search/format-result.ts +45 -0
  52. package/src/web-search/index.ts +62 -0
  53. package/src/web-search/loop.ts +188 -0
  54. package/src/web-search/parse.ts +128 -0
  55. package/src/web-search/synthetic-tool.ts +42 -0
@@ -0,0 +1,402 @@
1
+ import type {
2
+ OcxAssistantMessage,
3
+ OcxContentPart,
4
+ OcxContext,
5
+ OcxMessage,
6
+ OcxParsedRequest,
7
+ OcxRequestOptions,
8
+ OcxTextContent,
9
+ OcxThinkingContent,
10
+ OcxTool,
11
+ OcxToolCall,
12
+ } from "../types";
13
+ import { namespacedToolName } from "../types";
14
+ import { responsesRequestSchema } from "./schema";
15
+ import { extractHostedWebSearch } from "../web-search/synthetic-tool";
16
+
17
+ function isObj(v: unknown): v is Record<string, unknown> {
18
+ return typeof v === "object" && v !== null && !Array.isArray(v);
19
+ }
20
+
21
+ type InputBlock =
22
+ | { type: "input_text"; text: string }
23
+ | { type: "text"; text: string }
24
+ | { type: "input_image"; image_url?: string; file_id?: string; detail?: string }
25
+ | { type: "input_file"; file_id?: string; filename?: string };
26
+
27
+ function inputContentParts(blocks: unknown[] | string | undefined): string | OcxContentPart[] {
28
+ if (typeof blocks === "string") return blocks;
29
+ if (!blocks) return [];
30
+ const parts: OcxContentPart[] = [];
31
+ for (const raw of blocks) {
32
+ const block = raw as InputBlock;
33
+ if (block.type === "input_text" || block.type === "text") {
34
+ parts.push({ type: "text", text: (block as { text: string }).text });
35
+ } else if (block.type === "input_image") {
36
+ const b = block as { image_url?: string; file_id?: string; detail?: string };
37
+ if (b.image_url) {
38
+ // Preserve the image as a structured part — adapters send it as a native image block.
39
+ // NEVER inline the (often base64 data-URL) image_url as text: that explodes the token count.
40
+ parts.push({ type: "image", imageUrl: b.image_url, ...(b.detail ? { detail: b.detail } : {}) });
41
+ } else {
42
+ parts.push({ type: "text", text: `[image: ${b.file_id ?? "?"}]` }); // file_id ref → no inline data
43
+ }
44
+ } else if (block.type === "input_file") {
45
+ const ref = (block as { file_id?: string; filename?: string }).file_id ?? (block as { filename?: string }).filename ?? "?";
46
+ parts.push({ type: "text", text: `[file: ${ref}]` });
47
+ }
48
+ }
49
+ // Collapse to a plain string only for a single TEXT part; images must stay structured.
50
+ if (parts.length === 1 && parts[0].type === "text") return parts[0].text;
51
+ return parts;
52
+ }
53
+
54
+ type OutputBlock = { type: "output_text"; text: string } | { type: "text"; text: string } | { type: "refusal"; refusal: string };
55
+
56
+ function outputTextOf(blocks: unknown[] | string | undefined): OcxTextContent[] {
57
+ if (typeof blocks === "string") return blocks.length > 0 ? [{ type: "text", text: blocks }] : [];
58
+ if (!blocks) return [];
59
+ const out: OcxTextContent[] = [];
60
+ for (const raw of blocks) {
61
+ const b = raw as OutputBlock;
62
+ if (b.type === "output_text" || b.type === "text") out.push({ type: "text", text: (b as { text: string }).text });
63
+ else if (b.type === "refusal") out.push({ type: "text", text: `[refusal: ${(b as { refusal: string }).refusal}]` });
64
+ }
65
+ return out;
66
+ }
67
+
68
+ function mapToolChoice(value: unknown): OcxRequestOptions["toolChoice"] {
69
+ if (value === undefined || value === null) return undefined;
70
+ if (value === "auto" || value === "none" || value === "required") return value;
71
+ if (isObj(value) && "type" in value) {
72
+ const t = (value as { type: string }).type;
73
+ if ((t === "function" || t === "custom") && "name" in value) {
74
+ return { name: (value as { name: string }).name };
75
+ }
76
+ return "auto";
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
82
+ if (!tools) return undefined;
83
+ const out: OcxTool[] = [];
84
+ const pushFn = (t: Record<string, unknown>, namespace?: string) => {
85
+ const tool: OcxTool = {
86
+ name: t.name as string,
87
+ description: (t.description as string) ?? "",
88
+ parameters: (t.parameters ?? {}) as Record<string, unknown>,
89
+ };
90
+ if (t.strict !== undefined) tool.strict = t.strict as boolean;
91
+ if (namespace) tool.namespace = namespace;
92
+ out.push(tool);
93
+ };
94
+ for (const t of tools) {
95
+ if (!isObj(t)) continue;
96
+ if (t.type === "function" && typeof t.name === "string") {
97
+ pushFn(t);
98
+ } else if (t.type === "namespace" && Array.isArray(t.tools)) {
99
+ // MCP tools arrive grouped under a namespace tool; flatten the inner function tools so
100
+ // chat-completions models receive them (round-trip restores the namespace in the bridge).
101
+ const ns = typeof t.name === "string" ? t.name : undefined;
102
+ for (const inner of t.tools as unknown[]) {
103
+ if (isObj(inner) && inner.type === "function" && typeof inner.name === "string") pushFn(inner, ns);
104
+ }
105
+ }
106
+ else if (t.type === "custom" && typeof t.name === "string") {
107
+ // Freeform custom tool (e.g. apply_patch). Chat models can't emit a lark grammar, so expose a
108
+ // function with a single string `input` carrying the raw tool body; the bridge relays the model's
109
+ // call back as a custom_tool_call (Codex's freeform handler rejects a function_call → fatal abort).
110
+ out.push({
111
+ name: t.name,
112
+ description: (t.description as string) ?? "",
113
+ parameters: { type: "object", properties: { input: { type: "string", description: "Raw tool input (verbatim body, e.g. the apply_patch envelope)." } }, required: ["input"] },
114
+ freeform: true,
115
+ });
116
+ }
117
+ else if (t.type === "tool_search") {
118
+ // Client-executed tool discovery — the gateway to deferred tools (subagents, extra MCP tools).
119
+ // Expose as a function so chat models can call it; the bridge relays it as a tool_search_call.
120
+ out.push({
121
+ name: "tool_search",
122
+ description: (t.description as string) ?? "Search for additional tools to load for the next turn.",
123
+ parameters: (isObj(t.parameters) ? t.parameters : {
124
+ type: "object",
125
+ properties: {
126
+ query: { type: "string", description: "Search query for tools to load." },
127
+ limit: { type: "number", description: "Maximum number of tools to return." },
128
+ },
129
+ required: ["query"],
130
+ }) as Record<string, unknown>,
131
+ toolSearch: true,
132
+ });
133
+ }
134
+ else if (typeof t.name === "string" && t.type !== "web_search" && t.type !== "image_generation") {
135
+ // Any OTHER named tool (e.g. a native/computer-use tool type opencodex doesn't explicitly
136
+ // model) is client-executed — pass it through as a function so the routed model can read and
137
+ // call it naturally; the bridge relays its call as a function_call. Previously such tools were
138
+ // silently dropped, so the model never saw them.
139
+ pushFn(t);
140
+ }
141
+ // Only the OpenAI-hosted server-side tools (web_search, image_generation) are intentionally
142
+ // dropped — they're executed by OpenAI and can't be relayed to a routed chat model.
143
+ }
144
+ return out.length > 0 ? out : undefined;
145
+ }
146
+
147
+ function ensureAssistantPlaceholder(messages: OcxMessage[], modelId: string, now: number): OcxAssistantMessage {
148
+ const last = messages[messages.length - 1];
149
+ if (last && last.role === "assistant") return last;
150
+ const placeholder: OcxAssistantMessage = { role: "assistant", content: [], model: modelId, timestamp: now };
151
+ messages.push(placeholder);
152
+ return placeholder;
153
+ }
154
+
155
+ /**
156
+ * Tool-call output content. Preserves images (e.g. Codex `view_image` returns
157
+ * `input_image` items): returns content parts when any image is present, else a plain joined string.
158
+ * Never inlines an image_url as text (that would explode the token count).
159
+ */
160
+ function outputToToolResultContent(output: string | unknown[] | undefined): string | OcxContentPart[] {
161
+ if (typeof output === "string") return output;
162
+ if (!Array.isArray(output)) return "";
163
+ const parts: OcxContentPart[] = [];
164
+ let hasImage = false;
165
+ for (const raw of output) {
166
+ if (!isObj(raw)) continue;
167
+ if (raw.type === "output_text" || raw.type === "text") {
168
+ if (typeof raw.text === "string") parts.push({ type: "text", text: raw.text });
169
+ } else if (raw.type === "refusal" && typeof raw.refusal === "string") {
170
+ parts.push({ type: "text", text: `[refusal: ${raw.refusal}]` });
171
+ } else if (raw.type === "input_image" && typeof raw.image_url === "string") {
172
+ parts.push({ type: "image", imageUrl: raw.image_url, ...(typeof raw.detail === "string" ? { detail: raw.detail } : {}) });
173
+ hasImage = true;
174
+ }
175
+ }
176
+ if (!hasImage) return parts.map(p => (p.type === "text" ? p.text : "")).join("");
177
+ return parts;
178
+ }
179
+
180
+ function findToolNameById(messages: OcxMessage[], callId: string): string {
181
+ for (let i = messages.length - 1; i >= 0; i--) {
182
+ const m = messages[i];
183
+ if (m.role !== "assistant") continue;
184
+ for (const part of m.content) {
185
+ if (part.type === "toolCall" && part.id === callId) return part.name;
186
+ }
187
+ }
188
+ return "";
189
+ }
190
+
191
+ const REASONING_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh", "max"]);
192
+
193
+ export function parseRequest(body: unknown): OcxParsedRequest {
194
+ const parsed = responsesRequestSchema.safeParse(body);
195
+ if (!parsed.success) {
196
+ throw new Error(`responses parse error: ${parsed.error.message}`);
197
+ }
198
+ const data = parsed.data;
199
+ const now = Date.now();
200
+ const messages: OcxMessage[] = [];
201
+ const systemPrompt: string[] = [];
202
+ // Tool specs surfaced by a prior tool_search (deferred tools, e.g. subagents). Codex does not
203
+ // re-list these in `tools`, but chat models can only call listed tools — so we re-inject them.
204
+ const loadedToolSpecs: unknown[] = [];
205
+
206
+ if (typeof data.instructions === "string" && data.instructions.length > 0) {
207
+ systemPrompt.push(data.instructions);
208
+ }
209
+
210
+ if (typeof data.input === "string") {
211
+ messages.push({ role: "user", content: data.input, timestamp: now });
212
+ } else if (data.input) {
213
+ for (const item of data.input) {
214
+ const effectiveType = (item as { type?: string }).type ?? ("role" in item ? "message" : undefined);
215
+
216
+ if (effectiveType === "message") {
217
+ const msg = item as { role?: string; content?: unknown };
218
+ switch (msg.role) {
219
+ case "system": {
220
+ const text = inputContentParts(msg.content as unknown[] | string | undefined);
221
+ const flat = typeof text === "string" ? text : text.map(p => (p.type === "text" ? p.text : "")).join("");
222
+ if (flat.length > 0) systemPrompt.push(flat);
223
+ break;
224
+ }
225
+ case "user":
226
+ case "developer": {
227
+ const content = inputContentParts(msg.content as unknown[] | string | undefined);
228
+ messages.push({ role: msg.role, content, timestamp: now });
229
+ break;
230
+ }
231
+ case "assistant": {
232
+ const parts = outputTextOf(msg.content as unknown[] | string | undefined);
233
+ messages.push({ role: "assistant", content: parts, model: data.model, timestamp: now });
234
+ break;
235
+ }
236
+ }
237
+ continue;
238
+ }
239
+
240
+ if (effectiveType === "reasoning") {
241
+ const reasoning = item as { id?: string; summary?: { text: string }[]; content?: { text: string }[] };
242
+ const fromSummary = (reasoning.summary ?? []).map(c => c.text).join("");
243
+ const text = fromSummary || (reasoning.content ?? []).map(c => c.text).join("");
244
+ const thinking: OcxThinkingContent = {
245
+ type: "thinking",
246
+ thinking: text,
247
+ signature: JSON.stringify(reasoning),
248
+ ...(reasoning.id ? { itemId: reasoning.id } : {}),
249
+ };
250
+ ensureAssistantPlaceholder(messages, data.model, now).content.push(thinking);
251
+ continue;
252
+ }
253
+
254
+ if (effectiveType === "function_call") {
255
+ const call = item as { id?: string; call_id: string; name: string; arguments?: string; namespace?: string };
256
+ // Tolerate empty/non-JSON arguments (e.g. a no-arg tool call serialized as "") instead of
257
+ // throwing — a single poisoned history item would otherwise 400 every subsequent turn.
258
+ let args: Record<string, unknown> = {};
259
+ const rawArgs = call.arguments?.trim();
260
+ if (rawArgs) {
261
+ try {
262
+ const parsed: unknown = JSON.parse(rawArgs);
263
+ if (isObj(parsed)) args = parsed;
264
+ } catch {
265
+ console.warn(`[parser] function_call ${call.call_id} has non-JSON arguments; defaulting to {}`);
266
+ }
267
+ }
268
+ const toolCall: OcxToolCall = {
269
+ type: "toolCall", id: call.call_id, name: call.name, arguments: args,
270
+ ...(call.id ? { thoughtSignature: call.id } : {}),
271
+ ...(call.namespace ? { namespace: call.namespace } : {}),
272
+ };
273
+ ensureAssistantPlaceholder(messages, data.model, now).content.push(toolCall);
274
+ continue;
275
+ }
276
+
277
+ if (effectiveType === "custom_tool_call") {
278
+ const call = item as { id?: string; call_id: string; name: string; input: string };
279
+ const toolCall: OcxToolCall = {
280
+ type: "toolCall", id: call.call_id, name: call.name,
281
+ arguments: { input: call.input ?? "" },
282
+ customWireName: call.name,
283
+ ...(call.id ? { thoughtSignature: call.id } : {}),
284
+ };
285
+ ensureAssistantPlaceholder(messages, data.model, now).content.push(toolCall);
286
+ continue;
287
+ }
288
+
289
+ if (effectiveType === "tool_search_call") {
290
+ // Preserve the model's prior tool_search call as an assistant tool call so multi-turn
291
+ // history stays complete (otherwise the model re-issues tool_search forever).
292
+ const call = item as { id?: string; call_id?: string; arguments?: unknown };
293
+ const callId = call.call_id ?? call.id ?? "";
294
+ ensureAssistantPlaceholder(messages, data.model, now).content.push({
295
+ type: "toolCall", id: callId, name: "tool_search",
296
+ arguments: isObj(call.arguments) ? call.arguments : {},
297
+ });
298
+ continue;
299
+ }
300
+
301
+ if (effectiveType === "tool_search_output") {
302
+ // Pair the tool_search call with its result so the model sees what was loaded.
303
+ const out = item as { call_id?: string; tools?: unknown[] };
304
+ const specs = Array.isArray(out.tools) ? (out.tools as Record<string, unknown>[]) : [];
305
+ loadedToolSpecs.push(...specs);
306
+ // List the EXACT wire names the model must call (flattened for namespaced specs), matching
307
+ // how buildTools exposes them — otherwise the model guesses wrong names (e.g. the bare namespace).
308
+ const wireNames: string[] = [];
309
+ for (const spec of specs) {
310
+ if (spec.type === "namespace" && Array.isArray(spec.tools)) {
311
+ for (const inner of spec.tools as Record<string, unknown>[]) {
312
+ if (typeof inner.name === "string") wireNames.push(namespacedToolName(spec.name as string, inner.name));
313
+ }
314
+ } else if (typeof spec.name === "string") {
315
+ wireNames.push(spec.name);
316
+ }
317
+ }
318
+ messages.push({
319
+ role: "toolResult", toolCallId: out.call_id ?? "", toolName: "tool_search",
320
+ content: wireNames.length
321
+ ? `Tool search loaded these tools — they are now in your available tools. Call one by its EXACT name: ${wireNames.join(", ")}.`
322
+ : "Tool search returned no tools.",
323
+ isError: false, timestamp: now,
324
+ });
325
+ continue;
326
+ }
327
+
328
+ if (effectiveType === "function_call_output") {
329
+ const output = item as { call_id: string; output?: string | unknown[] };
330
+ messages.push({
331
+ role: "toolResult", toolCallId: output.call_id,
332
+ toolName: findToolNameById(messages, output.call_id),
333
+ content: outputToToolResultContent(output.output), isError: false, timestamp: now,
334
+ });
335
+ continue;
336
+ }
337
+
338
+ if (effectiveType === "custom_tool_call_output") {
339
+ const output = item as { call_id: string; output: string };
340
+ messages.push({
341
+ role: "toolResult", toolCallId: output.call_id,
342
+ toolName: findToolNameById(messages, output.call_id),
343
+ content: output.output ?? "", isError: false, timestamp: now,
344
+ });
345
+ }
346
+ }
347
+ }
348
+
349
+ const declaredTools = buildTools(data.tools as unknown[] | undefined) ?? [];
350
+ const loadedTools = buildTools(loadedToolSpecs) ?? [];
351
+ const seenTools = new Set<string>();
352
+ const mergedTools = [...declaredTools, ...loadedTools].filter(t => {
353
+ const k = namespacedToolName(t.namespace, t.name);
354
+ if (seenTools.has(k)) return false;
355
+ seenTools.add(k);
356
+ return true;
357
+ });
358
+ const context: OcxContext = {
359
+ ...(systemPrompt.length > 0 ? { systemPrompt } : {}),
360
+ messages,
361
+ ...(mergedTools.length > 0 ? { tools: mergedTools } : {}),
362
+ };
363
+
364
+ const options: OcxRequestOptions = {};
365
+ if (data.max_output_tokens !== undefined) options.maxOutputTokens = data.max_output_tokens;
366
+ if (data.temperature !== undefined) options.temperature = data.temperature;
367
+ if (data.top_p !== undefined) options.topP = data.top_p;
368
+ if (data.stop !== undefined && data.stop !== null) {
369
+ options.stopSequences = typeof data.stop === "string" ? [data.stop] : data.stop;
370
+ }
371
+ const tc = mapToolChoice(data.tool_choice);
372
+ if (tc !== undefined) options.toolChoice = tc;
373
+ if (data.reasoning?.effort && REASONING_EFFORTS.has(data.reasoning.effort)) {
374
+ options.reasoning = data.reasoning.effort;
375
+ }
376
+ if (data.reasoning?.summary === "none") options.hideThinkingSummary = true;
377
+ if (data.presence_penalty !== undefined) options.presencePenalty = data.presence_penalty;
378
+ if (data.frequency_penalty !== undefined) options.frequencyPenalty = data.frequency_penalty;
379
+
380
+ // Stash the hosted web_search config (if Codex enabled it) so the proxy can run searches via the
381
+ // gpt-mini sidecar for routed providers. buildTools still drops the hosted tool; the sidecar path
382
+ // re-injects a synthetic function tool only when it will actually handle the call.
383
+ const webSearch = extractHostedWebSearch(data.tools as unknown[] | undefined);
384
+ // Detect structured-output mode (Responses `text.format`) so the web-search sidecar can render its
385
+ // tool_result as JSON rather than prose that could corrupt the model's schema-constrained answer.
386
+ const structuredOutput = detectStructuredOutput(data.text);
387
+
388
+ return {
389
+ modelId: data.model, context, stream: data.stream === true, options, _rawBody: body,
390
+ ...(webSearch ? { _webSearch: webSearch } : {}),
391
+ ...(structuredOutput ? { _structuredOutput: true } : {}),
392
+ };
393
+ }
394
+
395
+ /** True when the Responses `text.format` requests structured output (json_schema or json_object). */
396
+ function detectStructuredOutput(text: unknown): boolean {
397
+ if (!isObj(text)) return false;
398
+ const format = (text as { format?: unknown }).format;
399
+ if (!isObj(format)) return false;
400
+ const t = (format as { type?: unknown }).type;
401
+ return t === "json_schema" || t === "json_object";
402
+ }
@@ -0,0 +1,145 @@
1
+ import * as z from "zod/v4";
2
+
3
+ const inputTextSchema = z.object({ type: z.literal("input_text"), text: z.string() });
4
+ const plainTextSchema = z.object({ type: z.literal("text"), text: z.string() });
5
+ const inputImageBlockSchema = z.object({
6
+ type: z.literal("input_image"),
7
+ detail: z.enum(["auto", "low", "high"]).optional(),
8
+ image_url: z.string().optional(),
9
+ file_id: z.string().optional(),
10
+ }).refine(v => typeof v.image_url === "string" || typeof v.file_id === "string", {
11
+ message: "input_image requires at least one of image_url or file_id",
12
+ });
13
+ const inputFileBlockSchema = z.object({
14
+ type: z.literal("input_file"),
15
+ file_id: z.string().optional(),
16
+ filename: z.string().optional(),
17
+ file_data: z.string().optional(),
18
+ });
19
+ const outputTextSchema = z.object({ type: z.literal("output_text"), text: z.string() });
20
+ const outputRefusalSchema = z.object({ type: z.literal("refusal"), refusal: z.string() });
21
+ const summaryTextSchema = z.object({ type: z.literal("summary_text"), text: z.string() });
22
+ const reasoningTextSchema = z.object({ type: z.literal("reasoning_text"), text: z.string() });
23
+
24
+ const inputContentBlockSchema = z.union([inputTextSchema, plainTextSchema, inputImageBlockSchema, inputFileBlockSchema]);
25
+ const outputContentBlockSchema = z.union([outputTextSchema, plainTextSchema, outputRefusalSchema]);
26
+
27
+ const userMessageItemSchema = z.object({
28
+ type: z.literal("message").optional(),
29
+ role: z.union([z.literal("user"), z.literal("developer")]),
30
+ content: z.union([z.string(), z.array(inputContentBlockSchema)]).optional(),
31
+ });
32
+ const systemMessageItemSchema = z.object({
33
+ type: z.literal("message").optional(),
34
+ role: z.literal("system"),
35
+ content: z.union([z.string(), z.array(inputContentBlockSchema)]).optional(),
36
+ });
37
+ const assistantMessageItemSchema = z.object({
38
+ type: z.literal("message").optional(),
39
+ role: z.literal("assistant"),
40
+ content: z.union([z.string(), z.array(outputContentBlockSchema)]).optional(),
41
+ });
42
+ const reasoningItemSchema = z.object({
43
+ type: z.literal("reasoning"),
44
+ id: z.string().optional(),
45
+ summary: z.array(summaryTextSchema).optional(),
46
+ content: z.array(reasoningTextSchema).optional(),
47
+ });
48
+ const functionCallItemSchema = z.object({
49
+ type: z.literal("function_call"),
50
+ id: z.string().optional(),
51
+ call_id: z.string().min(1),
52
+ name: z.string().min(1),
53
+ arguments: z.string().optional(),
54
+ });
55
+ const functionCallOutputItemSchema = z.object({
56
+ type: z.literal("function_call_output"),
57
+ call_id: z.string().min(1),
58
+ output: z.union([z.string(), z.array(outputContentBlockSchema)]).optional(),
59
+ });
60
+ const customToolCallItemSchema = z.object({
61
+ type: z.literal("custom_tool_call"),
62
+ id: z.string().optional(),
63
+ call_id: z.string().min(1),
64
+ name: z.string().min(1),
65
+ input: z.string(),
66
+ });
67
+ const customToolCallOutputItemSchema = z.object({
68
+ type: z.literal("custom_tool_call_output"),
69
+ call_id: z.string().min(1),
70
+ output: z.string(),
71
+ });
72
+
73
+ export const inputItemSchema = z.union([
74
+ userMessageItemSchema,
75
+ systemMessageItemSchema,
76
+ assistantMessageItemSchema,
77
+ reasoningItemSchema,
78
+ functionCallItemSchema,
79
+ functionCallOutputItemSchema,
80
+ customToolCallItemSchema,
81
+ customToolCallOutputItemSchema,
82
+ z.object({ type: z.string() }).loose(),
83
+ ]);
84
+
85
+ export const toolSchema = z.object({
86
+ type: z.literal("function"),
87
+ name: z.string().min(1),
88
+ description: z.string().optional(),
89
+ parameters: z.record(z.string(), z.unknown()).optional(),
90
+ strict: z.boolean().optional(),
91
+ });
92
+
93
+ const builtinToolSchema = z.object({ type: z.string() }).loose();
94
+
95
+ const hostedToolType = z.enum([
96
+ "web_search_preview", "file_search", "computer_use_preview",
97
+ "code_interpreter", "image_generation", "mcp",
98
+ ]);
99
+
100
+ const allowedToolEntrySchema = z.object({ type: z.string(), name: z.string().optional() });
101
+
102
+ export const toolChoiceSchema = z.union([
103
+ z.literal("auto"),
104
+ z.literal("none"),
105
+ z.literal("required"),
106
+ z.object({ type: z.literal("function"), name: z.string().min(1) }),
107
+ z.object({ type: z.literal("custom"), name: z.string().min(1) }),
108
+ z.object({ type: hostedToolType }),
109
+ z.object({ type: z.literal("allowed_tools"), mode: z.enum(["auto", "required"]), tools: z.array(allowedToolEntrySchema) }),
110
+ ]);
111
+
112
+ export const reasoningConfigSchema = z.object({
113
+ effort: z.string().optional(),
114
+ summary: z.enum(["auto", "concise", "detailed", "none"]).optional(),
115
+ });
116
+
117
+ export const stopSchema = z.union([z.string(), z.array(z.string()), z.null()]);
118
+
119
+ export const responsesRequestSchema = z.object({
120
+ model: z.string().min(1),
121
+ input: z.union([z.string(), z.array(inputItemSchema)]).optional(),
122
+ instructions: z.union([z.string(), z.null()]).optional(),
123
+ tools: z.array(z.union([toolSchema, builtinToolSchema])).optional(),
124
+ tool_choice: toolChoiceSchema.optional(),
125
+ max_output_tokens: z.number().optional(),
126
+ temperature: z.number().optional(),
127
+ top_p: z.number().optional(),
128
+ stop: stopSchema.optional(),
129
+ stream: z.boolean().optional(),
130
+ reasoning: reasoningConfigSchema.nullable().optional(),
131
+ store: z.boolean().optional(),
132
+ previous_response_id: z.string().optional(),
133
+ parallel_tool_calls: z.boolean().optional(),
134
+ prompt_cache_key: z.string().optional(),
135
+ metadata: z.unknown().optional(),
136
+ user: z.string().optional(),
137
+ service_tier: z.string().optional(),
138
+ presence_penalty: z.number().optional(),
139
+ frequency_penalty: z.number().optional(),
140
+ background: z.unknown().optional(),
141
+ include: z.unknown().optional(),
142
+ prompt: z.unknown().optional(),
143
+ text: z.unknown().optional(),
144
+ truncation: z.unknown().optional(),
145
+ });
package/src/router.ts ADDED
@@ -0,0 +1,86 @@
1
+ import type { OcxConfig, OcxProviderConfig } from "./types";
2
+ import { resolveEnvValue } from "./config";
3
+
4
+ interface RouteResult {
5
+ providerName: string;
6
+ provider: OcxProviderConfig;
7
+ modelId: string;
8
+ }
9
+
10
+ const MODEL_PROVIDER_PATTERNS: Record<string, string[]> = {
11
+ anthropic: [
12
+ "claude-", "claude-sonnet-", "claude-opus-", "claude-haiku-",
13
+ ],
14
+ openai: [
15
+ "gpt-", "o1-", "o3-", "o4-",
16
+ ],
17
+ groq: [
18
+ "llama-", "mixtral-", "gemma-",
19
+ ],
20
+ };
21
+
22
+ export function routeModel(config: OcxConfig, modelId: string): RouteResult {
23
+ // 0. Explicit "<provider>/<model>" namespace (e.g. "opencode-go/deepseek-v4-pro").
24
+ // Only triggers when the prefix matches a CONFIGURED provider, so genuine
25
+ // slash-containing model ids (e.g. "anthropic/claude-...") fall through when
26
+ // no such provider exists.
27
+ const slash = modelId.indexOf("/");
28
+ if (slash > 0) {
29
+ const provName = modelId.slice(0, slash);
30
+ const prov = config.providers[provName];
31
+ if (prov) {
32
+ return {
33
+ providerName: provName,
34
+ provider: { ...prov, apiKey: resolveEnvValue(prov.apiKey) },
35
+ modelId: modelId.slice(slash + 1),
36
+ };
37
+ }
38
+ }
39
+
40
+ for (const [provName, prov] of Object.entries(config.providers)) {
41
+ if (prov.defaultModel === modelId) {
42
+ return {
43
+ providerName: provName,
44
+ provider: { ...prov, apiKey: resolveEnvValue(prov.apiKey) },
45
+ modelId,
46
+ };
47
+ }
48
+ }
49
+
50
+ for (const [provName, prov] of Object.entries(config.providers)) {
51
+ if (prov.models && Array.isArray(prov.models) && (prov.models as string[]).includes(modelId)) {
52
+ return {
53
+ providerName: provName,
54
+ provider: { ...prov, apiKey: resolveEnvValue(prov.apiKey) },
55
+ modelId,
56
+ };
57
+ }
58
+ }
59
+
60
+ for (const [patternKey, prefixes] of Object.entries(MODEL_PROVIDER_PATTERNS)) {
61
+ if (prefixes.some(prefix => modelId.startsWith(prefix))) {
62
+ const matchingProvider = Object.entries(config.providers).find(
63
+ ([name]) => name === patternKey || name.startsWith(patternKey)
64
+ );
65
+ if (matchingProvider) {
66
+ const [provName, prov] = matchingProvider;
67
+ return {
68
+ providerName: provName,
69
+ provider: { ...prov, apiKey: resolveEnvValue(prov.apiKey) },
70
+ modelId,
71
+ };
72
+ }
73
+ }
74
+ }
75
+
76
+ const defaultProv = config.providers[config.defaultProvider];
77
+ if (defaultProv) {
78
+ return {
79
+ providerName: config.defaultProvider,
80
+ provider: { ...defaultProv, apiKey: resolveEnvValue(defaultProv.apiKey) },
81
+ modelId,
82
+ };
83
+ }
84
+
85
+ throw new Error(`No provider configured for model: ${modelId}`);
86
+ }