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