Skip to content

Commit f54666b

Browse files
committed
feat(export): serve client configs from the management API
Phase 030 of devlog/_plan/260731_client_config_export. GET /api/client-config?client=<opencode|pi> returns the same bytes the CLI prints, wrapped in an envelope carrying the destination path, the env var name, and the model counts the GUI needs to render its states. The /api/models row logic moved into listManagementModelRows() and both branches now call it. Copying it would have created a second definition of which models exist, free to drift from the first; disabled/dedupe precedence is ~40 lines and only correct in one place. modelsWithoutLimits is counted from the serialized document rather than re-derived from input rows, so the number cannot disagree with the bytes the caller receives. A catalog failure is 503, never a partial 200.
1 parent 569dc4e commit f54666b

3 files changed

Lines changed: 418 additions & 49 deletions

File tree

devlog/_plan/260731_client_config_export/030_management_api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ fetch path for no isolation benefit.
3232
{
3333
"client": "opencode",
3434
"filename": "opencode.json",
35-
"destination": "/Users/you/.config/opencode/opencode.json",
35+
"destination": "~/.config/opencode/opencode.json",
3636
"apiKeyEnv": "OPENCODEX_OPENCODE_API_KEY",
3737
"exportHint": "export OPENCODEX_OPENCODE_API_KEY=<your key>",
3838
"modelCount": 19,

src/server/management/model-routes.ts

Lines changed: 167 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ function readInputModalities(raw: unknown): { values?: string[]; error?: string
3131
}
3232
import type { CatalogModel } from "../../codex/catalog";
3333
import { catalogModelSlug, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
34+
import { CatalogGatherBusyError } from "../../codex/catalog/provider-fetch";
3435
import { getProviderLiveModelCount } from "../../codex/model-cache";
3536
import {
3637
DEFAULT_SUBAGENT_MODELS,
@@ -85,12 +86,127 @@ import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerS
8586
import type { PersistedUsageAttempt } from "../../usage/log";
8687
import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO, corsHeaders } from "../auth-cors";
8788
import { 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

89104
import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
90105
import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
91106
import type { ManagementContext } from "./context";
92107
import { 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+
94210
export 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

Comments
 (0)