diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 5dbbd24b54ce..bba4e816caca 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -5,6 +5,7 @@ import { EnvironmentId, MessageId, NodeId, + type OrchestratorMcpCapabilitiesResult, OrchestratorMcpTarget, type OrchestrationV2ThreadShell, type ScheduledTask, @@ -1156,6 +1157,176 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + describe("listed models", () => { + const opencodeInstanceId = ProviderInstanceId.make("opencode"); + const model = (slug: string, extra: Partial = {}) => ({ + slug, + name: slug, + isCustom: false, + capabilities: null, + ...extra, + }); + const opencodeSlugs = Array.from( + { length: 12 }, + (_, index) => `oc/m${String(index + 1).padStart(2, "0")}`, + ); + const catalog: ReadonlyArray = [ + { + ...providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + }), + models: [ + model("gpt-6", { + name: "Sol", + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + description: "How hard the model thinks.", + type: "select", + options: [ + { id: "low", label: "Low", description: "Fast." }, + { id: "high", label: "High", isDefault: true }, + ], + promptInjectedValues: ["high"], + }, + { id: "fastMode", label: "Fast", type: "boolean" }, + { + id: "agent", + label: "Agent", + type: "select", + options: [{ id: "build", label: "Build" }], + currentValue: "build", + }, + ], + }, + }), + model("gpt-5", { isLegacy: true }), + model("gpt-4", { isLegacy: true }), + model("gpt-3", { name: "GPT-3", isLegacy: true }), + model("my-alpha", { isCustom: true }), + ], + }, + { + ...providerSnapshot({ + instanceId: opencodeInstanceId, + driver: ProviderDriverKind.make("opencode"), + }), + models: [ + ...opencodeSlugs.map((slug) => model(slug, { isLegacy: slug === "oc/m03" })), + model("oc-custom", { isCustom: true }), + ], + }, + ]; + + const capabilitiesWith = (search?: string) => + Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.capabilities(scope, search === undefined ? {} : { search }); + }).pipe( + Effect.provide( + layerOrchestratorService.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: () => + Effect.succeed( + parentProjection([], { instanceId: codexInstanceId, model: "gpt-3" }), + ), + }), + ServerSettings.layerTest({ + favorites: [ + { provider: codexInstanceId, model: "gpt-4" }, + { provider: opencodeInstanceId, model: "oc/m10" }, + ], + providerModelPreferences: { + [opencodeInstanceId]: { hiddenModels: ["oc/m02"], modelOrder: ["oc/m12"] }, + }, + }), + providerRegistryLayer(catalog), + adapterRegistryLayer([codexInstanceId, opencodeInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ), + ), + ), + ), + ); + + const listing = (capabilities: OrchestratorMcpCapabilitiesResult) => + capabilities.providers.map((provider) => ({ + id: provider.providerInstanceId, + models: provider.models.map((entry) => entry.id), + omitted: provider.omittedModels ?? 0, + })); + + it.effect( + "skips legacy models, lists the first 8 of large catalogs, and keeps pinned models", + () => + Effect.gen(function* () { + assert.deepEqual(listing(yield* capabilitiesWith()), [ + // gpt-4 is a favorite and gpt-3 is the caller's model; legacy gpt-5 is left out. + { id: codexInstanceId, models: ["gpt-6", "gpt-4", "gpt-3", "my-alpha"], omitted: 1 }, + { + id: opencodeInstanceId, + models: [ + "oc/m12", + "oc/m01", + "oc/m04", + "oc/m05", + "oc/m06", + "oc/m07", + "oc/m08", + "oc/m09", + "oc/m10", + "oc-custom", + ], + omitted: 2, + }, + ]); + }), + ); + + it.effect("search matches every word and caps each provider's matches", () => + Effect.gen(function* () { + assert.deepEqual(listing(yield* capabilitiesWith("GPT 5")), [ + { id: codexInstanceId, models: ["gpt-5"], omitted: 0 }, + { id: opencodeInstanceId, models: [], omitted: 0 }, + ]); + const opencode = listing(yield* capabilitiesWith("oc/m"))[1]; + assert.deepEqual(opencode, { + id: opencodeInstanceId, + models: ["oc/m12", "oc/m01", "oc/m03", "oc/m04", "oc/m05", "oc/m06", "oc/m07", "oc/m08"], + omitted: 3, + }); + }), + ); + + it.effect("describes models by id, distinct label, and option values", () => + Effect.gen(function* () { + const codex = (yield* capabilitiesWith()).providers[0]; + assert.deepEqual(codex?.models.slice(0, 4), [ + { + id: "gpt-6", + label: "Sol", + options: [ + { id: "effort", values: ["low", "high"], default: "high" }, + { id: "fastMode", values: [false, true] }, + { id: "agent", values: ["build"], default: "build" }, + ], + }, + { id: "gpt-4" }, + { id: "gpt-3" }, + { id: "my-alpha" }, + ]); + }), + ); + }); + it.effect("rejects delegation to a provider without a registered adapter", () => Effect.gen(function* () { const forkOnlyInstanceId = ProviderInstanceId.make("forkOnly"); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index eca639ed7139..78ad1a5d5c9a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -13,7 +13,9 @@ import { type OrchestrationV2ThreadLaunchWorkspaceStrategy, type OrchestrationV2TurnItem, OrchestratorMcpFailure, + type OrchestratorMcpCapabilitiesInput, type OrchestratorMcpCapabilitiesResult, + type OrchestratorMcpModelOption, type OrchestratorMcpCreateThreadsInput, type OrchestratorMcpCreateThreadsResult, type OrchestratorMcpCreatedThread, @@ -59,9 +61,11 @@ import { type ScheduledTask, type ScheduledTaskUpsertInput, type ServerProvider, + type ServerProviderModel, ThreadId, isOrchestrationV2WorkActive, } from "@t3tools/contracts"; +import { getProviderOptionCurrentValue } from "@t3tools/shared/model"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { normalizeThreadPullRequestKey, @@ -136,6 +140,7 @@ type TerminalTaskStatus = Extract< export interface OrchestratorMcpServiceShape { readonly capabilities: ( scope: McpInvocationScope, + input?: OrchestratorMcpCapabilitiesInput, ) => Effect.Effect; readonly delegateTask: ( scope: McpInvocationScope, @@ -303,6 +308,61 @@ function scheduledTaskSummary(task: ScheduledTask, mayRun: boolean): Orchestrato }; } +/** + * Large catalogs (OpenCode, Cursor, Pi) can list hundreds of models, so agents + * get a short list by default and search for the rest. These providers have + * small official catalogs, so all their current models are listed. + */ +const FULL_CATALOG_DRIVERS: ReadonlySet = new Set(["codex", "claudeAgent", "muse", "grok"]); +const LISTED_MODEL_LIMIT = 8; + +/** + * Picks the models orchestrator_capabilities shows for one provider. Hidden + * models never appear. With search terms, lists matches; otherwise lists pinned + * models (custom, favorite, the caller's own) plus the provider's non-legacy + * models, or only the first few in the user's model order for large catalogs. + */ +function listCapabilityModels(input: { + readonly provider: ServerProvider; + readonly preferences: + | { readonly hiddenModels: ReadonlyArray; readonly modelOrder: ReadonlyArray } + | undefined; + readonly isPinned: (model: ServerProviderModel) => boolean; + readonly searchTerms: ReadonlyArray; +}) { + const rank = new Map(input.preferences?.modelOrder.map((slug, index) => [slug, index])); + const visible = input.provider.models + .filter((model) => model.isCustom || !input.preferences?.hiddenModels.includes(model.slug)) + .toSorted((a, b) => (rank.get(a.slug) ?? rank.size) - (rank.get(b.slug) ?? rank.size)); + if (input.searchTerms.length > 0) { + const matches = visible.filter((model) => { + const text = `${model.slug} ${model.name} ${model.subProvider ?? ""}`.toLowerCase(); + return input.searchTerms.every((term) => text.includes(term)); + }); + const listed = matches.slice(0, LISTED_MODEL_LIMIT); + return { listed, omitted: matches.length - listed.length }; + } + const current = visible.filter((model) => !model.isLegacy); + const shown = new Set( + FULL_CATALOG_DRIVERS.has(input.provider.driver) + ? current + : current.slice(0, LISTED_MODEL_LIMIT), + ); + const listed = visible.filter((model) => input.isPinned(model) || shown.has(model)); + return { listed, omitted: visible.length - listed.length }; +} + +/** Option ids and allowed values are all a delegating agent needs; labels are UI copy. */ +function capabilityModelOption(descriptor: ProviderOptionDescriptor): OrchestratorMcpModelOption { + const defaultValue = getProviderOptionCurrentValue(descriptor); + return { + id: descriptor.id, + values: + descriptor.type === "boolean" ? [false, true] : descriptor.options.map((choice) => choice.id), + ...(defaultValue === undefined ? {} : { default: defaultValue }), + }; +} + function providerConstraints( provider: ServerProvider | undefined, supportsOrchestrationV2: boolean, @@ -2011,19 +2071,20 @@ const make = Effect.gen(function* () { return { status, secretRef: secretRef.value }; }), - capabilities: Effect.fn("OrchestratorMcpService.capabilities")(function* (scope) { + capabilities: Effect.fn("OrchestratorMcpService.capabilities")(function* (scope, input = {}) { const { parent, limits } = yield* loadCaller(scope); const providers = yield* loadProviders; const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds(); - const modelPreferences = yield* serverSettings.getSettings.pipe( - Effect.map((settings) => settings.providerModelPreferences ?? {}), + const settings = yield* serverSettings.getSettings.pipe( Effect.mapError(() => failure("orchestration_error", "Unable to read server settings.")), ); + const inherited = parent?.thread.modelSelection; + const searchTerms = input.search?.toLowerCase().split(/\s+/) ?? []; return { parentThreadId: parent?.thread.id ?? null, - inheritedProviderInstanceId: parent?.thread.modelSelection.instanceId ?? null, - inheritedModel: parent?.thread.modelSelection.model ?? null, - inheritedOptions: parent?.thread.modelSelection.options ?? [], + inheritedProviderInstanceId: inherited?.instanceId ?? null, + inheritedModel: inherited?.model ?? null, + inheritedOptions: inherited?.options ?? [], runtimeMode: limits.runtimeMode, interactionMode: limits.interactionMode, providers: providers.map((provider) => { @@ -2031,24 +2092,33 @@ const make = Effect.gen(function* () { provider, orchestrationCapableInstanceIds.has(provider.instanceId), ); + const { listed, omitted } = listCapabilityModels({ + provider, + preferences: settings.providerModelPreferences?.[provider.instanceId], + searchTerms, + isPinned: (model) => + model.isCustom || + (inherited?.instanceId === provider.instanceId && inherited.model === model.slug) || + (settings.favorites ?? []).some( + (favorite) => + favorite.provider === provider.instanceId && favorite.model === model.slug, + ), + }); return { providerInstanceId: provider.instanceId, driverKind: provider.driver, - displayName: provider?.displayName ?? null, - models: - provider?.models - .filter( - (model) => - model.isCustom || - !modelPreferences[provider.instanceId]?.hiddenModels.includes(model.slug), - ) - .map((model) => ({ - id: model.slug, - label: model.name ?? null, - ...(model.capabilities?.optionDescriptors === undefined - ? {} - : { options: model.capabilities.optionDescriptors }), - })) ?? [], + displayName: provider.displayName ?? null, + models: listed.map((model) => { + const options = model.capabilities?.optionDescriptors ?? []; + return { + id: model.slug, + ...(model.name.toLowerCase() === model.slug.toLowerCase() + ? {} + : { label: model.name }), + ...(options.length === 0 ? {} : { options: options.map(capabilityModelOption) }), + }; + }), + ...(omitted === 0 ? {} : { omittedModels: omitted }), ...(provider.supportedRuntimeModes === undefined || provider.supportedRuntimeModes.length === 0 ? {} diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 7c05b330047c..0df604a85011 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -9,11 +9,11 @@ import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts"; const handlers = { - orchestrator_capabilities: McpToolAccess.reads(() => + orchestrator_capabilities: McpToolAccess.reads((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.capabilities(scope); + return yield* service.capabilities(scope, input); }), ), delegate_task: McpToolAccess.actsAsCaller((input) => diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 9c24fb62d8e9..111870be9bbd 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -1,4 +1,5 @@ import { + OrchestratorMcpCapabilitiesInput, OrchestratorMcpCapabilitiesResult, OrchestrationV2StopWorkflowInput, OrchestratorMcpCreateThreadsInput, @@ -52,7 +53,8 @@ const threadMetadataDependencies = [ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { description: - "List the V2 provider instances and their current models from the same live catalog as the composer, including configured custom models, inherited runtime settings, and app-owned orchestration features available to this caller. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy.", + "List the V2 provider instances, a short list of their models from the same live catalog as the composer, inherited runtime settings, and app-owned orchestration features available to this caller. The list has the user's favorite and custom models, your own model, every current Codex, Claude, Muse and Grok model, and the first 8 current models of other providers. Other legacy models are left out. omittedModels counts models left out; pass search, such as 'sonnet 4.6', to find them. delegate_task accepts any catalog model, listed or not. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy.", + parameters: OrchestratorMcpCapabilitiesInput, success: OrchestratorMcpCapabilitiesResult, failure: OrchestratorMcpFailure, failureMode: "return", diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 54be975b1834..e62f661fcfc2 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-19T07:21:38Z", + "updatedAt": "2026-10-21T06:20:00Z", "compatibility": [ { "driver": "codex", @@ -134,7 +134,9 @@ { "slug": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "status": "legacy" }, { "slug": "gpt-5.6-terra", "name": "GPT-5.6 Terra", "status": "legacy" }, { "slug": "gpt-5.6-luna", "name": "GPT-5.6 Luna", "status": "legacy" }, - { "slug": "gpt-5.5", "name": "GPT-5.5", "status": "legacy" } + { "slug": "gpt-5.5", "name": "GPT-5.5", "status": "legacy" }, + { "slug": "gpt-5.4-mini", "name": "GPT-5.4 Mini", "status": "legacy" }, + { "slug": "gpt-5.3-codex-spark", "name": "GPT-5.3 Codex Spark", "status": "legacy" } ] }, "claudeAgent": { diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 44aa2492a828..5f312b61bd2a 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -207,6 +207,14 @@ Returns: Unavailable providers include model-visible constraints such as missing V2 adapter support, disabled state, missing executable, or missing authentication. +The model list is short on purpose: catalogs such as OpenCode can hold hundreds +of models, and every call lands in the agent's context. Each provider lists +custom, favorite, and the caller's own models, plus all current (non-legacy) +models for Codex, Claude, Muse, and Grok, or the first 8 current models in the +user's model order for other providers. `omittedModels` counts the rest, and the +`search` input finds them, legacy models included. `delegate_task` still accepts +any catalog model. + ### `delegate_task` Creates a T3-owned child thread and immediately dispatches the supplied task diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 7d643be91078..bfbfadb9813e 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -37,11 +37,7 @@ import { OrchestrationV2RunStatus, OrchestrationV2TurnItemStatus, } from "./orchestrationV2.ts"; -import { - ProviderOptionDescriptor, - ProviderOptionSelection, - ProviderOptionSelectionValue, -} from "./model.ts"; +import { ProviderOptionSelection, ProviderOptionSelectionValue } from "./model.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; const OrchestratorMcpPrompt = TrimmedNonEmptyString.check(Schema.isMaxLength(120_000)).annotate({ @@ -507,6 +503,23 @@ export const OrchestratorMcpThreadInterruptResult = Schema.Struct({ }); export type OrchestratorMcpThreadInterruptResult = typeof OrchestratorMcpThreadInterruptResult.Type; +export const OrchestratorMcpCapabilitiesInput = Schema.Struct({ + search: Schema.optional( + TrimmedNonEmptyString.check(Schema.isMaxLength(200)).annotate({ + description: + "Words to match against model ids and labels. Lists matching models instead of the default short list.", + }), + ), +}); +export type OrchestratorMcpCapabilitiesInput = typeof OrchestratorMcpCapabilitiesInput.Type; + +export const OrchestratorMcpModelOption = Schema.Struct({ + id: Schema.String, + values: Schema.Array(ProviderOptionSelectionValue), + default: Schema.optional(ProviderOptionSelectionValue), +}); +export type OrchestratorMcpModelOption = typeof OrchestratorMcpModelOption.Type; + export const OrchestratorMcpProviderCapability = Schema.Struct({ providerInstanceId: ProviderInstanceId, driverKind: ProviderDriverKind, @@ -514,10 +527,13 @@ export const OrchestratorMcpProviderCapability = Schema.Struct({ models: Schema.Array( Schema.Struct({ id: Schema.String, - label: Schema.NullOr(Schema.String), - options: Schema.optional(Schema.Array(ProviderOptionDescriptor)), + /** Omitted when it only restates the id. */ + label: Schema.optional(Schema.String), + options: Schema.optional(Schema.Array(OrchestratorMcpModelOption)), }), ), + /** Models left out of `models`: unlisted visible models, or matches past the cap with `search`. */ + omittedModels: Schema.optional(NonNegativeInt), runtimeModes: Schema.optional(Schema.Array(RuntimeMode)), canRunChildTask: Schema.Boolean, canRunCrossProviderChildTask: Schema.Boolean, diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index f06aacd6a3e1..88053dee9b54 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -10,7 +10,7 @@ export const T3_CODE_ORCHESTRATION_SECTIONS: ReadonlyArray = [ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concepts distinctly: -- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model and no model options are requested or inherited. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. For a child task that needs a separate checkout, use \`delegate_task\` with \`isolation:"worktree"\` across any provider or model. It starts from the parent local commit and runs project setup; uncommitted edits are not copied. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent, not the target for starting another delegated review round. +- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. It lists a short set of models per provider; pass \`search\` to find others. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model and no model options are requested or inherited. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. For a child task that needs a separate checkout, use \`delegate_task\` with \`isolation:"worktree"\` across any provider or model. It starts from the parent local commit and runs project setup; uncommitted edits are not copied. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent, not the target for starting another delegated review round. - \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work. - For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review.`, `- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing. diff --git a/packages/provider-opencode/src/server/openCode2Catalog.ts b/packages/provider-opencode/src/server/openCode2Catalog.ts index 6c6c30846e44..2a4152291d22 100644 --- a/packages/provider-opencode/src/server/openCode2Catalog.ts +++ b/packages/provider-opencode/src/server/openCode2Catalog.ts @@ -44,6 +44,9 @@ export const loadOpenCode2Catalog = ( name: model.name, ...(providerName ? { providerName } : {}), variants: model.variants.map((variant) => ({ id: variant.id })), + family: model.family, + released: model.time.released, + deprecated: model.status === "deprecated", }; }); }), diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index 69ef0b90b278..f55e07d2fa02 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -665,6 +665,72 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { }), ); + it.effect("marks older releases of a family and deprecated models as legacy", () => + Effect.gen(function* () { + const releases = [ + ["opencode", "opus-4-5", "claude-opus", "2025-11-24", "active"], + ["opencode", "opus-5-5", "claude-opus", "2026-09-22", "active"], + ["opencode", "opus-preview", "claude-opus", "2026-10-01", "deprecated"], + ["opencode", "flash", "flash", "2026-09-10", "active"], + ["opencode", "flash-free", "flash", "2026-09-10", "active"], + ["opencode", "big-pickle", undefined, "2025-10-17", "active"], + ["openrouter", "opus-4-5", "claude-opus", "2025-11-24", "active"], + ] as const; + const legacySlugs = ( + models: ReadonlyArray<{ readonly slug: string; readonly isLegacy?: boolean | undefined }>, + ) => + models + .filter((model) => model.isLegacy) + .map((model) => model.slug) + .toSorted(); + const expected = ["opencode/opus-4-5", "opencode/opus-preview"]; + + runtimeMock.state.inventory = { + providerList: { + connected: ["opencode", "openrouter"], + all: ["opencode", "openrouter"].map((providerID) => ({ + id: providerID, + name: providerID, + models: Object.fromEntries( + releases + .filter(([provider]) => provider === providerID) + .map(([, id, family, releaseDate, status]) => [ + id, + { id, name: id, family, release_date: releaseDate, status, variants: {} }, + ]), + ), + })), + default: {}, + }, + agents: [], + }; + NodeAssert.deepEqual( + legacySlugs((yield* checkProvider(makeOpenCodeSettings())).models), + expected, + ); + + runtimeMock.state.versionStdout = "opencode v2.0.18\n"; + const openCode2 = yield* checkProvider( + makeOpenCodeSettings(), + process.cwd(), + undefined, + undefined, + Effect.succeed( + releases.map(([providerID, id, family, releaseDate, status]) => ({ + providerID, + id, + name: id, + variants: [], + family, + released: Date.parse(releaseDate), + deprecated: status === "deprecated", + })), + ), + ); + NodeAssert.deepEqual(legacySlugs(openCode2.models), expected); + }), + ); + it.effect("reports a failed OpenCode 2 model list without the server's response", () => Effect.gen(function* () { runtimeMock.state.versionStdout = "opencode v2.0.18\n"; diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index e4c985a13b33..b20130e1528f 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -245,11 +245,46 @@ function openCodeCapabilitiesForModel(input: { }); } +interface OpenCodeRelease { + readonly providerID: string; + readonly family?: string | undefined; + readonly released?: number | undefined; + readonly deprecated?: boolean | undefined; +} + +/** + * OpenCode catalogs keep every release of a family, such as Claude Opus 4.5 + * through 5.5. Only the newest active release of each family within a + * sub-provider stays current; older releases and deprecated models are legacy. + */ +function markOpenCodeLegacy( + entries: ReadonlyArray<{ + readonly model: ServerProviderModel; + readonly release: OpenCodeRelease; + }>, +): Array { + const familyKey = ({ providerID, family }: OpenCodeRelease) => + family ? `${providerID}/${family}` : undefined; + const newest = new Map(); + for (const { release } of entries) { + const key = familyKey(release); + if (key === undefined || release.released === undefined || release.deprecated) continue; + newest.set(key, Math.max(newest.get(key) ?? release.released, release.released)); + } + return entries.map(({ model, release }) => { + const key = familyKey(release); + const latest = key === undefined ? undefined : newest.get(key); + const superseded = + latest !== undefined && release.released !== undefined && release.released < latest; + return release.deprecated || superseded ? { ...model, isLegacy: true } : model; + }); +} + function flattenOpenCodeModels( input: OpenCodeRuntime.OpenCodeInventory, ): ReadonlyArray { const connected = new Set(input.providerList.connected); - const models: Array = []; + const entries: Array<{ model: ServerProviderModel; release: OpenCodeRelease }> = []; for (const provider of input.providerList.all) { if (!connected.has(provider.id)) { @@ -263,21 +298,30 @@ function flattenOpenCodeModels( } const subProvider = nonEmptyTrimmed(provider.name); - models.push({ - slug: `${provider.id}/${model.id}`, - name, - ...(subProvider ? { subProvider } : {}), - isCustom: false, - capabilities: openCodeCapabilitiesForModel({ + const released = Date.parse(model.release_date); + entries.push({ + model: { + slug: `${provider.id}/${model.id}`, + name, + ...(subProvider ? { subProvider } : {}), + isCustom: false, + capabilities: openCodeCapabilitiesForModel({ + providerID: provider.id, + model, + agents: input.agents, + }), + }, + release: { providerID: provider.id, - model, - agents: input.agents, - }), + family: model.family, + released: Number.isNaN(released) ? undefined : released, + deprecated: model.status === "deprecated", + }, }); } } - return models.toSorted((left, right) => left.name.localeCompare(right.name)); + return markOpenCodeLegacy(entries).toSorted((left, right) => left.name.localeCompare(right.name)); } function trimOptional(value: string | null | undefined): string | undefined { @@ -372,8 +416,7 @@ export const makePendingOpenCodeProvider = Effect.fn( }); }); -export interface OpenCode2Model { - readonly providerID: string; +export interface OpenCode2Model extends OpenCodeRelease { readonly id: string; readonly name: string; readonly providerName?: string; @@ -564,18 +607,21 @@ const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( }); } const models = providerModelsFromSettings( - result.value - .map((model) => { + markOpenCodeLegacy( + result.value.map((model) => { const subProvider = subProviderLabelFor(model); return { - slug: `${model.providerID}/${model.id}`, - name: model.name, - ...(subProvider ? { subProvider } : {}), - isCustom: false, - capabilities: openCode2ModelCapabilities(model), + model: { + slug: `${model.providerID}/${model.id}`, + name: model.name, + ...(subProvider ? { subProvider } : {}), + isCustom: false, + capabilities: openCode2ModelCapabilities(model), + }, + release: model, }; - }) - .toSorted((left, right) => left.name.localeCompare(right.name)), + }), + ).toSorted((left, right) => left.name.localeCompare(right.name)), settings.customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, );