diff --git a/devlog/_plan/260905_fast_default_exports/000_plan.md b/devlog/_plan/260905_fast_default_exports/000_plan.md new file mode 100644 index 0000000000..266c7ec705 --- /dev/null +++ b/devlog/_plan/260905_fast_default_exports/000_plan.md @@ -0,0 +1,14 @@ +# Fast discovery and external exports + +Class C3; one work phase (wp1), spec-satisfaction repair. +Trigger: Fast selectors require opt-in and do not reach pi/config exports. +Goal: default-on eligible Fast rows on discovery and every shared external export. +Non-goals: global fastMode changes, Ultra Fast, service deployment, new client protocols. +Verifier: GitHub Cross-platform CI on exact PR head; local tests and typecheck are prohibited by user. CI definitions in .github/workflows/ci.yml own runtime/typecheck checks. git diff --check observes the patch; no claim that it verifies behavior. +Stop: passing CI, independent review, authorized admin merge, fetched dev ancestry. +Memory: this unit and session-bound goalplan. Outcome DONE or evidence-backed external blockage. +Scope: existing repository credentials for branch push/PR/merge; no external account or service changes. Two-hour work phase, no paid external AI or additional resource spend. Subagents may inspect/audit and implement disjoint declared slices; parent reclaims after two distinct failed dispatches. + +Existing structure: src/server/fast-row.ts owns selectors and canonical Fast eligibility; src/clients/config-export/ owns common metadata and client serializers; src/server/management/model-rows.ts and src/cli/opencode.ts own catalog projections. Reuse these boundaries; no dependency or UI changes. Source of truth: structure/09_client-integrations.md and docs-site configuration reference. + +Omission means on; explicit false and malformed hand edits mean off. Native rows additionally require upstream speed-tier metadata. Real complete IDs win over synthetic selectors. Remote catalog authority must survive export without guessing from the local client config. Existing ordinary rows stay selectable. diff --git a/devlog/_plan/260905_fast_default_exports/010_implementation.md b/devlog/_plan/260905_fast_default_exports/010_implementation.md new file mode 100644 index 0000000000..99e4ef6261 --- /dev/null +++ b/devlog/_plan/260905_fast_default_exports/010_implementation.md @@ -0,0 +1,18 @@ +# wp1 implementation + +- MODIFY src/config.ts: fastRows optional/catch(false) -> default(true)/catch(false); seed getDefaultConfig true. MODIFY src/types/config.ts semantics. +- MODIFY src/server/fast-row.ts and src/server/index.ts: off iff === false, on iff !== false, across listing and ingress. Preserve grammar collision/eligibility logic. Add reusable catalog Fast availability predicate if needed to share native-tier and resolved routed eligibility with exports. +- MODIFY src/server/management/model-rows.ts: compute an optional boolean fastRowAvailable from live server config and row metadata, including native metadata slug; preserve it in toExportModel. No extra picker rows in management itself. +- MODIFY src/clients/config-export/contracts.ts, src/cli/opencode.ts and src/cli/export-command.ts: carry fastRowAvailable through each typed projection, including false; remote rows own their hub switch state. +- NEW src/clients/config-export/fast-models.ts (or shared metadata owner): expand eligible rows, preserve metadata, name Fast distinctly, reserve exact input IDs before synthesis, avoid repeated expansion, stable first-wins dedupe. Fallback to canonical local policy only if no resolved remote flag is supplied. Native fallback requires upstream advertised Fast. +- MODIFY config-export.ts and its omp/dsh/mcode/zcode serializers: use shared projection; OpenCode direct provider blocks use it too. Preserve pure normalizeExportModels compatibility if useful. +- MODIFY existing focused tests: omission/on/off/malformed config; parser/listing default; pi and every EXPORT_CLIENT_ID, OpenCode V1/V2 direct path, remote enabled/disabled with no local provider, duplicate/collision and metadata preservation. CI executes tests; no local suite or typecheck. +- MODIFY docs-site/src/content/docs/reference/configuration.md and structure/09_client-integrations.md: default true, false opt-out, supported export paths, refresh previously written client configs. + +Activation scenarios: omitted flag publishes and parses Fast; explicit false preserves old rows; malformed flag disables without dropping providers; eligible provider publishes but ineligible does not; native needs upstream fast; real base--fast wins over synthesis; re-expansion adds no nested rows; remote false stays false even when local default on. CI plus independent static review is the acceptance gate. + +Delegation: export-path explorer/auditor reads clients and callers; implementation worker may own config-export subtree plus CLI projections and their existing tests. Parent owns config/server/model-rows/default tests/docs. Reviews are read-only and run no suites. + +## Audit amendment + +Reviewer Huygens GO-WITH-FIXES identified old hubs without metadata and disabled real-ID collisions. Accept both. Freeze contract: `fastRowAvailable?: boolean` is resolved by the hub on EVERY management row. Export projection emits Fast ONLY when this field is true; no serializer-side local policy fallback at all. Missing metadata from old hubs conservatively means unavailable. Parent owns hub availability, using full management row IDs plus knownEffortRowIds BEFORE filtering disabled rows. Worker owns export contracts/CLI transports and a pure generic expansion helper. It changes namespaced/displayName only, preserves provider/id/native, marks synthesized rows fastRowAvailable:false for idempotence, and reserves all supplied exact IDs. Direct OpenCode launcher consumes the same hub projection, so does not need local inference. Tests cover hub/local conflicting flags, missing metadata and disabled complete-ID collisions. diff --git a/devlog/_plan/260905_fast_default_exports/011_review.md b/devlog/_plan/260905_fast_default_exports/011_review.md new file mode 100644 index 0000000000..7c6bd68e2e --- /dev/null +++ b/devlog/_plan/260905_fast_default_exports/011_review.md @@ -0,0 +1,13 @@ +# Review synthesis + +A: Huygens GO-WITH-FIXES. Accepted old-hub metadata absence and disabled exact-ID collision blockers; final implementation uses explicit hub booleans only and checks complete IDs before filtering. +B: Mendel found one documentation contradiction (old export exclusion). Accepted and replaced the stale paragraph. No runtime/auth/secret blocker in parent scope. Worker Noether completed all shared serializers and CLI transport with no local tests. Parent preserved direct OpenCode ordering and corrected CLI emitted counts using serializer summaries. +Verification pending: exact-head CI and final independent export review. No local suite or typecheck executed. + +C: Nietzsche independently reviewed the complete export slice at 13f8d0391. No blockers; accepted P3 launcher count correction so both CLI commands report expanded model counts. All twelve serializer paths, remote authority, collision/idempotence, metadata and auth preservation reviewed. + +## C repair: suffix-shaped real model identities + +CodeRabbit suggested suppressing every selector already ending in --fast. Independent adjudication by Nietzsche rebutted that blanket fix: fastRowBases seeds configured real IDs before its structural suffix refusal, and parsing strips exactly one suffix. Thus a configured model--fast legitimately has a model--fast--fast priority selector. Synthetic export rows already carry false availability, so repeated expansion is inert. + +Accepted the narrower defect: live-only suffix-shaped bases are deliberately not recognized by the parser, but capability-only discovery could advertise them. Fix the shared listing/export eligibility predicate to reject suffix-shaped bases absent from fastRowBases, and test configured versus live-only base behavior plus all-client configured-suffix exports. Both raw discovery and management exports consume this predicate, avoiding divergent fixes. Previous 4ca1ec5ff full CI passed; this repair requires a new exact-head CI run. diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 1ad3801177..b1136ee86e 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -59,15 +59,17 @@ a known configured model id. Cursor may require a model-list refresh or restart ### Fast rows -`fastRows` is an optional boolean and defaults to `false`. When enabled, the raw OpenAI-style -`/v1/models` list and Claude Code discovery add a `--fast` selector for every model whose +`fastRows` is an optional boolean and defaults to `true`. The raw OpenAI-style +`/v1/models` list, Claude Code discovery, and client config exports (including pi, OpenCode, +OMP, Hermes, OpenClaw, Kimi, Gajae, DSH, MCode, ZCode, Prime, and Aside) add a `--fast` selector for every model whose resolved Fast policy is eligible. Selecting one routes the base model and requests the `priority` service tier — the same Fast the Codex app exposes through its picker toggle. The base row stays listed, so the row is an addition rather than a replacement. -The flag exists because Fast was otherwise reachable only from Codex. Codex reads the tier from -catalog metadata and renders a toggle; every other client selects a model by id alone, so a -Claude Code or OpenAI-compatible client had no way to ask for it. +Set `"fastRows": false` to hide generated Fast selectors. Malformed values also disable them. +Refresh the client model list or regenerate/refresh an existing managed client configuration to +receive the new entries. Connected clients use the serving proxy's availability metadata; older +proxies without that metadata do not gain guessed Fast entries. Codex keeps its native Fast toggle. The suffix is `--fast`, with two hyphens, because a terminal `-fast` is already a real model id for several providers (`grok-4-fast`, `glm-5.3-fast`, and Cursor's own fast variants), and a single @@ -85,8 +87,9 @@ the two surfaces cannot disagree about which natives have Fast. Scope: this covers the request-serving surfaces — `/v1/models`, Claude Code discovery, and the `/v1/responses`, `/v1/chat/completions`, `/v1/messages`, `/v1/messages/count_tokens`, and -`/v1/responses/compact` endpoints. `ocx export` and the OpenCode integration emit base ids only, -because those identities are written into config files that outlive the flag. +`/v1/responses/compact` endpoints, plus `ocx export`, managed client integrations, and +the OpenCode launcher. After disabling Fast rows, refresh saved client configs and select a base +model instead of a previously saved Fast selector. Valid values in `config.json` override built-in defaults. Missing optional fields use the defaults documented on the domain pages. `OPENCODEX_HOME` takes precedence over the default configuration diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index 052a1a8841..73e47552d4 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -68,13 +68,6 @@ type ExportProxyModelRow = OpencodeProxyModelRow & { defaultReasoningEffort?: string; }; -/** Same authoritativeness rule the serializers apply, for the degraded-count line. */ -function hasContextLimit(model: ExportModel): boolean { - return typeof model.contextWindow === "number" - && Number.isFinite(model.contextWindow) - && model.contextWindow > 0; -} - /** * Export rows from proxy `/api/models` rows. * @@ -106,6 +99,7 @@ export function exportModelsFromProxyRows( id: entry.id ?? entry.namespaced, }; if (entry.native) model.native = true; + if (entry.fastRowAvailable !== undefined) model.fastRowAvailable = entry.fastRowAvailable; if (entry.displayName) model.displayName = entry.displayName; if (entry.contextWindow !== undefined) model.contextWindow = entry.contextWindow; if (entry.reasoningEfforts && entry.reasoningEfforts.length > 0) { @@ -195,7 +189,7 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep // stderr, so `--json` stdout stays a standalone JSON document. if (out !== undefined && wantsJson) console.error(`Wrote ${out}`); - const degraded = models.filter(model => !hasContextLimit(model)).length; + const { modelCount, modelsWithoutLimits } = spec.summarize(clientConfig); // `--json` keeps emitting the DOCUMENT at the top level as JSON for scripts; // `--out` is the path that writes the selected client's native format. // Format metadata rides in the human lines below. @@ -206,7 +200,7 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep `Destination: ${spec.destination(process.env)}`, "Merge this generated configuration into that file; do not replace it.", `Before launching: ${spec.exportHint}`, - `${models.length} model${models.length === 1 ? "" : "s"}; ${degraded} omit context limits (the client applies its own defaults).`, + `${modelCount} model${modelCount === 1 ? "" : "s"}; ${modelsWithoutLimits} omit context limits (the client applies its own defaults).`, ]); }); } diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index 793ee46cb7..d56f5745e5 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -88,6 +88,8 @@ export interface OpencodeRoutedModel { /** Row shape from authenticated GET /api/models on the running proxy. */ export interface OpencodeProxyModelRow { + /** Hub-resolved availability, independent of the launcher's local Fast setting. */ + fastRowAvailable?: boolean; provider?: string; id?: string; namespaced?: string; @@ -389,6 +391,7 @@ export function opencodeCatalogFromProxyRows( id: row.id, contextWindow: row.contextWindow, displayName: row.displayNameSource === "fallback" ? undefined : row.displayName, + ...(typeof row.fastRowAvailable === "boolean" ? { fastRowAvailable: row.fastRowAvailable } : {}), ...(Array.isArray(row.reasoningEfforts) && row.reasoningEfforts.length > 0 ? { reasoningEfforts: [...row.reasoningEfforts] } : {}), @@ -648,7 +651,7 @@ export async function cmdOpencode(args: string[]): Promise { const catalog = opencodeCatalogFromProxyRows(proxyModels, config); const blocks = buildOpencodeProviderBlocksFromCatalog(live.port, catalog, live.hostname, config); const baseUrl = blocks.v1.options.baseURL; - const modelCount = catalog.length; + const modelCount = Object.keys(blocks.v1.models).length; console.error(`✅ opencode wired to ${baseUrl} — ${modelCount} model(s) under provider \`${OPENCODE_PROVIDER_ID}\`.`); console.error(" Your existing opencode config files are left untouched; only the runtime provider blocks are injected."); const providerOverride = opencodeProviderOverridePath(process.cwd()); diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 7aaf556f2b..372abcc00e 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -25,6 +25,7 @@ import { isAbsolute, join, resolve } from "node:path"; import { shouldInjectApiAuthHeader, standaloneCodexRoutingTarget } from "../codex/inject"; import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize"; import { canonicalizeReasoningEfforts } from "../reasoning-effort"; +import { expandFastExportModels } from "./config-export/fast-models"; import { probeHostname } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; @@ -587,9 +588,8 @@ export function opencodeProviderBlocks( ): OpencodeProviderBlocks { const v1Models: Record = {}; const v2Models: Record = {}; - for (const model of catalogModels) { + for (const model of expandFastExportModels(catalogModels)) { const key = model.namespaced; - if (v1Models[key]) continue; // first entry wins; native rows lead /api/models const entry: OpencodeModelEntry = { name: exportModelLabel(model) }; const context = authoritativeContextWindow(model.contextWindow); if (context !== undefined) { diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts index 3edd91eb5e..039d7eaaf0 100644 --- a/src/clients/config-export/contracts.ts +++ b/src/clients/config-export/contracts.ts @@ -31,6 +31,8 @@ export interface OpencodeLaunchEnv { /** Visible catalog entry keyed by the proxy's canonical namespaced selector. */ export interface OpencodeCatalogModel { namespaced: string; + /** Hub-resolved Fast availability. Missing metadata means unavailable. */ + fastRowAvailable?: boolean; native?: boolean; provider?: string; id?: string; @@ -54,6 +56,8 @@ export interface OpencodeCatalogModel { export interface ExportModel { /** Canonical proxy selector: `provider/id`, or bare slug for native. */ namespaced: string; + /** Hub-resolved Fast availability; exporters never infer it from local config. */ + fastRowAvailable?: boolean; provider: string; id: string; /** Native OpenAI entry. Read by the shared label rule. */ diff --git a/src/clients/config-export/fast-models.ts b/src/clients/config-export/fast-models.ts new file mode 100644 index 0000000000..d655d26e9b --- /dev/null +++ b/src/clients/config-export/fast-models.ts @@ -0,0 +1,29 @@ +import type { OpencodeCatalogModel } from "./contracts"; + +const FAST_ROW_SUFFIX = "--fast"; + +/** + * Expand only hub-resolved availability; older hubs without the field advertise no Fast. + * Reserve every exact input selector before synthesis and retain the first duplicate. + * Keep routing/capability metadata intact: only the selector, label and availability change. + */ +export function expandFastExportModels(models: readonly T[]): T[] { + const exact = new Map(); + for (const model of models) { + if (!exact.has(model.namespaced)) exact.set(model.namespaced, model); + } + const expanded = [...exact.values()]; + for (const model of exact.values()) { + if (model.fastRowAvailable !== true) continue; + const namespaced = `${model.namespaced}${FAST_ROW_SUFFIX}`; + if (exact.has(namespaced)) continue; + expanded.push({ + ...model, + namespaced, + displayName: `${model.displayName || model.id || model.namespaced} Fast`, + // Re-normalizing cannot synthesize a Fast row from this synthetic row. + fastRowAvailable: false, + }); + } + return expanded; +} diff --git a/src/clients/config-export/model-metadata.ts b/src/clients/config-export/model-metadata.ts index 4f3038efac..7b24390341 100644 --- a/src/clients/config-export/model-metadata.ts +++ b/src/clients/config-export/model-metadata.ts @@ -1,5 +1,6 @@ // Shared client export model metadata. import { SCHEMA_REQUIRED_OUTPUT_BUDGET } from "./constants"; +import { expandFastExportModels } from "./fast-models"; import type { OpencodeCatalogModel, ExportModel, ExportClientId, ManagedContribution } from "./contracts"; import type { OcxConfig } from "../../types"; import { shouldInjectApiAuthHeader } from "../../codex/inject"; @@ -86,20 +87,14 @@ export function exportModelLabel(model: OpencodeCatalogModel): string { } /** - * Shared precondition for every serializer: drop duplicate `namespaced` (first wins, - * native rows lead `/api/models`) and sort by `namespaced` so two calls with the same + * Shared precondition for every serializer: expand hub-approved Fast rows, drop duplicate + * `namespaced` (first wins, native rows lead `/api/models`) and sort so calls with the same * models produce identical bytes. Stability matters because the GUI shows a diffable * preview and agents may checksum the payload. */ export function normalizeExportModels(models: readonly ExportModel[]): ExportModel[] { - const seen = new Set(); - const unique: ExportModel[] = []; - for (const model of models) { - if (seen.has(model.namespaced)) continue; - seen.add(model.namespaced); - unique.push(model); - } - return unique.sort((a, b) => (a.namespaced < b.namespaced ? -1 : a.namespaced > b.namespaced ? 1 : 0)); + return expandFastExportModels(models) + .sort((a, b) => (a.namespaced < b.namespaced ? -1 : a.namespaced > b.namespaced ? 1 : 0)); } /** Extra headers a non-loopback bind needs, or nothing on loopback. */ diff --git a/src/config.ts b/src/config.ts index fdcda9547c..d2b0bb707a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1113,9 +1113,8 @@ const configSchema = z.object({ defaultModelAliases: z.boolean().optional(), // Malformed hand edits disable this opt-in projection without rejecting providers. cursorEffortRows: z.boolean().optional().catch(false), - // Same opt-in discipline: a malformed hand edit degrades to off rather than rejecting - // every provider. - fastRows: z.boolean().optional().catch(false), + // Fast selectors default on; malformed hand edits disable them without rejecting providers. + fastRows: z.boolean().default(true).catch(false), // Ultra Fast is opt-in for the same reason and degrades the same way: a malformed hand // edit turns the tier off rather than rejecting the config that carries it. ultraFastTier: z.boolean().optional().catch(false), @@ -3684,6 +3683,7 @@ export function getDefaultConfig(): OcxConfig { return { port: 10100, emptyCompletionRetry: false, + fastRows: true, managementUsageMaxReadBytes: 64 * 1024 * 1024, appOwnedMemoryBudgetMb: DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024), // Fresh/re-initialized configs are already written in the current three-tier diff --git a/src/server/fast-row.ts b/src/server/fast-row.ts index 05de632c9f..f07bb25b70 100644 --- a/src/server/fast-row.ts +++ b/src/server/fast-row.ts @@ -61,6 +61,28 @@ export function fastRowEligible( return fastPolicyForModel(provider, modelId, providerName, inbound).eligibility === "eligible"; } +/** Shared discovery/export policy; native rows additionally need upstream tier evidence. */ +export function catalogFastRowEligible( + config: OcxConfig, + model: { provider: string; id: string; native?: boolean; supportsServiceTier?: boolean }, +): boolean { + if (config.fastRows === false) return false; + // Configured suffix-shaped IDs are real bases; live-only ones are deliberately + // refused by ingress. Discovery must not advertise a selector ingress cannot strip. + if (model.id.endsWith(FAST_ROW_SUFFIX) + && !fastRowBases(config)(model.native ? model.id : `${model.provider}/${model.id}`)) return false; + if (model.native) { + const id = model.id.slice(model.id.lastIndexOf("/") + 1); + const tiers = UPSTREAM_NATIVE_ENTRIES.get(id)?.additional_speed_tiers; + const provider = config.providers.openai; + return Array.isArray(tiers) && tiers.includes("fast") && provider !== undefined + && fastRowEligible(provider, id, "openai"); + } + if (model.supportsServiceTier !== undefined) return model.supportsServiceTier === true; + const provider = config.providers[model.provider]; + return provider !== undefined && fastRowEligible(provider, model.id, model.provider); +} + /** * Bases that may carry a fast row. * @@ -180,7 +202,7 @@ export function parseFastRowId( knownIds?: EffortRowKnownIds, routableBases?: EffortRowKnownIds, ): ParsedFastRowId | null { - if (config.fastRows !== true) return null; + if (config.fastRows === false) return null; if (!id.endsWith(FAST_ROW_SUFFIX)) return null; // An exact configured/public id always beats the synthetic grammar, the same precedence // effort rows use. An operator who really named a model `x--fast` keeps it. @@ -218,9 +240,8 @@ export function parseSyntheticRowId( // alias lookups even on the fastRows-off path this function exists to leave untouched. fastSelector?: () => string, ): ParsedSyntheticRow { - // Fast off: delegate to the SAME function shipped today, so an install that never enables - // this feature cannot observe any change, in behaviour or in cost. - if (config.fastRows !== true) { + // Explicit opt-out preserves the effort-only parser and avoids Fast inventory work. + if (config.fastRows === false) { return { fastRow: null, effortRow: parseRequestEffortRowId(id, config) }; } const selector = fastSelector?.() ?? id; @@ -252,7 +273,7 @@ export function parseFastOnlyRowId( config: OcxConfig, selector: () => string, ): ParsedFastRowId | null { - if (config.fastRows !== true) return null; + if (config.fastRows === false) return null; return parseSyntheticRowId("", config, selector).fastRow; } @@ -268,7 +289,7 @@ export function expandFastRow( config: Pick, knownIds?: EffortRowKnownIds, ): T[] { - if (config.fastRows !== true || !eligible) return [row]; + if (config.fastRows === false || !eligible) return [row]; const id = fastRowId(row.id); return isKnownId(knownIds, id) ? [row] : [row, { ...row, id }]; } diff --git a/src/server/index.ts b/src/server/index.ts index 82f36d7b6a..15f56dec0c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -234,9 +234,7 @@ import { recordCursorSeen } from "../integrations/cursor-seen"; import { detectCursorInstalls } from "../integrations/cursor-detect"; import { loadCursorEffortTable } from "../integrations/cursor-effort-table"; import { expandCursorEffortRow, knownEffortRowIds } from "./effort-row"; -import { expandFastRow, fastRowEligible } from "./fast-row"; -// Direct import: the catalog facade does not re-export this table. -import { UPSTREAM_NATIVE_ENTRIES } from "../codex/catalog/metadata"; +import { catalogFastRowEligible, expandFastRow } from "./fast-row"; export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024; const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0; @@ -1457,15 +1455,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { - if (config.fastRows !== true) return false; - const upstream = UPSTREAM_NATIVE_ENTRIES.get(metadataId); - const speedTiers = upstream?.additional_speed_tiers; - if (!Array.isArray(speedTiers) || !speedTiers.includes("fast")) return false; - const nativeProvider = config.providers[OPENAI_CODEX_PROVIDER_ID]; - return nativeProvider !== undefined - && fastRowEligible(nativeProvider, metadataId, OPENAI_CODEX_PROVIDER_ID); - }; + const nativeFastEligible = (metadataId: string): boolean => + catalogFastRowEligible(config, { provider: OPENAI_CODEX_PROVIDER_ID, id: metadataId, native: true }); + /** * Whether a routed catalog row may carry a Fast sibling. * @@ -1479,12 +1471,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { - if (config.fastRows !== true) return false; - if (m.supportsServiceTier !== undefined) return m.supportsServiceTier === true; - const rowProvider = config.providers[m.provider]; - return rowProvider !== undefined && fastRowEligible(rowProvider, m.id, m.provider); - }; + const catalogRowFastEligible = (m: { provider: string; id: string; supportsServiceTier?: boolean }): boolean => + catalogFastRowEligible(config, m); + if (wantsAnthropicList && !url.searchParams.has("client_version")) { if (config.claudeCode?.enabled === false) return jsonResponse({ data: [] }, 200, req, policy); // Build Desktop 3P registry so inbound alias resolution works for subsequent requests. @@ -1514,9 +1503,8 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server model.provider === "native" ? nativeFastEligible(model.id) @@ -1651,8 +1639,8 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server & { native?: boolean; custom?: boolean; customId?: string; + fastRowAvailable?: boolean; displayNameOverride?: string; displayNameSource?: "operator" | "provider" | "fallback"; }; @@ -166,10 +169,20 @@ export async function listManagementModelRows( ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}), }; }).filter((row): row is ManagementModelRow => row !== null); - return [...native, ...dedupedRouted, ...visibleCustomModels].map(row => - initialModelSelectionPending(config.providers[row.provider]) - ? { ...row, disabled: true, initialSelectionPending: true } - : row); + const rows = [...native, ...dedupedRouted, ...visibleCustomModels]; + // Include disabled rows and configured aliases before the export visibility filter: + // a hidden real `x--fast` must never become a synthetic selector for another model. + const knownIds = config.fastRows === false ? new Set() : knownEffortRowIds(config); + for (const row of rows) knownIds.add(row.namespaced); + return rows.map(row => { + const pending = initialModelSelectionPending(config.providers[row.provider]); + return { + ...row, + ...(pending ? { disabled: true, initialSelectionPending: true } : {}), + fastRowAvailable: !row.disabled && !pending + && !knownIds.has(fastRowId(row.namespaced)) && catalogFastRowEligible(config, row), + }; + }); } /** `/api/models` row → the narrower input the client-config serializers accept. */ @@ -178,6 +191,7 @@ export function toExportModel(row: ManagementModelRow): ExportModel { namespaced: row.namespaced, provider: row.provider, id: row.id, + fastRowAvailable: row.fastRowAvailable === true, ...(row.native ? { native: true } : {}), ...(row.displayName && row.displayNameSource !== "fallback" ? { displayName: row.displayName } : {}), ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), diff --git a/src/types/config.ts b/src/types/config.ts index 8cf1246979..df7ac25727 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -376,10 +376,11 @@ export interface OcxConfig { */ cursorEffortRows?: boolean; /** - * Opt-in synthetic Fast selectors. When true, the raw OpenAI-style `/v1/models` list and + * Default-on synthetic Fast selectors. The raw OpenAI-style `/v1/models` list and * Claude Code discovery add a `--fast` row for every model whose resolved Fast * policy is eligible, and selecting one routes the base model with the canonical - * `priority` service tier. Omitted/false preserves discovery output exactly. + * `priority` service tier. Client config exports include the same selectors. Set false + * to disable them; omission enables them. */ fastRows?: boolean; /** diff --git a/structure/09_client-integrations.md b/structure/09_client-integrations.md index fde4e5cf8a..2c2e42b419 100644 --- a/structure/09_client-integrations.md +++ b/structure/09_client-integrations.md @@ -36,6 +36,16 @@ Status and mutation must use the same classifier. A special case added only to a would be misleading because refresh or disable could still reject the same file; a special case added only to a writer would let a mutation bypass the state users saw. +## Fast model selectors + +The serving proxy resolves `fastRowAvailable` on every management model row, including its +`fastRows` setting (default true), canonical eligibility, native upstream tier evidence, and +exact-ID collisions checked before disabled rows are filtered. Management and CLI projections +carry the boolean into the shared client serializers. Only true creates an additive `--fast` +selector, preserving the underlying provider, model ID, modalities, limits, and effort metadata. +False or missing metadata never causes local inference, so old or disabled remote hubs remain +authoritative. Existing client configs receive the entries on export or managed refresh. + ## Hermes Model Capabilities Hermes cannot infer custom-provider capabilities from its built-in registry. The OpenCodex diff --git a/tests/codex-integration/fast-row.test.ts b/tests/codex-integration/fast-row.test.ts index 6a0437b8af..a8e9525e49 100644 --- a/tests/codex-integration/fast-row.test.ts +++ b/tests/codex-integration/fast-row.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { clearModelCache, setCached } from "../../src/codex/model-cache"; import { knownEffortRowIds, parseEffortRowId, parseRequestEffortRowId } from "../../src/server/effort-row"; import { + catalogFastRowEligible, effortBaseCarriesFastMarker, expandFastRow, fastRowBases, @@ -23,7 +24,7 @@ import type { OcxConfig, OcxProviderConfig } from "../../src/types"; * it, and a suffix-shape composite guard suppressed rows this feature itself publishes. */ -const OFF = {} as Pick; +const OFF = { fastRows: false } as Pick; const ON = { fastRows: true } as Pick; function provider(overrides: Partial = {}): OcxProviderConfig { @@ -45,13 +46,16 @@ function configWith(providers: Record, extra: Partial } describe("fast-row grammar", () => { - test("the flag is off by default, on both the parser and the expander", () => { - // The path every existing install runs. Both inventories are optional, so this needs no - // config at all. + test("explicit opt-out disables both the parser and the expander", () => { expect(parseFastRowId("x--fast", OFF)).toBeNull(); expect(expandFastRow({ id: "x" }, true, OFF)).toEqual([{ id: "x" }]); }); + test("omission enables parsing and additive listing", () => { + expect(parseFastRowId("x--fast", {}, new Set(), new Set(["x"]))).toEqual({ baseId: "x" }); + expect(expandFastRow({ id: "x" }, true, {})).toEqual([{ id: "x" }, { id: "x--fast" }]); + }); + test("a fast row is additive, never a replacement", () => { // Unlike the fastMode global rewrite: a per-request selector has to leave the default // pickable beside it. @@ -370,3 +374,23 @@ describe("review findings from PR #3457", () => { }); }); + + +test("shared native Fast listing policy requires upstream evidence and allows account selectors", () => { + const config = configWith({ openai: provider({ supportsServiceTier: true }) }, { fastRows: undefined }); + expect(catalogFastRowEligible(config, { provider: "openai", id: "gpt-5.6-sol", native: true })).toBe(true); + expect(catalogFastRowEligible(config, { provider: "openai", id: "account/gpt-5.6-sol", native: true })).toBe(true); + expect(catalogFastRowEligible(config, { provider: "openai", id: "unknown-native", native: true })).toBe(false); + expect(catalogFastRowEligible({ ...config, fastRows: false }, { provider: "openai", id: "gpt-5.6-sol", native: true })).toBe(false); +}); + +test("Fast discovery agrees with ingress for configured and live-only suffix-shaped bases", () => { + const row = { provider: "fixture", id: "model--fast", supportsServiceTier: true }; + const config = configWith({ fixture: provider({ models: ["model--fast"], supportsServiceTier: true }) }); + expect(catalogFastRowEligible(config, row)).toBe(true); + expect(parseSyntheticRowId("fixture/model--fast--fast", config).fastRow) + .toEqual({ baseId: "fixture/model--fast" }); + config.providers.fixture.models = ["ordinary"]; + expect(catalogFastRowEligible(config, row)).toBe(false); + expect(parseSyntheticRowId("fixture/model--fast--fast", config).fastRow).toBeNull(); +}); diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index ec969a8455..707d6dd62c 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -14,6 +14,7 @@ import { buildClientConfigText, isExportClientId, normalizeExportModels, + opencodeProviderBlocks, ompModelsConfigPath, type DshGeneratedConfig, type ExportContext, @@ -21,7 +22,8 @@ import { type OpencodeGeneratedConfig, type PiGeneratedConfig, } from "../../src/clients/config-export"; -import { buildOpencodeProviderBlockFromCatalog, opencodeGlobalConfigPath } from "../../src/cli/opencode"; +import { buildOpencodeProviderBlockFromCatalog, opencodeCatalogFromProxyRows, opencodeGlobalConfigPath } from "../../src/cli/opencode"; +import { exportModelsFromProxyRows } from "../../src/cli/export-command"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import * as facade from "../../src/clients/config-export"; @@ -651,6 +653,155 @@ describe("stable ordering (accept criterion 4)", () => { }); }); +describe("hub-resolved Fast exports", () => { + const eligible: ExportModel = { + namespaced: "remote/model", provider: "remote", id: "model", displayName: "Remote Model", + fastRowAvailable: true, contextWindow: 8192, inputModalities: ["text", "image"], + reasoningEfforts: ["none", "high", "ultra"], defaultReasoningEffort: "high", + }; + + test("preserves underlying metadata, labels Fast, and normalizes idempotently without mutation", () => { + const native = Object.freeze({ ...eligible, namespaced: "native-model", id: "native-model", provider: "openai", native: true }); + const input = Object.freeze([native, Object.freeze({ ...eligible })]); + const before = JSON.stringify(input); + const expanded = normalizeExportModels(input); + expect(expanded.map(model => model.namespaced)).toEqual([ + "native-model", "native-model--fast", "remote/model", "remote/model--fast", + ]); + expect(expanded[1]).toEqual({ + ...native, namespaced: "native-model--fast", displayName: "Remote Model Fast", fastRowAvailable: false, + }); + expect(expanded[3]).toEqual({ + ...eligible, namespaced: "remote/model--fast", displayName: "Remote Model Fast", fastRowAvailable: false, + }); + expect(normalizeExportModels(expanded)).toEqual(expanded); + expect(JSON.stringify(input)).toBe(before); + }); + + test("reserves all exact IDs before synthesis in either order and keeps the first duplicate", () => { + const real = { ...eligible, namespaced: "remote/model--fast", id: "real-fast", displayName: "Real model", fastRowAvailable: false }; + const shadow = { ...real, displayName: "Shadow loses", fastRowAvailable: true }; + for (const input of [[eligible, real, shadow], [real, shadow, eligible]]) { + const expanded = normalizeExportModels(input); + expect(expanded).toEqual([eligible, real]); + expect(normalizeExportModels(expanded)).toEqual(expanded); + } + const unavailable = { ...eligible, fastRowAvailable: false }; + expect(normalizeExportModels([unavailable, eligible])).toEqual([unavailable]); + expect(normalizeExportModels([eligible, unavailable]).map(model => model.namespaced)) + .toEqual(["remote/model", "remote/model--fast"]); + }); + + for (const client of EXPORT_CLIENT_IDS) { + test(`${client} emits only hub-approved selectors and remains stable across ordering and re-expansion`, () => { + const absent: ExportModel = { namespaced: "remote/old-hub", provider: "remote", id: "old-hub", contextWindow: 8192 }; + const disabled = { ...eligible, namespaced: "remote/off", fastRowAvailable: false }; + const models = [eligible, absent, disabled]; + const context = ctx({ models, config: cfg({ fastRows: false }) }); + const document = buildClientConfig(client, context); + const bytes = JSON.stringify(document); + expect(EXPORT_CLIENTS[client].summarize(document).modelCount).toBe(4); + expect(bytes).toContain('"remote/model--fast"'); + expect(bytes).not.toContain("remote/off--fast"); + expect(bytes).not.toContain("remote/old-hub--fast"); + expect(bytes).not.toContain("--fast--fast"); + expect(bytes).not.toContain("fastRowAvailable"); + expect(JSON.stringify(buildClientConfig(client, { ...context, models: [...models].reverse() }))).toBe(bytes); + expect(JSON.stringify(buildClientConfig(client, { ...context, models: normalizeExportModels(models) }))).toBe(bytes); + // Local default-on cannot override an old or explicitly disabled hub. + const localOn = buildClientConfig(client, ctx({ models: [absent, disabled], config: cfg({ fastRows: true }) })); + expect(EXPORT_CLIENTS[client].summarize(localOn).modelCount).toBe(2); + expect(JSON.stringify(localOn)).not.toContain("--fast"); + }); + } + + test("an eligible real suffix-shaped model has a valid Fast sibling in every client", () => { + const real = { ...eligible, namespaced: "remote/model--fast", id: "model--fast" }; + for (const client of EXPORT_CLIENT_IDS) { + const context = ctx({ models: [real] }); + const document = buildClientConfig(client, context); + expect(EXPORT_CLIENTS[client].summarize(document).modelCount).toBe(2); + expect(JSON.stringify(document)).toContain('"remote/model--fast--fast"'); + expect(buildClientConfig(client, { ...context, models: normalizeExportModels([real]) })).toEqual(document); + } + }); + + test("pi Fast rows retain modalities, context and the exact thinking ladder", () => { + const models = piConfig(ctx({ models: [eligible] })).providers.opencodex!.models; + expect(models).toHaveLength(2); + expect(models[1]).toEqual({ + id: "remote/model--fast", name: "Remote Model Fast (remote)", input: ["text", "image"], + contextWindow: 8192, maxTokens: 8192, reasoning: true, + thinkingLevelMap: { off: "none", minimal: null, low: null, medium: null, high: "high", xhigh: null, max: "ultra" }, + }); + }); + + test("direct OpenCode V1 and V2 expand the hub projection and preserve limits, variants and auth", () => { + const sparse = { namespaced: "z/sparse", fastRowAvailable: true }; + const real = { ...eligible, namespaced: "remote/model--fast", displayName: "Exact row", fastRowAvailable: false }; + const catalog = [sparse, eligible, real, { ...real, displayName: "Duplicate loses" }]; + const blocks = opencodeProviderBlocks(BASE_URL, catalog, cfg({ fastRows: false })); + for (const block of [blocks.v1, blocks.v2]) { + expect(Object.keys(block.models)).toEqual(["z/sparse", "remote/model", "remote/model--fast", "z/sparse--fast"]); + expect(block.models["remote/model--fast"]!.name).toBe("Exact row (remote)"); + expect(block.models["z/sparse--fast"]).toEqual({ name: "z/sparse Fast (routed)" }); + } + const expanded = opencodeProviderBlocks(BASE_URL, [eligible], cfg({ fastRows: false })); + expect(expanded.v1.models["remote/model--fast"]).toEqual({ + name: "Remote Model Fast (remote)", limit: { context: 8192, output: 8192 }, + }); + expect(expanded.v2.models["remote/model--fast"]).toEqual({ + name: "Remote Model Fast (remote)", limit: { context: 8192, output: 8192 }, + variants: [ + { id: "high", settings: { reasoningEffort: "high" } }, + { id: "ultra", settings: { reasoningEffort: "ultra" } }, + ], + }); + expect(expanded.v1.options).toEqual({ baseURL: BASE_URL, apiKey: OPENCODE_API_KEY_ENV_REF }); + expect(expanded.v2.settings).toEqual({ baseURL: BASE_URL, apiKey: OPENCODE_API_KEY_ENV_REF }); + const remote = opencodeProviderBlocks(BASE_URL, [eligible], cfg({ hostname: "0.0.0.0" })); + expect(remote.v1.options).toEqual({ baseURL: BASE_URL, headers: { "x-opencodex-api-key": OPENCODE_API_KEY_ENV_REF } }); + expect(remote.v2.settings).toEqual(remote.v1.options); + }); + + test("both CLI projections retain hub true/false/absence despite conflicting local settings", () => { + for (const localFast of [false, true]) { + for (const hubFast of [undefined, false, true]) { + const row = { ...eligible, fastRowAvailable: hubFast }; + const config = cfg({ fastRows: localFast }); // No matching remote provider locally. + const catalog = opencodeCatalogFromProxyRows([row], config); + const models = exportModelsFromProxyRows([row], config); + expect(catalog[0]!.fastRowAvailable).toBe(hubFast); + expect(models[0]!.fastRowAvailable).toBe(hubFast); + if (hubFast === undefined) { + expect(catalog[0]).not.toHaveProperty("fastRowAvailable"); + expect(models[0]).not.toHaveProperty("fastRowAvailable"); + } + const expected = hubFast === true ? ["remote/model", "remote/model--fast"] : ["remote/model"]; + expect(normalizeExportModels(models).map(model => model.namespaced)).toEqual(expected); + const direct = opencodeProviderBlocks(BASE_URL, catalog, config); + expect(Object.keys(direct.v1.models)).toEqual(expected); + expect(Object.keys(direct.v2.models)).toEqual(expected); + } + } + }); + + test("filtered and duplicate rows cannot donate availability; a hub collision decision survives filtering", () => { + const visible = { ...eligible, fastRowAvailable: false }; + const rows = [ + { ...eligible, disabled: true }, visible, eligible, + { ...eligible, namespaced: "remote/model--fast", disabled: true }, + ]; + const config = cfg({ fastRows: true }); + const projected = exportModelsFromProxyRows(rows, config); + expect(projected).toEqual([visible]); + expect(normalizeExportModels(projected)).toEqual([visible]); + const blocks = opencodeProviderBlocks(BASE_URL, opencodeCatalogFromProxyRows(rows, config), config); + expect(Object.keys(blocks.v1.models)).toEqual(["remote/model"]); + expect(Object.keys(blocks.v2.models)).toEqual(["remote/model"]); + }); +}); + describe("EXPORT_CLIENTS registry", () => { test("covers exactly the twelve file-toggle clients", () => { expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); diff --git a/tests/config/config-load-degrade.test.ts b/tests/config/config-load-degrade.test.ts index 3d26ffd107..4d8cca68f7 100644 --- a/tests/config/config-load-degrade.test.ts +++ b/tests/config/config-load-degrade.test.ts @@ -127,3 +127,15 @@ test("load warnings never reveal display values or secret shaped provider names" warn.mockRestore(); } }); + + +test("Fast rows default on for fresh and omitted config; explicit false and malformed values disable", () => { + expect(getDefaultConfig().fastRows).toBe(true); + for (const [value, expected] of [[undefined, true], [true, true], [false, false], ["invalid", false]] as const) { + const config = { ...candidate({}), fastRows: value }; + writeFileSync(getConfigPath(), JSON.stringify(config), "utf8"); + const loaded = loadConfig(); + expect(loaded.fastRows).toBe(expected); + expect(loaded.providers.xai.note).toBe("keep me"); + } +}); diff --git a/tests/providers/fast-row-ingress.test.ts b/tests/providers/fast-row-ingress.test.ts index 46ce6b038c..f6165c831c 100644 --- a/tests/providers/fast-row-ingress.test.ts +++ b/tests/providers/fast-row-ingress.test.ts @@ -113,8 +113,8 @@ describe("surfaces that never parsed an effort row", () => { }); }); -describe("the flag stays off by default at the request path", () => { - test("a --fast selector is an ordinary unknown model when the flag is unset", () => { +describe("explicit opt-out at the request path", () => { + test("a --fast selector is an ordinary unknown model when the flag is false", () => { const config = configWith({ fixture: provider({ models: ["m"], supportsServiceTier: true }) }, { fastRows: false }); const rows = parseSyntheticRowId("m--fast", config); expect(rows.fastRow).toBeNull(); @@ -124,3 +124,11 @@ describe("the flag stays off by default at the request path", () => { }); }); + + +test("omitted Fast flag resolves selectors on ordinary and Fast-only ingress", () => { + const config = configWith({ fixture: provider({ models: ["m"], supportsServiceTier: true }) }); + delete config.fastRows; + expect(parseSyntheticRowId("fixture/m--fast", config).fastRow).toEqual({ baseId: "fixture/m" }); + expect(parseFastOnlyRowId(config, () => "fixture/m--fast")).toEqual({ baseId: "fixture/m" }); +}); diff --git a/tests/server/management-client-config-route.test.ts b/tests/server/management-client-config-route.test.ts index 93c3e79cdb..a7d4d080cb 100644 --- a/tests/server/management-client-config-route.test.ts +++ b/tests/server/management-client-config-route.test.ts @@ -79,6 +79,7 @@ interface ModelRow { namespaced: string; disabled: boolean; native?: boolean; + fastRowAvailable?: boolean; displayName?: string; displayNameSource?: "operator" | "provider" | "fallback"; contextWindow?: number; @@ -149,6 +150,7 @@ function toExportModel(row: ModelRow): ExportModel { namespaced: row.namespaced, provider: row.provider, id: row.id, + fastRowAvailable: row.fastRowAvailable === true, ...(row.native ? { native: true } : {}), ...(row.displayName && row.displayNameSource !== "fallback" ? { displayName: row.displayName } : {}), ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), @@ -553,3 +555,48 @@ describe("GET /api/client-config", () => { expect(response?.status).toBe(403); }, 15_000); }); + + +describe("default Fast availability reaches external exports", () => { + function fastConfig(overrides: Partial = {}): OcxConfig { + return baseConfig({ + defaultProvider: "fixture", + providers: { fixture: { + adapter: "openai-responses", baseUrl: "https://fixture.example/v1", + liveModels: false, models: ["m", "slow"], supportsServiceTier: true, + modelSupportsServiceTier: { slow: false }, + } }, + ...overrides, + }); + } + + test("omitted flag exports eligible Fast to pi, with the base still selectable", async () => { + const config = fastConfig(); + const rows = await modelRows(config); + expect(rows.find(row => row.namespaced === "fixture/m")?.fastRowAvailable).toBe(true); + expect(rows.find(row => row.namespaced === "fixture/slow")?.fastRowAvailable).toBe(false); + const response = await clientConfigApi(config, "?client=pi"); + const body = await response.json() as ClientConfigEnvelope; + const models = (body.config as PiGeneratedConfig).providers.opencodex.models.map(model => model.id); + expect(models).toContain("fixture/m"); + expect(models).toContain("fixture/m--fast"); + expect(models).not.toContain("fixture/slow--fast"); + }); + + test("explicit off survives management and export projection", async () => { + const config = fastConfig({ fastRows: false }); + const rows = await loadExportModels(config); + expect(rows.every(row => row.fastRowAvailable === false)).toBe(true); + const result = buildClientConfig("pi", { baseUrl: "http://127.0.0.1:10100/v1", models: rows, config }) as PiGeneratedConfig; + expect(result.providers.opencodex.models.map(model => model.id)).not.toContain("fixture/m--fast"); + }); + + test("a disabled real Fast-named model defeats synthesis before visibility filtering", async () => { + const config = fastConfig({ disabledModels: ["fixture/m--fast"] }); + config.providers.fixture.models = ["m", "m--fast"]; + const rows = await loadExportModels(config); + expect(rows.find(row => row.namespaced === "fixture/m")?.fastRowAvailable).toBe(false); + const result = buildClientConfig("pi", { baseUrl: "http://127.0.0.1:10100/v1", models: rows, config }) as PiGeneratedConfig; + expect(result.providers.opencodex.models.map(model => model.id)).not.toContain("fixture/m--fast"); + }); +});