@bitkyc08/opencodex 2.10.0 → 2.10.1
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 +77 -0
- package/README.md +4 -10
- package/bin/ocx.mjs +71 -18
- package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
- package/gui/dist/assets/index-ChZQsmBY.js +70 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/alibaba-color.svg +1 -1
- package/gui/dist/provider-icons/antigravity-color.svg +1 -1
- package/gui/dist/provider-icons/claude-color.svg +1 -1
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/copilot-color.svg +1 -1
- package/gui/dist/provider-icons/cursor-color.svg +1 -1
- package/gui/dist/provider-icons/deepseek-color.svg +1 -1
- package/gui/dist/provider-icons/firepass-color.svg +1 -1
- package/gui/dist/provider-icons/fireworks-color.svg +1 -1
- package/gui/dist/provider-icons/gemini-color.svg +1 -1
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
- package/gui/dist/provider-icons/grok.svg +1 -1
- package/gui/dist/provider-icons/groq-color.svg +1 -1
- package/gui/dist/provider-icons/huggingface-color.svg +1 -1
- package/gui/dist/provider-icons/kimi-color.svg +1 -1
- package/gui/dist/provider-icons/kiro-color.svg +2 -2
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
- package/gui/dist/provider-icons/mistral-color.svg +1 -1
- package/gui/dist/provider-icons/moonshot-color.svg +1 -1
- package/gui/dist/provider-icons/nvidia-color.svg +1 -1
- package/gui/dist/provider-icons/ollama-color.svg +1 -1
- package/gui/dist/provider-icons/openai.svg +1 -1
- package/gui/dist/provider-icons/opencode.svg +2 -1
- package/gui/dist/provider-icons/openrouter-color.svg +1 -1
- package/gui/dist/provider-icons/pi.svg +2 -2
- package/gui/dist/provider-icons/qianfan-color.svg +1 -1
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/vllm-color.svg +1 -1
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
- package/package.json +8 -4
- package/src/adapters/anthropic.ts +208 -14
- package/src/adapters/base.ts +16 -5
- package/src/adapters/cursor/effort-map.ts +3 -2
- package/src/adapters/cursor/framing.ts +39 -0
- package/src/adapters/cursor/live-transport.ts +105 -95
- package/src/adapters/cursor/native-exec.ts +32 -6
- package/src/adapters/cursor/protobuf-request.ts +20 -15
- package/src/adapters/cursor/request-builder.ts +21 -7
- package/src/adapters/cursor/types.ts +7 -0
- package/src/adapters/google-antigravity-replay.ts +237 -21
- package/src/adapters/google-truncation.ts +11 -0
- package/src/adapters/google.ts +50 -9
- package/src/adapters/identity.ts +39 -6
- package/src/adapters/kiro-errors.ts +11 -0
- package/src/adapters/kiro-events.ts +19 -1
- package/src/adapters/kiro-thinking.ts +10 -2
- package/src/adapters/kiro-tools.ts +10 -1
- package/src/adapters/kiro.ts +37 -11
- package/src/adapters/openai-chat.ts +284 -83
- package/src/adapters/openai-responses.ts +182 -24
- package/src/bridge.ts +177 -7
- package/src/chat/outbound.ts +78 -23
- package/src/claude/agents-inject.ts +27 -5
- package/src/claude/inbound.ts +11 -1
- package/src/claude/model-info.ts +13 -10
- package/src/claude/outbound.ts +17 -0
- package/src/cli/account-api.ts +24 -0
- package/src/cli/account-auth.ts +31 -6
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +5 -0
- package/src/cli/claude.ts +2 -1
- package/src/cli/doctor.ts +93 -22
- package/src/cli/export-command.ts +26 -12
- package/src/cli/help.ts +8 -6
- package/src/cli/index.ts +56 -22
- package/src/cli/integrations.ts +84 -1
- package/src/cli/observe.ts +54 -1
- package/src/cli/opencode.ts +2 -1
- package/src/cli/provider-runtime.ts +18 -1
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +6 -3
- package/src/cli/star-prompt.ts +71 -15
- package/src/cli/status.ts +1 -1
- package/src/cli/v2.ts +36 -9
- package/src/clients/config-export.ts +687 -10
- package/src/codex/account-lifecycle.ts +30 -5
- package/src/codex/account-usability.ts +22 -2
- package/src/codex/admission.ts +255 -0
- package/src/codex/auth-api.ts +427 -140
- package/src/codex/auth-context.ts +155 -30
- package/src/codex/autostart-health.ts +8 -1
- package/src/codex/catalog/account-models.ts +62 -0
- package/src/codex/catalog/aggregation.ts +14 -1
- package/src/codex/catalog/bundled.ts +282 -32
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/metadata.ts +51 -6
- package/src/codex/catalog/parsing.ts +6 -3
- package/src/codex/catalog/provider-fetch.ts +576 -41
- package/src/codex/catalog/sync.ts +505 -66
- package/src/codex/catalog-admission.ts +197 -0
- package/src/codex/catalog-write-serialization.ts +241 -0
- package/src/codex/catalog.ts +2 -1
- package/src/codex/codex-write-lock.ts +372 -0
- package/src/codex/convergence-types.ts +593 -0
- package/src/codex/convergence.ts +441 -0
- package/src/codex/desired-state.ts +177 -0
- package/src/codex/features.ts +52 -8
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +257 -0
- package/src/codex/history-lock.ts +241 -0
- package/src/codex/history-migration-guardian.ts +18 -5
- package/src/codex/history-provider.ts +9 -2
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +176 -0
- package/src/codex/inject-coordination.ts +245 -0
- package/src/codex/inject.ts +605 -124
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +80 -0
- package/src/codex/journal.ts +10 -1
- package/src/codex/main-account-cache.ts +24 -0
- package/src/codex/management-convergence.ts +114 -0
- package/src/codex/native-main-admission.ts +47 -0
- package/src/codex/native-main-auth-temp.ts +187 -0
- package/src/codex/native-main-claim.ts +167 -0
- package/src/codex/native-main-lock-file.ts +158 -0
- package/src/codex/native-main-owner.ts +315 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1512 -0
- package/src/codex/native-profile-processes.ts +121 -0
- package/src/codex/native-profile-recovery.ts +99 -0
- package/src/codex/native-profile-stage-store.ts +387 -0
- package/src/codex/native-profile-startup.ts +340 -0
- package/src/codex/native-profile-store.ts +855 -0
- package/src/codex/native-profile-types.ts +120 -0
- package/src/codex/native-residue.ts +557 -0
- package/src/codex/project-config-warnings.ts +18 -4
- package/src/codex/prompt-journal.ts +311 -0
- package/src/codex/prompt-layers.ts +967 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/quota-rejection.ts +224 -0
- package/src/codex/quota.ts +86 -3
- package/src/codex/routing.ts +299 -62
- package/src/codex/runtime.ts +159 -38
- package/src/codex/shim.ts +39 -13
- package/src/codex/subagent-model-fallback.ts +73 -12
- package/src/codex/transition-state.ts +604 -0
- package/src/codex/upstream-host-health.ts +70 -0
- package/src/codex/user-identity.ts +266 -0
- package/src/codex/write-coordination.ts +114 -0
- package/src/config.ts +562 -26
- package/src/generated/jawcode-model-metadata.ts +2 -2
- package/src/grok/inject.ts +15 -4
- package/src/grok/inspect.ts +45 -0
- package/src/images/loop.ts +113 -20
- package/src/integrations/config-io.ts +151 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/native/ownership-preflight.ts +165 -0
- package/src/integrations/ownership.ts +111 -0
- package/src/integrations/registry.ts +101 -0
- package/src/integrations/serialize.ts +235 -0
- package/src/integrations/state.ts +290 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer.ts +492 -0
- package/src/lib/bounded-body.ts +46 -8
- package/src/lib/bun-runtime.ts +110 -1
- package/src/lib/bun-stream-caps.ts +2 -1
- package/src/lib/redact.ts +407 -2
- package/src/lib/shadow-call.ts +24 -0
- package/src/lib/translator-budget.ts +10 -0
- package/src/lib/upstream-reachability.ts +91 -0
- package/src/lib/upstream-retry.ts +154 -2
- package/src/lib/windows-secret-acl.ts +212 -11
- package/src/lib/winsw.ts +9 -3
- package/src/oauth/index.ts +61 -3
- package/src/oauth/key-providers.ts +4 -0
- package/src/oauth/kiro.ts +50 -6
- package/src/oauth/store.ts +31 -6
- package/src/oauth/token-guardian.ts +9 -3
- package/src/providers/codex-capacity.ts +288 -0
- package/src/providers/derive.ts +33 -1
- package/src/providers/free-directory.ts +3 -1
- package/src/providers/key-failover.ts +71 -3
- package/src/providers/openai-sidecar.ts +64 -4
- package/src/providers/openai-virtual-models.ts +1 -0
- package/src/providers/quota.ts +334 -26
- package/src/providers/registry.ts +284 -16
- package/src/providers/xai-transport.ts +11 -4
- package/src/responses/compaction.ts +8 -1
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/parser.ts +44 -2
- package/src/responses/reasoning-envelope.ts +9 -1
- package/src/responses/reasoning-replay-cache.ts +105 -0
- package/src/responses/spill-store.ts +45 -8
- package/src/responses/state.ts +161 -17
- package/src/router.ts +302 -16
- package/src/routing/analytics.ts +377 -0
- package/src/routing/capability.ts +204 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +444 -0
- package/src/routing/health.ts +401 -0
- package/src/routing/history/cursor.ts +43 -0
- package/src/routing/history/indexer.ts +590 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile.ts +423 -0
- package/src/routing/quota.ts +124 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +686 -0
- package/src/server/auth-cors.ts +46 -6
- package/src/server/chat-completions.ts +28 -13
- package/src/server/claude-messages.ts +23 -15
- package/src/server/gui-static.ts +39 -10
- package/src/server/images.ts +10 -1
- package/src/server/index.ts +238 -52
- package/src/server/lifecycle.ts +228 -9
- package/src/server/live.ts +14 -3
- package/src/server/management/agent-settings-routes.ts +64 -14
- package/src/server/management/combo-routes.ts +5 -5
- package/src/server/management/config-routes.ts +1 -1
- package/src/server/management/context.ts +42 -2
- package/src/server/management/integration-routes.ts +538 -0
- package/src/server/management/logs-usage-routes.ts +1 -1
- package/src/server/management/model-routes.ts +32 -113
- package/src/server/management/model-rows.ts +117 -0
- package/src/server/management/native-integration-routes.ts +587 -0
- package/src/server/management/oauth-account-routes.ts +1 -1
- package/src/server/management/provider-routes.ts +218 -117
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +177 -0
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +47 -31
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +276 -24
- package/src/server/management/system-routes.ts +4 -0
- package/src/server/management-api.ts +84 -9
- package/src/server/management-auth.ts +43 -5
- package/src/server/relay-eager.ts +82 -42
- package/src/server/relay.ts +120 -6
- package/src/server/request-log.ts +26 -6
- package/src/server/responses/collaboration.ts +63 -8
- package/src/server/responses/compact.ts +272 -41
- package/src/server/responses/core.ts +730 -132
- package/src/server/responses/fetch-helpers.ts +15 -1
- package/src/server/responses-item-id-repair.ts +32 -3
- package/src/server/responses-json-events.ts +52 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/search.ts +51 -6
- package/src/server/sse-payload-rewrite.ts +89 -12
- package/src/server/startup-health-cache.ts +7 -1
- package/src/server/ws-bridge.ts +11 -17
- package/src/service-manager-probe.ts +297 -0
- package/src/service.ts +222 -32
- package/src/tray/windows-tray.ps1 +9 -0
- package/src/tray/windows.ts +15 -7
- package/src/types.ts +194 -14
- package/src/update/index.ts +13 -13
- package/src/update/job.ts +24 -21
- package/src/update/notify.ts +7 -3
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +129 -10
- package/src/usage/log.ts +50 -15
- package/src/usage/summary.ts +4 -4
- package/src/vision/index.ts +6 -1
- package/src/web-search/loop.ts +161 -34
- package/gui/dist/assets/index-OY43ubAq.css +0 -1
- package/gui/dist/assets/index-YwNnKZcL.js +0 -67
- package/gui/dist/provider-icons/antigravity.svg +0 -1
- package/gui/dist/provider-icons/claude.svg +0 -1
- package/gui/dist/provider-icons/copilot.svg +0 -1
- package/gui/dist/provider-icons/cursor.svg +0 -2
- package/gui/dist/provider-icons/gemini.svg +0 -1
- package/gui/dist/provider-icons/grok-color.svg +0 -1
- package/gui/dist/provider-icons/kiro.svg +0 -14
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
2
|
+
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
3
3
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
|
|
4
4
|
import { delimiter, dirname, join, resolve } from "node:path";
|
|
5
|
-
import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config";
|
|
5
|
+
import { atomicWriteFile, expandUserPath, getConfigDir, resolveEnvValue, websocketsEnabled } from "../../config";
|
|
6
6
|
import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths";
|
|
7
7
|
import {
|
|
8
8
|
clearModelCache,
|
|
@@ -18,7 +18,12 @@ import {
|
|
|
18
18
|
setCached,
|
|
19
19
|
type ProviderModelDiscoveryFailure,
|
|
20
20
|
} from "../model-cache";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
buildModelsRequest,
|
|
23
|
+
observeActiveOAuthAccessToken,
|
|
24
|
+
resolveModelsAuthToken,
|
|
25
|
+
type OAuthActiveTokenObservation,
|
|
26
|
+
} from "../../oauth";
|
|
22
27
|
import type { OcxConfig, OcxProviderConfig } from "../../types";
|
|
23
28
|
import { modelInList } from "../../types";
|
|
24
29
|
import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
|
|
@@ -51,6 +56,7 @@ import {
|
|
|
51
56
|
resolveProviderModelDiscovery,
|
|
52
57
|
type ModelDiscoveryResponseFailure,
|
|
53
58
|
type ProviderModelsApiItem,
|
|
59
|
+
type ResolvedProviderModelDiscovery,
|
|
54
60
|
} from "../../providers/model-discovery";
|
|
55
61
|
import upstreamModelsSnapshot from "../data/upstream-models.json";
|
|
56
62
|
import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission";
|
|
@@ -61,15 +67,125 @@ import type { CatalogModel } from "./parsing";
|
|
|
61
67
|
import { disabledNativeSlugs, hasComboTargets, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata";
|
|
62
68
|
import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation";
|
|
63
69
|
import type { ComboCatalogOmission } from "./aggregation";
|
|
70
|
+
import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence";
|
|
71
|
+
import type {
|
|
72
|
+
CatalogAdmissionSnapshot,
|
|
73
|
+
CatalogDiscoveryPolicyField,
|
|
74
|
+
CatalogGatherAuthorityIdentity,
|
|
75
|
+
CatalogProviderDiscoveryPolicySnapshot,
|
|
76
|
+
CatalogProcessLocalEvidence,
|
|
77
|
+
CatalogSourceEvidence,
|
|
78
|
+
CatalogTrustedOpenAiApiPolicySnapshot,
|
|
79
|
+
} from "../convergence-types";
|
|
80
|
+
|
|
81
|
+
export type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence";
|
|
64
82
|
|
|
65
83
|
/** Concurrent gatherRoutedModels callers with the same catalog identity share one live discovery.
|
|
66
84
|
* Keyed by gatherFlightKey so a different config cannot join or evict the wrong flight. */
|
|
85
|
+
export interface CatalogGatherProviderAuthOutcome {
|
|
86
|
+
readonly provider: string;
|
|
87
|
+
readonly state: OAuthActiveTokenObservation["kind"];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface GatherRoutedModelsOptions {
|
|
91
|
+
comboOmissions?: ComboCatalogOmission[];
|
|
92
|
+
providerAuthOutcomes?: CatalogGatherProviderAuthOutcome[];
|
|
93
|
+
/** Internal convergence sink for the immutable policy that produced the returned rows. */
|
|
94
|
+
discoveryPolicySnapshots?: CatalogProviderDiscoveryPolicySnapshot[];
|
|
95
|
+
}
|
|
96
|
+
|
|
67
97
|
interface GatherFlightResult {
|
|
68
98
|
models: CatalogModel[];
|
|
69
99
|
comboOmissions: ComboCatalogOmission[];
|
|
100
|
+
providerAuthOutcomes: readonly CatalogGatherProviderAuthOutcome[];
|
|
101
|
+
discoveryPolicySnapshots: readonly CatalogProviderDiscoveryPolicySnapshot[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface ModelsAuthResolution {
|
|
105
|
+
readonly apiKey: string | undefined;
|
|
106
|
+
readonly observed: boolean;
|
|
107
|
+
readonly oauthApiBaseUrl?: string;
|
|
70
108
|
}
|
|
71
109
|
|
|
72
|
-
|
|
110
|
+
type ModelsAuthResolver =
|
|
111
|
+
| { readonly kind: "refreshing" }
|
|
112
|
+
| {
|
|
113
|
+
readonly kind: "observed";
|
|
114
|
+
readonly resolve: (name: string, provider: OcxProviderConfig) => ModelsAuthResolution;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
type ModelsAuthResolverFactory = (
|
|
118
|
+
outcomes: CatalogGatherProviderAuthOutcome[],
|
|
119
|
+
) => ModelsAuthResolver;
|
|
120
|
+
|
|
121
|
+
interface CapturedModelsRequest {
|
|
122
|
+
readonly method: "GET";
|
|
123
|
+
readonly url: string;
|
|
124
|
+
readonly headersWithoutCredential: Readonly<Record<string, string>>;
|
|
125
|
+
readonly headersWithCredential: Readonly<Record<string, string>>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface CapturedProviderGather {
|
|
129
|
+
readonly name: string;
|
|
130
|
+
readonly provider: OcxProviderConfig;
|
|
131
|
+
readonly discovery: ResolvedProviderModelDiscovery;
|
|
132
|
+
readonly policy: CatalogProviderDiscoveryPolicySnapshot;
|
|
133
|
+
readonly request: CapturedModelsRequest;
|
|
134
|
+
readonly observedAuth?: ModelsAuthResolution;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface GatherFlightCapture {
|
|
138
|
+
readonly discoveryPolicyIdentity: string;
|
|
139
|
+
readonly authIdentity: string;
|
|
140
|
+
readonly providerGraphIdentity: string;
|
|
141
|
+
readonly discoveryPolicySnapshots: readonly CatalogProviderDiscoveryPolicySnapshot[];
|
|
142
|
+
readonly providers: readonly CapturedProviderGather[];
|
|
143
|
+
readonly authResolver: ModelsAuthResolver;
|
|
144
|
+
readonly providerAuthOutcomes: readonly CatalogGatherProviderAuthOutcome[];
|
|
145
|
+
readonly openAiApiPolicy: CatalogTrustedOpenAiApiPolicySnapshot;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface GatherInflightEntry {
|
|
149
|
+
readonly discoveryPolicyIdentity: string;
|
|
150
|
+
/**
|
|
151
|
+
* The credential half of the join decision.
|
|
152
|
+
*
|
|
153
|
+
* `gatherFlightKey`'s fingerprint carries endpoints and model lists but no
|
|
154
|
+
* `authMode`, key or headers, and discovery policy does not carry them either.
|
|
155
|
+
* Two admissions differing ONLY in credential therefore produced the same key
|
|
156
|
+
* and the same policy, so the second joined the first and published rows the
|
|
157
|
+
* old key had fetched — reproduced against the real routes by rotating a key
|
|
158
|
+
* through `/api/providers/keys` mid-flight.
|
|
159
|
+
*
|
|
160
|
+
* Now REDUNDANT with `providerGraphIdentity`, which hashes the whole provider
|
|
161
|
+
* row and therefore covers `apiKey` too: removing this term alone leaves the
|
|
162
|
+
* credential regression green. It is kept deliberately, for two reasons. It
|
|
163
|
+
* covers what the graph cannot — the RESOLVED auth (`observedAuth`) and the
|
|
164
|
+
* final materialized headers, which are derived rather than stored, so an
|
|
165
|
+
* OAuth token that changes while the row is byte-identical still separates
|
|
166
|
+
* admissions. And it states the credential rule where a reader looks for it,
|
|
167
|
+
* instead of leaving it as an emergent property of hashing everything.
|
|
168
|
+
*/
|
|
169
|
+
readonly authIdentity: string;
|
|
170
|
+
/**
|
|
171
|
+
* The whole admitted provider graph, not a chosen subset.
|
|
172
|
+
*
|
|
173
|
+
* `providerCatalogFingerprint` is an ALLOW-LIST, so every field it forgot was
|
|
174
|
+
* silently treated as equivalence: credentials leaked a flight until
|
|
175
|
+
* `authIdentity` landed, and `reasoningEfforts` leaked one after that — both
|
|
176
|
+
* reproduced against real routes. Enumerating fields cannot converge, because
|
|
177
|
+
* the next field added to a provider row inherits the same defect. This
|
|
178
|
+
* identity therefore covers the enriched, frozen provider objects the flight
|
|
179
|
+
* actually gathered from, so a join is refused unless the admissions agree on
|
|
180
|
+
* everything rather than on everything somebody remembered to list.
|
|
181
|
+
*/
|
|
182
|
+
readonly providerGraphIdentity: string;
|
|
183
|
+
readonly promise: Promise<GatherFlightResult>;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const gatherInflight = new Map<string, GatherInflightEntry[]>();
|
|
187
|
+
const CATALOG_GATHER_AUTHORITY_KEY = randomBytes(32);
|
|
188
|
+
const REQUEST_CREDENTIAL_SENTINEL = `ocx-catalog-credential-${randomBytes(16).toString("hex")}`;
|
|
73
189
|
const MAX_CONCURRENT_CATALOG_GATHERS = 8;
|
|
74
190
|
const gatherGate = createAdmissionGate("catalog_gathers", MAX_CONCURRENT_CATALOG_GATHERS);
|
|
75
191
|
|
|
@@ -95,6 +211,268 @@ function stableJson(value: unknown): string {
|
|
|
95
211
|
});
|
|
96
212
|
}
|
|
97
213
|
|
|
214
|
+
function framed(value: string): string {
|
|
215
|
+
return `${Buffer.byteLength(value, "utf8")}:${value}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function canonicalAuthorityEncoding(value: unknown): string {
|
|
219
|
+
if (value === null) return "null";
|
|
220
|
+
if (value === undefined) return "undefined";
|
|
221
|
+
if (typeof value === "string") return `string${framed(value)}`;
|
|
222
|
+
if (typeof value === "boolean") return value ? "boolean1" : "boolean0";
|
|
223
|
+
if (typeof value === "number") {
|
|
224
|
+
if (!Number.isFinite(value)) throw new TypeError("Catalog authority cannot encode a non-finite number.");
|
|
225
|
+
const encoded = Object.is(value, -0) ? "-0" : String(value);
|
|
226
|
+
return `number${framed(encoded)}`;
|
|
227
|
+
}
|
|
228
|
+
if (Array.isArray(value)) {
|
|
229
|
+
return `array${value.length}:${value.map(item => framed(canonicalAuthorityEncoding(item))).join("")}`;
|
|
230
|
+
}
|
|
231
|
+
if (typeof value === "object") {
|
|
232
|
+
const record = value as Record<string, unknown>;
|
|
233
|
+
const keys = Object.keys(record).sort((left, right) => left.localeCompare(right));
|
|
234
|
+
return `object${keys.length}:${keys.map(key => (
|
|
235
|
+
`${framed(key)}${framed(canonicalAuthorityEncoding(record[key]))}`
|
|
236
|
+
)).join("")}`;
|
|
237
|
+
}
|
|
238
|
+
throw new TypeError(`Catalog authority cannot encode ${typeof value}.`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function keyedGatherIdentity(domain: string, value: unknown): string {
|
|
242
|
+
return createHmac("sha256", CATALOG_GATHER_AUTHORITY_KEY)
|
|
243
|
+
.update(framed(domain))
|
|
244
|
+
.update(framed(canonicalAuthorityEncoding(value)))
|
|
245
|
+
.digest("hex");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function keyedGatherBytesIdentity(domain: string, value: Uint8Array): string {
|
|
249
|
+
return createHmac("sha256", CATALOG_GATHER_AUTHORITY_KEY)
|
|
250
|
+
.update(framed(domain))
|
|
251
|
+
.update(`${value.byteLength}:`)
|
|
252
|
+
.update(value)
|
|
253
|
+
.digest("hex");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function createCatalogGatherAuthorityIdentity(
|
|
257
|
+
snapshot: CatalogAdmissionSnapshot,
|
|
258
|
+
sourceEvidence: CatalogSourceEvidence,
|
|
259
|
+
processLocal: CatalogProcessLocalEvidence,
|
|
260
|
+
discoveryPolicies: readonly CatalogProviderDiscoveryPolicySnapshot[],
|
|
261
|
+
): CatalogGatherAuthorityIdentity {
|
|
262
|
+
const sourceEvidenceIdentity = keyedGatherIdentity("catalog-source-evidence-v1", sourceEvidence);
|
|
263
|
+
const processLocalEvidenceIdentity = keyedGatherIdentity("catalog-process-local-v1", processLocal);
|
|
264
|
+
const discoveryPolicyIdentity = keyedGatherIdentity("catalog-discovery-policy-v1", discoveryPolicies);
|
|
265
|
+
return Object.freeze({
|
|
266
|
+
version: 1 as const,
|
|
267
|
+
authorityId: keyedGatherIdentity("catalog-authority-v1", {
|
|
268
|
+
admittedConfig: snapshot.configIdentity,
|
|
269
|
+
discoveryPolicyIdentity,
|
|
270
|
+
sourceEvidenceIdentity,
|
|
271
|
+
processLocalEvidenceIdentity,
|
|
272
|
+
}),
|
|
273
|
+
admittedConfig: Object.freeze({
|
|
274
|
+
...snapshot.configIdentity,
|
|
275
|
+
generation: Object.freeze({ ...snapshot.configIdentity.generation }),
|
|
276
|
+
}),
|
|
277
|
+
authSnapshotIdentity: keyedGatherIdentity(
|
|
278
|
+
"catalog-auth-v1",
|
|
279
|
+
sourceEvidence.conditional["provider-auth-selection"],
|
|
280
|
+
),
|
|
281
|
+
discoveryPolicyIdentity,
|
|
282
|
+
nativeCatalogSourceIdentity: keyedGatherIdentity(
|
|
283
|
+
"catalog-native-v1",
|
|
284
|
+
sourceEvidence.conditional["native-catalog-selection"],
|
|
285
|
+
),
|
|
286
|
+
sourceEvidenceIdentity,
|
|
287
|
+
processLocalEvidenceIdentity,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function detachedClone<T>(value: T): T {
|
|
292
|
+
if (Array.isArray(value)) return value.map(item => detachedClone(item)) as T;
|
|
293
|
+
if (value && typeof value === "object") {
|
|
294
|
+
const clone: Record<string, unknown> = {};
|
|
295
|
+
for (const key of Object.keys(value)) {
|
|
296
|
+
clone[key] = detachedClone((value as Record<string, unknown>)[key]);
|
|
297
|
+
}
|
|
298
|
+
return clone as T;
|
|
299
|
+
}
|
|
300
|
+
return value;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function recursivelyFreeze<T>(value: T): T {
|
|
304
|
+
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
|
|
305
|
+
for (const nested of Object.values(value as Record<string, unknown>)) recursivelyFreeze(nested);
|
|
306
|
+
return Object.freeze(value);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function detachedFrozen<T>(value: T): T {
|
|
310
|
+
return recursivelyFreeze(detachedClone(value));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function capturedField<T extends object, K extends keyof T>(
|
|
314
|
+
value: T | undefined,
|
|
315
|
+
key: K,
|
|
316
|
+
): CatalogDiscoveryPolicyField<T[K]> {
|
|
317
|
+
if (!value || !Object.hasOwn(value, key)) return Object.freeze({ state: "absent" });
|
|
318
|
+
return detachedFrozen({ state: "present" as const, value: value[key] });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function captureTrustedOpenAiApiPolicy(
|
|
322
|
+
name: string,
|
|
323
|
+
registryTransportMatch: boolean,
|
|
324
|
+
): CatalogTrustedOpenAiApiPolicySnapshot {
|
|
325
|
+
if (name !== OPENAI_API_PROVIDER_ID) return Object.freeze({ state: "unused" });
|
|
326
|
+
if (!registryTransportMatch) return Object.freeze({ state: "transport-mismatch" });
|
|
327
|
+
const entry = getProviderRegistryEntry(name);
|
|
328
|
+
if (!entry?.models) return Object.freeze({ state: "registry-models-absent" });
|
|
329
|
+
return detachedFrozen({
|
|
330
|
+
state: "captured" as const,
|
|
331
|
+
models: entry.models,
|
|
332
|
+
...(entry.modelContextWindows ? { modelContextWindows: entry.modelContextWindows } : {}),
|
|
333
|
+
...(entry.modelMaxInputTokens ? { modelMaxInputTokens: entry.modelMaxInputTokens } : {}),
|
|
334
|
+
...(entry.modelInputModalities ? { modelInputModalities: entry.modelInputModalities } : {}),
|
|
335
|
+
...(entry.modelReasoningEfforts ? { modelReasoningEfforts: entry.modelReasoningEfforts } : {}),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function captureModelsRequest(
|
|
340
|
+
name: string,
|
|
341
|
+
provider: OcxProviderConfig,
|
|
342
|
+
observedAuth: ModelsAuthResolution | undefined,
|
|
343
|
+
): CapturedModelsRequest {
|
|
344
|
+
const observed = observedAuth
|
|
345
|
+
? { oauthApiBaseUrl: observedAuth.oauthApiBaseUrl }
|
|
346
|
+
: undefined;
|
|
347
|
+
const withoutCredential = buildModelsRequest(provider, undefined, name, observed);
|
|
348
|
+
const withCredential = buildModelsRequest(provider, REQUEST_CREDENTIAL_SENTINEL, name, observed);
|
|
349
|
+
if (withoutCredential.url !== withCredential.url) {
|
|
350
|
+
throw new TypeError(`Provider model discovery URL for ${name} depends on credential bytes.`);
|
|
351
|
+
}
|
|
352
|
+
return detachedFrozen({
|
|
353
|
+
method: "GET" as const,
|
|
354
|
+
url: withoutCredential.url,
|
|
355
|
+
headersWithoutCredential: withoutCredential.headers,
|
|
356
|
+
headersWithCredential: withCredential.headers,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function captureProviderGather(
|
|
361
|
+
name: string,
|
|
362
|
+
configured: OcxProviderConfig,
|
|
363
|
+
authResolver: ModelsAuthResolver,
|
|
364
|
+
): CapturedProviderGather {
|
|
365
|
+
const enriched = detachedClone(configured);
|
|
366
|
+
enrichProviderFromRegistry(name, enriched);
|
|
367
|
+
const provider = recursivelyFreeze(enriched);
|
|
368
|
+
const observedAuth = authResolver.kind === "observed"
|
|
369
|
+
&& provider.authMode !== "forward"
|
|
370
|
+
&& provider.liveModels !== false
|
|
371
|
+
? authResolver.resolve(name, provider)
|
|
372
|
+
: undefined;
|
|
373
|
+
const request = captureModelsRequest(name, provider, observedAuth);
|
|
374
|
+
const resolved = resolveProviderModelDiscovery(name, provider);
|
|
375
|
+
const discovery = detachedFrozen({
|
|
376
|
+
...(resolved.spec ? { spec: resolved.spec } : {}),
|
|
377
|
+
maxResponseBytes: resolved.maxResponseBytes,
|
|
378
|
+
maxModels: resolved.maxModels,
|
|
379
|
+
});
|
|
380
|
+
const registryTransportMatch = providerMatchesRegistryTransport(name, provider);
|
|
381
|
+
const trustedOpenAiApi = captureTrustedOpenAiApiPolicy(name, registryTransportMatch);
|
|
382
|
+
const policy = detachedFrozen({
|
|
383
|
+
provider: name,
|
|
384
|
+
registryTransportMatch,
|
|
385
|
+
location: {
|
|
386
|
+
spec: discovery.spec ? "present" as const : "absent" as const,
|
|
387
|
+
url: capturedField(discovery.spec, "url"),
|
|
388
|
+
path: capturedField(discovery.spec, "path"),
|
|
389
|
+
query: capturedField(discovery.spec, "query"),
|
|
390
|
+
},
|
|
391
|
+
finalMethod: request.method,
|
|
392
|
+
finalUrl: request.url,
|
|
393
|
+
filter: capturedField(discovery.spec, "filter"),
|
|
394
|
+
maxResponseBytes: discovery.maxResponseBytes,
|
|
395
|
+
maxModels: discovery.maxModels,
|
|
396
|
+
trustedOpenAiApi,
|
|
397
|
+
});
|
|
398
|
+
return Object.freeze({
|
|
399
|
+
name,
|
|
400
|
+
provider,
|
|
401
|
+
discovery,
|
|
402
|
+
policy,
|
|
403
|
+
request,
|
|
404
|
+
...(observedAuth ? { observedAuth: Object.freeze({ ...observedAuth }) } : {}),
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function captureGatherFlight(
|
|
409
|
+
config: OcxConfig,
|
|
410
|
+
createAuthResolver: ModelsAuthResolverFactory,
|
|
411
|
+
): GatherFlightCapture {
|
|
412
|
+
const providerAuthOutcomes: CatalogGatherProviderAuthOutcome[] = [];
|
|
413
|
+
const authResolver = createAuthResolver(providerAuthOutcomes);
|
|
414
|
+
const providers = Object.entries(config.providers)
|
|
415
|
+
.filter(([, provider]) => provider.disabled !== true)
|
|
416
|
+
.map(([name, provider]) => captureProviderGather(name, provider, authResolver));
|
|
417
|
+
const discoveryPolicySnapshots = Object.freeze(providers.map(provider => provider.policy));
|
|
418
|
+
return Object.freeze({
|
|
419
|
+
discoveryPolicyIdentity: keyedGatherIdentity("catalog-discovery-policy-v1", discoveryPolicySnapshots),
|
|
420
|
+
// Credentials are hashed under the same unexported per-process key, never
|
|
421
|
+
// stored or compared in the clear: this value can reach a map key and must
|
|
422
|
+
// not disclose a token. The final headers are included because a static
|
|
423
|
+
// header can carry authority just as an `apiKey` can.
|
|
424
|
+
authIdentity: keyedGatherIdentity("catalog-gather-auth-v1", providers.map(provider => ({
|
|
425
|
+
name: provider.name,
|
|
426
|
+
authMode: provider.provider.authMode ?? null,
|
|
427
|
+
liveModels: provider.provider.liveModels ?? null,
|
|
428
|
+
credential: provider.provider.apiKey ?? null,
|
|
429
|
+
observedAuth: provider.observedAuth ?? null,
|
|
430
|
+
headers: provider.request.headersWithCredential,
|
|
431
|
+
url: provider.request.url,
|
|
432
|
+
}))),
|
|
433
|
+
// Every enriched provider row the flight will gather from, in admission order.
|
|
434
|
+
// Anything that can change a catalog row lives in here by construction.
|
|
435
|
+
providerGraphIdentity: keyedGatherIdentity("catalog-gather-provider-graph-v1",
|
|
436
|
+
providers.map(provider => ({
|
|
437
|
+
name: provider.name,
|
|
438
|
+
// `fetch` is a caller-owned transport executor, not admitted state: the
|
|
439
|
+
// outbound transport honors it so a caller can supply its own HTTP path.
|
|
440
|
+
// It is the one member of a provider row that is legitimately a function,
|
|
441
|
+
// so it is dropped here rather than allowed to break every encode.
|
|
442
|
+
provider: omitProviderTransportExecutor(provider.provider),
|
|
443
|
+
}))),
|
|
444
|
+
discoveryPolicySnapshots,
|
|
445
|
+
providers: Object.freeze(providers),
|
|
446
|
+
authResolver,
|
|
447
|
+
providerAuthOutcomes: Object.freeze([...providerAuthOutcomes]),
|
|
448
|
+
openAiApiPolicy: providers.find(provider => provider.name === OPENAI_API_PROVIDER_ID)?.policy.trustedOpenAiApi
|
|
449
|
+
?? Object.freeze({ state: "unused" as const }),
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Drop the caller-owned transport executor before hashing a provider row.
|
|
455
|
+
*
|
|
456
|
+
* Fails closed on anything ELSE that cannot be encoded: the point of hashing the
|
|
457
|
+
* whole row is that no field escapes the comparison, so a second function member
|
|
458
|
+
* must surface as an encode error rather than being quietly skipped here.
|
|
459
|
+
*/
|
|
460
|
+
function omitProviderTransportExecutor(provider: OcxProviderConfig): Record<string, unknown> {
|
|
461
|
+
const entries = Object.entries(provider).filter(([key]) => key !== "fetch");
|
|
462
|
+
return Object.fromEntries(entries);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function materializeCapturedHeaders(
|
|
466
|
+
request: CapturedModelsRequest,
|
|
467
|
+
apiKey: string | undefined,
|
|
468
|
+
): Record<string, string> {
|
|
469
|
+
const source = apiKey ? request.headersWithCredential : request.headersWithoutCredential;
|
|
470
|
+
return Object.fromEntries(Object.entries(source).map(([name, value]) => [
|
|
471
|
+
name,
|
|
472
|
+
apiKey ? value.split(REQUEST_CREDENTIAL_SENTINEL).join(apiKey) : value,
|
|
473
|
+
]));
|
|
474
|
+
}
|
|
475
|
+
|
|
98
476
|
function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Record<string, unknown> {
|
|
99
477
|
return {
|
|
100
478
|
n: name,
|
|
@@ -407,7 +785,39 @@ function boundedOwnedBy(value: unknown): string | undefined {
|
|
|
407
785
|
return value;
|
|
408
786
|
}
|
|
409
787
|
|
|
410
|
-
|
|
788
|
+
const refreshingModelsAuthResolver: ModelsAuthResolver = { kind: "refreshing" };
|
|
789
|
+
|
|
790
|
+
function observedModelsAuthResolver(
|
|
791
|
+
authStoreBuffer: Uint8Array | null,
|
|
792
|
+
outcomes: CatalogGatherProviderAuthOutcome[],
|
|
793
|
+
): ModelsAuthResolver {
|
|
794
|
+
return {
|
|
795
|
+
kind: "observed",
|
|
796
|
+
resolve(name, provider) {
|
|
797
|
+
if (provider.authMode === "forward") return { apiKey: undefined, observed: true };
|
|
798
|
+
if (provider.authMode !== "oauth") {
|
|
799
|
+
return { apiKey: resolveEnvValue(provider.apiKey), observed: true };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const observation = observeActiveOAuthAccessToken(name, authStoreBuffer);
|
|
803
|
+
outcomes.push({ provider: name, state: observation.kind });
|
|
804
|
+
if (observation.kind !== "available") return { apiKey: undefined, observed: true };
|
|
805
|
+
return {
|
|
806
|
+
apiKey: observation.snapshot.accessToken,
|
|
807
|
+
observed: true,
|
|
808
|
+
...(observation.snapshot.apiBaseUrl ? { oauthApiBaseUrl: observation.snapshot.apiBaseUrl } : {}),
|
|
809
|
+
};
|
|
810
|
+
},
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async function fetchProviderModelsWithAuth(
|
|
815
|
+
captured: CapturedProviderGather,
|
|
816
|
+
ttlMs: number,
|
|
817
|
+
contextCap: number | undefined,
|
|
818
|
+
resolveAuth: ModelsAuthResolver,
|
|
819
|
+
): Promise<CatalogModel[]> {
|
|
820
|
+
const { name, provider: prov, discovery, request } = captured;
|
|
411
821
|
if (prov.authMode === "forward") return []; // ChatGPT backend has no /models
|
|
412
822
|
const seedVertexDefault = prov.adapter === "google"
|
|
413
823
|
&& prov.googleMode === "vertex"
|
|
@@ -425,7 +835,10 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
|
|
|
425
835
|
clearProviderDiscoveryStatus(name);
|
|
426
836
|
return configured;
|
|
427
837
|
}
|
|
428
|
-
const
|
|
838
|
+
const auth: ModelsAuthResolution = captured.observedAuth ?? (resolveAuth.kind === "refreshing"
|
|
839
|
+
? { apiKey: await resolveModelsAuthToken(name, prov), observed: false }
|
|
840
|
+
: resolveAuth.resolve(name, prov));
|
|
841
|
+
const apiKey = auth.apiKey;
|
|
429
842
|
// A configured default is a real callable selector and must remain discoverable when a
|
|
430
843
|
// compatible provider's live /models request fails (issue #308). Keep this separate from the
|
|
431
844
|
// explicit static list: `liveModels: false` + empty `models[]` intentionally publishes zero
|
|
@@ -486,8 +899,8 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
|
|
|
486
899
|
const stale = getStaleCached(name);
|
|
487
900
|
return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : failedDiscoveryConfigured;
|
|
488
901
|
}
|
|
489
|
-
const
|
|
490
|
-
const
|
|
902
|
+
const url = request.url;
|
|
903
|
+
const headers = materializeCapturedHeaders(request, apiKey);
|
|
491
904
|
const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com")
|
|
492
905
|
? "vertex-aiplatform"
|
|
493
906
|
: "provider-models";
|
|
@@ -633,6 +1046,16 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
|
|
|
633
1046
|
}
|
|
634
1047
|
}
|
|
635
1048
|
|
|
1049
|
+
export async function fetchProviderModels(
|
|
1050
|
+
name: string,
|
|
1051
|
+
prov: OcxProviderConfig,
|
|
1052
|
+
ttlMs: number,
|
|
1053
|
+
contextCap?: number,
|
|
1054
|
+
): Promise<CatalogModel[]> {
|
|
1055
|
+
const captured = captureProviderGather(name, prov, refreshingModelsAuthResolver);
|
|
1056
|
+
return fetchProviderModelsWithAuth(captured, ttlMs, contextCap, refreshingModelsAuthResolver);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
636
1059
|
export function shouldExposeProviderModel(providerName: string, modelId: string): boolean {
|
|
637
1060
|
if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free");
|
|
638
1061
|
return true;
|
|
@@ -669,35 +1092,104 @@ export function filterCatalogVisibleModels(
|
|
|
669
1092
|
|
|
670
1093
|
export async function gatherRoutedModels(
|
|
671
1094
|
config: OcxConfig,
|
|
672
|
-
options?:
|
|
1095
|
+
options?: GatherRoutedModelsOptions,
|
|
673
1096
|
): Promise<CatalogModel[]> {
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
1097
|
+
return gatherRoutedModelsWithAuth(
|
|
1098
|
+
config,
|
|
1099
|
+
`refreshing:${gatherFlightKey(config)}`,
|
|
1100
|
+
() => refreshingModelsAuthResolver,
|
|
1101
|
+
options,
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Catalog-gather model discovery using only auth-store bytes already captured by the
|
|
1107
|
+
* filesystem-evidence owner. This entry point never reaches the refreshing resolver.
|
|
1108
|
+
*/
|
|
1109
|
+
export async function gatherRoutedModelsForCatalogGather(
|
|
1110
|
+
config: OcxConfig,
|
|
1111
|
+
evidence: CatalogGatherProviderAuthEvidence,
|
|
1112
|
+
options?: GatherRoutedModelsOptions,
|
|
1113
|
+
): Promise<CatalogModel[]> {
|
|
1114
|
+
const authStoreBuffer = evidence.authStoreBuffer === null
|
|
1115
|
+
? null
|
|
1116
|
+
: Uint8Array.from(evidence.authStoreBuffer);
|
|
1117
|
+
const authIdentity = authStoreBuffer === null
|
|
1118
|
+
? "absent"
|
|
1119
|
+
: keyedGatherBytesIdentity("catalog-observed-auth-v1", authStoreBuffer);
|
|
1120
|
+
return gatherRoutedModelsWithAuth(
|
|
1121
|
+
config,
|
|
1122
|
+
`observed:${authIdentity}:${gatherFlightKey(config)}`,
|
|
1123
|
+
outcomes => observedModelsAuthResolver(authStoreBuffer, outcomes),
|
|
1124
|
+
options,
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
async function gatherRoutedModelsWithAuth(
|
|
1129
|
+
config: OcxConfig,
|
|
1130
|
+
key: string,
|
|
1131
|
+
createAuthResolver: ModelsAuthResolverFactory,
|
|
1132
|
+
options?: GatherRoutedModelsOptions,
|
|
1133
|
+
): Promise<CatalogModel[]> {
|
|
1134
|
+
const capture = captureGatherFlight(config, createAuthResolver);
|
|
1135
|
+
const bucket = gatherInflight.get(key) ?? [];
|
|
1136
|
+
let entry = bucket.find(candidate => (
|
|
1137
|
+
candidate.discoveryPolicyIdentity === capture.discoveryPolicyIdentity
|
|
1138
|
+
&& candidate.authIdentity === capture.authIdentity
|
|
1139
|
+
&& candidate.providerGraphIdentity === capture.providerGraphIdentity
|
|
1140
|
+
));
|
|
1141
|
+
if (!entry) {
|
|
677
1142
|
const lease = gatherGate.tryAcquire();
|
|
678
1143
|
if (!lease) throw new CatalogGatherBusyError();
|
|
679
1144
|
// Claim the slot synchronously before any await so same-key callers join this flight.
|
|
680
|
-
// Distinct
|
|
681
|
-
|
|
682
|
-
|
|
1145
|
+
// Distinct authorities retain separate entries even when their legacy bucket matches.
|
|
1146
|
+
let ownedEntry!: GatherInflightEntry;
|
|
1147
|
+
const flight = gatherRoutedModelsUncached(config, capture).finally(() => {
|
|
1148
|
+
const current = gatherInflight.get(key);
|
|
1149
|
+
const index = current?.indexOf(ownedEntry) ?? -1;
|
|
1150
|
+
if (current && index >= 0) current.splice(index, 1);
|
|
1151
|
+
if (current?.length === 0) gatherInflight.delete(key);
|
|
683
1152
|
lease.release();
|
|
684
1153
|
});
|
|
685
|
-
|
|
686
|
-
|
|
1154
|
+
ownedEntry = Object.freeze({
|
|
1155
|
+
discoveryPolicyIdentity: capture.discoveryPolicyIdentity,
|
|
1156
|
+
authIdentity: capture.authIdentity,
|
|
1157
|
+
providerGraphIdentity: capture.providerGraphIdentity,
|
|
1158
|
+
promise: flight,
|
|
1159
|
+
});
|
|
1160
|
+
bucket.push(ownedEntry);
|
|
1161
|
+
gatherInflight.set(key, bucket);
|
|
1162
|
+
entry = ownedEntry;
|
|
687
1163
|
}
|
|
688
|
-
const {
|
|
1164
|
+
const {
|
|
1165
|
+
models,
|
|
1166
|
+
comboOmissions,
|
|
1167
|
+
providerAuthOutcomes,
|
|
1168
|
+
discoveryPolicySnapshots,
|
|
1169
|
+
} = await entry.promise;
|
|
689
1170
|
if (options?.comboOmissions) {
|
|
690
1171
|
options.comboOmissions.length = 0;
|
|
691
1172
|
options.comboOmissions.push(...comboOmissions);
|
|
692
1173
|
}
|
|
1174
|
+
if (options?.providerAuthOutcomes) {
|
|
1175
|
+
options.providerAuthOutcomes.length = 0;
|
|
1176
|
+
options.providerAuthOutcomes.push(...providerAuthOutcomes);
|
|
1177
|
+
}
|
|
1178
|
+
if (options?.discoveryPolicySnapshots) {
|
|
1179
|
+
options.discoveryPolicySnapshots.length = 0;
|
|
1180
|
+
options.discoveryPolicySnapshots.push(...discoveryPolicySnapshots);
|
|
1181
|
+
}
|
|
693
1182
|
return models;
|
|
694
1183
|
}
|
|
695
1184
|
|
|
696
1185
|
async function gatherRoutedModelsUncached(
|
|
697
1186
|
config: OcxConfig,
|
|
1187
|
+
capture: GatherFlightCapture,
|
|
698
1188
|
): Promise<GatherFlightResult> {
|
|
699
1189
|
// Flight-local list: joiners copy from the resolved promise, not a process-global last write.
|
|
700
1190
|
const localOmissions: ComboCatalogOmission[] = [];
|
|
1191
|
+
const localProviderAuthOutcomes = capture.providerAuthOutcomes;
|
|
1192
|
+
const resolveAuth = capture.authResolver;
|
|
701
1193
|
const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS;
|
|
702
1194
|
// Persisted provider entries can predate newer registry fields (noVisionModels,
|
|
703
1195
|
// modelInputModalities, ...). The ROUTER merges registry seeds at request time
|
|
@@ -705,18 +1197,21 @@ async function gatherRoutedModelsUncached(
|
|
|
705
1197
|
// same merged view or its advertisements drift from actual proxy behavior (e.g. a
|
|
706
1198
|
// vision-sidecar model advertised text-only, blocking image attachments app-side).
|
|
707
1199
|
// Enrich a CLONE: hydrated defaults must never leak into the persisted config.
|
|
708
|
-
const activeProviders =
|
|
709
|
-
.filter(([, prov]) => prov.disabled !== true)
|
|
710
|
-
.map(([name, prov]): [string, OcxProviderConfig] => {
|
|
711
|
-
const enriched = { ...prov };
|
|
712
|
-
enrichProviderFromRegistry(name, enriched);
|
|
713
|
-
return [name, enriched];
|
|
714
|
-
});
|
|
1200
|
+
const activeProviders = capture.providers;
|
|
715
1201
|
const lists = await Promise.all(
|
|
716
|
-
activeProviders.map(
|
|
1202
|
+
activeProviders.map(provider => fetchProviderModelsWithAuth(
|
|
1203
|
+
provider,
|
|
1204
|
+
ttlMs,
|
|
1205
|
+
providerContextCap(config, provider.name),
|
|
1206
|
+
resolveAuth,
|
|
1207
|
+
)),
|
|
1208
|
+
);
|
|
1209
|
+
const apiAugmented = augmentRoutedModelsWithCapturedOpenAiApiRows(
|
|
1210
|
+
lists.flat(),
|
|
1211
|
+
config,
|
|
1212
|
+
capture.openAiApiPolicy,
|
|
717
1213
|
);
|
|
718
|
-
const
|
|
719
|
-
const all = augmentRoutedModelsWithJawcodeMetadata(apiAugmented, activeProviders.map(([name]) => name), config.providers, config)
|
|
1214
|
+
const all = augmentRoutedModelsWithJawcodeMetadata(apiAugmented, activeProviders.map(provider => provider.name), config.providers, config)
|
|
720
1215
|
// Drop image/video generation models (e.g. Grok image/video) by default. Cursor's static catalog
|
|
721
1216
|
// intentionally mirrors Cursor's public model table, including Gemini image preview, so the
|
|
722
1217
|
// exposure decision goes through shouldExposeRoutedModel (single choke point).
|
|
@@ -781,7 +1276,10 @@ async function gatherRoutedModelsUncached(
|
|
|
781
1276
|
all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
|
|
782
1277
|
// Enriched (registry-hydrated) provider clones, keyed by name — the same view used above so
|
|
783
1278
|
// custom rows get the same noVisionModels / inputModalities treatment as discovered rows.
|
|
784
|
-
const enrichedByName = new Map(activeProviders);
|
|
1279
|
+
const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider]));
|
|
1280
|
+
// Provider-derived rows keyed by their Codex-facing slug: a custom override replaces the row
|
|
1281
|
+
// with the same slug below, so that row's provider capability metadata is the inheritance source.
|
|
1282
|
+
const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model]));
|
|
785
1283
|
const customModels = (config.customModels ?? []).map(cm => {
|
|
786
1284
|
const rawProvider = config.providers[cm.provider];
|
|
787
1285
|
const supportsReasoningSummaries = configuredReasoningSummarySupport(rawProvider, cm.modelId);
|
|
@@ -794,24 +1292,48 @@ async function gatherRoutedModelsUncached(
|
|
|
794
1292
|
...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}),
|
|
795
1293
|
...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}),
|
|
796
1294
|
};
|
|
1295
|
+
// #962: the dedupe below drops the provider-derived row this custom row replaces. Inherit that
|
|
1296
|
+
// row's provider capability metadata (reasoning ladder, default effort, parallel tool calls,
|
|
1297
|
+
// context, ...) so the generated catalog keeps advertising what the router actually provides.
|
|
1298
|
+
// Explicit custom fields win by construction; this only fills gaps. Without it a
|
|
1299
|
+
// noReasoningModels model loses its empty ladder and the catalog synthesizes the generic one,
|
|
1300
|
+
// which Codex then rejects for spawn_agent with effort "none".
|
|
1301
|
+
const replaced = replacedByRoutedSlug.get(routedSlug(cm.provider, cm.modelId));
|
|
1302
|
+
const merged: CatalogModel = replaced ? {
|
|
1303
|
+
...base,
|
|
1304
|
+
...(base.contextWindow === undefined && replaced.contextWindow !== undefined ? { contextWindow: replaced.contextWindow } : {}),
|
|
1305
|
+
...(base.maxInputTokens === undefined && replaced.maxInputTokens !== undefined ? { maxInputTokens: replaced.maxInputTokens } : {}),
|
|
1306
|
+
...(base.inputModalities === undefined && replaced.inputModalities !== undefined ? { inputModalities: replaced.inputModalities } : {}),
|
|
1307
|
+
...(base.reasoningEfforts === undefined && replaced.reasoningEfforts !== undefined ? { reasoningEfforts: replaced.reasoningEfforts } : {}),
|
|
1308
|
+
...(base.defaultReasoningEffort === undefined && replaced.defaultReasoningEffort !== undefined ? { defaultReasoningEffort: replaced.defaultReasoningEffort } : {}),
|
|
1309
|
+
...(base.parallelToolCalls === undefined && replaced.parallelToolCalls !== undefined ? { parallelToolCalls: replaced.parallelToolCalls } : {}),
|
|
1310
|
+
...(base.supportsVerbosity === undefined && replaced.supportsVerbosity !== undefined ? { supportsVerbosity: replaced.supportsVerbosity } : {}),
|
|
1311
|
+
...(base.supportsReasoningSummaries === undefined && replaced.supportsReasoningSummaries !== undefined ? { supportsReasoningSummaries: replaced.supportsReasoningSummaries } : {}),
|
|
1312
|
+
...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}),
|
|
1313
|
+
} : base;
|
|
797
1314
|
// Vision-sidecar coverage ONLY: if the custom model is in the enriched provider's
|
|
798
1315
|
// noVisionModels, advertise image input so the Codex app lets images reach the sidecar
|
|
799
1316
|
// (#349/#344). Deliberately NOT the full applyProviderConfigHints pass — custom rows are a
|
|
800
1317
|
// user override, so their explicit contextWindow / inputModalities / reasoning fields must be
|
|
801
1318
|
// preserved verbatim (the hint pass would cap context and overwrite modalities from registry).
|
|
802
1319
|
const enrichedProvider = enrichedByName.get(cm.provider) ?? rawProvider;
|
|
803
|
-
if (enrichedProvider && modelInList(enrichedProvider.noVisionModels,
|
|
804
|
-
const current =
|
|
1320
|
+
if (enrichedProvider && modelInList(enrichedProvider.noVisionModels, merged.id)) {
|
|
1321
|
+
const current = merged.inputModalities ?? ["text"];
|
|
805
1322
|
if (!current.includes("image")) {
|
|
806
|
-
return { ...
|
|
1323
|
+
return { ...merged, inputModalities: [...current, "image"] };
|
|
807
1324
|
}
|
|
808
1325
|
}
|
|
809
|
-
return
|
|
1326
|
+
return merged;
|
|
810
1327
|
});
|
|
811
1328
|
// Custom rows override discovered rows that encode to the same Codex-facing slug.
|
|
812
1329
|
const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id)));
|
|
813
1330
|
const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id)));
|
|
814
|
-
return {
|
|
1331
|
+
return {
|
|
1332
|
+
models: [...deduped, ...customModels],
|
|
1333
|
+
comboOmissions: localOmissions,
|
|
1334
|
+
providerAuthOutcomes: localProviderAuthOutcomes,
|
|
1335
|
+
discoveryPolicySnapshots: capture.discoveryPolicySnapshots,
|
|
1336
|
+
};
|
|
815
1337
|
}
|
|
816
1338
|
|
|
817
1339
|
export function augmentRoutedModelsWithRegistryOpenAiApiRows(
|
|
@@ -820,15 +1342,28 @@ export function augmentRoutedModelsWithRegistryOpenAiApiRows(
|
|
|
820
1342
|
): CatalogModel[] {
|
|
821
1343
|
const configured = config.providers[OPENAI_API_PROVIDER_ID];
|
|
822
1344
|
if (!configured || configured.disabled === true || !providerMatchesRegistryTransport(OPENAI_API_PROVIDER_ID, configured)) return models;
|
|
823
|
-
|
|
824
|
-
|
|
1345
|
+
return augmentRoutedModelsWithCapturedOpenAiApiRows(
|
|
1346
|
+
models,
|
|
1347
|
+
config,
|
|
1348
|
+
captureTrustedOpenAiApiPolicy(OPENAI_API_PROVIDER_ID, true),
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function augmentRoutedModelsWithCapturedOpenAiApiRows(
|
|
1353
|
+
models: CatalogModel[],
|
|
1354
|
+
config: OcxConfig,
|
|
1355
|
+
policy: CatalogTrustedOpenAiApiPolicySnapshot,
|
|
1356
|
+
): CatalogModel[] {
|
|
1357
|
+
if (policy.state !== "captured" || !policy.models) return models;
|
|
1358
|
+
const configured = config.providers[OPENAI_API_PROVIDER_ID];
|
|
1359
|
+
if (!configured || configured.disabled === true) return models;
|
|
825
1360
|
|
|
826
1361
|
const existingById = new Map(
|
|
827
1362
|
models.filter(model => model.provider === OPENAI_API_PROVIDER_ID).map(model => [model.id, model]),
|
|
828
1363
|
);
|
|
829
|
-
const trustedRows =
|
|
830
|
-
const officialContext =
|
|
831
|
-
const officialMaxInput =
|
|
1364
|
+
const trustedRows = policy.models.map((id): CatalogModel => {
|
|
1365
|
+
const officialContext = policy.modelContextWindows?.[id];
|
|
1366
|
+
const officialMaxInput = policy.modelMaxInputTokens?.[id];
|
|
832
1367
|
const userContext = configured.modelContextWindows?.[id] ?? configured.contextWindow;
|
|
833
1368
|
const userMaxInput = configured.modelMaxInputTokens?.[id];
|
|
834
1369
|
const providerCap = providerContextCap(config, OPENAI_API_PROVIDER_ID);
|
|
@@ -844,8 +1379,8 @@ export function augmentRoutedModelsWithRegistryOpenAiApiRows(
|
|
|
844
1379
|
owned_by: OPENAI_API_PROVIDER_ID,
|
|
845
1380
|
...(contextWindow ? { contextWindow } : {}),
|
|
846
1381
|
...(maxInputTokens ? { maxInputTokens } : {}),
|
|
847
|
-
...(
|
|
848
|
-
...(
|
|
1382
|
+
...(policy.modelInputModalities?.[id] ? { inputModalities: [...policy.modelInputModalities[id]!] } : {}),
|
|
1383
|
+
...(policy.modelReasoningEfforts?.[id] ? { reasoningEfforts: [...policy.modelReasoningEfforts[id]!] } : {}),
|
|
849
1384
|
};
|
|
850
1385
|
});
|
|
851
1386
|
|