@@ -31,6 +31,7 @@ function readInputModalities(raw: unknown): { values?: string[]; error?: string
3131}
3232import type { CatalogModel } from "../../codex/catalog" ;
3333import { catalogModelSlug , disabledNativeSlugs , invalidateCodexModelsCache , nativeModelRows , uniqueCatalogModelsForPublicList } from "../../codex/catalog" ;
34+ import { CatalogGatherBusyError } from "../../codex/catalog/provider-fetch" ;
3435import { getProviderLiveModelCount } from "../../codex/model-cache" ;
3536import {
3637 DEFAULT_SUBAGENT_MODELS ,
@@ -85,11 +86,126 @@ import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerS
8586import type { PersistedUsageAttempt } from "../../usage/log" ;
8687import { isAllowedRequestOrigin , jsonResponse , providerManagementConfigError , publicProviderBaseUrl , safeConfigDTO , corsHeaders } from "../auth-cors" ;
8788import { applySystemEnvToggle } from "../system-env" ;
89+ import {
90+ EXPORT_CLIENTS ,
91+ EXPORT_CLIENT_IDS ,
92+ OPENCODE_PROVIDER_ID ,
93+ buildClientConfig ,
94+ isExportClientId ,
95+ opencodeProxyBaseUrl ,
96+ } from "../../clients/config-export" ;
97+ import type {
98+ ExportClientId ,
99+ ExportModel ,
100+ OpencodeGeneratedConfig ,
101+ PiGeneratedConfig ,
102+ } from "../../clients/config-export" ;
88103
89104import { isPlainRecord , parseDebugLogQuery , tokPerSecondResult , unavailableCostReason , costResult , requestLogDto , stripRegistryOnlyStaticHeaders , fetchAllModels } from "./shared" ;
90105import type { MetricUnavailableReason , TokPerSecondResult , CostEstimateReason , CostResult , MetricSource } from "./shared" ;
91106import type { ManagementContext } from "./context" ;
92107
108+ /**
109+ * One row of the `/api/models` list. Routed rows spread a `CatalogModel`, so the shape is
110+ * that model plus the identity/visibility fields this boundary computes for every row
111+ * regardless of source. `disabled` is always present; the rest vary by row origin.
112+ */
113+ type ManagementModelRow = Partial < CatalogModel > & {
114+ provider : string ;
115+ id : string ;
116+ namespaced : string ;
117+ disabled : boolean ;
118+ native ?: boolean ;
119+ custom ?: boolean ;
120+ customId ?: string ;
121+ } ;
122+
123+ /**
124+ * The exact row list `/api/models` returns. Extracted so `/api/client-config` exports the
125+ * models the GUI's Models tab shows — including this function's `disabled` computation,
126+ * which the export core (src/clients/config-export.ts) deliberately does not perform.
127+ */
128+ async function listManagementModelRows ( config : OcxConfig ) : Promise < ManagementModelRow [ ] > {
129+ const models = await fetchAllModels ( config ) ;
130+ const disabled = new Set ( config . disabledModels ?? [ ] ) ;
131+ // Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced
132+ // from the static supported set so a disabled model stays listed and re-enableable.
133+ const native : ManagementModelRow [ ] = nativeModelRows ( config ) . map ( row => ( {
134+ provider : "openai" ,
135+ id : row . slug ,
136+ namespaced : row . slug ,
137+ disabled : row . disabled ,
138+ native : true ,
139+ ...( row . contextWindow !== undefined ? { contextWindow : row . contextWindow } : { } ) ,
140+ } ) ) ;
141+ const customModels : ManagementModelRow [ ] = ( config . customModels ?? [ ] ) . map ( cm => {
142+ const namespaced = routedSlug ( cm . provider , cm . modelId ) ;
143+ return {
144+ provider : cm . provider ,
145+ id : cm . modelId ,
146+ namespaced,
147+ disabled : [ ...disabled ] . some ( stored => slugEquals ( stored , cm . provider , cm . modelId ) ) ,
148+ custom : true ,
149+ customId : cm . id ,
150+ displayName : cm . displayName ,
151+ ...( cm . contextWindow ? { contextWindow : cm . contextWindow } : { } ) ,
152+ ...( cm . inputModalities ? { inputModalities : cm . inputModalities } : { } ) ,
153+ } ;
154+ } ) ;
155+ const publicModels = uniqueCatalogModelsForPublicList ( models ) ;
156+ const comboNamespaced = new Set (
157+ publicModels . filter ( model => model . provider === "combo" ) . map ( catalogModelSlug ) ,
158+ ) ;
159+ const visibleCustomModels = customModels . filter ( model => ! comboNamespaced . has ( model . namespaced ) ) ;
160+ // Custom metadata wins when a physical live/static row resolves to the same Codex-facing
161+ // slug, while a combo keeps the same precedence it has in routing and /v1/models.
162+ const customNamespaced = new Set ( visibleCustomModels . map ( c => c . namespaced ) ) ;
163+ const dedupedRouted = publicModels . map ( ( m ) : ManagementModelRow | null => {
164+ // Codex-facing slug (one "/", slug-codec); disabledModels compares tolerate both forms.
165+ const namespaced = catalogModelSlug ( m ) ;
166+ if ( m . provider !== "combo" && customNamespaced . has ( namespaced ) ) return null ;
167+ const contextCap = providerContextCap ( config , m . provider ) ;
168+ return {
169+ ...m ,
170+ namespaced,
171+ disabled : [ ...disabled ] . some ( stored => (
172+ stored === namespaced || slugEquals ( stored , m . provider , m . id )
173+ ) ) ,
174+ ...( contextCap !== undefined ? { contextCap, contextCapped : m . contextCapped === true } : { } ) ,
175+ } ;
176+ } ) . filter ( ( row ) : row is ManagementModelRow => row !== null ) ;
177+ return [ ...native , ...dedupedRouted , ...visibleCustomModels ] ;
178+ }
179+
180+ /** `/api/models` row → the narrower input the client-config serializers accept. */
181+ function toExportModel ( row : ManagementModelRow ) : ExportModel {
182+ return {
183+ namespaced : row . namespaced ,
184+ provider : row . provider ,
185+ id : row . id ,
186+ ...( row . native ? { native : true } : { } ) ,
187+ ...( row . displayName ? { displayName : row . displayName } : { } ) ,
188+ ...( row . contextWindow !== undefined ? { contextWindow : row . contextWindow } : { } ) ,
189+ ...( row . inputModalities ? { inputModalities : row . inputModalities } : { } ) ,
190+ } ;
191+ }
192+
193+ /**
194+ * Counts read back off the SERIALIZED document rather than recomputed from the input rows.
195+ * `modelsWithoutLimits` drives a GUI line claiming "these models ship without limits", so it
196+ * has to describe the bytes the user actually receives — a parallel reimplementation of the
197+ * core's "authoritative context window" rule would be free to drift from it silently.
198+ */
199+ function summarizeExportedModels ( client : ExportClientId , document : unknown ) : { modelCount : number ; modelsWithoutLimits : number } {
200+ if ( client === "opencode" ) {
201+ const models = ( document as OpencodeGeneratedConfig ) . provider [ OPENCODE_PROVIDER_ID ] . models ;
202+ const entries = Object . values ( models ) ;
203+ return { modelCount : entries . length , modelsWithoutLimits : entries . filter ( entry => entry . limit === undefined ) . length } ;
204+ }
205+ const models = ( document as PiGeneratedConfig ) . providers [ OPENCODE_PROVIDER_ID ] . models ;
206+ return { modelCount : models . length , modelsWithoutLimits : models . filter ( entry => entry . contextWindow === undefined ) . length } ;
207+ }
208+
93209export async function handleModelRoutes ( ctx : ManagementContext ) : Promise < Response | null > {
94210 const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx ;
95211 // A handler persists the exact config object passed in. Production defaults to
@@ -113,55 +229,58 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
113229 }
114230
115231 if ( url . pathname === "/api/models" && req . method === "GET" ) {
116- const models = await fetchAllModels ( config ) ;
117- const disabled = new Set ( config . disabledModels ?? [ ] ) ;
118- // Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced
119- // from the static supported set so a disabled model stays listed and re-enableable.
120- const native = nativeModelRows ( config ) . map ( row => ( {
121- provider : "openai" ,
122- id : row . slug ,
123- namespaced : row . slug ,
124- disabled : row . disabled ,
125- native : true ,
126- ...( row . contextWindow !== undefined ? { contextWindow : row . contextWindow } : { } ) ,
127- } ) ) ;
128- const customModels = ( config . customModels ?? [ ] ) . map ( cm => {
129- const namespaced = routedSlug ( cm . provider , cm . modelId ) ;
130- return {
131- provider : cm . provider ,
132- id : cm . modelId ,
133- namespaced,
134- disabled : [ ...disabled ] . some ( stored => slugEquals ( stored , cm . provider , cm . modelId ) ) ,
135- custom : true ,
136- customId : cm . id ,
137- displayName : cm . displayName ,
138- ...( cm . contextWindow ? { contextWindow : cm . contextWindow } : { } ) ,
139- ...( cm . inputModalities ? { inputModalities : cm . inputModalities } : { } ) ,
140- } ;
232+ return jsonResponse ( await listManagementModelRows ( config ) ) ;
233+ }
234+
235+ /**
236+ * Client config document for OpenCode / Pi, built from the SAME function `ocx export`
237+ * calls, so the bytes a user downloads here and the bytes they pipe from the CLI cannot
238+ * disagree. Read-only: this route never writes the user's client config.
239+ */
240+ if ( url . pathname === "/api/client-config" && req . method === "GET" ) {
241+ const requested = url . searchParams . get ( "client" ) ?. trim ( ) ?? "" ;
242+ if ( ! isExportClientId ( requested ) ) {
243+ return jsonResponse (
244+ { error : `client must be one of: ${ EXPORT_CLIENT_IDS . join ( ", " ) } ` } ,
245+ 400 ,
246+ req ,
247+ config ,
248+ ) ;
249+ }
250+ const spec = EXPORT_CLIENTS [ requested ] ;
251+ let rows : ManagementModelRow [ ] ;
252+ try {
253+ rows = await listManagementModelRows ( config ) ;
254+ } catch ( error ) {
255+ // A partial or empty `models` block reads as a valid config while offering nothing,
256+ // so a catalog failure is surfaced as unavailable rather than serialized. The
257+ // catalog-busy error keeps its own 503 from handleManagementAPI.
258+ if ( error instanceof CatalogGatherBusyError ) throw error ;
259+ return jsonResponse (
260+ { error : `model catalog unavailable: ${ error instanceof Error ? error . message : String ( error ) } ` } ,
261+ 503 ,
262+ req ,
263+ config ,
264+ ) ;
265+ }
266+ // The export core does not filter visibility — it serializes what it is given. A model the
267+ // user disabled in the Models tab is absent from /v1/models, so exporting it would hand the
268+ // client a selector the proxy refuses to route.
269+ const models = rows . filter ( row => ! row . disabled ) . map ( toExportModel ) ;
270+ const document = buildClientConfig ( requested , {
271+ baseUrl : opencodeProxyBaseUrl ( Number ( url . port ) || config . port , config . hostname ) ,
272+ models,
273+ config,
141274 } ) ;
142- const publicModels = uniqueCatalogModelsForPublicList ( models ) ;
143- const comboNamespaced = new Set (
144- publicModels . filter ( model => model . provider === "combo" ) . map ( catalogModelSlug ) ,
145- ) ;
146- const visibleCustomModels = customModels . filter ( model => ! comboNamespaced . has ( model . namespaced ) ) ;
147- // Custom metadata wins when a physical live/static row resolves to the same Codex-facing
148- // slug, while a combo keeps the same precedence it has in routing and /v1/models.
149- const customNamespaced = new Set ( visibleCustomModels . map ( c => c . namespaced ) ) ;
150- const dedupedRouted = publicModels . map ( m => {
151- // Codex-facing slug (one "/", slug-codec); disabledModels compares tolerate both forms.
152- const namespaced = catalogModelSlug ( m ) ;
153- if ( m . provider !== "combo" && customNamespaced . has ( namespaced ) ) return null ;
154- const contextCap = providerContextCap ( config , m . provider ) ;
155- return {
156- ...m ,
157- namespaced,
158- disabled : [ ...disabled ] . some ( stored => (
159- stored === namespaced || slugEquals ( stored , m . provider , m . id )
160- ) ) ,
161- ...( contextCap !== undefined ? { contextCap, contextCapped : m . contextCapped === true } : { } ) ,
162- } ;
163- } ) . filter ( Boolean ) ;
164- return jsonResponse ( [ ...native , ...dedupedRouted , ...visibleCustomModels ] ) ;
275+ return jsonResponse ( {
276+ client : spec . id ,
277+ filename : spec . filename ,
278+ destination : spec . destination ( process . env ) ,
279+ apiKeyEnv : spec . apiKeyEnv ,
280+ exportHint : spec . exportHint ,
281+ ...summarizeExportedModels ( requested , document ) ,
282+ config : document ,
283+ } , 200 , req , config ) ;
165284 }
166285
167286 // Enable/disable models: which routed models Codex sees. PUT hides them from the catalog +
0 commit comments