@bitkyc08/opencodex 2.15.1 → 2.17.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 (41) hide show
  1. package/gui/dist/assets/{index-CMCDkQ7U.js → index-DOKr6RBR.js} +10 -10
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/base.ts +2 -0
  5. package/src/adapters/google-antigravity-replay.ts +9 -1
  6. package/src/adapters/kiro-thinking.ts +8 -0
  7. package/src/adapters/kiro.ts +45 -42
  8. package/src/adapters/openai-chat.ts +5 -2
  9. package/src/adapters/openai-responses.ts +5 -1
  10. package/src/cli/dispatch.ts +6 -3
  11. package/src/cli/export-command.ts +19 -7
  12. package/src/cli/help.ts +1 -1
  13. package/src/cli/index.ts +1 -0
  14. package/src/cli/registry.ts +2 -2
  15. package/src/clients/config-export.ts +165 -3
  16. package/src/generated/compatibility-version.json +59 -31
  17. package/src/integrations/config-io.ts +119 -1
  18. package/src/integrations/omp-yaml-source.ts +232 -99
  19. package/src/integrations/registry.ts +14 -0
  20. package/src/integrations/serialize.ts +80 -1
  21. package/src/integrations/state.ts +38 -6
  22. package/src/integrations/writer-lock.ts +98 -0
  23. package/src/integrations/writer.ts +152 -19
  24. package/src/lab/automation/orchestrator.ts +19 -0
  25. package/src/lib/lab-activation.ts +161 -0
  26. package/src/lib/lab-passive-linker-registration.ts +26 -0
  27. package/src/lib/optional-shutdown-hooks.ts +57 -0
  28. package/src/lib/shadow-call.ts +6 -14
  29. package/src/lib/translator-budget.ts +34 -0
  30. package/src/providers/antigravity-models.ts +65 -10
  31. package/src/routing/compatibility/assemble.ts +21 -107
  32. package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
  33. package/src/routing/compatibility/provider-slot.ts +56 -0
  34. package/src/server/index.ts +8 -17
  35. package/src/server/lifecycle.ts +5 -3
  36. package/src/server/management/integration-routes.ts +21 -14
  37. package/src/server/management/routing-profile-routes.ts +9 -1
  38. package/src/server/management-api.ts +37 -6
  39. package/src/server/passive-route-linker.ts +66 -0
  40. package/src/server/responses/core.ts +20 -21
  41. package/src/types.ts +15 -5
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-CMCDkQ7U.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-DOKr6RBR.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-DUCH59lJ.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.15.1",
3
+ "version": "2.17.0",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -46,6 +46,8 @@ export interface AdapterRequest {
46
46
  method: string;
47
47
  headers: Record<string, string>;
48
48
  body: string;
49
+ /** Custom-tool names actually lowered to upstream function calls while building this request. */
50
+ convertedRoutedCustomToolNames?: ReadonlySet<string>;
49
51
  /** Releases observation of a serialized request body after its final fetch attempt settles. */
50
52
  releaseBodyObservation?: () => void;
51
53
  /** Exact reasoning parameter emitted by the adapter, for request-log diagnostics only. */
@@ -522,7 +522,15 @@ function refreshReplaySessionCandidate(key: string, entry: ReplayEntry): void {
522
522
  }
523
523
 
524
524
  function deleteExpiredReplaySessions(now: number): void {
525
- for (const [key, entry] of replayCache) if (entry.expiresAtMs <= now) deleteReplaySession(key);
525
+ let deleted = false;
526
+ for (const [key, entry] of replayCache) {
527
+ if (entry.expiresAtMs > now) continue;
528
+ deleteReplaySession(key);
529
+ deleted = true;
530
+ }
531
+ // Expiry is a durable mutation too: rewrite the snapshot so opaque thought
532
+ // signatures do not remain at rest after their in-memory TTL has elapsed.
533
+ if (deleted) markReplayDirty();
526
534
  }
527
535
 
528
536
  /**
@@ -75,6 +75,14 @@ export class KiroThinkingParser {
75
75
  return [];
76
76
  }
77
77
 
78
+ /** Release any partial tag/content carry when the owning stream stops early. */
79
+ dispose(): void {
80
+ this.replaceCarry("preBuffer", "");
81
+ this.replaceCarry("thinkingBuffer", "");
82
+ this.closeTag = "";
83
+ this.state = "streaming";
84
+ }
85
+
78
86
  private drainThinking(): AdapterEvent[] {
79
87
  const close = this.closeTag;
80
88
  const idx = this.thinkingBuffer.indexOf(close);
@@ -19,6 +19,8 @@ import { createKiroToolNameRegistry, fallbackToolUseId, fingerprint, invocationI
19
19
  import { namespacedToolName } from "../types";
20
20
  import {
21
21
  isTranslatorBudgetExceededError,
22
+ releaseTranslatedEvent,
23
+ retainTranslatedEvent,
22
24
  type TranslatorBudget,
23
25
  } from "../lib/translator-budget";
24
26
  import type {
@@ -636,8 +638,8 @@ export function buildKiroPayload(
636
638
 
637
639
  // Stream parsing (shared by parseStream + parseResponse)
638
640
  // CodeWhisperer GenerateAssistantResponse ALWAYS returns an AWS eventstream body (there is no
639
- // non-streaming mode), so both the streaming bridge and the non-streaming web-search sidecar loop
640
- // decode the same way — parseResponse just collects what parseStream yields.
641
+ // non-streaming wire mode), so the streaming bridge and non-streaming Responses path decode the
642
+ // same way — parseResponse just collects what parseStream yields.
641
643
  interface KiroAttemptParseResult {
642
644
  terminal?: AdapterEvent;
643
645
  needsFallback?: boolean;
@@ -859,16 +861,12 @@ async function* parseKiroAttempt(
859
861
  );
860
862
  let handedOff = false;
861
863
  try {
862
- let next = await attempt.next();
863
- while (!next.done) {
864
- yield next.value;
865
- next = await attempt.next();
866
- }
864
+ const result = yield* attempt;
867
865
  for (const event of deferred.splice(0)) {
868
866
  try { yield event; } finally { retention.releaseEvent(event); }
869
867
  }
870
868
  handedOff = true;
871
- return { ...next.value, releaseRetained: () => retention.releaseAll() };
869
+ return { ...result, releaseRetained: () => retention.releaseAll() };
872
870
  } finally {
873
871
  if (!handedOff) retention.releaseAll();
874
872
  }
@@ -897,6 +895,12 @@ async function* parseKiroAttemptEvents(
897
895
  }
898
896
 
899
897
  let open: { id: string; name: string; chunks: string[]; completion: boolean } | null = null;
898
+ let openCallId: string | undefined;
899
+ const closeOpenCall = () => {
900
+ if (!openCallId) return;
901
+ budget.closeCall(openCallId);
902
+ openCallId = undefined;
903
+ };
900
904
  let outputChars = "";
901
905
  let outputCharsBytes = 0;
902
906
  let contextUsagePercentage: number | undefined;
@@ -1109,7 +1113,7 @@ async function* parseKiroAttemptEvents(
1109
1113
  if (!open) return { events: [] };
1110
1114
  const tool = open;
1111
1115
  open = null;
1112
- budget.closeCall(tool.id);
1116
+ closeOpenCall();
1113
1117
  const input = tool.chunks.join("");
1114
1118
  if (!isCompleteKiroToolInput(input)) {
1115
1119
  return { events: [], terminal: protocolTerminal(kiroTruncationErrorMessage("incomplete tool input JSON"), tool.completion) };
@@ -1221,11 +1225,12 @@ async function* parseKiroAttemptEvents(
1221
1225
  if (started.terminal) return { assistantText, sawReasoning, terminal: started.terminal };
1222
1226
  open = started.tool!;
1223
1227
  budget.openCall(open.id);
1228
+ openCallId = open.id;
1224
1229
  } else if (
1225
1230
  (ev.toolUseId && ev.toolUseId !== open.id)
1226
1231
  || (ev.name && open.name !== "unknown" && ev.name !== open.name)
1227
1232
  ) {
1228
- budget.closeCall(open.id);
1233
+ closeOpenCall();
1229
1234
  open = null;
1230
1235
  return { assistantText, sawReasoning, terminal: protocolTerminal(kiroTruncationErrorMessage("tool input changed identity before stop")) };
1231
1236
  }
@@ -1489,7 +1494,7 @@ async function* parseKiroAttemptEvents(
1489
1494
  };
1490
1495
  } catch (err) {
1491
1496
  if (isTranslatorBudgetExceededError(err)) {
1492
- if (open) budget.closeCall(open.id);
1497
+ closeOpenCall();
1493
1498
  return {
1494
1499
  assistantText,
1495
1500
  sawReasoning,
@@ -1531,6 +1536,9 @@ async function* parseKiroAttemptEvents(
1531
1536
  usage: usage(),
1532
1537
  },
1533
1538
  };
1539
+ } finally {
1540
+ thinking.dispose();
1541
+ closeOpenCall();
1534
1542
  }
1535
1543
  }
1536
1544
 
@@ -1547,7 +1555,7 @@ export async function* parseKiroStream(
1547
1555
  contextInputEstimate?: number,
1548
1556
  ): AsyncGenerator<AdapterEvent> {
1549
1557
  const contextWindowState: KiroContextWindowState = { value: contextWindow };
1550
- const first = parseKiroAttempt(
1558
+ const firstResult = yield* parseKiroAttempt(
1551
1559
  response,
1552
1560
  budget,
1553
1561
  completionMode,
@@ -1559,12 +1567,6 @@ export async function* parseKiroStream(
1559
1567
  contextInputEstimate,
1560
1568
  false,
1561
1569
  );
1562
- let firstNext = await first.next();
1563
- while (!firstNext.done) {
1564
- yield firstNext.value;
1565
- firstNext = await first.next();
1566
- }
1567
- const firstResult = firstNext.value;
1568
1570
  try {
1569
1571
  if (!firstResult.needsFallback) {
1570
1572
  if (firstResult.terminal) yield firstResult.terminal;
@@ -1642,7 +1644,7 @@ export async function* parseKiroStream(
1642
1644
  return;
1643
1645
  }
1644
1646
 
1645
- const second = parseKiroAttempt(
1647
+ const secondResult = yield* parseKiroAttempt(
1646
1648
  fallback.response,
1647
1649
  budget,
1648
1650
  "text_fallback",
@@ -1656,12 +1658,6 @@ export async function* parseKiroStream(
1656
1658
  // A zero-output transport failure here must stay non-retryable to avoid duplicating that text.
1657
1659
  priorEmittedOutput,
1658
1660
  );
1659
- let secondNext = await second.next();
1660
- while (!secondNext.done) {
1661
- yield secondNext.value;
1662
- secondNext = await second.next();
1663
- }
1664
- const secondResult = secondNext.value;
1665
1661
  try {
1666
1662
  if (!secondResult.terminal) {
1667
1663
  yield retryableKiroIncomplete(
@@ -1909,25 +1905,32 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
1909
1905
  return safeKiroHttpErrorMessage(status, headers, payloadText);
1910
1906
  },
1911
1907
 
1912
- // Non-streaming path used by the web-search sidecar loop (loop.ts runs each iteration
1913
- // non-streamed so it can inspect tool calls). CW only ever event-streams, so we drain the
1914
- // same decoder into an array. Without this, any Codex request that includes the web_search
1915
- // tool failed with "web-search sidecar requires a non-streaming adapter" (kiro-only).
1908
+ // Kiro always returns an event stream, including for non-streaming Responses requests. Drain
1909
+ // the decoder into a budget-owned batch so an upstream stream cannot grow this array without
1910
+ // bound while the caller waits for the complete JSON response.
1916
1911
  async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
1917
1912
  const events: AdapterEvent[] = [];
1918
- for await (const e of parseKiroStream(
1919
- response,
1920
- budget,
1921
- modelId,
1922
- inputTokens,
1923
- contextWindow,
1924
- toolNameMap,
1925
- conversationId,
1926
- completionMode,
1927
- completionMode === "required" ? fallbackFactory : undefined,
1928
- contextInputEstimate,
1929
- )) events.push(e);
1930
- return events;
1913
+ try {
1914
+ for await (const e of parseKiroStream(
1915
+ response,
1916
+ budget,
1917
+ modelId,
1918
+ inputTokens,
1919
+ contextWindow,
1920
+ toolNameMap,
1921
+ conversationId,
1922
+ completionMode,
1923
+ completionMode === "required" ? fallbackFactory : undefined,
1924
+ contextInputEstimate,
1925
+ )) {
1926
+ retainTranslatedEvent(e, budget, events.at(-1));
1927
+ events.push(e);
1928
+ }
1929
+ return events;
1930
+ } catch (error) {
1931
+ for (const event of events) releaseTranslatedEvent(event, budget);
1932
+ throw error;
1933
+ }
1931
1934
  },
1932
1935
  };
1933
1936
  }
@@ -968,8 +968,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
968
968
  if (provider.parallelToolCalls === false) {
969
969
  // NIM documents the Boolean defaulting to false and kimi rejects true; pin the
970
970
  // wire bit so Codex cannot opt in via request.options. Other opted-out providers
971
- // omit the field so strict OpenAI-compatible hosts never see an unsupported knob.
972
- if (provider.baseUrl === "https://integrate.api.nvidia.com/v1") {
971
+ // omit the field by default so strict OpenAI-compatible hosts never see an
972
+ // unsupported knob, but a self-hosted gateway that DOES honor the field and keeps
973
+ // emitting parallel calls without it can opt in via pinParallelToolCallsFalse.
974
+ if (provider.baseUrl === "https://integrate.api.nvidia.com/v1"
975
+ || provider.pinParallelToolCallsFalse === true) {
973
976
  body.parallel_tool_calls = false;
974
977
  }
975
978
  } else if (provider.parallelToolCalls === true) {
@@ -1361,6 +1361,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1361
1361
  }
1362
1362
 
1363
1363
  const forward = provider.authMode === "forward";
1364
+ let convertedRoutedCustomToolNames: Set<string> | undefined;
1364
1365
  const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true;
1365
1366
  let outBody = stripPreviousResponseId(
1366
1367
  parsed._rawBody,
@@ -1408,7 +1409,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1408
1409
  outBody = promoteClientLoadedTools(outBody);
1409
1410
  }
1410
1411
  if (provider.authMode !== "forward") {
1411
- outBody = rewriteRoutedCustomToolsForUpstream(outBody).body;
1412
+ const rewritten = rewriteRoutedCustomToolsForUpstream(outBody);
1413
+ outBody = rewritten.body;
1414
+ convertedRoutedCustomToolNames = rewritten.names;
1412
1415
  }
1413
1416
  const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true })))))));
1414
1417
  const body = JSON.stringify(stripDisabledReasoningSummaries(
@@ -1426,6 +1429,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1426
1429
  headers,
1427
1430
  body,
1428
1431
  releaseBodyObservation,
1432
+ ...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}),
1429
1433
  };
1430
1434
  },
1431
1435
 
@@ -21,7 +21,6 @@ import { restoreNativeCodexAsync } from "../codex/inject";
21
21
  import { stripGrokConfig } from "../grok/inject";
22
22
  import { afterCatalogWriteHandleAppServers } from "../codex/app-server-processes";
23
23
  import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
24
- import { serviceCommand } from "../service";
25
24
 
26
25
  export interface CliDispatchDeps {
27
26
  args: string[];
@@ -45,6 +44,7 @@ export interface CliDispatchDeps {
45
44
  handleStatus: () => Promise<void>;
46
45
  handleRecoverHistory: () => Promise<void>;
47
46
  handleReady: (args: ReadyArgs) => Promise<number>;
47
+ serviceCommand: (...args: string[]) => Promise<void>;
48
48
  }
49
49
 
50
50
  type CommandRunner = (deps: CliDispatchDeps) => Promise<number>;
@@ -261,8 +261,11 @@ const commandRunners: Record<string, CommandRunner> = {
261
261
  return 0;
262
262
  },
263
263
  service: async deps => {
264
- await serviceCommand(...deps.args.slice(1));
265
- return 0;
264
+ process.exitCode = 0;
265
+ await deps.serviceCommand(...deps.args.slice(1));
266
+ // serviceCommand uses process.exitCode for recoverable install/stop failures
267
+ // that must finish cleanup before the single top-level process.exit runs.
268
+ return Number(process.exitCode ?? 0);
266
269
  },
267
270
  tray: async deps => {
268
271
  const { windowsTrayCommand } = await import("../tray/windows");
@@ -62,7 +62,11 @@ export interface ExportCommandDeps extends RuntimeApiDeps {
62
62
  * `/api/models` row plus the modality list Pi consumes. The launcher's row type predates
63
63
  * the Pi exporter and stops at the fields OpenCode needs.
64
64
  */
65
- type ExportProxyModelRow = OpencodeProxyModelRow & { inputModalities?: string[] };
65
+ type ExportProxyModelRow = OpencodeProxyModelRow & {
66
+ inputModalities?: string[];
67
+ reasoningEfforts?: string[];
68
+ defaultReasoningEffort?: string;
69
+ };
66
70
 
67
71
  /** Same authoritativeness rule the serializers apply, for the degraded-count line. */
68
72
  function hasContextLimit(model: ExportModel): boolean {
@@ -83,12 +87,21 @@ export function exportModelsFromProxyRows(
83
87
  rows: readonly ExportProxyModelRow[],
84
88
  config: OcxConfig,
85
89
  ): ExportModel[] {
86
- const modalities = new Map<string, string[]>();
90
+ const metadata = new Map<string, Pick<ExportModel, "inputModalities" | "reasoningEfforts" | "defaultReasoningEffort">>();
87
91
  for (const row of rows) {
88
92
  const namespaced = row.namespaced?.trim();
89
- if (namespaced && Array.isArray(row.inputModalities) && row.inputModalities.length > 0) {
90
- if (!modalities.has(namespaced)) modalities.set(namespaced, [...row.inputModalities]);
91
- }
93
+ if (!namespaced || metadata.has(namespaced)) continue;
94
+ metadata.set(namespaced, {
95
+ ...(Array.isArray(row.inputModalities) && row.inputModalities.length > 0
96
+ ? { inputModalities: [...row.inputModalities] }
97
+ : {}),
98
+ ...(Array.isArray(row.reasoningEfforts) && row.reasoningEfforts.length > 0
99
+ ? { reasoningEfforts: [...row.reasoningEfforts] }
100
+ : {}),
101
+ ...(typeof row.defaultReasoningEffort === "string" && row.defaultReasoningEffort.length > 0
102
+ ? { defaultReasoningEffort: row.defaultReasoningEffort }
103
+ : {}),
104
+ });
92
105
  }
93
106
  return opencodeCatalogFromProxyRows(rows, config).map(entry => {
94
107
  const model: ExportModel = {
@@ -99,8 +112,7 @@ export function exportModelsFromProxyRows(
99
112
  if (entry.native) model.native = true;
100
113
  if (entry.displayName) model.displayName = entry.displayName;
101
114
  if (entry.contextWindow !== undefined) model.contextWindow = entry.contextWindow;
102
- const input = modalities.get(entry.namespaced);
103
- if (input) model.inputModalities = input;
115
+ Object.assign(model, metadata.get(entry.namespaced));
104
116
  return model;
105
117
  });
106
118
  }
package/src/cli/help.ts CHANGED
@@ -58,7 +58,7 @@ Usage:
58
58
  ocx memory [--json] Alias of ocx observe memory
59
59
  ocx api-key <sub> Alias of ocx access key
60
60
  ocx access <sub> External API keys and endpoint information
61
- ocx export --client <id> Print a client config wired to the running proxy (7 clients)
61
+ ocx export --client <id> Print a client config wired to the running proxy (8 clients)
62
62
  ocx integration client <sub> Enable, disable, inspect or roll back a client integration
63
63
  ocx grok <sub> Grok Build model selection and apply
64
64
  ocx system <sub> Runtime settings, startup, sync, and updates
package/src/cli/index.ts CHANGED
@@ -941,4 +941,5 @@ process.exit(await dispatchCommand(head, {
941
941
  handleStatus,
942
942
  handleRecoverHistory,
943
943
  handleReady,
944
+ serviceCommand,
944
945
  }));
@@ -216,8 +216,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
216
216
  { name: "api-key", usage: "ocx api-key <list|create|remove> ...", summary: "Alias of ocx access key." },
217
217
  {
218
218
  name: "export",
219
- usage: "ocx export --client <opencode|pi|omp|hermes|openclaw|kimi|gajae> [--json] [--out <path>] [--force]",
220
- summary: "Print a client config (opencode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code) wired to the running proxy.",
219
+ usage: "ocx export --client <opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh> [--json] [--out <path>] [--force]",
220
+ summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness) wired to the running proxy.",
221
221
  details: [
222
222
  "--json prints the generated document as JSON on stdout; use --out for the client's native format.",
223
223
  "--out <path> writes the native config there and refuses to replace an existing file without --force.",
@@ -21,9 +21,10 @@
21
21
  */
22
22
  import { homedir } from "node:os";
23
23
  import { existsSync } from "node:fs";
24
- import { isAbsolute, join } from "node:path";
24
+ import { isAbsolute, join, resolve } from "node:path";
25
25
  import { shouldInjectApiAuthHeader } from "../codex/inject";
26
26
  import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize";
27
+ import { providerCodexAccountMode } from "../providers/registry";
27
28
  import { probeHostname } from "../server/proxy-liveness";
28
29
  import type { OcxConfig } from "../types";
29
30
 
@@ -376,6 +377,28 @@ export function gajaeConfigPath(env: OpencodeLaunchEnv = process.env, home: stri
376
377
  return join(gajaeHomeDir(env, home), "agent", "models.yml");
377
378
  }
378
379
 
380
+ /** DSH_HOME uses the raw nonblank value; trimming it would name a different path. */
381
+ export function dshHomeDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
382
+ const raw = env.DSH_HOME;
383
+ if (raw === undefined || raw.trim().length === 0) return join(home, ".dsh");
384
+ if (raw === "~") return home;
385
+ if (raw.startsWith("~/") || raw.startsWith("~\\")) return join(home, raw.slice(2));
386
+ if (!isAbsolute(raw)) {
387
+ throw new ClientPathError(
388
+ `DSH_HOME must be an absolute path or start with ~; "${raw}" depends on the working directory, `
389
+ + "so opencodex and DSH would disagree about which settings file it names.",
390
+ );
391
+ }
392
+ // DSH calls node:path.resolve after tilde expansion. Preserve the raw value
393
+ // for the decision above, then normalize the absolute spelling the same way
394
+ // so both processes bind ownership and locks to one path string.
395
+ return resolve(raw);
396
+ }
397
+
398
+ export function dshConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
399
+ return join(dshHomeDir(env, home), "settings.yaml");
400
+ }
401
+
379
402
  /**
380
403
  * One proxy-routed model destined for a client config. Deliberately narrower than
381
404
  * `CatalogModel` so a serializer cannot reach for a field that does not survive the
@@ -414,7 +437,8 @@ export type ExportClientId =
414
437
  | "hermes"
415
438
  | "openclaw"
416
439
  | "kimi"
417
- | "gajae";
440
+ | "gajae"
441
+ | "dsh";
418
442
 
419
443
  export interface ExportClientSpec {
420
444
  id: ExportClientId;
@@ -463,7 +487,8 @@ export interface ExportClientSpec {
463
487
  */
464
488
  function authoritativeContextWindow(contextWindow: number | undefined): number | undefined {
465
489
  if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) {
466
- return Math.floor(contextWindow);
490
+ const integer = Math.floor(contextWindow);
491
+ return integer > 0 ? integer : undefined;
467
492
  }
468
493
  return undefined;
469
494
  }
@@ -522,6 +547,18 @@ function inputModalitiesForClient(
522
547
  return kept.length > 0 ? kept : null;
523
548
  }
524
549
 
550
+ /** DSH rc.6 accepts text/image; unknown values degrade to text, while audio-only cannot be represented. */
551
+ function dshInputModalities(modalities: readonly string[] | undefined): string[] | null {
552
+ const declared = modalities ?? [];
553
+ if (declared.length === 0) return ["text"];
554
+ const kept: string[] = [];
555
+ for (const value of declared) {
556
+ if ((value === "text" || value === "image") && !kept.includes(value)) kept.push(value);
557
+ }
558
+ if (kept.length > 0) return kept;
559
+ return declared.every(value => value === "audio") ? null : ["text"];
560
+ }
561
+
525
562
  /**
526
563
  * Label shared by every client: `"<displayName|id> (<native|provider|routed>)"`. The
527
564
  * provider suffix is what makes two same-named models from different upstreams
@@ -770,6 +807,31 @@ export interface GajaeGeneratedConfig {
770
807
  providers: Record<string, GajaeProviderBlock>;
771
808
  }
772
809
 
810
+ export type DshReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max";
811
+ export type DshWireReasoningEffort = DshReasoningEffort | "ultra";
812
+
813
+ export interface DshModelEntry {
814
+ id: string;
815
+ name: string;
816
+ input: string[];
817
+ contextWindow?: number;
818
+ reasoningEfforts?: Partial<Record<DshReasoningEffort, DshWireReasoningEffort>>;
819
+ }
820
+
821
+ export interface DshProviderBlock {
822
+ displayName: "OpenCodex";
823
+ api: "openai-responses";
824
+ baseURL: string;
825
+ headers: { Authorization: "Bearer ocx_data_dsh" };
826
+ models: DshModelEntry[];
827
+ }
828
+
829
+ export interface DshGeneratedConfig {
830
+ "llm-pi-ai": {
831
+ providers: Record<string, DshProviderBlock>;
832
+ };
833
+ }
834
+
773
835
  /**
774
836
  * Pi's `~/.pi/agent/models.json` shape. `models` is an ARRAY (identity lives in `id`),
775
837
  * unlike OpenCode's keyed object.
@@ -975,6 +1037,84 @@ function buildGajaeClientConfig(ctx: ExportContext): GajaeGeneratedConfig {
975
1037
  };
976
1038
  }
977
1039
 
1040
+ const DSH_EFFORT_ORDER: readonly DshReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"];
1041
+
1042
+ function dshReasoningEfforts(model: ExportModel): DshModelEntry["reasoningEfforts"] {
1043
+ const offered = new Set<string>();
1044
+ for (const raw of model.reasoningEfforts ?? []) {
1045
+ const effort = raw.trim().toLowerCase();
1046
+ if (effort === "ultra" || DSH_EFFORT_ORDER.includes(effort as DshReasoningEffort)) offered.add(effort);
1047
+ }
1048
+ if (offered.size === 0) return undefined;
1049
+ const entries: Array<[DshReasoningEffort, DshWireReasoningEffort]> = [];
1050
+ for (const effort of DSH_EFFORT_ORDER) {
1051
+ if (effort !== "max") {
1052
+ if (offered.has(effort)) entries.push([effort, effort]);
1053
+ continue;
1054
+ }
1055
+ // DSH's key is the selectable level; the value is what it sends on the
1056
+ // wire. Preserve OpenCodex's `ultra` spelling when that is the only
1057
+ // highest effort, exactly like the rc.6 `max: ultra` contract.
1058
+ if (offered.has("max")) entries.push(["max", "max"]);
1059
+ else if (offered.has("ultra")) entries.push(["max", "ultra"]);
1060
+ }
1061
+ return Object.fromEntries(entries);
1062
+ }
1063
+
1064
+ function isKnownSafeDshCombo(model: ExportModel, config: OcxConfig): boolean {
1065
+ const combos = (config as { combos?: unknown }).combos;
1066
+ if (typeof combos !== "object" || combos === null || Array.isArray(combos)) return false;
1067
+ const combo = (combos as Record<string, unknown>)[model.id];
1068
+ if (typeof combo !== "object" || combo === null || Array.isArray(combo)) return false;
1069
+ const targets = (combo as { targets?: unknown }).targets;
1070
+ if (!Array.isArray(targets) || targets.length === 0) return false;
1071
+ return targets.every(target => {
1072
+ if (typeof target !== "object" || target === null || Array.isArray(target)) return false;
1073
+ const provider = (target as { provider?: unknown }).provider;
1074
+ const modelId = (target as { model?: unknown }).model;
1075
+ return typeof provider === "string"
1076
+ && provider.length > 0
1077
+ && provider === provider.trim()
1078
+ && provider !== "openai"
1079
+ && typeof modelId === "string"
1080
+ && modelId.length > 0
1081
+ && modelId === modelId.trim();
1082
+ });
1083
+ }
1084
+
1085
+ function buildDshClientConfig(ctx: ExportContext): DshGeneratedConfig {
1086
+ const direct = providerCodexAccountMode("openai", ctx.config?.providers?.openai) === "direct";
1087
+ const models: DshModelEntry[] = [];
1088
+ for (const model of normalizeExportModels(ctx.models)) {
1089
+ if (direct && (model.native === true || model.provider === "openai")) continue;
1090
+ if (direct && model.provider === "combo" && (!ctx.config || !isKnownSafeDshCombo(model, ctx.config))) continue;
1091
+ const input = dshInputModalities(model.inputModalities);
1092
+ if (input === null) continue;
1093
+ const contextWindow = authoritativeContextWindow(model.contextWindow);
1094
+ const reasoningEfforts = dshReasoningEfforts(model);
1095
+ models.push({
1096
+ id: model.namespaced,
1097
+ name: exportModelLabel(model),
1098
+ input,
1099
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
1100
+ ...(reasoningEfforts ? { reasoningEfforts } : {}),
1101
+ });
1102
+ }
1103
+ return {
1104
+ "llm-pi-ai": {
1105
+ providers: {
1106
+ [OPENCODE_PROVIDER_ID]: {
1107
+ displayName: "OpenCodex",
1108
+ api: "openai-responses",
1109
+ baseURL: ctx.baseUrl,
1110
+ headers: { Authorization: "Bearer ocx_data_dsh" },
1111
+ models,
1112
+ },
1113
+ },
1114
+ },
1115
+ };
1116
+ }
1117
+
978
1118
  /**
979
1119
  * Per-client model counts, read back off the SERIALIZED document rather than
980
1120
  * recomputed from the input rows: `modelsWithoutLimits` drives a GUI line about
@@ -1019,6 +1159,11 @@ function summarizeGajae(document: unknown): { modelCount: number; modelsWithoutL
1019
1159
  return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length };
1020
1160
  }
1021
1161
 
1162
+ function summarizeDsh(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
1163
+ const models = (document as DshGeneratedConfig | undefined)?.["llm-pi-ai"]?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? [];
1164
+ return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length };
1165
+ }
1166
+
1022
1167
  /** One fragment at `path`, built from this client's own document. */
1023
1168
  function singleFragment(clientId: ExportClientId, path: readonly string[], value: unknown): ManagedContribution {
1024
1169
  return { clientId, fragments: [{ path, value }] };
@@ -1070,6 +1215,11 @@ function buildGajaeContribution(ctx: ExportContext): ManagedContribution {
1070
1215
  return singleFragment("gajae", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
1071
1216
  }
1072
1217
 
1218
+ function buildDshContribution(ctx: ExportContext): ManagedContribution {
1219
+ const doc = buildDshClientConfig(ctx);
1220
+ return singleFragment("dsh", ["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID], doc["llm-pi-ai"].providers[OPENCODE_PROVIDER_ID]);
1221
+ }
1222
+
1073
1223
  export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
1074
1224
  opencode: {
1075
1225
  id: "opencode",
@@ -1167,6 +1317,18 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
1167
1317
  // strict schema with no header field, so the dedicated header has nowhere to go
1168
1318
  loopbackOnly: true,
1169
1319
  },
1320
+ dsh: {
1321
+ id: "dsh",
1322
+ filename: "settings.yaml",
1323
+ destination: env => dshConfigPath(env),
1324
+ apiKeyEnv: "",
1325
+ exportHint: "DSH uses a non-secret loopback bearer placeholder in settings.yaml; loopback needs no key.",
1326
+ build: buildDshClientConfig,
1327
+ format: "yaml",
1328
+ summarize: summarizeDsh,
1329
+ buildContribution: buildDshContribution,
1330
+ loopbackOnly: true,
1331
+ },
1170
1332
  };
1171
1333
 
1172
1334
  export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[];