@bitkyc08/opencodex 2.67.0 → 2.68.0-preview.20260927
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/bin/ocx.mjs +3 -0
- package/gui/dist/assets/App-B5-cAEd2.js +51 -0
- package/gui/dist/assets/App-DCBismRi.css +1 -0
- package/gui/dist/assets/Tray-DFnMiyD0.js +1 -0
- package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
- package/gui/dist/assets/index-BcXblvem.js +86 -0
- package/gui/dist/assets/quota-summary-i84eOU3o.js +1 -0
- package/gui/dist/index.html +21 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-guard.ts +13 -1
- package/src/adapters/anthropic-image-normalize.ts +3 -2
- package/src/adapters/anthropic-output-schema.ts +36 -0
- package/src/adapters/anthropic.ts +10 -2
- package/src/adapters/base.ts +7 -0
- package/src/adapters/codebuddy/live-models.ts +180 -0
- package/src/adapters/coding-agent/protocol.ts +91 -12
- package/src/adapters/coding-agent/turn.ts +32 -27
- package/src/adapters/devin/cloud-direct/index.ts +1 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
- package/src/adapters/devin.ts +9 -6
- package/src/adapters/google-http.ts +4 -4
- package/src/adapters/google.ts +93 -3
- package/src/adapters/kiro/adapter.ts +6 -2
- package/src/adapters/kiro/stream.ts +12 -2
- package/src/adapters/kiro/usage.ts +3 -1
- package/src/adapters/kiro-errors.ts +11 -1
- package/src/adapters/kiro-events.ts +27 -1
- package/src/adapters/kiro-refusal.ts +30 -0
- package/src/adapters/kiro-retry.ts +71 -31
- package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
- package/src/adapters/openai-chat/messages.ts +31 -4
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
- package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
- package/src/adapters/openai-chat/tool-schema.ts +6 -2
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
- package/src/adapters/openai-responses/passthrough.ts +9 -3
- package/src/adapters/opencode-go-additional-tools.ts +19 -10
- package/src/adapters/physical-send.ts +10 -3
- package/src/adapters/registry.ts +2 -1
- package/src/adapters/responses-tool-schema.ts +31 -3
- package/src/adapters/run-turn-queue.ts +63 -23
- package/src/adapters/unique-tool-call-ids.ts +63 -0
- package/src/adapters/xai-web-search.ts +32 -2
- package/src/bridge/sse.ts +14 -2
- package/src/chat/inbound.ts +20 -6
- package/src/claude/agents-inject.ts +4 -0
- package/src/claude/desktop-3p.ts +53 -13
- package/src/claude/desktop-profile.ts +41 -4
- package/src/claude/inbound-model-options.ts +14 -2
- package/src/claude/inbound.ts +1 -1
- package/src/claude/intercept/connect-proxy.ts +17 -1
- package/src/claude/intercept/local-ca.ts +7 -1
- package/src/claude/message-threads.ts +28 -0
- package/src/cli/account-api.ts +13 -0
- package/src/cli/account-auth.ts +49 -2
- package/src/cli/account-extended.ts +4 -2
- package/src/cli/account.ts +4 -2
- package/src/cli/capabilities.ts +17 -0
- package/src/cli/claude.ts +3 -1
- package/src/cli/dispatch.ts +62 -5
- package/src/cli/index.ts +84 -79
- package/src/cli/minimax.ts +4 -2
- package/src/cli/opencode.ts +6 -1
- package/src/cli/restart-handoff.ts +110 -0
- package/src/cli/status.ts +51 -0
- package/src/client/connect.ts +34 -15
- package/src/client/link-ingress.ts +102 -0
- package/src/client/link-join.ts +37 -16
- package/src/client/link-relay.ts +228 -45
- package/src/client/link-state.ts +54 -0
- package/src/client/link-status.ts +39 -0
- package/src/client/link-teardown.ts +2 -2
- package/src/client/link-tunnel.ts +615 -86
- package/src/client/machine-api.ts +10 -4
- package/src/client/machine-listener.ts +57 -10
- package/src/client/runtime.ts +210 -35
- package/src/clients/config-export/constants.ts +1 -12
- package/src/clients/config-export/contracts.ts +2 -0
- package/src/clients/config-export/model-metadata.ts +9 -3
- package/src/clients/config-export/omp.ts +1 -1
- package/src/clients/config-export/zcode-store.ts +2 -2
- package/src/clients/config-export.ts +36 -3
- package/src/codex/app-server-processes.ts +32 -0
- package/src/codex/app-server-restart-service.ts +20 -3
- package/src/codex/catalog/access-programs.ts +50 -0
- package/src/codex/catalog/build-entries.ts +13 -0
- package/src/codex/catalog/derive-entry.ts +1 -0
- package/src/codex/catalog/model-hints.ts +11 -0
- package/src/codex/catalog/parsing.ts +6 -2
- package/src/codex/catalog/provider-models.ts +81 -0
- package/src/codex/catalog/retained-sync.ts +3 -0
- package/src/codex/codex-write-lock.ts +2 -2
- package/src/codex/convergence.ts +2 -0
- package/src/codex/desired-state.ts +14 -4
- package/src/codex/home.ts +21 -3
- package/src/codex/inject/restore.ts +17 -0
- package/src/codex/inject/routing-classify.ts +3 -2
- package/src/codex/inject-coordination.ts +4 -1
- package/src/codex/inject.ts +8 -1
- package/src/codex/journal.ts +6 -2
- package/src/codex/management-convergence.ts +9 -0
- package/src/codex/model-entitlements.ts +28 -5
- package/src/codex/routing/idle-window.ts +58 -0
- package/src/codex/routing-drift.ts +134 -0
- package/src/codex/routing-healer.ts +419 -0
- package/src/codex/routing.ts +10 -0
- package/src/codex/runtime.ts +188 -57
- package/src/codex/sibling-handoff.ts +71 -0
- package/src/codex/sibling-start.ts +139 -0
- package/src/codex/sync.ts +5 -3
- package/src/combos/failover.ts +32 -5
- package/src/combos/request.ts +11 -3
- package/src/combos/reset-window.ts +10 -5
- package/src/combos/resolve.ts +36 -6
- package/src/config/diagnostics.ts +2 -1
- package/src/config/load-degrade.ts +2 -0
- package/src/config/paths.ts +7 -1
- package/src/config/process-state.ts +10 -1
- package/src/config/schema/compaction-recovery.ts +15 -0
- package/src/config/schema/config-schema.ts +2 -0
- package/src/config/schema/leaf-validators.ts +9 -3
- package/src/generated/compatibility-version.json +360 -204
- package/src/images/loop.ts +47 -32
- package/src/integrations/catalog-refresh.ts +4 -0
- package/src/integrations/omp-yaml-source.ts +1 -0
- package/src/lib/claude-request-projection.ts +101 -0
- package/src/lib/local-account-switch-capability.ts +48 -0
- package/src/lib/local-upstream.ts +146 -0
- package/src/lib/package-tree-integrity.ts +25 -3
- package/src/lib/package-tree-retarget.ts +130 -0
- package/src/lib/process-control.ts +4 -1
- package/src/lib/system-restart-contract.ts +106 -0
- package/src/lib/upstream-retry.ts +57 -1
- package/src/link/ports.ts +9 -0
- package/src/link/ssh-argv.ts +16 -0
- package/src/link/ssh-runner.ts +70 -2
- package/src/link/tunnel-state.ts +58 -9
- package/src/oauth/account-quota-rank.ts +29 -18
- package/src/oauth/generic-account-failover.ts +179 -22
- package/src/oauth/index.ts +17 -4
- package/src/oauth/kiro-account-load.ts +107 -0
- package/src/oauth/kiro-device-login.ts +312 -0
- package/src/oauth/kiro-terminal-failover.ts +28 -0
- package/src/oauth/login-flow-state.ts +7 -1
- package/src/oauth/pool-settings-capability.ts +23 -14
- package/src/oauth/store.ts +56 -3
- package/src/oauth/types.ts +4 -0
- package/src/plugins/loader.ts +350 -0
- package/src/plugins/upstream-hooks.ts +119 -0
- package/src/protocols/encoders/adapter-events.ts +9 -2
- package/src/providers/account-quota-disk.ts +42 -4
- package/src/providers/codebuddy-models.ts +6 -2
- package/src/providers/command-code-efforts.ts +33 -10
- package/src/providers/kiro-account-state-disk.ts +44 -0
- package/src/providers/kiro-model-catalog.ts +162 -0
- package/src/providers/kiro-models.ts +5 -4
- package/src/providers/kiro-quota-metrics.ts +35 -0
- package/src/providers/kiro-usage.ts +118 -14
- package/src/providers/quota/account-cache.ts +43 -7
- package/src/providers/quota/antigravity.ts +8 -3
- package/src/providers/quota/kiro-account-probe.ts +12 -0
- package/src/providers/quota/vendor-probes-key.ts +31 -26
- package/src/providers/quota/vendor-probes-oauth.ts +16 -7
- package/src/providers/quota-types.ts +14 -0
- package/src/providers/quota.ts +21 -21
- package/src/providers/registry/entries-extended.ts +5 -3
- package/src/providers/request-pacing.ts +410 -12
- package/src/remote-control/workspace-codex-runtime.ts +4 -3
- package/src/responses/citation-markers.ts +132 -65
- package/src/responses/hosted-tool-policy.ts +14 -3
- package/src/responses/parser-content.ts +3 -2
- package/src/responses/parser.ts +4 -1
- package/src/responses/schema.ts +4 -0
- package/src/responses/visualization-directives.ts +182 -0
- package/src/server/chat-native-sse.ts +24 -12
- package/src/server/chat-native.ts +137 -68
- package/src/server/claude-messages.ts +147 -12
- package/src/server/index/link-listener.ts +7 -0
- package/src/server/index/optional-listeners.ts +4 -1
- package/src/server/index/package-tree-guard.ts +35 -16
- package/src/server/index/serve-options.ts +4 -2
- package/src/server/index/startup-warnings.ts +6 -0
- package/src/server/index.ts +7 -7
- package/src/server/inference/client-encoder-delivery.ts +3 -0
- package/src/server/inference/context.ts +30 -2
- package/src/server/local-account-switch-auth.ts +79 -0
- package/src/server/management/agent-settings-routes.ts +11 -3
- package/src/server/management/config-routes.ts +28 -11
- package/src/server/management/context.ts +2 -0
- package/src/server/management/link-routes.ts +123 -39
- package/src/server/management/logs-usage-routes.ts +9 -42
- package/src/server/management/model-rows.ts +1 -0
- package/src/server/management/oauth-account-routes.ts +67 -12
- package/src/server/management/route-registry.ts +5 -5
- package/src/server/management/sibling-guard.ts +60 -0
- package/src/server/management/storage-log-guard-routes.ts +72 -6
- package/src/server/management/system-restart.ts +92 -135
- package/src/server/management/system-routes.ts +1 -1
- package/src/server/management-api.ts +31 -3
- package/src/server/management-auth.ts +3 -0
- package/src/server/port-reclaim.ts +111 -1
- package/src/server/proxy-liveness.ts +16 -0
- package/src/server/relay.ts +16 -15
- package/src/server/request-log-filter.ts +28 -27
- package/src/server/request-log.ts +7 -3
- package/src/server/request-metrics.ts +28 -0
- package/src/server/responses/adapter-continuation.ts +151 -21
- package/src/server/responses/adapter-delivery.ts +21 -7
- package/src/server/responses/adapter-dispatch.ts +239 -51
- package/src/server/responses/codex-ws-pool.ts +5 -4
- package/src/server/responses/codex-ws-request.ts +4 -1
- package/src/server/responses/compact.ts +8 -4
- package/src/server/responses/compaction-recovery-policy.ts +116 -0
- package/src/server/responses/compaction-recovery.ts +330 -0
- package/src/server/responses/core-combo-failure.ts +1 -0
- package/src/server/responses/core-combo.ts +5 -1
- package/src/server/responses/core-lifetime.ts +22 -0
- package/src/server/responses/core-options.ts +16 -1
- package/src/server/responses/core.ts +14 -4
- package/src/server/responses/empty-completion-guard.ts +2 -0
- package/src/server/responses/fetch-helpers.ts +82 -31
- package/src/server/responses/passthrough-delivery.ts +3 -2
- package/src/server/responses/passthrough-dispatch.ts +12 -2
- package/src/server/responses/passthrough-execution.ts +2 -2
- package/src/server/responses/request-send-budget.ts +21 -5
- package/src/server/responses/request-transport.ts +116 -18
- package/src/server/responses/reset-replay.ts +32 -0
- package/src/server/responses/run-turn-execution.ts +136 -19
- package/src/server/responses/sidecar-execution.ts +41 -8
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/responses/ws-upstream.ts +44 -4
- package/src/server/restart-replacement.ts +382 -0
- package/src/server/startup-health-cache.ts +23 -4
- package/src/server/stop-teardown.ts +35 -1
- package/src/server/system-env.ts +3 -0
- package/src/service/windows-taskxml.ts +11 -7
- package/src/service/windows-wrapper-exit.ts +8 -0
- package/src/stall-timeout.ts +35 -10
- package/src/storage/policy-job.ts +4 -0
- package/src/storage/scanner.ts +177 -29
- package/src/storage/storage-mutation-coordinator.ts +17 -0
- package/src/tray/windows-tray.ps1 +50 -23
- package/src/types/config.ts +4 -0
- package/src/types/provider.ts +5 -1
- package/src/types/request.ts +13 -2
- package/src/update/index.ts +3 -1
- package/src/update/job.ts +6 -2
- package/src/update/mise-launcher-target.ts +100 -0
- package/src/update/worker-launch.ts +46 -0
- package/src/usage/log.ts +2 -0
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/App-BJsT8Icc.css +0 -1
- package/gui/dist/assets/App-D3pNGiN4.js +0 -51
- package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
- package/gui/dist/assets/index-CouvvtMV.js +0 -86
- package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
|
@@ -2,9 +2,14 @@ import type { AdapterFetchContext } from "./base";
|
|
|
2
2
|
import type { SendClass } from "../lib/request-execution-budget";
|
|
3
3
|
import type { AttemptRecoveryKind } from "../usage/log";
|
|
4
4
|
import { abortError, SendBudgetExhaustedError } from "../lib/upstream-retry";
|
|
5
|
+
import {
|
|
6
|
+
releaseProviderRequestSlot,
|
|
7
|
+
sendTrackingRequestSlot,
|
|
8
|
+
type ProviderRequestSlot,
|
|
9
|
+
} from "../providers/request-pacing";
|
|
5
10
|
|
|
6
11
|
type PacedFetch = typeof globalThis.fetch & {
|
|
7
|
-
waitForPacing?: (signal?: AbortSignal) => Promise<void>;
|
|
12
|
+
waitForPacing?: (signal?: AbortSignal) => Promise<ProviderRequestSlot | void>;
|
|
8
13
|
unpacedFetch?: typeof globalThis.fetch;
|
|
9
14
|
};
|
|
10
15
|
|
|
@@ -37,13 +42,15 @@ export function createAdapterPhysicalSend(ctx: AdapterFetchContext = {}, fallbac
|
|
|
37
42
|
ctx.onPhysicalSend?.({ ordinal, ...(options.recovery ? { recovery: options.recovery } : {}) });
|
|
38
43
|
return (executor.unpacedFetch ?? executor)(input, init);
|
|
39
44
|
}) as typeof globalThis.fetch;
|
|
45
|
+
let slot: ProviderRequestSlot | undefined;
|
|
40
46
|
try {
|
|
41
|
-
await executor.waitForPacing?.(ctx.abortSignal);
|
|
47
|
+
slot = (await executor.waitForPacing?.(ctx.abortSignal)) || undefined;
|
|
42
48
|
if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
|
|
43
49
|
await options.beforeDispatch?.();
|
|
44
50
|
if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
|
|
45
|
-
return await options.dispatch(physicalExecutor);
|
|
51
|
+
return await sendTrackingRequestSlot(slot, () => options.dispatch(physicalExecutor));
|
|
46
52
|
} finally {
|
|
53
|
+
releaseProviderRequestSlot(slot);
|
|
47
54
|
permit?.release();
|
|
48
55
|
}
|
|
49
56
|
};
|
package/src/adapters/registry.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { createAzureAdapter } from "./azure";
|
|
|
3
3
|
import type { ProviderAdapter } from "./base";
|
|
4
4
|
import { createClaudeCliAdapter } from "./claude-cli/adapter";
|
|
5
5
|
import { withClinePassDeepSeekV4ToolReplayCompatibility } from "./cline-pass-deepseek-v4-tool-replay";
|
|
6
|
+
import { withUniqueToolCallIds } from "./unique-tool-call-ids";
|
|
6
7
|
import { createCodeBuddyAdapter } from "./codebuddy/adapter";
|
|
7
8
|
import { createQoderAdapter } from "./qoder/adapter";
|
|
8
9
|
import { createCommandCodeAdapter } from "./command-code";
|
|
@@ -84,7 +85,7 @@ export const ADAPTER_REGISTRY = {
|
|
|
84
85
|
wire: "openai-chat",
|
|
85
86
|
mutation: "codex-owned",
|
|
86
87
|
create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) =>
|
|
87
|
-
withClinePassDeepSeekV4ToolReplayCompatibility(createOpenAIChatAdapter(provider)),
|
|
88
|
+
withUniqueToolCallIds(withClinePassDeepSeekV4ToolReplayCompatibility(createOpenAIChatAdapter(provider))),
|
|
88
89
|
},
|
|
89
90
|
"ollama-native": {
|
|
90
91
|
wire: "ollama-native",
|
|
@@ -96,9 +96,32 @@ function usesUnicodePropertyEscape(pattern: string): boolean {
|
|
|
96
96
|
return false;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Rewrite each unescaped `\0` not followed by a digit to the equivalent `\x00`. Both spell NUL
|
|
101
|
+
* in ECMAScript and Python `re`, but some destinations' schema validators refuse `\0` inside a
|
|
102
|
+
* character class: Meta's Responses API answers 400 "is not a \"regex\"" to Claude Code's
|
|
103
|
+
* Artifact tool, whose file-path parameters carry `^[^\0]*$`. `\0` followed by a digit is an
|
|
104
|
+
* octal escape in Python, so it is left alone. Returns the input when nothing changed.
|
|
105
|
+
*/
|
|
106
|
+
function rewriteNulEscapes(pattern: string): string {
|
|
107
|
+
let out: string | undefined;
|
|
108
|
+
let copied = 0;
|
|
109
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
110
|
+
if (pattern[i] !== "\\") continue;
|
|
111
|
+
const next = pattern[i + 1];
|
|
112
|
+
if (next === "0" && !/[0-9]/.test(pattern[i + 2] ?? "")) {
|
|
113
|
+
out = (out ?? "") + pattern.slice(copied, i) + "\\x00";
|
|
114
|
+
copied = i + 2;
|
|
115
|
+
}
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
return out === undefined ? pattern : out + pattern.slice(copied);
|
|
119
|
+
}
|
|
120
|
+
|
|
99
121
|
/**
|
|
100
122
|
* Remove unsupported Unicode property escapes from scalar `pattern` constraints in ordinary
|
|
101
|
-
* positive schema positions. This keeps built-in
|
|
123
|
+
* positive schema positions, and spell `\0` as `\x00` (rewriteNulEscapes). This keeps built-in
|
|
124
|
+
* Artifact tools usable on Python-re backends and on Meta's Responses API;
|
|
102
125
|
* the omitted constraint is not enforced by this proxy and tools must validate their inputs.
|
|
103
126
|
*
|
|
104
127
|
* Regex-keyed objects are preserved. Removing a matcher can lose evaluated-property annotations
|
|
@@ -181,8 +204,13 @@ export function stripUnicodePropertyPatterns(node: unknown, inNameBag = false):
|
|
|
181
204
|
continue;
|
|
182
205
|
}
|
|
183
206
|
const [key, value] = next.value;
|
|
184
|
-
if (!frame.inNameBag && key === "pattern" && typeof value === "string"
|
|
185
|
-
|
|
207
|
+
if (!frame.inNameBag && key === "pattern" && typeof value === "string") {
|
|
208
|
+
if (usesUnicodePropertyEscape(value)) {
|
|
209
|
+
delete (cloneContainer(frame) as Record<string, unknown>)[key];
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const rewritten = rewriteNulEscapes(value);
|
|
213
|
+
if (rewritten !== value) (cloneContainer(frame) as Record<string, unknown>)[key] = rewritten;
|
|
186
214
|
continue;
|
|
187
215
|
}
|
|
188
216
|
if (!frame.inNameBag && (PRESERVED_PATTERN_SUBTREES.has(key) || SCHEMA_LITERAL_VALUE_KEYS.has(key))) {
|
|
@@ -132,14 +132,22 @@ export interface AdapterEventPreflight {
|
|
|
132
132
|
error?: Extract<AdapterEvent, { type: "error" }>;
|
|
133
133
|
empty: boolean;
|
|
134
134
|
replayUnsafe: boolean;
|
|
135
|
+
ready?: boolean;
|
|
136
|
+
timedOut?: boolean;
|
|
135
137
|
}
|
|
136
138
|
|
|
137
139
|
async function* replay(
|
|
138
140
|
buffered: readonly AdapterEvent[],
|
|
139
141
|
iterator: AsyncIterator<AdapterEvent>,
|
|
142
|
+
pendingNext?: Promise<IteratorResult<AdapterEvent>>,
|
|
140
143
|
): AsyncGenerator<AdapterEvent> {
|
|
141
144
|
try {
|
|
142
145
|
for (const event of buffered) yield event;
|
|
146
|
+
if (pendingNext) {
|
|
147
|
+
const next = await pendingNext;
|
|
148
|
+
if (next.done) return;
|
|
149
|
+
yield next.value;
|
|
150
|
+
}
|
|
143
151
|
while (true) {
|
|
144
152
|
const next = await iterator.next();
|
|
145
153
|
if (next.done) return;
|
|
@@ -153,31 +161,61 @@ async function* replay(
|
|
|
153
161
|
export async function preflightAdapterEvents(
|
|
154
162
|
source: AsyncIterable<AdapterEvent>,
|
|
155
163
|
classifyFirstEvent?: (event: AdapterEvent) => Extract<AdapterEvent, { type: "error" }> | undefined,
|
|
164
|
+
options?: { maxWaitMs?: number; honorReady?: boolean },
|
|
156
165
|
): Promise<AdapterEventPreflight> {
|
|
157
166
|
const iterator = source[Symbol.asyncIterator]();
|
|
158
167
|
const buffered: AdapterEvent[] = [];
|
|
159
168
|
let replayUnsafe = false;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
169
|
+
const maxWaitMs = options?.maxWaitMs;
|
|
170
|
+
if (maxWaitMs !== undefined && maxWaitMs <= 0) {
|
|
171
|
+
return { stream: replay(buffered, iterator), empty: false, replayUnsafe, timedOut: true };
|
|
172
|
+
}
|
|
173
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
174
|
+
let timeoutPromise: Promise<"timeout"> | undefined;
|
|
175
|
+
if (maxWaitMs !== undefined) {
|
|
176
|
+
timeoutPromise = new Promise(resolve => {
|
|
177
|
+
timeout = setTimeout(() => resolve("timeout"), maxWaitMs);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
while (true) {
|
|
182
|
+
const pendingNext = iterator.next();
|
|
183
|
+
const raced = timeoutPromise ? await Promise.race([pendingNext, timeoutPromise]) : await pendingNext;
|
|
184
|
+
if (raced === "timeout") {
|
|
185
|
+
return {
|
|
186
|
+
stream: replay(buffered, iterator, pendingNext),
|
|
187
|
+
empty: false,
|
|
188
|
+
replayUnsafe,
|
|
189
|
+
timedOut: true,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const next = raced;
|
|
193
|
+
if (next.done) return { stream: replay(buffered, iterator), empty: true, replayUnsafe };
|
|
194
|
+
if (next.value.type === "heartbeat") {
|
|
195
|
+
replayUnsafe ||= next.value.replayUnsafe === true;
|
|
196
|
+
// Preserve the latch in replay even after the original unsafe heartbeat is evicted.
|
|
197
|
+
buffered.push(replayUnsafe ? { ...next.value, replayUnsafe: true } : next.value);
|
|
198
|
+
if (next.value.preflightReady === true && options?.honorReady !== false) {
|
|
199
|
+
return { stream: replay(buffered, iterator), empty: false, replayUnsafe, ready: true };
|
|
200
|
+
}
|
|
201
|
+
if (buffered.length > PREFLIGHT_HEARTBEAT_RETAIN_LIMIT) buffered.shift();
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const classifiedError = replayUnsafe ? undefined : classifyFirstEvent?.(next.value);
|
|
205
|
+
if (classifiedError) {
|
|
206
|
+
buffered.push(classifiedError);
|
|
207
|
+
await iterator.return?.();
|
|
208
|
+
return { stream: replay(buffered, iterator), error: classifiedError, empty: false, replayUnsafe };
|
|
209
|
+
}
|
|
165
210
|
buffered.push(next.value);
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
buffered.push(classifiedError);
|
|
172
|
-
await iterator.return?.();
|
|
173
|
-
return { stream: replay(buffered, iterator), error: classifiedError, empty: false, replayUnsafe };
|
|
174
|
-
}
|
|
175
|
-
buffered.push(next.value);
|
|
176
|
-
if (next.value.type === "error") {
|
|
177
|
-
await iterator.return?.();
|
|
178
|
-
return { stream: replay(buffered, iterator), error: next.value, empty: false, replayUnsafe };
|
|
211
|
+
if (next.value.type === "error") {
|
|
212
|
+
await iterator.return?.();
|
|
213
|
+
return { stream: replay(buffered, iterator), error: next.value, empty: false, replayUnsafe };
|
|
214
|
+
}
|
|
215
|
+
return { stream: replay(buffered, iterator), empty: false, replayUnsafe };
|
|
179
216
|
}
|
|
180
|
-
|
|
217
|
+
} finally {
|
|
218
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
181
219
|
}
|
|
182
220
|
}
|
|
183
221
|
|
|
@@ -221,10 +259,12 @@ export function createAdapterEventQueue(opts?: {
|
|
|
221
259
|
// marker is not ordering — it is a latch. Dropping the incoming event
|
|
222
260
|
// would discard the only record that Cursor already performed a local
|
|
223
261
|
// side effect, and preflight would then permit an OAuth replay of it.
|
|
224
|
-
if (event.replayUnsafe
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
262
|
+
if (event.replayUnsafe !== true && event.preflightReady !== true) return tail;
|
|
263
|
+
return {
|
|
264
|
+
type: "heartbeat",
|
|
265
|
+
...(tail.replayUnsafe === true || event.replayUnsafe === true ? { replayUnsafe: true as const } : {}),
|
|
266
|
+
...(tail.preflightReady === true || event.preflightReady === true ? { preflightReady: true as const } : {}),
|
|
267
|
+
};
|
|
228
268
|
}
|
|
229
269
|
if (event.type === "text_delta" && tail.type === "text_delta" && tail.phase === event.phase) {
|
|
230
270
|
if (tail.text.length + event.text.length > COALESCE_MAX_CHUNK_LENGTH) return null;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ProviderAdapter } from "./base";
|
|
2
|
+
import type { AdapterEvent, OcxMessage } from "../types";
|
|
3
|
+
import { createToolCallIdReminter, reservedToolCallIdsFromHistory } from "./openai-chat/tool-call-id-remint";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Give every tool call in a conversation a wire id the client has not already stored.
|
|
7
|
+
*
|
|
8
|
+
* The openai-chat adapter forwards `tool_calls[].id` upstream-verbatim, which is correct for an
|
|
9
|
+
* upstream that mints a fresh random id per call. An upstream that derives the id from the call's
|
|
10
|
+
* position in its response mints the same `call-0-0` on every turn. The client has already paired
|
|
11
|
+
* that id with an earlier call, so it drops the duplicate; the dropped call has no result, the turn
|
|
12
|
+
* reads as an assistant message with no content, and the model re-issues the same call forever.
|
|
13
|
+
*
|
|
14
|
+
* Applied as a wrapper rather than inside the adapter so the adapter's own id handling stays
|
|
15
|
+
* untouched, matching how this repository already scopes a wire-compatibility policy
|
|
16
|
+
* (`withClinePassDeepSeekV4ToolReplayCompatibility`).
|
|
17
|
+
*
|
|
18
|
+
* Reminting happens at emission — on the events the adapter yields — never at ingestion: ingestion
|
|
19
|
+
* matches streamed deltas and continuation fragments against the id the upstream sent, so rewriting
|
|
20
|
+
* there would strip a pending call of its own identity mid-stream. The ids to avoid come from the
|
|
21
|
+
* caller's own history, captured where the request is built because that is the only point that sees
|
|
22
|
+
* the inbound conversation; the client is the authority on which ids are taken, since it is the side
|
|
23
|
+
* that discards the duplicates the proxy would otherwise never observe.
|
|
24
|
+
*
|
|
25
|
+
* The first occurrence of an id is emitted byte-identical, so prompt-cache keys, reasoning-replay
|
|
26
|
+
* lookups, and upstreams that already mint unique ids are unaffected.
|
|
27
|
+
*/
|
|
28
|
+
export function withUniqueToolCallIds(adapter: ProviderAdapter): ProviderAdapter {
|
|
29
|
+
// Set where the request is built and consumed by the two emission paths below — the same
|
|
30
|
+
// build-into-parse handoff the openai-chat adapter already uses for its requested model id. The
|
|
31
|
+
// identity default means a parse that runs without a build cannot fail: it has no history to
|
|
32
|
+
// collide with.
|
|
33
|
+
let remintToolCallId: (rawId: string) => string = id => id;
|
|
34
|
+
|
|
35
|
+
const remintEvents = (events: AdapterEvent[]): AdapterEvent[] =>
|
|
36
|
+
events.map(event => event.type === "tool_call_start" ? { ...event, id: remintToolCallId(event.id) } : event);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
...adapter,
|
|
40
|
+
|
|
41
|
+
async buildRequest(parsed, incoming) {
|
|
42
|
+
const history: OcxMessage[] | undefined = parsed.context?.messages;
|
|
43
|
+
remintToolCallId = createToolCallIdReminter(
|
|
44
|
+
Array.isArray(history) ? reservedToolCallIdsFromHistory(history) : [],
|
|
45
|
+
);
|
|
46
|
+
return adapter.buildRequest(parsed, incoming);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
async *parseStream(response, budget, tierMetadata): AsyncGenerator<AdapterEvent> {
|
|
50
|
+
for await (const event of adapter.parseStream(response, budget, tierMetadata)) {
|
|
51
|
+
yield event.type === "tool_call_start" ? { ...event, id: remintToolCallId(event.id) } : event;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
...(adapter.parseResponse
|
|
56
|
+
? {
|
|
57
|
+
async parseResponse(response, budget, tierMetadata) {
|
|
58
|
+
return remintEvents(await adapter.parseResponse!(response, budget, tierMetadata));
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
: {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OcxProviderConfig } from "../types";
|
|
2
2
|
import { debugProviderDiagnostic } from "../lib/debug";
|
|
3
3
|
import { isXaiResponsesDestination } from "../providers/xai-transport";
|
|
4
|
+
import { isOpenCodeGoResponsesUrl } from "./opencode-go-additional-tools";
|
|
4
5
|
|
|
5
6
|
const CODEX_WEB_SEARCH_TOOL = "web_search";
|
|
6
7
|
const CODEX_WEB_SEARCH_PREVIEW_TOOL = "web_search_preview";
|
|
@@ -146,6 +147,24 @@ function hasToolType(tools: unknown, type: string): boolean {
|
|
|
146
147
|
return Array.isArray(tools) && tools.some(tool => isPlainObject(tool) && tool.type === type);
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
/**
|
|
151
|
+
* OpenCode Go serves Grok on its Responses endpoint by relaying to xAI, and relays xAI's schema
|
|
152
|
+
* verbatim. Probed 2026-09-26 on `https://opencode.ai/zen/go/v1/responses` with grok-4.6 and
|
|
153
|
+
* grok-4.7, one field per request: `external_web_access` and `search_context_size` -> 400
|
|
154
|
+
* "Argument not supported", `web_search_preview` -> 422 `unknown variant`, while a plain
|
|
155
|
+
* `web_search` with `search_content_types`, `enable_image_search`, `user_location` or
|
|
156
|
+
* `filters` -> 200 and performs live searches. That is the xAI dialect exactly, so the same
|
|
157
|
+
* normalization applies. Sibling Go models (Luna, Muse Spark) accept the OpenAI fields and are
|
|
158
|
+
* left alone. Membership is by family prefix: an exact-id match is what let grok-4.7 reach this
|
|
159
|
+
* endpoint unnormalized once it joined grok-4.6 on the Responses wire.
|
|
160
|
+
*/
|
|
161
|
+
function isOpenCodeGoGrokResponses(modelId: unknown, responseUrl: string | undefined): boolean {
|
|
162
|
+
return typeof modelId === "string"
|
|
163
|
+
&& modelId.trim().toLowerCase().startsWith("grok-")
|
|
164
|
+
&& typeof responseUrl === "string"
|
|
165
|
+
&& isOpenCodeGoResponsesUrl(responseUrl);
|
|
166
|
+
}
|
|
167
|
+
|
|
149
168
|
/**
|
|
150
169
|
* Make Codex's hosted web-search declaration acceptable to xAI Responses without changing other
|
|
151
170
|
* providers or mutating the caller-owned request body.
|
|
@@ -157,12 +176,23 @@ function hasToolType(tools: unknown, type: string): boolean {
|
|
|
157
176
|
* `web_search_preview` -> 422 `unknown variant`, `external_web_access` -> 400 on every value,
|
|
158
177
|
* `search_context_size` -> 400, while `user_location` and `search_content_types` -> 200. Identical
|
|
159
178
|
* to the public API, which is what makes one shared gate correct.
|
|
179
|
+
*
|
|
180
|
+
* Grok models on the exact OpenCode Go Responses URL speak the same dialect and share this gate;
|
|
181
|
+
* see `isOpenCodeGoGrokResponses`. `target` carries the final model id and request URL for that
|
|
182
|
+
* check, and callers without it keep the xAI-host-only behavior.
|
|
160
183
|
*/
|
|
161
184
|
export function normalizeXaiResponsesWebSearch(
|
|
162
185
|
body: unknown,
|
|
163
186
|
provider: Pick<OcxProviderConfig, "baseUrl">,
|
|
187
|
+
target?: { modelId?: unknown; responseUrl?: string },
|
|
164
188
|
): unknown {
|
|
165
|
-
if (!
|
|
189
|
+
if (!isPlainObject(body)) return body;
|
|
190
|
+
const xaiDestination = isXaiResponsesDestination(provider);
|
|
191
|
+
if (!xaiDestination && !isOpenCodeGoGrokResponses(target?.modelId, target?.responseUrl)) return body;
|
|
192
|
+
// On Go, a wrapper emptied here stays in place: Go promotion (opencode-go-additional-tools.ts)
|
|
193
|
+
// removes every wrapper before the wire and splits them into history and current turn by
|
|
194
|
+
// index against _replayPrefixLen, so deleting one would shift the current turn into history.
|
|
195
|
+
const keepEmptiedWrappers = !xaiDestination;
|
|
166
196
|
|
|
167
197
|
let next: Record<string, unknown> = body;
|
|
168
198
|
if (Array.isArray(body.tools)) {
|
|
@@ -188,7 +218,7 @@ export function normalizeXaiResponsesWebSearch(
|
|
|
188
218
|
continue;
|
|
189
219
|
}
|
|
190
220
|
inputChanged = true;
|
|
191
|
-
if (rewritten.tools.length > 0) input.push({ ...item, tools: rewritten.tools });
|
|
221
|
+
if (rewritten.tools.length > 0 || keepEmptiedWrappers) input.push({ ...item, tools: rewritten.tools });
|
|
192
222
|
}
|
|
193
223
|
if (inputChanged) next = { ...next, input };
|
|
194
224
|
}
|
package/src/bridge/sse.ts
CHANGED
|
@@ -73,6 +73,12 @@ export function bridgeToResponsesSSE(
|
|
|
73
73
|
options?: {
|
|
74
74
|
responseId?: string;
|
|
75
75
|
stallTimeoutSec?: number;
|
|
76
|
+
/**
|
|
77
|
+
* The upstream is local infrastructure (loopback / private / `.local` / `.lan`); an unset
|
|
78
|
+
* `stallTimeoutSec` then resolves to disabled so a slow local model is not cut mid-turn.
|
|
79
|
+
* Wire keep-alives still re-arm the client's idle clock either way.
|
|
80
|
+
*/
|
|
81
|
+
localUpstream?: boolean;
|
|
76
82
|
hideThinkingSummary?: boolean;
|
|
77
83
|
/**
|
|
78
84
|
* Remote compaction v2 turn: accumulate all assistant text and, on done, emit ONE synthetic
|
|
@@ -341,7 +347,9 @@ export function bridgeToResponsesSSE(
|
|
|
341
347
|
? encoder.encode(': opencodex heartbeat\n\n')
|
|
342
348
|
: encoder.encode('event: response.heartbeat\ndata: {"type":"response.heartbeat"}\n\n');
|
|
343
349
|
let stallTicks = 0;
|
|
344
|
-
const stallSec = resolveStallTimeoutSec(options?.stallTimeoutSec
|
|
350
|
+
const stallSec = resolveStallTimeoutSec(options?.stallTimeoutSec, {
|
|
351
|
+
localUpstream: options?.localUpstream,
|
|
352
|
+
});
|
|
345
353
|
const maxStallTicks = Math.ceil((stallSec * 1000) / heartbeatMs);
|
|
346
354
|
|
|
347
355
|
let currentMsg: {
|
|
@@ -1407,7 +1415,11 @@ export function bridgeToResponsesSSE(
|
|
|
1407
1415
|
if (upstreamActivity) {
|
|
1408
1416
|
upstreamActivity = false;
|
|
1409
1417
|
stallTicks = 0;
|
|
1410
|
-
} else if (++stallTicks >= maxStallTicks) {
|
|
1418
|
+
} else if (stallSec > 0 && ++stallTicks >= maxStallTicks) {
|
|
1419
|
+
// stallSec of 0 is an explicit disabled budget (local upstream, or the operator's
|
|
1420
|
+
// stallTimeoutSec: 0). Never let `maxStallTicks === 0` arm a kill on the first beat:
|
|
1421
|
+
// with it the `>= 0` comparison is true the moment a single tick lands, which would
|
|
1422
|
+
// terminate a healthy silent local model after ~2s instead of leaving it alone.
|
|
1411
1423
|
if (!attemptTerminationCleanup(() => {
|
|
1412
1424
|
if (currentMsg) closeCurrentMessage();
|
|
1413
1425
|
if (currentReasoning) closeCurrentReasoning();
|
package/src/chat/inbound.ts
CHANGED
|
@@ -57,11 +57,21 @@ function contentToText(content: unknown): string {
|
|
|
57
57
|
// route-eligibility predicate and this translator cannot drift apart again.
|
|
58
58
|
const imageUrlFromPart = chatImageUrlFromPart;
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
/**
|
|
61
|
+
* A caller's video part, with the `processing` mode Gemini's agentic video
|
|
62
|
+
* understanding is requested by (#3271). The object form is the only one that
|
|
63
|
+
* can carry it — `video_url` as a bare string has nowhere to put it.
|
|
64
|
+
*/
|
|
65
|
+
function videoFromPart(part: Rec): { url: string; processing?: string } | null {
|
|
61
66
|
if (part.type !== "video_url") return null;
|
|
62
67
|
const videoUrl = part.video_url;
|
|
63
|
-
if (typeof videoUrl === "string" && videoUrl.length > 0) return videoUrl;
|
|
64
|
-
if (isRec(videoUrl) && typeof videoUrl.url === "string" && videoUrl.url.length > 0)
|
|
68
|
+
if (typeof videoUrl === "string" && videoUrl.length > 0) return { url: videoUrl };
|
|
69
|
+
if (isRec(videoUrl) && typeof videoUrl.url === "string" && videoUrl.url.length > 0) {
|
|
70
|
+
const processing = typeof videoUrl.processing === "string" && videoUrl.processing.length > 0
|
|
71
|
+
? videoUrl.processing
|
|
72
|
+
: undefined;
|
|
73
|
+
return { url: videoUrl.url, ...(processing ? { processing } : {}) };
|
|
74
|
+
}
|
|
65
75
|
return null;
|
|
66
76
|
}
|
|
67
77
|
|
|
@@ -91,9 +101,13 @@ function userContentToBlocks(content: unknown): Rec[] {
|
|
|
91
101
|
});
|
|
92
102
|
continue;
|
|
93
103
|
}
|
|
94
|
-
const
|
|
95
|
-
if (
|
|
96
|
-
blocks.push({
|
|
104
|
+
const video = videoFromPart(raw);
|
|
105
|
+
if (video) {
|
|
106
|
+
blocks.push({
|
|
107
|
+
type: "input_video",
|
|
108
|
+
video_url: video.url,
|
|
109
|
+
...(video.processing ? { processing: video.processing } : {}),
|
|
110
|
+
});
|
|
97
111
|
continue;
|
|
98
112
|
}
|
|
99
113
|
const file = fileFromPart(raw);
|
|
@@ -23,6 +23,7 @@ import { effectiveBlockedSkillNames, resolveInboundModel } from "./inbound";
|
|
|
23
23
|
import { AnthropicRequestError } from "./inbound-records";
|
|
24
24
|
import { knownModelIdsForProvider } from "../router";
|
|
25
25
|
import { decodeRoutedModelIdOrThrow } from "../providers/slug-codec";
|
|
26
|
+
import { siblingOfLivePort } from "../codex/sibling-start";
|
|
26
27
|
|
|
27
28
|
export interface ClaudeAgentDef {
|
|
28
29
|
file: string;
|
|
@@ -285,6 +286,9 @@ export function injectClaudeAgentDefs(
|
|
|
285
286
|
/** Hub-sourced roster on a connected client; see `buildClaudeAgentDefs`. */
|
|
286
287
|
rosterOverride?: readonly string[],
|
|
287
288
|
): string[] | null {
|
|
289
|
+
// `~/.claude/agents` is shared with the live proxy a sibling instance runs beside, which owns
|
|
290
|
+
// both its roster and its pruning (`src/codex/sibling-start.ts`).
|
|
291
|
+
if (siblingOfLivePort() !== null) return null;
|
|
288
292
|
if (config.claudeCode?.enabled === false || config.claudeCode?.injectAgents === false) {
|
|
289
293
|
// Disabled: prune verified-owned files so stale definitions stop loading
|
|
290
294
|
// in future sessions (audit 071 #3). The roster override is irrelevant here by
|
package/src/claude/desktop-3p.ts
CHANGED
|
@@ -193,8 +193,26 @@ function collectDesktop3pModels(
|
|
|
193
193
|
const rendered = renderDesktopProfile(reconciled, profileModels);
|
|
194
194
|
const aliasesByRoute = new Map<string, string>();
|
|
195
195
|
for (const model of rendered) {
|
|
196
|
+
const realAnthropic = model.route.startsWith("anthropic/claude-");
|
|
197
|
+
if (!realAnthropic && realAnthropicIds.has(model.name)) {
|
|
198
|
+
console.warn(`[opencodex] Claude Desktop profile alias ${model.name} for ${model.route} conflicts with a real Anthropic model; skipping the routed model`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
196
201
|
aliasesByRoute.set(model.route, model.name);
|
|
197
|
-
if (!
|
|
202
|
+
if (!realAnthropic) {
|
|
203
|
+
registry.set(model.name, model.route);
|
|
204
|
+
// Profiles persist their historical date slots, while Desktop receives
|
|
205
|
+
// a non-date wire id. Keep the date form resolvable for running/stale
|
|
206
|
+
// sessions during the migration.
|
|
207
|
+
const storedAlias = reconciled.assignments[model.route]?.alias;
|
|
208
|
+
if (storedAlias && storedAlias !== model.name) {
|
|
209
|
+
if (realAnthropicIds.has(storedAlias)) {
|
|
210
|
+
console.warn(`[opencodex] Claude Desktop stored alias ${storedAlias} for ${model.route} conflicts with a real Anthropic model; ignoring the alias`);
|
|
211
|
+
} else {
|
|
212
|
+
registry.set(storedAlias, model.route);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
198
216
|
models.push({
|
|
199
217
|
name: model.name,
|
|
200
218
|
labelOverride: model.label,
|
|
@@ -203,25 +221,33 @@ function collectDesktop3pModels(
|
|
|
203
221
|
...(model.supports1m ? { supports1m: true, prefer1m: true } : {}),
|
|
204
222
|
});
|
|
205
223
|
}
|
|
206
|
-
//
|
|
207
|
-
//
|
|
224
|
+
// Hash aliases from both pre-profile generations are compatibility-only and can
|
|
225
|
+
// collide. Bind them in stable route order so changing a family default or
|
|
226
|
+
// rendered ordering can never silently rebind an old Desktop id.
|
|
208
227
|
for (const model of [...rendered].sort((a, b) => a.route.localeCompare(b.route))) {
|
|
209
228
|
if (model.route.startsWith("anthropic/claude-")) continue;
|
|
210
229
|
const providerEnd = model.route.indexOf("/");
|
|
211
230
|
const provider = model.route.slice(0, providerEnd);
|
|
212
231
|
const id = model.route.slice(providerEnd + 1);
|
|
213
|
-
const legacy
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
232
|
+
for (const legacy of [desktop3pAlias(provider, id), legacyDesktop3pAlias(provider, id)]) {
|
|
233
|
+
if (realAnthropicIds.has(legacy)) {
|
|
234
|
+
console.warn(`[opencodex] Claude Desktop legacy alias ${legacy} for ${model.route} conflicts with a real Anthropic model; ignoring the alias`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const existing = registry.get(legacy);
|
|
238
|
+
if (existing && existing !== model.route) {
|
|
239
|
+
console.warn(`[opencodex] Claude Desktop legacy alias collision: ${legacy} stays bound to ${existing}; ignoring ${model.route}`);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
registry.set(legacy, model.route);
|
|
218
243
|
}
|
|
219
|
-
registry.set(legacy, model.route);
|
|
220
244
|
}
|
|
221
245
|
desktop3pAliasesByRoute = aliasesByRoute;
|
|
222
246
|
return { models, registry, realAnthropicIds };
|
|
223
247
|
}
|
|
224
248
|
|
|
249
|
+
const aliasesByRoute = new Map<string, string>();
|
|
250
|
+
const emittedNames = new Set<string>();
|
|
225
251
|
for (const { provider, id, contextWindow } of candidates) {
|
|
226
252
|
const route = `${provider}/${id}`;
|
|
227
253
|
const alias = desktop3pAlias(provider, id);
|
|
@@ -232,13 +258,20 @@ function collectDesktop3pModels(
|
|
|
232
258
|
// Real Anthropic model: keep it OUT of the decode registry — registering it would
|
|
233
259
|
// make resolveInboundModel() non-identity and kill the sk-ant native passthrough
|
|
234
260
|
// (audit 133 #1). It still appears in the static Desktop model list below.
|
|
261
|
+
if (emittedNames.has(alias)) continue;
|
|
235
262
|
models.push({
|
|
236
263
|
name: alias,
|
|
237
264
|
labelOverride: `${displayModelId(id)} (${provider})`,
|
|
238
265
|
anthropicFamilyTier: "opus",
|
|
239
266
|
...supports1m,
|
|
240
|
-
|
|
267
|
+
...(supports1m.supports1m ? { prefer1m: true as const } : {}),
|
|
241
268
|
});
|
|
269
|
+
emittedNames.add(alias);
|
|
270
|
+
aliasesByRoute.set(route, alias);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (realAnthropicIds.has(alias)) {
|
|
274
|
+
console.warn(`[opencodex] Claude Desktop 3P alias ${alias} for ${route} conflicts with a real Anthropic model; skipping the routed model`);
|
|
242
275
|
continue;
|
|
243
276
|
}
|
|
244
277
|
const existingRoute = registry.get(alias);
|
|
@@ -250,7 +283,11 @@ function collectDesktop3pModels(
|
|
|
250
283
|
registry.set(alias, route);
|
|
251
284
|
// Back-compat decode for Desktop configs written before the opus-4-8 rename.
|
|
252
285
|
const legacy = legacyDesktop3pAlias(provider, id);
|
|
253
|
-
if (
|
|
286
|
+
if (realAnthropicIds.has(legacy)) {
|
|
287
|
+
console.warn(`[opencodex] Claude Desktop legacy alias ${legacy} for ${route} conflicts with a real Anthropic model; ignoring the alias`);
|
|
288
|
+
} else if (!registry.has(legacy)) {
|
|
289
|
+
registry.set(legacy, route);
|
|
290
|
+
}
|
|
254
291
|
models.push({
|
|
255
292
|
name: alias,
|
|
256
293
|
labelOverride: `${displayModelId(id)} (${provider})`,
|
|
@@ -258,10 +295,11 @@ function collectDesktop3pModels(
|
|
|
258
295
|
...supports1m,
|
|
259
296
|
...(supports1m.supports1m ? { prefer1m: true as const } : {}),
|
|
260
297
|
});
|
|
298
|
+
aliasesByRoute.set(route, alias);
|
|
261
299
|
}
|
|
262
300
|
|
|
263
301
|
if (models[0]) models[0].isFamilyDefault = true;
|
|
264
|
-
desktop3pAliasesByRoute =
|
|
302
|
+
desktop3pAliasesByRoute = aliasesByRoute;
|
|
265
303
|
return { models, registry, realAnthropicIds };
|
|
266
304
|
}
|
|
267
305
|
|
|
@@ -308,7 +346,9 @@ export function isUnresolvedDesktop3pAlias(id: string): boolean {
|
|
|
308
346
|
// identity only; it neither enables a tier nor strips an exact full catalog ID.
|
|
309
347
|
const base = id.endsWith("--fast") ? id.slice(0, -"--fast".length) : id;
|
|
310
348
|
if (isKnownDesktop3pModelId(base)) return false;
|
|
311
|
-
return validDateAlias(base)
|
|
349
|
+
return validDateAlias(base)
|
|
350
|
+
|| /^claude-opus-4-(?:8-)?[a-z][a-z0-9]{2}$/.test(base)
|
|
351
|
+
|| /^claude-opus-4-8-p[0-9a-z]{3}$/.test(base);
|
|
312
352
|
}
|
|
313
353
|
|
|
314
354
|
/** Alias selected by the installed profile registry, falling back to the legacy hash shape. */
|
|
@@ -28,9 +28,11 @@ export interface RenderedDesktopModel extends DesktopProfileModel {
|
|
|
28
28
|
// design, so the set only grows). Years before 2026 stay rejected: dated
|
|
29
29
|
// ids like `claude-opus-4-8-20250201` are real model snapshot ids, not
|
|
30
30
|
// managed aliases, and the inbound decoder relies on that distinction.
|
|
31
|
-
//
|
|
32
|
-
//
|
|
31
|
+
// The persisted slot stays date-shaped for stable allocation and migration.
|
|
32
|
+
// renderDesktopProfile() converts it to a letter-first wire id because current
|
|
33
|
+
// Claude Desktop strips terminal dates before comparing model identities.
|
|
33
34
|
const DATE_ALIAS = /^claude-opus-4-8-(202[6-9]\d{4}|203[0-5]\d{4})$/;
|
|
35
|
+
const PROFILE_WIRE_PREFIX = "claude-opus-4-8-";
|
|
34
36
|
const LEGACY_YEAR = 2026;
|
|
35
37
|
const LEGACY_DAY_COUNT = 365;
|
|
36
38
|
const ALIAS_FIRST_YEAR = 2026;
|
|
@@ -123,6 +125,24 @@ export function validDateAlias(alias: string): boolean {
|
|
|
123
125
|
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
|
124
126
|
}
|
|
125
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Turn a persisted date slot into a unique non-date Desktop wire id.
|
|
130
|
+
*
|
|
131
|
+
* The 2026-2035 namespace has fewer than 3,888 days, so a `p`-prefixed
|
|
132
|
+
* three-character base36 slot has ample room. The four-character suffix is
|
|
133
|
+
* disjoint from both terminal dates and the historical three-character hash
|
|
134
|
+
* namespace used by profiles written before this allocator existed.
|
|
135
|
+
*/
|
|
136
|
+
export function desktopProfileWireAlias(alias: string): string {
|
|
137
|
+
const match = DATE_ALIAS.exec(alias);
|
|
138
|
+
if (!match || !validDateAlias(alias)) return alias;
|
|
139
|
+
const year = Number(match[1]!.slice(0, 4));
|
|
140
|
+
const month = Number(match[1]!.slice(4, 6));
|
|
141
|
+
const day = Number(match[1]!.slice(6, 8));
|
|
142
|
+
const slot = Math.round((Date.UTC(year, month - 1, day) - Date.UTC(ALIAS_FIRST_YEAR, 0, 1)) / 86_400_000);
|
|
143
|
+
return `${PROFILE_WIRE_PREFIX}p${slot.toString(36).padStart(3, "0")}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
126
146
|
export function parseDesktopProfile(value: unknown): DesktopProfile {
|
|
127
147
|
if (!isPlainObject(value)) throw new DesktopProfileError("must be an object");
|
|
128
148
|
// `appliedFingerprint`/`appliedAt` are written back by the apply route once a profile
|
|
@@ -235,7 +255,24 @@ export function reconcileDesktopProfile(
|
|
|
235
255
|
const assignments: DesktopProfile["assignments"] = Object.fromEntries(
|
|
236
256
|
Object.entries(profile.assignments).map(([route, assignment]) => [route, { ...assignment }]),
|
|
237
257
|
);
|
|
238
|
-
const
|
|
258
|
+
const activeRealAliases = new Set(models
|
|
259
|
+
.filter(model => isRealAnthropicRoute(model.route))
|
|
260
|
+
.map(model => routeModelId(model.route)));
|
|
261
|
+
const displacedRoutes = Object.entries(assignments)
|
|
262
|
+
.filter(([route, assignment]) => !isRealAnthropicRoute(route) && activeRealAliases.has(assignment.alias))
|
|
263
|
+
.map(([route]) => route)
|
|
264
|
+
.sort();
|
|
265
|
+
const displaced = new Set(displacedRoutes);
|
|
266
|
+
const used = new Set(Object.entries(assignments)
|
|
267
|
+
.filter(([route]) => !displaced.has(route))
|
|
268
|
+
.map(([, assignment]) => assignment.alias));
|
|
269
|
+
for (const alias of activeRealAliases) used.add(alias);
|
|
270
|
+
for (const route of displacedRoutes) {
|
|
271
|
+
const assignment = assignments[route]!;
|
|
272
|
+
const alias = allocateAlias(route, used);
|
|
273
|
+
used.add(alias);
|
|
274
|
+
assignments[route] = { ...assignment, alias };
|
|
275
|
+
}
|
|
239
276
|
for (const model of [...models].sort((a, b) => a.route.localeCompare(b.route))) {
|
|
240
277
|
if (assignments[model.route]) continue;
|
|
241
278
|
const alias = allocateAlias(model.route, used);
|
|
@@ -319,7 +356,7 @@ export function renderDesktopProfile(
|
|
|
319
356
|
const assignment = parsed.assignments[route]!;
|
|
320
357
|
return {
|
|
321
358
|
...model,
|
|
322
|
-
name: assignment.alias,
|
|
359
|
+
name: isRealAnthropicRoute(route) ? assignment.alias : desktopProfileWireAlias(assignment.alias),
|
|
323
360
|
family: assignment.family,
|
|
324
361
|
isFamilyDefault: effectiveDefaults[assignment.family] === route,
|
|
325
362
|
supports1m: typeof model.contextWindow === "number" && model.contextWindow >= 1_000_000,
|