@bitkyc08/opencodex 2.6.13 → 2.6.14

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.
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-BTTqyZ-C.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-B64_jgH-.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-DIBiVVC0.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.6.13",
3
+ "version": "2.6.14",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -40,7 +40,7 @@ const MIN_THINKING_BUDGET = 1024;
40
40
  const OUTPUT_HEADROOM = 8192;
41
41
  /** Minimum visible-output room kept below `max_tokens` (so `max_tokens > budget_tokens` always holds). */
42
42
  const OUTPUT_FLOOR = 4096;
43
- const COMPAT_TOOL_PREFIX = "ocx_";
43
+ const COMPAT_TOOL_PREFIX = "cx_";
44
44
 
45
45
  /** Map a Responses reasoning effort to an Anthropic extended-thinking budget (tokens, >= 1024). */
46
46
  function reasoningBudget(effort: string): number {
@@ -172,7 +172,7 @@ function messagesToAnthropicFormat(
172
172
  resultBlocks.push({
173
173
  type: "tool_result",
174
174
  tool_use_id: id,
175
- content: "[opencodex: missing tool_result for this tool_use in Codex history]",
175
+ content: "[missing tool_result for this tool_use in history]",
176
176
  is_error: true,
177
177
  });
178
178
  }
@@ -240,7 +240,10 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
240
240
  if (parsed.options.topP !== undefined) body.top_p = parsed.options.topP;
241
241
  if (parsed.options.stopSequences) body.stop_sequences = parsed.options.stopSequences;
242
242
 
243
- if (parsed.options.reasoning) {
243
+ // `reasoning` is a Codex effort string; "none" is the disable sentinel (see parser.ts
244
+ // REASONING_EFFORTS). A bare truthy check would treat "none" as truthy and wrongly enable
245
+ // extended thinking (and strip temperature/top_p), so gate on a real, non-disable effort.
246
+ if (typeof parsed.options.reasoning === "string" && parsed.options.reasoning !== "none") {
244
247
  // Anthropic requires max_tokens > thinking.budget_tokens (max_tokens caps thinking +
245
248
  // visible output) and budget_tokens >= 1024. Codex sends the SAME value for both, which
246
249
  // 400s ("max_tokens must be greater than thinking.budget_tokens"). Size them so max_tokens
@@ -270,6 +273,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
270
273
  const headers: Record<string, string> = {
271
274
  "Content-Type": "application/json",
272
275
  "anthropic-version": "2023-06-01",
276
+ "Accept": parsed.stream ? "text/event-stream" : "application/json",
277
+ "User-Agent": "@anthropic-ai/sdk/0.74.0",
273
278
  };
274
279
  if (isOAuth) {
275
280
  if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`;
@@ -20,6 +20,10 @@ export const CLAUDE_CODE_HEADERS: Record<string, string> = {
20
20
  "X-Stainless-Runtime": "node",
21
21
  "X-Stainless-Lang": "js",
22
22
  "X-Stainless-Timeout": "600",
23
+ "X-Stainless-Arch": process.arch,
24
+ "X-Stainless-OS": process.platform,
25
+ "X-Stainless-Package-Version": "0.74.0",
26
+ "X-Stainless-Runtime-Version": process.version.slice(1),
23
27
  };
24
28
 
25
29
  /**
@@ -19,7 +19,6 @@ import { isVertexTruncationReason, vertexTruncationErrorMessage } from "./google
19
19
  import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire";
20
20
  import { sanitizeGeminiToolParameters } from "./google-tool-schema";
21
21
  import { neutralizeIdentity } from "./identity";
22
- import { ANTIGRAVITY_GOOG_API_CLIENT_UA } from "./client-fingerprint";
23
22
  import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay";
24
23
 
25
24
  // Google-family models (Gemini/Vertex/Antigravity) tend to emit long running commentary between
@@ -264,8 +263,6 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
264
263
  request,
265
264
  };
266
265
  headers["User-Agent"] = ANTIGRAVITY_REQUEST_UA;
267
- // The Antigravity client library reports a secondary Google API client UA alongside the CLI UA.
268
- headers["x-goog-api-client"] = ANTIGRAVITY_GOOG_API_CLIENT_UA;
269
266
  if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`;
270
267
  return { url, method: "POST", headers, body: JSON.stringify(envelope) };
271
268
  }
@@ -8,9 +8,9 @@ let cachedFp: string | undefined;
8
8
  export function fingerprint(): string {
9
9
  if (cachedFp) return cachedFp;
10
10
  try {
11
- cachedFp = createHash("sha256").update(`${hostname()}-${userInfo().username}-kiro-ocx`).digest("hex");
11
+ cachedFp = createHash("sha256").update(`${hostname()}-${userInfo().username}-kiro`).digest("hex");
12
12
  } catch {
13
- cachedFp = createHash("sha256").update("default-kiro-ocx").digest("hex");
13
+ cachedFp = createHash("sha256").update("default-kiro").digest("hex");
14
14
  }
15
15
  return cachedFp;
16
16
  }
@@ -33,7 +33,7 @@ import { neutralizeIdentity } from "./identity";
33
33
  const AMZ_TARGET = "AmazonCodeWhispererStreamingService.GenerateAssistantResponse";
34
34
  const SDK_VERSION = "1.0.27";
35
35
  const NODE_VERSION = "22.21.1";
36
- const KIRO_IDE_VERSION = "1.2.0";
36
+ const KIRO_IDE_VERSION = "1.0.0";
37
37
 
38
38
  // Payload construction (conversationState)
39
39
  interface KiroToolUse {
@@ -223,92 +223,127 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
223
223
  let currentToolCallId = "";
224
224
  let currentToolCallName = "";
225
225
  let pendingUsage: OcxUsage | undefined;
226
+ // Track terminal signals so a socket EOF without any terminator can fail closed instead of
227
+ // being reported as a clean completion (silent truncation). A graceful close is either an
228
+ // explicit `[DONE]` sentinel OR a chunk carrying a non-null `finish_reason` (some
229
+ // OpenAI-compatible providers omit `[DONE]` but do send finish_reason).
230
+ let sawFinish = false;
226
231
 
227
- try {
228
- while (true) {
229
- const { done, value } = await reader.read();
230
- if (done) break;
231
- buffer += decoder.decode(value, { stream: true });
232
-
233
- const lines = buffer.split("\n");
234
- buffer = lines.pop() ?? "";
232
+ // Single per-line handler shared by the streaming loop and the EOF residual-frame flush, so
233
+ // a final frame is parsed identically wherever it lands (no duplicated, drift-prone parsing).
234
+ // Yields adapter events and returns "terminate" for a terminal frame ([DONE] / error) that
235
+ // must end the stream, or "continue" otherwise. Mutates the closure's terminal-signal state.
236
+ const handleDataLine = function* (line: string): Generator<AdapterEvent, "continue" | "terminate"> {
237
+ if (!line.startsWith("data: ")) return "continue";
238
+ const payload = line.slice(6).trim();
239
+ if (payload === "[DONE]") {
240
+ if (currentToolCallId) {
241
+ yield { type: "tool_call_end" };
242
+ currentToolCallId = "";
243
+ }
244
+ yield { type: "done", usage: pendingUsage };
245
+ return "terminate";
246
+ }
235
247
 
236
- for (const line of lines) {
237
- if (!line.startsWith("data: ")) continue;
238
- const payload = line.slice(6).trim();
239
- if (payload === "[DONE]") {
240
- if (currentToolCallId) {
241
- yield { type: "tool_call_end" };
242
- currentToolCallId = "";
243
- }
244
- yield { type: "done", usage: pendingUsage };
245
- return;
246
- }
248
+ let chunk: Record<string, unknown>;
249
+ try {
250
+ chunk = JSON.parse(payload) as Record<string, unknown>;
251
+ } catch {
252
+ debugDroppedFrame("openai-chat", payload);
253
+ return "continue";
254
+ }
247
255
 
248
- let chunk: Record<string, unknown>;
249
- try {
250
- chunk = JSON.parse(payload) as Record<string, unknown>;
251
- } catch {
252
- debugDroppedFrame("openai-chat", payload);
253
- continue;
254
- }
256
+ // A 200/OK chat-completions stream may carry an inline provider error envelope
257
+ // instead of a clean [DONE]. Surface it as a terminal error so the bridge emits a
258
+ // classified response.failed (bridge case "error") never a truncated completion.
259
+ if (chunk.error) {
260
+ const err = chunk.error as { message?: string } | undefined;
261
+ if (currentToolCallId) yield { type: "tool_call_end" };
262
+ yield { type: "error", message: err?.message ?? "upstream error" };
263
+ return "terminate";
264
+ }
255
265
 
256
- // A 200/OK chat-completions stream may carry an inline provider error envelope
257
- // instead of a clean [DONE]. Surface it as a terminal error so the bridge emits a
258
- // classified response.failed (bridge case "error") never a truncated completion.
259
- if (chunk.error) {
260
- const err = chunk.error as { message?: string } | undefined;
261
- if (currentToolCallId) yield { type: "tool_call_end" };
262
- yield { type: "error", message: err?.message ?? "upstream error" };
263
- return;
264
- }
266
+ if (chunk.usage) {
267
+ // Record usage but keep parsing: some providers send usage and the final content
268
+ // delta in the SAME chunk; a bail here would drop that content. The choices
269
+ // guard below no-ops a usage-only chunk.
270
+ pendingUsage = usageFromOpenAIChat(chunk.usage as Record<string, unknown>);
271
+ }
265
272
 
266
- if (chunk.usage) {
267
- // Record usage but keep parsing: some providers send usage and the final content
268
- // delta in the SAME chunk; a `continue` here would drop that content. The choices
269
- // guard below no-ops a usage-only chunk.
270
- pendingUsage = usageFromOpenAIChat(chunk.usage as Record<string, unknown>);
271
- }
273
+ const choices = chunk.choices as { delta?: Record<string, unknown>; finish_reason?: string }[] | undefined;
274
+ if (!choices || choices.length === 0) return "continue";
275
+ // Observe the terminator BEFORE the delta guard: a finish-only chunk (finish_reason set,
276
+ // no delta) is a graceful close and must mark sawFinish even though we skip it below.
277
+ if (typeof choices[0].finish_reason === "string" && choices[0].finish_reason) {
278
+ sawFinish = true;
279
+ }
280
+ const delta = choices[0].delta;
281
+ if (delta) {
282
+ if (typeof delta.content === "string" && delta.content.length > 0) {
283
+ yield { type: "text_delta", text: delta.content };
284
+ }
272
285
 
273
- const choices = chunk.choices as { delta?: Record<string, unknown>; finish_reason?: string }[] | undefined;
274
- if (!choices || choices.length === 0) continue;
275
- const delta = choices[0].delta;
276
- if (!delta) continue;
286
+ if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
287
+ yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
288
+ }
277
289
 
278
- if (typeof delta.content === "string" && delta.content.length > 0) {
279
- yield { type: "text_delta", text: delta.content };
290
+ const toolCalls = delta.tool_calls as { index: number; id?: string; function?: { name?: string; arguments?: string } }[] | undefined;
291
+ if (toolCalls) {
292
+ for (const tc of toolCalls) {
293
+ if (tc.id && tc.id !== currentToolCallId) {
294
+ if (currentToolCallId) yield { type: "tool_call_end" };
295
+ currentToolCallId = tc.id;
296
+ currentToolCallName = tc.function?.name ?? "";
297
+ yield { type: "tool_call_start", id: tc.id, name: currentToolCallName };
298
+ }
299
+ if (tc.function?.arguments) {
300
+ yield { type: "tool_call_delta", arguments: tc.function.arguments };
301
+ }
280
302
  }
303
+ }
304
+ }
281
305
 
282
- if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
283
- yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
284
- }
306
+ if (choices[0].finish_reason === "tool_calls" && currentToolCallId) {
307
+ yield { type: "tool_call_end" };
308
+ currentToolCallId = "";
309
+ }
310
+ return "continue";
311
+ };
285
312
 
286
- const toolCalls = delta.tool_calls as { index: number; id?: string; function?: { name?: string; arguments?: string } }[] | undefined;
287
- if (toolCalls) {
288
- for (const tc of toolCalls) {
289
- if (tc.id && tc.id !== currentToolCallId) {
290
- if (currentToolCallId) yield { type: "tool_call_end" };
291
- currentToolCallId = tc.id;
292
- currentToolCallName = tc.function?.name ?? "";
293
- yield { type: "tool_call_start", id: tc.id, name: currentToolCallName };
294
- }
295
- if (tc.function?.arguments) {
296
- yield { type: "tool_call_delta", arguments: tc.function.arguments };
297
- }
298
- }
299
- }
313
+ try {
314
+ while (true) {
315
+ const { done, value } = await reader.read();
316
+ if (done) break;
317
+ buffer += decoder.decode(value, { stream: true });
300
318
 
301
- if (choices[0].finish_reason === "tool_calls" && currentToolCallId) {
302
- yield { type: "tool_call_end" };
303
- currentToolCallId = "";
304
- }
319
+ const lines = buffer.split("\n");
320
+ buffer = lines.pop() ?? "";
321
+
322
+ for (const line of lines) {
323
+ if ((yield* handleDataLine(line)) === "terminate") return;
305
324
  }
306
325
  }
307
326
 
327
+ // Some providers send the terminal `data:` frame (carrying the final delta, finish_reason,
328
+ // and/or usage) WITHOUT a trailing newline before closing the socket, so it never crosses
329
+ // the split("\n") boundary and stays in `buffer`. Run it through the SAME handler so its
330
+ // content/tool-calls are emitted and its terminal signal observed — otherwise a genuinely
331
+ // complete stream loses its last frame and may be falsely failed below.
332
+ if (buffer.length > 0) {
333
+ if ((yield* handleDataLine(buffer)) === "terminate") return;
334
+ }
308
335
  if (currentToolCallId) {
309
336
  yield { type: "tool_call_end" };
310
337
  }
311
- // EOF without a [DONE] sentinel: still surface any usage accumulated mid-stream.
338
+ // Reader EOF. A graceful close shows at least one terminal signal: `[DONE]` (returns above),
339
+ // a non-null finish_reason (sawFinish), or a trailing usage chunk (providers emit usage only
340
+ // at end-of-generation). If NONE of those were seen, the stream was cut mid-flight — fail
341
+ // closed so the bridge emits a classified response.failed rather than a silent truncation.
342
+ if (!sawFinish && pendingUsage === undefined) {
343
+ yield { type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" };
344
+ return;
345
+ }
346
+ // Graceful close that omitted [DONE] but delivered finish_reason and/or final usage.
312
347
  yield { type: "done", usage: pendingUsage };
313
348
  } finally {
314
349
  reader.releaseLock();
@@ -42,6 +42,41 @@ function sanitizeReasoningInputContent(body: unknown): unknown {
42
42
  return changed ? { ...raw, input } : body;
43
43
  }
44
44
 
45
+ /**
46
+ * Hosted (OpenAI-executed) tool types that specific native slugs reject at request time. Codex
47
+ * attaches these for app skills (e.g. `image_generation` for imagegen) regardless of the target
48
+ * model, and the passthrough path forwards the raw body untouched — so a slug that doesn't support
49
+ * the tool 400s (`Tool 'image_generation' is not supported with gpt-5.3-codex-spark.`). Each entry
50
+ * maps a model-slug matcher to the hosted tool types that must be stripped before forwarding.
51
+ * Extend this when another native slug rejects a hosted tool (e.g. `code_interpreter`).
52
+ */
53
+ const UNSUPPORTED_HOSTED_TOOLS: ReadonlyArray<{ match: (model: string) => boolean; tools: ReadonlySet<string> }> = [
54
+ { match: model => model.includes("codex-spark"), tools: new Set(["image_generation"]) },
55
+ ];
56
+
57
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
58
+ return !!v && typeof v === "object" && !Array.isArray(v);
59
+ }
60
+
61
+ /**
62
+ * Remove hosted tool entries the target native slug rejects, so the OAuth-passthrough body never
63
+ * carries a tool the upstream model 400s on. No-op (returns the original reference) when nothing
64
+ * matches, keeping the common path allocation-free.
65
+ */
66
+ function stripUnsupportedHostedTools(body: unknown): unknown {
67
+ if (!isPlainObject(body) || !Array.isArray(body.tools)) return body;
68
+ const model = typeof body.model === "string" ? body.model : "";
69
+ const unsupported = UNSUPPORTED_HOSTED_TOOLS.filter(e => e.match(model));
70
+ if (unsupported.length === 0) return body;
71
+
72
+ const tools = body.tools.filter(t => {
73
+ const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined;
74
+ if (!type) return true;
75
+ return !unsupported.some(e => e.tools.has(type));
76
+ });
77
+ return tools.length === body.tools.length ? body : { ...body, tools };
78
+ }
79
+
45
80
  export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ProviderAdapter & { passthrough: true } {
46
81
  return {
47
82
  name: "openai-responses",
@@ -82,7 +117,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
82
117
  url,
83
118
  method: "POST",
84
119
  headers,
85
- body: JSON.stringify(sanitizeReasoningInputContent(parsed._rawBody)),
120
+ body: JSON.stringify(stripUnsupportedHostedTools(sanitizeReasoningInputContent(parsed._rawBody))),
86
121
  };
87
122
  },
88
123
 
@@ -13,7 +13,7 @@ const SCOPES = "org:create_api_key user:profile user:inference";
13
13
  // ── OAuth-request requirements applied by the anthropic adapter when authMode==="oauth" ──
14
14
  export const ANTHROPIC_OAUTH_BETA = "claude-code-20250219,oauth-2025-04-20";
15
15
  export const CLAUDE_CODE_SYSTEM_INSTRUCTION = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
16
- const CLAUDE_TOOL_PREFIX = "proxy_";
16
+ const CLAUDE_TOOL_PREFIX = "custom_";
17
17
  const ANTHROPIC_BUILTIN_TOOLS = new Set(["web_search", "code_execution", "text_editor", "computer"]);
18
18
 
19
19
  /** OAuth tokens reject arbitrary tool names; prefix custom tools (Anthropic builtins are exempt). */
@@ -22,7 +22,7 @@ export function applyClaudeToolPrefix(name: string): string {
22
22
  return CLAUDE_TOOL_PREFIX + name;
23
23
  }
24
24
 
25
- /** Strip the proxy_ prefix from a returned tool_use name so the caller (Codex) sees the original. */
25
+ /** Strip the custom_ prefix from a returned tool_use name so the caller (Codex) sees the original. */
26
26
  export function stripClaudeToolPrefix(name: string): string {
27
27
  return name.startsWith(CLAUDE_TOOL_PREFIX) ? name.slice(CLAUDE_TOOL_PREFIX.length) : name;
28
28
  }
@@ -12,6 +12,7 @@
12
12
  import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server";
13
13
  import { generatePKCE } from "./pkce";
14
14
  import type { OAuthController, OAuthCredentials } from "./types";
15
+ import { antigravityUserAgent, ANTIGRAVITY_GOOG_API_CLIENT_UA } from "../adapters/client-fingerprint";
15
16
 
16
17
  const CLIENT_ID = process.env.GOOGLE_ANTIGRAVITY_CLIENT_ID
17
18
  || "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
@@ -94,7 +95,7 @@ function extractProjectId(data: Record<string, unknown> | undefined): string | u
94
95
  async function loadCodeAssistProject(accessToken: string, signal?: AbortSignal): Promise<string | undefined> {
95
96
  const response = await fetch(`${PROD_API}/${API_VERSION}:loadCodeAssist`, {
96
97
  method: "POST",
97
- headers: { Authorization: `Bearer ${accessToken}`, Accept: "*/*", "Content-Type": "application/json" },
98
+ headers: { Authorization: `Bearer ${accessToken}`, Accept: "*/*", "Content-Type": "application/json", "User-Agent": antigravityUserAgent() },
98
99
  body: JSON.stringify({ metadata: { ideType: "ANTIGRAVITY" } }),
99
100
  signal: requestSignal(signal),
100
101
  });
@@ -107,8 +108,8 @@ async function onboardProject(accessToken: string, signal?: AbortSignal): Promis
107
108
  if (signal?.aborted) throw signal.reason ?? new Error("Antigravity onboarding aborted");
108
109
  const response = await fetch(`${DAILY_API}/${API_VERSION}:onboardUser`, {
109
110
  method: "POST",
110
- headers: { Authorization: `Bearer ${accessToken}`, Accept: "*/*", "Content-Type": "application/json" },
111
- body: JSON.stringify({ tier_id: "free-tier", metadata: { ide_type: "ANTIGRAVITY", ide_name: "antigravity" } }),
111
+ headers: { Authorization: `Bearer ${accessToken}`, Accept: "*/*", "Content-Type": "application/json", "User-Agent": antigravityUserAgent(), "x-goog-api-client": ANTIGRAVITY_GOOG_API_CLIENT_UA },
112
+ body: JSON.stringify({ tier_id: "free-tier", metadata: { ide_type: "ANTIGRAVITY", ide_name: "antigravity", ide_version: antigravityUserAgent() } }),
112
113
  signal: requestSignal(signal),
113
114
  });
114
115
  if (!response.ok) {
package/src/oauth/kimi.ts CHANGED
@@ -12,7 +12,7 @@ const DEVICE_ID_FILENAME = "kimi-device-id";
12
12
  const DEFAULT_POLL_INTERVAL_MS = 5000;
13
13
  const DEFAULT_DEVICE_FLOW_TTL_MS = 15 * 60 * 1000;
14
14
  const OAUTH_EXPIRY_SKEW_MS = 5 * 60 * 1000;
15
- const KIMI_CLI_VERSION = "1.0.0";
15
+ const KIMI_CLI_VERSION = "0.14.0";
16
16
 
17
17
  interface DeviceAuthorizationResponse {
18
18
  user_code?: string;
@@ -70,7 +70,7 @@ function getDeviceId(): string {
70
70
  function getKimiCommonHeaders(): Record<string, string> {
71
71
  return {
72
72
  "User-Agent": `KimiCLI/${KIMI_CLI_VERSION}`,
73
- "X-Msh-Platform": "kimi_cli",
73
+ "X-Msh-Platform": "kimi_code_cli",
74
74
  "X-Msh-Version": KIMI_CLI_VERSION,
75
75
  "X-Msh-Device-Name": os.hostname(),
76
76
  "X-Msh-Device-Model": getDeviceModel(),
@@ -0,0 +1,43 @@
1
+ import { createAnthropicAdapter } from "../adapters/anthropic";
2
+ import { createAzureAdapter } from "../adapters/azure";
3
+ import { createGoogleAdapter } from "../adapters/google";
4
+ import { createKiroAdapter } from "../adapters/kiro";
5
+ import { createOpenAIChatAdapter } from "../adapters/openai-chat";
6
+ import { createResponsesPassthroughAdapter } from "../adapters/openai-responses";
7
+ import type { OcxProviderConfig } from "../types";
8
+
9
+ /** Providers whose listed model ids must be driven over the Anthropic wire even if the provider's
10
+ * configured adapter is something else (the upstream only speaks Anthropic for these models). */
11
+ const ANTHROPIC_WIRE_MODELS: Record<string, Set<string>> = {
12
+ "opencode-go": new Set(["minimax-m2.5", "minimax-m2.7", "minimax-m3", "qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus"]),
13
+ };
14
+
15
+ /** Return a provider config whose adapter is forced to "anthropic" when the model id is wire-pinned. */
16
+ export function resolveWireProtocolOverride(providerName: string, modelId: string, providerConfig: OcxProviderConfig): OcxProviderConfig {
17
+ const overrideSet = ANTHROPIC_WIRE_MODELS[providerName];
18
+ if (overrideSet?.has(modelId) && providerConfig.adapter !== "anthropic") {
19
+ return { ...providerConfig, adapter: "anthropic" };
20
+ }
21
+ return providerConfig;
22
+ }
23
+
24
+ /** Build the provider adapter for a resolved provider config. */
25
+ export function resolveAdapter(providerConfig: OcxProviderConfig) {
26
+ switch (providerConfig.adapter) {
27
+ case "openai-chat":
28
+ return createOpenAIChatAdapter(providerConfig);
29
+ case "anthropic":
30
+ return createAnthropicAdapter(providerConfig);
31
+ case "openai-responses":
32
+ return createResponsesPassthroughAdapter(providerConfig);
33
+ case "google":
34
+ return createGoogleAdapter(providerConfig);
35
+ case "kiro":
36
+ return createKiroAdapter(providerConfig);
37
+ case "azure":
38
+ case "azure-openai":
39
+ return createAzureAdapter(providerConfig);
40
+ default:
41
+ throw new Error(`Unknown adapter: ${providerConfig.adapter}`);
42
+ }
43
+ }
@@ -0,0 +1,98 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { extname, isAbsolute, join, relative, resolve } from "node:path";
3
+
4
+ /** opencodex version, read from the packaged package.json (same source as the server bootstrap). */
5
+ const VERSION = (() => {
6
+ try {
7
+ return JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version as string;
8
+ } catch {
9
+ return "0.0.0";
10
+ }
11
+ })();
12
+
13
+ const MIME_TYPES: Record<string, string> = {
14
+ ".html": "text/html", ".js": "application/javascript", ".css": "text/css",
15
+ ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png",
16
+ ".ico": "image/x-icon",
17
+ };
18
+
19
+ function findGuiDist(): string | null {
20
+ const candidates = [
21
+ join(import.meta.dir, "..", "..", "gui", "dist"),
22
+ join(import.meta.dir, "..", "..", "..", "gui", "dist"),
23
+ ];
24
+ for (const c of candidates) {
25
+ if (existsSync(join(c, "index.html"))) return c;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ export function resolveGuiFilePath(guiDist: string, pathname: string): string | null {
31
+ let decodedPath: string;
32
+ try {
33
+ decodedPath = decodeURIComponent(pathname);
34
+ } catch {
35
+ return null;
36
+ }
37
+ if (decodedPath.includes("\0")) return null;
38
+
39
+ const relativePath = decodedPath === "/" || decodedPath === ""
40
+ ? "index.html"
41
+ : decodedPath.replace(/\\/g, "/").replace(/^\/+/, "");
42
+ const root = resolve(guiDist);
43
+ const filePath = resolve(root, relativePath);
44
+ const rel = relative(root, filePath);
45
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null;
46
+ return filePath;
47
+ }
48
+
49
+ function isFile(path: string): boolean {
50
+ try {
51
+ return statSync(path).isFile();
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ export function serveGuiFile(pathname: string): Response | null {
58
+ const guiDist = findGuiDist();
59
+ if (!guiDist) return null;
60
+ const filePath = resolveGuiFilePath(guiDist, pathname);
61
+ if (!filePath) return null;
62
+
63
+ if (!isFile(filePath)) {
64
+ if (!extname(pathname)) {
65
+ const indexPath = join(guiDist, "index.html");
66
+ if (isFile(indexPath)) {
67
+ return new Response(Bun.file(indexPath), {
68
+ headers: { "Content-Type": "text/html" },
69
+ });
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+
75
+ const ext = extname(filePath);
76
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
77
+ return new Response(Bun.file(filePath), {
78
+ headers: { "Content-Type": contentType },
79
+ });
80
+ }
81
+
82
+ export function rootFallbackPayload() {
83
+ return {
84
+ status: "ok",
85
+ service: "opencodex",
86
+ version: VERSION,
87
+ dashboard: {
88
+ available: false,
89
+ reason: "GUI build not found. Run `bun run build:gui` from the opencodex repo, or use `ocx gui` from a packaged install.",
90
+ },
91
+ endpoints: {
92
+ health: "/healthz",
93
+ models: "/v1/models",
94
+ responses: "/v1/responses",
95
+ management: "/api/*",
96
+ },
97
+ };
98
+ }