@bitkyc08/opencodex 2.42.0 → 2.43.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.
- package/AGENTS_INSTALL.md +2 -2
- package/README.md +31 -0
- package/bin/ocx.mjs +10 -1
- package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
- package/gui/dist/assets/index-Djowl68T.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-codec.ts +304 -0
- package/src/adapters/anthropic-image-normalize.ts +8 -298
- package/src/adapters/anthropic.ts +30 -7
- package/src/adapters/command-code.ts +7 -5
- package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
- package/src/adapters/cursor/images.ts +36 -6
- package/src/adapters/cursor/live-transport.ts +7 -2
- package/src/adapters/cursor/native-exec-desktop.ts +2 -15
- package/src/adapters/cursor/protobuf-request.ts +13 -3
- package/src/adapters/cursor/tool-definitions.ts +5 -670
- package/src/adapters/cursor/tool-guidance.ts +236 -0
- package/src/adapters/cursor/tool-naming.ts +252 -0
- package/src/adapters/cursor/tool-schemas.ts +195 -0
- package/src/adapters/cursor/types.ts +6 -3
- package/src/adapters/exec-tool-result-normalize.ts +1 -1
- package/src/adapters/google-errors.ts +9 -1
- package/src/adapters/google.ts +1 -0
- package/src/adapters/kiro-calibration.ts +181 -0
- package/src/adapters/kiro.ts +135 -3
- package/src/adapters/openai-responses.ts +216 -26
- package/src/adapters/responses-code-mode.ts +59 -0
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/adapters/xai-schema-analysis.ts +86 -0
- package/src/adapters/xai-tool-schema.ts +2 -87
- package/src/adapters/xai-web-search.ts +1 -1
- package/src/bridge.ts +22 -10
- package/src/chat/inbound.ts +11 -3
- package/src/claude/inbound-content-options.ts +60 -0
- package/src/claude/inbound-model-options.ts +142 -0
- package/src/claude/inbound-records.ts +7 -0
- package/src/claude/inbound.ts +10 -202
- package/src/claude/model-info.ts +45 -0
- package/src/cli/account-auth.ts +21 -6
- package/src/cli/capabilities.ts +13 -2
- package/src/cli/claude.ts +232 -39
- package/src/cli/config-command.ts +9 -1
- package/src/cli/dispatch.ts +5 -1
- package/src/cli/doctor.ts +10 -0
- package/src/cli/effort.ts +372 -0
- package/src/cli/export-command.ts +3 -9
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +13 -0
- package/src/cli/init.ts +4 -0
- package/src/cli/model-selection-guidance.ts +30 -0
- package/src/cli/models-runtime.ts +3 -2
- package/src/cli/models.ts +8 -3
- package/src/cli/opencode.ts +4 -1
- package/src/cli/provider-runtime.ts +65 -0
- package/src/cli/provider.ts +8 -0
- package/src/cli/registry.ts +16 -2
- package/src/cli/runtime-api.ts +3 -1
- package/src/cli/star-prompt.ts +22 -6
- package/src/cli/status-probes.ts +168 -0
- package/src/cli/status.ts +5 -168
- package/src/clients/config-export/constants.ts +69 -0
- package/src/clients/config-export/contracts.ts +154 -0
- package/src/clients/config-export/dsh.ts +132 -0
- package/src/clients/config-export/fast-models.ts +29 -0
- package/src/clients/config-export/mcode.ts +83 -0
- package/src/clients/config-export/model-metadata.ts +108 -0
- package/src/clients/config-export/omp.ts +104 -0
- package/src/clients/config-export/zcode.ts +92 -0
- package/src/clients/config-export.ts +18 -710
- package/src/codex/account-lifecycle.ts +20 -3
- package/src/codex/account-usability.ts +2 -0
- package/src/codex/auth-api.ts +170 -23
- package/src/codex/auth-context.ts +328 -24
- package/src/codex/catalog/effort.ts +30 -4
- package/src/codex/catalog/metadata.ts +39 -9
- package/src/codex/catalog/native-models.ts +53 -12
- package/src/codex/catalog/parsing.ts +119 -5
- package/src/codex/catalog/provider-fetch.ts +150 -20
- package/src/codex/catalog/reserve.ts +52 -0
- package/src/codex/catalog/sync.ts +89 -16
- package/src/codex/convergence-types.ts +1 -0
- package/src/codex/convergence.ts +2 -0
- package/src/codex/data/upstream-models.json +1 -1
- package/src/codex/forward-transport-headers.ts +25 -0
- package/src/codex/inject.ts +3 -28
- package/src/codex/legacy-config-keys.ts +68 -0
- package/src/codex/log-guard/inspect-schema.ts +137 -0
- package/src/codex/log-guard/inspect.ts +2 -134
- package/src/codex/loopback-target.ts +54 -0
- package/src/codex/main-account-cache.ts +63 -1
- package/src/codex/main-account-hard-lock.ts +52 -0
- package/src/codex/main-account.ts +3 -1
- package/src/codex/management-convergence.ts +3 -0
- package/src/codex/model-entitlements.ts +54 -4
- package/src/codex/project-config-warnings.ts +92 -2
- package/src/codex/prompt-layers/encoding.ts +80 -0
- package/src/codex/prompt-layers/paths.ts +54 -0
- package/src/codex/prompt-layers/revision.ts +55 -0
- package/src/codex/prompt-layers/toml-edit.ts +163 -0
- package/src/codex/prompt-layers/toml-read.ts +181 -0
- package/src/codex/prompt-layers.ts +14 -520
- package/src/codex/quota-auto-refresh-state.ts +16 -0
- package/src/codex/quota-auto-refresh.ts +219 -0
- package/src/codex/quota-types.ts +51 -0
- package/src/codex/quota.ts +252 -93
- package/src/codex/reserve-availability.ts +177 -0
- package/src/codex/routing.ts +28 -9
- package/src/codex/shim.ts +53 -11
- package/src/codex/subagent-model-fallback.ts +23 -3
- package/src/combos/failover.ts +125 -7
- package/src/combos/identifiers.ts +89 -0
- package/src/combos/index.ts +4 -0
- package/src/combos/resolve.ts +80 -9
- package/src/combos/types.ts +20 -93
- package/src/config/subagent-models.ts +24 -0
- package/src/config.ts +156 -13
- package/src/generated/compatibility-version.json +454 -166
- package/src/generated/model-metadata.ts +1 -1
- package/src/integrations/journal.ts +65 -4
- package/src/integrations/store.ts +5 -0
- package/src/lab/events/limits.ts +4 -0
- package/src/lib/destination-policy.ts +31 -2
- package/src/lib/errors.ts +39 -0
- package/src/lib/provider-outbound.ts +69 -3
- package/src/lib/proxy-env.ts +22 -0
- package/src/lib/redact-folding.ts +176 -0
- package/src/lib/redact.ts +2 -175
- package/src/lib/state-store-sweeper.ts +20 -6
- package/src/lib/token-estimate.ts +94 -27
- package/src/lib/windows-user-principal.ts +53 -5
- package/src/oauth/anthropic-routing.ts +99 -3
- package/src/oauth/generic-account-failover.ts +36 -13
- package/src/oauth/index.ts +127 -26
- package/src/oauth/login-cli.ts +5 -0
- package/src/oauth/meta-muse.ts +117 -15
- package/src/oauth/pool-settings-capability.ts +15 -4
- package/src/providers/api-keys.ts +8 -10
- package/src/providers/default-aliases.ts +39 -0
- package/src/providers/derive.ts +10 -2
- package/src/providers/fastwire.ts +36 -7
- package/src/providers/initial-model-selection-runtime.ts +90 -0
- package/src/providers/initial-model-selection.ts +120 -0
- package/src/providers/key-failover.ts +134 -54
- package/src/providers/key-store.ts +11 -1
- package/src/providers/label.ts +1 -1
- package/src/providers/model-discovery.ts +76 -0
- package/src/providers/model-rename-startup.ts +72 -8
- package/src/providers/openai-sidecar.ts +17 -5
- package/src/providers/openai-tiers-destination.ts +102 -0
- package/src/providers/openai-tiers.ts +2 -99
- package/src/providers/opencode-go-transport.ts +41 -0
- package/src/providers/quota-key-accounts.ts +141 -0
- package/src/providers/quota-types.ts +9 -0
- package/src/providers/quota.ts +535 -104
- package/src/providers/registry.ts +60 -17
- package/src/providers/xai-responses-opt-in.ts +31 -5
- package/src/quota/reset-activation.ts +81 -0
- package/src/quota/reset-detector.ts +305 -0
- package/src/quota/reset-notify-config.ts +162 -0
- package/src/quota/reset-observer.ts +125 -0
- package/src/quota/reset-poller.ts +160 -0
- package/src/quota/reset-seen-store.ts +385 -0
- package/src/quota/reset-sinks.ts +199 -0
- package/src/quota/window-mapping.ts +106 -0
- package/src/responses/apply-patch-envelope.ts +46 -0
- package/src/responses/code-mode-helper-compat.ts +39 -1
- package/src/responses/custom-tool-compat.ts +10 -4
- package/src/responses/hosted-tool-policy.ts +12 -4
- package/src/responses/parser-content.ts +133 -0
- package/src/responses/parser-text-format.ts +24 -0
- package/src/responses/parser-tools.ts +188 -0
- package/src/responses/parser.ts +3 -326
- package/src/responses/state.ts +124 -28
- package/src/router.ts +48 -13
- package/src/routing/analytics.ts +1 -0
- package/src/routing/capability.ts +17 -4
- package/src/server/auth-cors.ts +7 -1
- package/src/server/background-lifecycle.ts +23 -1
- package/src/server/chat-completions.ts +25 -3
- package/src/server/claude-messages.ts +62 -5
- package/src/server/effort-row.ts +1 -1
- package/src/server/fast-row.ts +295 -0
- package/src/server/gui-static.ts +30 -4
- package/src/server/index.ts +107 -21
- package/src/server/management/agent-settings-routes.ts +2 -2
- package/src/server/management/combo-routes.ts +37 -9
- package/src/server/management/config-routes.ts +93 -2
- package/src/server/management/integration-routes.ts +108 -0
- package/src/server/management/model-routes.ts +13 -3
- package/src/server/management/model-rows.ts +20 -1
- package/src/server/management/native-integration-routes.ts +4 -1
- package/src/server/management/oauth-account-routes.ts +38 -10
- package/src/server/management/provider-routes.ts +34 -3
- package/src/server/management/quota-reset-routes.ts +57 -0
- package/src/server/management/route-registry.ts +7 -4
- package/src/server/management/shared.ts +19 -5
- package/src/server/management/system-routes.ts +3 -2
- package/src/server/management-api.ts +14 -2
- package/src/server/ports.ts +12 -2
- package/src/server/relay-eager.ts +38 -23
- package/src/server/relay.ts +4 -0
- package/src/server/request-log.ts +6 -0
- package/src/server/responses/codex-ws-correlation.ts +65 -0
- package/src/server/responses/codex-ws-exchange.ts +261 -0
- package/src/server/responses/codex-ws-metadata.ts +134 -0
- package/src/server/responses/codex-ws-pool.ts +162 -0
- package/src/server/responses/codex-ws-request.ts +87 -0
- package/src/server/responses/codex-ws-session.ts +93 -0
- package/src/server/responses/codex-ws-wire.ts +144 -0
- package/src/server/responses/collaboration.ts +41 -1
- package/src/server/responses/compact.ts +105 -12
- package/src/server/responses/core.ts +476 -57
- package/src/server/responses/fetch-helpers.ts +10 -3
- package/src/server/responses/input-admission.ts +16 -9
- package/src/server/responses/responses-field-backfill.ts +1 -1
- package/src/server/responses/ws-upstream.ts +34 -318
- package/src/server/responses-custom-tool-repair.ts +20 -4
- package/src/server/responses-undeclared-tool-guard.ts +100 -8
- package/src/server/safe-response-headers.ts +23 -0
- package/src/server/search.ts +9 -0
- package/src/server/subagent-models-startup.ts +27 -0
- package/src/server/system-env-shell.ts +238 -0
- package/src/server/system-env.ts +7 -234
- package/src/server/ws-bridge.ts +3 -25
- package/src/server/xai-responses-startup.ts +21 -0
- package/src/service-manager-probe.ts +1 -1
- package/src/service.ts +55 -16
- package/src/types/config.ts +108 -12
- package/src/types/provider.ts +36 -7
- package/src/types/tools.ts +26 -1
- package/src/types.ts +2 -0
- package/src/update/notify.ts +8 -2
- package/src/usage/cost.ts +38 -28
- package/src/usage/expected-prices.ts +34 -15
- package/src/usage/log.ts +2 -0
- package/src/usage/model-identity.ts +26 -0
- package/src/usage/summary.ts +15 -1
- package/src/vision/describe.ts +6 -0
- package/src/vision/image-rewrite.ts +108 -0
- package/src/vision/index.ts +19 -306
- package/src/vision/plan.ts +205 -0
- package/src/web-search/executor.ts +6 -0
- package/src/web-search/index.ts +8 -1
- package/gui/dist/assets/index-BU1tE0sr.js +0 -112
- package/gui/dist/assets/index-DL9-iS6J.css +0 -1
|
@@ -244,9 +244,22 @@ export function resolveFastPolicy(
|
|
|
244
244
|
};
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
|
|
247
|
+
/**
|
|
248
|
+
* Fold a caller's service tier onto a canonical fast marker.
|
|
249
|
+
*
|
|
250
|
+
* `ultrafast` is recognised as INTENT even though no shipped catalog advertises it and
|
|
251
|
+
* `DEFAULT_SERVICE_TIER_FAST_WIRE` has no wire mapping for it. That asymmetry is
|
|
252
|
+
* deliberate: a caller who sends `ultrafast` (which #3429's reporter did, via their own
|
|
253
|
+
* catalog edit) was previously folded to `undefined`, which made `fastIntent` false and
|
|
254
|
+
* recorded `fastOutcome: "not-requested"` — the log asserting the user asked for nothing.
|
|
255
|
+
* Recognising the intent without a wire mapping lands the attempt on `unknown` instead,
|
|
256
|
+
* which is the truth: the tier was requested, and we cannot confirm it was honored.
|
|
257
|
+
*/
|
|
258
|
+
export function canonicalFastTierMarker(callerTier: string | undefined): "priority" | "ultrafast" | undefined {
|
|
248
259
|
const folded = callerTier?.trim().toLowerCase();
|
|
249
|
-
|
|
260
|
+
if (folded === "priority" || folded === "fast") return "priority";
|
|
261
|
+
if (folded === "ultrafast") return "ultrafast";
|
|
262
|
+
return undefined;
|
|
250
263
|
}
|
|
251
264
|
|
|
252
265
|
/** Capture Fast demand before the final A1 serialization action rewrites the parsed tier view. */
|
|
@@ -298,7 +311,18 @@ export function createAdapterTierMetadata(
|
|
|
298
311
|
): AdapterTierMetadata | undefined {
|
|
299
312
|
if (!context || !decision) return undefined;
|
|
300
313
|
|
|
301
|
-
const
|
|
314
|
+
const callerMarker = canonicalFastTierMarker(context.callerTier);
|
|
315
|
+
// Two different questions, and conflating them mislabels the record.
|
|
316
|
+
//
|
|
317
|
+
// "Did the caller ask for FAST?" governs the drop and suppression facts: the Fast
|
|
318
|
+
// toggle suppressing a request is only true of the 1.5x Fast tier, so an `ultrafast`
|
|
319
|
+
// caller turned away by `fastMode: false` was NOT a suppressed Fast request and must
|
|
320
|
+
// still read as `callerTierDropped`.
|
|
321
|
+
//
|
|
322
|
+
// "Did the caller ask for SOME fast-family tier?" is the wider question, and only
|
|
323
|
+
// `fastIntent` below is entitled to it.
|
|
324
|
+
const callerCanonicalFast = callerMarker === "priority";
|
|
325
|
+
const callerFastFamilyIntent = callerMarker !== undefined;
|
|
302
326
|
const callerTierDropped = context.callerTier !== undefined
|
|
303
327
|
&& !callerCanonicalFast
|
|
304
328
|
&& wireValue === null;
|
|
@@ -338,7 +362,7 @@ export function createAdapterTierMetadata(
|
|
|
338
362
|
// Known-unsupported routes still need a downgrade when the caller/config expressed Fast intent,
|
|
339
363
|
// but they are deliberately outside the effective-demand calculation above.
|
|
340
364
|
const fastIntent = context.demandDecision === "force-fast"
|
|
341
|
-
|| (context.demandDecision === "inherit" &&
|
|
365
|
+
|| (context.demandDecision === "inherit" && callerFastFamilyIntent);
|
|
342
366
|
|
|
343
367
|
if (!fastIntent) {
|
|
344
368
|
outcome.fastOutcome = "not-requested";
|
|
@@ -421,9 +445,14 @@ export function decideTier(
|
|
|
421
445
|
const callerCanonicalFast = canonicalFastTierMarker(callerTier);
|
|
422
446
|
if (callerCanonicalFast !== undefined) {
|
|
423
447
|
const value = policy.fastWire.canonicalToWire[callerCanonicalFast];
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
448
|
+
if (typeof value === "string" && value.length > 0) return { kind: "set", value };
|
|
449
|
+
// A canonical marker with NO wire mapping is not a reason to drop the tier.
|
|
450
|
+
//
|
|
451
|
+
// `ultrafast` is recognised as intent but deliberately unmapped, because no wire
|
|
452
|
+
// advertises it. Dropping here would have made recognition strictly worse than not
|
|
453
|
+
// recognising it at all: before, `ultrafast` was a foreign tier and
|
|
454
|
+
// `foreignCallerTiers: "verbatim"` forwarded it untouched. Falling through keeps that
|
|
455
|
+
// behavior, so an operator-supplied tier still reaches the provider.
|
|
427
456
|
}
|
|
428
457
|
if (callerTier !== undefined && !policy.forwardCallerTier) return { kind: "drop" };
|
|
429
458
|
if (
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { mutatePersistedConfig, validateConfigCandidate } from "../config";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
4
|
+
import type { CatalogModel } from "../codex/catalog";
|
|
5
|
+
import {
|
|
6
|
+
adoptInitialModelSelections,
|
|
7
|
+
initialModelSelection,
|
|
8
|
+
initialModelSelectionPending,
|
|
9
|
+
reconcileInitialModelSelections,
|
|
10
|
+
} from "./initial-model-selection";
|
|
11
|
+
|
|
12
|
+
interface InitialSelectionBaseline {
|
|
13
|
+
providers: string[];
|
|
14
|
+
inventory: unknown;
|
|
15
|
+
disabled: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function inventoryIdentity(config: OcxConfig): unknown {
|
|
19
|
+
const validated = validateConfigCandidate(config);
|
|
20
|
+
if (!validated.ok) return null;
|
|
21
|
+
// Compare all inventory-producing configuration, including custom rows and combos.
|
|
22
|
+
// Normalize schema defaults, ignoring completed-selection state and switch values.
|
|
23
|
+
// Listener binding intentionally differs between live and disk after a port/host edit;
|
|
24
|
+
// it cannot affect provider discovery and must not leave registration pending forever.
|
|
25
|
+
// The incarnation remains: identical delete/re-add is NOT the same registration.
|
|
26
|
+
const providers = Object.fromEntries(Object.entries(validated.config.providers).map(([name, provider]) => [name, {
|
|
27
|
+
...provider,
|
|
28
|
+
initialModelSelection: initialModelSelection(provider)?.registrationId,
|
|
29
|
+
}]));
|
|
30
|
+
// Ephemeral only: never log this value, which may contain credentials.
|
|
31
|
+
return JSON.parse(JSON.stringify({ ...validated.config, providers, disabledModels: undefined, port: undefined, hostname: undefined }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function captureInitialSelectionBaseline(config: OcxConfig): InitialSelectionBaseline | null {
|
|
35
|
+
const providers = Object.entries(config.providers)
|
|
36
|
+
.filter(([, provider]) => initialModelSelectionPending(provider))
|
|
37
|
+
.map(([name]) => name);
|
|
38
|
+
if (!providers.length) return null;
|
|
39
|
+
const inventory = inventoryIdentity(config);
|
|
40
|
+
return inventory === null ? null : { providers, inventory, disabled: JSON.stringify(config.disabledModels ?? []) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Commit only decisions whose provider and user-selection snapshot still match. */
|
|
44
|
+
export function finalizeInitialModelSelection(
|
|
45
|
+
config: OcxConfig,
|
|
46
|
+
baseline: InitialSelectionBaseline | null,
|
|
47
|
+
models: readonly CatalogModel[],
|
|
48
|
+
authoritativeProviders: readonly string[],
|
|
49
|
+
): void {
|
|
50
|
+
if (!baseline || JSON.stringify(config.disabledModels ?? []) !== baseline.disabled) return;
|
|
51
|
+
if (!isDeepStrictEqual(inventoryIdentity(config), baseline.inventory)) return;
|
|
52
|
+
try {
|
|
53
|
+
const outcome = mutatePersistedConfig(fresh => {
|
|
54
|
+
if (!isDeepStrictEqual(inventoryIdentity(fresh), baseline.inventory)) return { changed: false, value: null };
|
|
55
|
+
const providers: Record<string, OcxProviderConfig> = {};
|
|
56
|
+
for (const name of baseline.providers) {
|
|
57
|
+
const provider = fresh.providers[name];
|
|
58
|
+
if (!provider || !initialModelSelection(provider)) continue;
|
|
59
|
+
// A concurrent successful initializer may already have committed its result.
|
|
60
|
+
// Adopt that result, including any later manual switch edits; never initialize twice.
|
|
61
|
+
if (initialModelSelectionPending(provider) && JSON.stringify(fresh.disabledModels ?? []) !== baseline.disabled) continue;
|
|
62
|
+
providers[name] = provider;
|
|
63
|
+
}
|
|
64
|
+
const projection = { ...fresh, providers };
|
|
65
|
+
const changed = reconcileInitialModelSelections(projection, models, authoritativeProviders);
|
|
66
|
+
if (changed) fresh.disabledModels = projection.disabledModels;
|
|
67
|
+
return { changed, value: { ...projection, disabledModels: fresh.disabledModels } };
|
|
68
|
+
});
|
|
69
|
+
if (outcome.status === "unavailable" || !outcome.value) return;
|
|
70
|
+
adoptInitialModelSelections(config, outcome.value);
|
|
71
|
+
if (Object.keys(outcome.value.providers).length) {
|
|
72
|
+
config.disabledModels = outcome.value.disabledModels === undefined ? undefined : [...outcome.value.disabledModels];
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Keep pending publication fenced on contention or failed persistence. A later ordinary
|
|
76
|
+
// model refresh retries; no dedicated timer and no private exception/path output.
|
|
77
|
+
console.warn("[initial-model-selection] Could not save initial model choices; model exposure remains pending. Retry model discovery.");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Ordinary discovery, before retained catalog evidence is captured. */
|
|
82
|
+
export async function resolvePendingInitialModelSelection(config: OcxConfig): Promise<void> {
|
|
83
|
+
const baseline = captureInitialSelectionBaseline(config);
|
|
84
|
+
if (!baseline) return;
|
|
85
|
+
const { gatherRoutedModels, uniqueCatalogModelsForPublicList } = await import("../codex/catalog");
|
|
86
|
+
const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = [];
|
|
87
|
+
const models = await gatherRoutedModels(config, { providerModelOutcomes: outcomes });
|
|
88
|
+
finalizeInitialModelSelection(config, baseline, uniqueCatalogModelsForPublicList(models),
|
|
89
|
+
outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider));
|
|
90
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "./registry";
|
|
4
|
+
import { routedSlug, slugEquivalenceKey } from "./slug-codec";
|
|
5
|
+
import { comboDisabledModelSelectors } from "../combos/types";
|
|
6
|
+
import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./key-store";
|
|
7
|
+
|
|
8
|
+
export const INITIAL_MODEL_SELECTION_THRESHOLD = 20;
|
|
9
|
+
type Selection = NonNullable<OcxProviderConfig["initialModelSelection"]>;
|
|
10
|
+
|
|
11
|
+
/** Read only the public, non-secret shape; editor input never owns this state. */
|
|
12
|
+
export function initialModelSelection(provider: OcxProviderConfig | undefined): Selection | undefined {
|
|
13
|
+
const value = provider?.initialModelSelection;
|
|
14
|
+
if (!value || value.version !== 1 || typeof value.registrationId !== "string"
|
|
15
|
+
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value.registrationId)
|
|
16
|
+
|| !["pending", "ready", "all-off"].includes(value.status)) return undefined;
|
|
17
|
+
return {
|
|
18
|
+
version: 1,
|
|
19
|
+
registrationId: value.registrationId,
|
|
20
|
+
status: value.status,
|
|
21
|
+
...(Number.isSafeInteger(value.modelCount) && value.modelCount! >= 0 ? { modelCount: value.modelCount } : {}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function initialModelSelectionPending(provider: OcxProviderConfig | undefined): boolean {
|
|
26
|
+
return initialModelSelection(provider)?.status === "pending";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function loginConnection(name: string, provider: OcxProviderConfig): boolean {
|
|
30
|
+
const entry = getProviderRegistryEntry(name);
|
|
31
|
+
if (entry && providerMatchesRegistryTransport(name, provider)) {
|
|
32
|
+
if (entry.authKind === "forward") return true;
|
|
33
|
+
if (entry.authKind === "oauth") {
|
|
34
|
+
return !providerUsesKeyAuthOverride(entry, provider, resolveProviderApiKey(provider.apiKey));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return provider.authMode === "oauth" || provider.authMode === "forward";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Registration only: absence on an existing row is legacy/exempt, never a migration trigger. */
|
|
41
|
+
export function initializeProviderModelSelection(
|
|
42
|
+
name: string,
|
|
43
|
+
next: OcxProviderConfig,
|
|
44
|
+
existing?: OcxProviderConfig,
|
|
45
|
+
config?: Pick<OcxConfig, "disabledModels" | "combos" | "modelDiscovery">,
|
|
46
|
+
): void {
|
|
47
|
+
delete next.initialModelSelection;
|
|
48
|
+
if (existing) {
|
|
49
|
+
for (const key of ["selectedModels", "modelPreset", "newModelPolicy"] as const) {
|
|
50
|
+
if (next[key] === undefined && existing[key] !== undefined) {
|
|
51
|
+
Object.assign(next, { [key]: structuredClone(existing[key]) });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (existing.initialModelSelection !== undefined) next.initialModelSelection = structuredClone(existing.initialModelSelection);
|
|
55
|
+
} else {
|
|
56
|
+
// A deleted provider's discovery history belongs to that old registration too.
|
|
57
|
+
if (config?.modelDiscovery?.knownModels) delete config.modelDiscovery.knownModels[name];
|
|
58
|
+
if (config?.modelDiscovery?.recentArrivals) delete config.modelDiscovery.recentArrivals[name];
|
|
59
|
+
if (config?.disabledModels) {
|
|
60
|
+
const comboSelectors = new Set(Object.entries(config.combos ?? {})
|
|
61
|
+
.flatMap(([id, combo]) => comboDisabledModelSelectors(id, combo)));
|
|
62
|
+
config.disabledModels = config.disabledModels.filter(selector =>
|
|
63
|
+
!selector.startsWith(`${name}/`) || comboSelectors.has(selector));
|
|
64
|
+
}
|
|
65
|
+
if (!loginConnection(name, next)) {
|
|
66
|
+
next.initialModelSelection = { version: 1, registrationId: randomUUID(), status: "pending" };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Count the canonical switch identities that the Models inventory displays. */
|
|
72
|
+
export function reconcileInitialModelSelections(
|
|
73
|
+
config: OcxConfig,
|
|
74
|
+
models: Iterable<{ provider: string; id: string }>,
|
|
75
|
+
authoritativeProviders: Iterable<string>,
|
|
76
|
+
): boolean {
|
|
77
|
+
const selectors = new Map<string, Set<string>>();
|
|
78
|
+
for (const model of models) {
|
|
79
|
+
const ids = selectors.get(model.provider) ?? new Set<string>();
|
|
80
|
+
ids.add(routedSlug(model.provider, model.id));
|
|
81
|
+
selectors.set(model.provider, ids);
|
|
82
|
+
}
|
|
83
|
+
const authoritative = new Set(authoritativeProviders);
|
|
84
|
+
let changed = false;
|
|
85
|
+
for (const [name, provider] of Object.entries(config.providers)) {
|
|
86
|
+
const initial = initialModelSelection(provider);
|
|
87
|
+
if (initial?.status !== "pending") continue;
|
|
88
|
+
if (loginConnection(name, provider)) {
|
|
89
|
+
provider.initialModelSelection = { version: 1, registrationId: initial.registrationId, status: "ready" };
|
|
90
|
+
changed = true;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!authoritative.has(name)) continue;
|
|
94
|
+
const ids = selectors.get(name) ?? new Set<string>();
|
|
95
|
+
const allOff = ids.size >= INITIAL_MODEL_SELECTION_THRESHOLD;
|
|
96
|
+
if (allOff) {
|
|
97
|
+
const disabled = config.disabledModels ??= [];
|
|
98
|
+
const keys = new Set(disabled.map(slugEquivalenceKey));
|
|
99
|
+
for (const id of ids) {
|
|
100
|
+
const key = slugEquivalenceKey(id);
|
|
101
|
+
if (!keys.has(key)) { disabled.push(id); keys.add(key); }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
provider.initialModelSelection = { version: 1, registrationId: initial.registrationId, status: allOff ? "all-off" : "ready", modelCount: ids.size };
|
|
105
|
+
changed = true;
|
|
106
|
+
}
|
|
107
|
+
return changed;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function adoptInitialModelSelections(target: OcxConfig, source: OcxConfig): void {
|
|
111
|
+
for (const [name, provider] of Object.entries(source.providers)) {
|
|
112
|
+
if (target.providers[name] && provider.initialModelSelection !== undefined) {
|
|
113
|
+
target.providers[name].initialModelSelection = structuredClone(provider.initialModelSelection);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function pendingModelSelectionProviders(config: Pick<OcxConfig, "providers">): Set<string> {
|
|
119
|
+
return new Set(Object.entries(config.providers).filter(([, provider]) => initialModelSelectionPending(provider)).map(([name]) => name));
|
|
120
|
+
}
|
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Modelled after src/codex/routing.ts cooldown logic but scoped to plain API-key pools.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import { mutatePersistedConfig } from "../config";
|
|
12
|
+
import { routedProviderConfig } from "../router";
|
|
12
13
|
import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy, TransientRetryPolicy } from "../types";
|
|
14
|
+
import { OPENCODE_GO_SESSION_HEADER } from "./opencode-go-transport";
|
|
13
15
|
import { resolveProviderTransport, type OcxProviderTransport } from "./xai-transport";
|
|
14
16
|
import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
|
|
15
17
|
|
|
@@ -174,11 +176,13 @@ export function rateLimitRetryDelayMs(
|
|
|
174
176
|
* The returned object is a snapshot of the PERSISTED config — it carries none of the
|
|
175
177
|
* registry backfills `routedProviderConfig` merges in at request time. Request paths must
|
|
176
178
|
* not assign it to an active route wholesale; use `rotateProviderTransportOn429`, which
|
|
177
|
-
*
|
|
179
|
+
* rebuilds from this committed row, reapplies registry metadata, and retains only explicit
|
|
180
|
+
* runtime transport state (`fetch` and generated OpenCode session affinity).
|
|
178
181
|
*/
|
|
179
|
-
|
|
182
|
+
function rotateKeyAfterFailure(
|
|
180
183
|
config: OcxConfig,
|
|
181
184
|
providerName: string,
|
|
185
|
+
failureStatus: 401 | 429,
|
|
182
186
|
retryAfterHeader: string | null | undefined,
|
|
183
187
|
now = Date.now(),
|
|
184
188
|
attemptedKey?: string,
|
|
@@ -187,50 +191,101 @@ export function rotateKeyOn429(
|
|
|
187
191
|
if (!provider) return null;
|
|
188
192
|
if (provider.authMode === "oauth" || provider.authMode === "forward") return null;
|
|
189
193
|
|
|
190
|
-
const pool = provider.apiKeyPool;
|
|
191
|
-
if (!pool || pool.length < 2) return null;
|
|
192
|
-
|
|
193
|
-
// Cool the key that ACTUALLY failed. Under concurrent 429s another request may already have
|
|
194
|
-
// rotated provider.apiKey — cooling the live key would punish an innocent replacement and can
|
|
195
|
-
// exhaust a 2-key pool from a single bad key. CAS semantics: callers pass the key they used.
|
|
196
194
|
const failedKey = attemptedKey ?? provider.apiKey;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
195
|
+
type Rotation =
|
|
196
|
+
| { provider: OcxProviderConfig; failedId?: string; candidateId?: string }
|
|
197
|
+
| { exhaustedCount: number; failedId?: string };
|
|
198
|
+
const outcome = mutatePersistedConfig<Rotation | null>(fresh => {
|
|
199
|
+
const freshProvider = fresh.providers[providerName];
|
|
200
|
+
if (!freshProvider || freshProvider.authMode === "oauth" || freshProvider.authMode === "forward") {
|
|
201
|
+
return { changed: false, value: null };
|
|
202
|
+
}
|
|
203
|
+
const pool = freshProvider.apiKeyPool;
|
|
204
|
+
if (!pool || pool.length < 2) return { changed: false, value: null };
|
|
205
205
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (
|
|
211
|
-
|
|
206
|
+
// The callback can be rerun after rebasing, so identify the failed key here but
|
|
207
|
+
// defer the in-memory cooldown side effect until persistence has succeeded.
|
|
208
|
+
const failedEntry = pool.find(entry => entry.key === failedKey);
|
|
209
|
+
|
|
210
|
+
if (freshProvider.apiKey !== failedKey) {
|
|
211
|
+
const activeEntry = pool.find(entry => entry.key === freshProvider.apiKey);
|
|
212
|
+
if (activeEntry && !isKeyInCooldown(providerName, activeEntry.id, now)) {
|
|
213
|
+
return {
|
|
214
|
+
changed: false,
|
|
215
|
+
value: { provider: structuredClone(freshProvider), failedId: failedEntry?.id },
|
|
216
|
+
};
|
|
217
|
+
}
|
|
212
218
|
}
|
|
213
|
-
}
|
|
214
219
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
220
|
+
const currentIndex = failedEntry ? pool.indexOf(failedEntry) : -1;
|
|
221
|
+
const candidateCount = failedEntry ? pool.length - 1 : pool.length;
|
|
222
|
+
for (let offset = 1; offset <= candidateCount; offset += 1) {
|
|
223
|
+
const candidate = pool[(currentIndex + offset) % pool.length]!;
|
|
224
|
+
if (isKeyInCooldown(providerName, candidate.id, now)) continue;
|
|
225
|
+
freshProvider.apiKey = candidate.key;
|
|
226
|
+
return {
|
|
227
|
+
changed: true,
|
|
228
|
+
value: {
|
|
229
|
+
provider: structuredClone(freshProvider),
|
|
230
|
+
failedId: failedEntry?.id,
|
|
231
|
+
candidateId: candidate.id,
|
|
232
|
+
},
|
|
233
|
+
};
|
|
228
234
|
}
|
|
235
|
+
return { changed: false, value: { exhaustedCount: pool.length, failedId: failedEntry?.id } };
|
|
236
|
+
});
|
|
237
|
+
if (outcome.status === "unavailable" || outcome.value === null) return null;
|
|
238
|
+
if (outcome.value.failedId) {
|
|
239
|
+
// A 401 is a verdict about the credential itself, not a timing signal: the key is rejected
|
|
240
|
+
// until an operator replaces it, and upstreams send no Retry-After for it. Hold it for the
|
|
241
|
+
// full cap instead of the 429 default so a dead key is not re-tried once a minute.
|
|
242
|
+
const cooldownMs = failureStatus === 401
|
|
243
|
+
? MAX_COOLDOWN_MS
|
|
244
|
+
: parseRetryAfterMs(retryAfterHeader, now) ?? DEFAULT_COOLDOWN_MS;
|
|
245
|
+
keyCooldowns.set(cooldownKey(providerName, outcome.value.failedId), { cooldownUntil: now + cooldownMs });
|
|
246
|
+
sweepExpiredOnWrite(now);
|
|
247
|
+
}
|
|
248
|
+
if ("exhaustedCount" in outcome.value) {
|
|
249
|
+
console.warn(`[key-failover] ${providerName}: all ${outcome.value.exhaustedCount} keys in cooldown after ${failureStatus}; returning the upstream status to the client`);
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const committed = structuredClone(outcome.value.provider);
|
|
254
|
+
config.providers[providerName] = committed;
|
|
255
|
+
if (outcome.value.candidateId) {
|
|
256
|
+
console.warn(
|
|
257
|
+
// Log ids only — labels are user-supplied free text and could carry secret material.
|
|
258
|
+
`[key-failover] ${providerName}: ${failureStatus} on key ${outcome.value.failedId ?? "?"}; rotating to key ${outcome.value.candidateId}`,
|
|
259
|
+
);
|
|
229
260
|
}
|
|
261
|
+
return structuredClone(committed);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function rotateKeyOn429(
|
|
265
|
+
config: OcxConfig,
|
|
266
|
+
providerName: string,
|
|
267
|
+
retryAfterHeader: string | null | undefined,
|
|
268
|
+
now = Date.now(),
|
|
269
|
+
attemptedKey?: string,
|
|
270
|
+
): OcxProviderConfig | null {
|
|
271
|
+
return rotateKeyAfterFailure(config, providerName, 429, retryAfterHeader, now, attemptedKey);
|
|
272
|
+
}
|
|
230
273
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
274
|
+
/**
|
|
275
|
+
* Record a 401 for the current key and attempt to switch to the next available one.
|
|
276
|
+
*
|
|
277
|
+
* A static key pool can recover a credential-scoped 401 without abandoning the provider: one
|
|
278
|
+
* revoked or mistyped key in a pool of several says nothing about its siblings. OAuth and
|
|
279
|
+
* forward providers never reach here — they refresh or re-authenticate instead, and
|
|
280
|
+
* `rotateKeyAfterFailure` rejects both auth modes outright.
|
|
281
|
+
*/
|
|
282
|
+
export function rotateKeyOn401(
|
|
283
|
+
config: OcxConfig,
|
|
284
|
+
providerName: string,
|
|
285
|
+
now = Date.now(),
|
|
286
|
+
attemptedKey?: string,
|
|
287
|
+
): OcxProviderConfig | null {
|
|
288
|
+
return rotateKeyAfterFailure(config, providerName, 401, null, now, attemptedKey);
|
|
234
289
|
}
|
|
235
290
|
|
|
236
291
|
export function sweepExpiredApiKeyCooldowns(now = Date.now()): number {
|
|
@@ -253,13 +308,8 @@ interface RotateProviderTransportOptions {
|
|
|
253
308
|
/**
|
|
254
309
|
* Rotate a failed key and re-apply provider-specific transport metadata to the replacement.
|
|
255
310
|
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
* config that `rotateKeyOn429` snapshots predates registry backfill, so building the retry
|
|
259
|
-
* provider from that snapshot would silently drop every field the registry merged in at
|
|
260
|
-
* routing time (scalar flags like `promptCacheKey`/`parallelToolCalls`, merged model
|
|
261
|
-
* metadata such as `noTemperatureModels`, a pinned baseUrl). Mirrors the OAuth-401 replay
|
|
262
|
-
* path in src/server/responses/core.ts, which spreads `route.provider` for the same reason.
|
|
311
|
+
* Route the authoritative committed row again so concurrent provider edits take effect, then
|
|
312
|
+
* restore only transport-only state that can never come from persisted configuration.
|
|
263
313
|
*/
|
|
264
314
|
export function rotateProviderTransportOn429(
|
|
265
315
|
config: OcxConfig,
|
|
@@ -274,13 +324,43 @@ export function rotateProviderTransportOn429(
|
|
|
274
324
|
options.now,
|
|
275
325
|
options.attemptedKey,
|
|
276
326
|
);
|
|
277
|
-
return
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
327
|
+
if (!rotated) return null;
|
|
328
|
+
return applyRotatedTransport(providerName, routedProvider, rotated, options.promptCacheKey);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** 401 counterpart of `rotateProviderTransportOn429`; shares its transport-rebuild rules. */
|
|
332
|
+
export function rotateProviderTransportOn401(
|
|
333
|
+
config: OcxConfig,
|
|
334
|
+
providerName: string,
|
|
335
|
+
routedProvider: OcxProviderTransport,
|
|
336
|
+
options: Omit<RotateProviderTransportOptions, "retryAfter"> = {},
|
|
337
|
+
): OcxProviderTransport | null {
|
|
338
|
+
const rotated = rotateKeyOn401(config, providerName, options.now, options.attemptedKey);
|
|
339
|
+
if (!rotated) return null;
|
|
340
|
+
return applyRotatedTransport(providerName, routedProvider, rotated, options.promptCacheKey);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function applyRotatedTransport(
|
|
344
|
+
providerName: string,
|
|
345
|
+
routedProvider: OcxProviderTransport,
|
|
346
|
+
rotated: OcxProviderConfig,
|
|
347
|
+
promptCacheKey?: string,
|
|
348
|
+
): OcxProviderTransport {
|
|
349
|
+
const committedRoute = routedProviderConfig(providerName, rotated);
|
|
350
|
+
const routedSession = routedProvider.headers?.[OPENCODE_GO_SESSION_HEADER];
|
|
351
|
+
const retryProvider: OcxProviderTransport = {
|
|
352
|
+
...committedRoute,
|
|
353
|
+
...(routedProvider.fetch !== undefined ? { fetch: routedProvider.fetch } : {}),
|
|
354
|
+
...(routedSession !== undefined
|
|
355
|
+
? {
|
|
356
|
+
headers: {
|
|
357
|
+
...(committedRoute.headers ?? {}),
|
|
358
|
+
[OPENCODE_GO_SESSION_HEADER]: routedSession,
|
|
359
|
+
},
|
|
360
|
+
}
|
|
361
|
+
: {}),
|
|
362
|
+
};
|
|
363
|
+
return resolveProviderTransport(providerName, retryProvider, promptCacheKey);
|
|
284
364
|
}
|
|
285
365
|
|
|
286
366
|
/** Clear cooldown state for a provider (e.g. after manual key management). */
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { resolveEnvValue, saveConfigPreservingClaudeCode } from "../config";
|
|
3
3
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
4
|
+
import type { ProviderRegistryEntry } from "./registry";
|
|
5
|
+
|
|
6
|
+
/** Shared with routing: a key-mode override is effective only while its key resolves. */
|
|
7
|
+
export function providerUsesKeyAuthOverride(
|
|
8
|
+
entry: Pick<ProviderRegistryEntry, "authKind" | "allowKeyAuthOverride">,
|
|
9
|
+
provider: Pick<OcxProviderConfig, "authMode">,
|
|
10
|
+
resolvedKey: string | undefined,
|
|
11
|
+
): boolean {
|
|
12
|
+
return entry.authKind === "oauth" && entry.allowKeyAuthOverride === true
|
|
13
|
+
&& provider.authMode === "key" && typeof resolvedKey === "string" && resolvedKey.trim().length > 0;
|
|
14
|
+
}
|
|
4
15
|
|
|
5
16
|
/**
|
|
6
17
|
* Opt-in OS keychain storage for provider API keys (#1221).
|
|
@@ -194,4 +205,3 @@ export function restoreProviderKeyFromKeychain(config: OcxConfig, name: string):
|
|
|
194
205
|
saveConfigPreservingClaudeCode(config);
|
|
195
206
|
return { ok: true, restored: resolved.size };
|
|
196
207
|
}
|
|
197
|
-
|
package/src/providers/label.ts
CHANGED
|
@@ -36,7 +36,7 @@ export function baseProviderLabel(provider: string): string {
|
|
|
36
36
|
* It lives here rather than in `account-label.ts` because it needs `baseProviderLabel`, and this
|
|
37
37
|
* module already imports from that one -- the reverse direction would be an import cycle. This
|
|
38
38
|
* file stays Lab-clean, which matters because `core.ts` is one of the three files
|
|
39
|
-
* `tests/core-lab-boundary.test.ts` guards.
|
|
39
|
+
* `tests/lab/core-lab-boundary.test.ts` guards.
|
|
40
40
|
*/
|
|
41
41
|
export function stampOAuthAccountLabel(
|
|
42
42
|
logCtx: { accountLogLabel?: string },
|
|
@@ -158,6 +158,82 @@ function appendDiscoveryQuery(url: URL, query: Readonly<Record<string, string>>
|
|
|
158
158
|
return url;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Whether a model-discovery request URL is a registry-owned fixed discovery
|
|
163
|
+
* URL — the canonical-URL proof for the transparent fake-IP (Clash/Surge/
|
|
164
|
+
* Mihomo TUN) exception in provider-outbound.
|
|
165
|
+
*
|
|
166
|
+
* The proof is on the FINAL URL, not the provider name: an OAuth/forward name
|
|
167
|
+
* matches any baseUrl by design (`providerMatchesRegistryTransport` returns
|
|
168
|
+
* true regardless of destination), while the bearer is pinned to the registry
|
|
169
|
+
* destination independently in `buildModelsRequest`. Comparing the fetched URL
|
|
170
|
+
* against registry spec URLs keeps a renamed custom row fetching an
|
|
171
|
+
* attacker-controlled URL from gaining the exception.
|
|
172
|
+
*
|
|
173
|
+
* Both spec shapes are covered: an absolute `url` spec matches its own URL
|
|
174
|
+
* (plus the spec's fixed query), and a `path` spec matches the URL it resolves
|
|
175
|
+
* to against the registry's own baseUrl (plus the spec's fixed query) — so the
|
|
176
|
+
* `commandcode` key preset's `path: "models"` proves the same
|
|
177
|
+
* `https://api.commandcode.ai/provider/v1/models` string the `command-code`
|
|
178
|
+
* OAuth `url` spec proves, and the `nebius` `path: "models"` plus
|
|
179
|
+
* `query: { verbose: "true" }` proves
|
|
180
|
+
* `https://api.tokenfactory.nebius.com/v1/models?verbose=true`.
|
|
181
|
+
*
|
|
182
|
+
* Registry-owned fixed query parameters are canonical only on EXACT match:
|
|
183
|
+
* a missing, changed, or additional parameter is not canonical, so `?token=`
|
|
184
|
+
* smuggling on the right origin+path stays rejected. Fragments are never
|
|
185
|
+
* canonical.
|
|
186
|
+
*/
|
|
187
|
+
export function isRegistryModelDiscoveryUrl(providerName: string, url: string): boolean {
|
|
188
|
+
const entry = getProviderRegistryEntry(providerName);
|
|
189
|
+
const spec = entry?.modelDiscovery;
|
|
190
|
+
if (!spec) return false;
|
|
191
|
+
let candidate: URL;
|
|
192
|
+
try {
|
|
193
|
+
candidate = new URL(url);
|
|
194
|
+
} catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
if (candidate.protocol !== "https:") return false;
|
|
198
|
+
if (candidate.username || candidate.password) return false;
|
|
199
|
+
if (candidate.hash) return false;
|
|
200
|
+
const sameUrl = (canonical: string): boolean => {
|
|
201
|
+
let expected: URL;
|
|
202
|
+
try {
|
|
203
|
+
expected = new URL(canonical);
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
return candidate.origin === expected.origin
|
|
208
|
+
&& candidate.pathname.replace(/\/+$/, "") === expected.pathname.replace(/\/+$/, "")
|
|
209
|
+
&& candidate.search === expected.search;
|
|
210
|
+
};
|
|
211
|
+
// One shared construction with `resolveProviderModelDiscoveryUrl` below: the
|
|
212
|
+
// absolute `url` form carries the spec's fixed query (if any), and the `path`
|
|
213
|
+
// form resolves against the REGISTRY's own baseUrl (never a configured one)
|
|
214
|
+
// before appending the spec's fixed query. The candidate's own query must
|
|
215
|
+
// equal the registry-owned query exactly — no subset/superset matching.
|
|
216
|
+
if ("url" in spec && spec.url) {
|
|
217
|
+
try {
|
|
218
|
+
return sameUrl(appendDiscoveryQuery(new URL(spec.url), spec.query).toString());
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if ("path" in spec && spec.path) {
|
|
224
|
+
try {
|
|
225
|
+
const base = new URL(entry.baseUrl.endsWith("/") ? entry.baseUrl : `${entry.baseUrl}/`);
|
|
226
|
+
const resolved = spec.path.startsWith("/")
|
|
227
|
+
? new URL(spec.path, base.origin)
|
|
228
|
+
: new URL(spec.path, base);
|
|
229
|
+
return sameUrl(appendDiscoveryQuery(resolved, spec.query).toString());
|
|
230
|
+
} catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
161
237
|
/** Apply a registry-owned URL/path/query policy to the adapter's normal discovery endpoint. */
|
|
162
238
|
export function resolveProviderModelDiscoveryUrl(
|
|
163
239
|
providerName: string,
|