diff --git a/apps/web/src/components/chat/SubagentTooltipContent.tsx b/apps/web/src/components/chat/SubagentTooltipContent.tsx index 50b50273ba96..67b0750ffb5f 100644 --- a/apps/web/src/components/chat/SubagentTooltipContent.tsx +++ b/apps/web/src/components/chat/SubagentTooltipContent.tsx @@ -12,7 +12,11 @@ import { subagentDetailPreview, } from "@t3tools/client-runtime/state/subagent-display"; import { REASONING_EFFORT_OPTION_IDS } from "@t3tools/client-runtime/state/thread-execution"; -import { getModelSelectionStringOptionValue, resolveSelectableModel } from "@t3tools/shared/model"; +import { + DEFAULT_VARIANT_CHOICE_ID, + getModelSelectionStringOptionValue, + resolveSelectableModel, +} from "@t3tools/shared/model"; import { getTraitsSpeedDisplay, TraitsSpeedIcon } from "./TraitsSpeed"; import type { ReactNode } from "react"; import { @@ -74,7 +78,7 @@ export function SubagentTooltipContent(props: { : undefined; const effort = REASONING_EFFORT_OPTION_IDS.map((id) => getModelSelectionStringOptionValue(matchingSelection, id), - ).find(Boolean); + ).find((value) => value && value !== DEFAULT_VARIANT_CHOICE_ID); const speed = provider ? providerModel?.capabilities?.optionDescriptors ?.map((descriptor) => { diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index cd85f1e8c68e..0f2c57aa2780 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -29,6 +29,7 @@ import { derivePendingBackgroundWork, } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { + DEFAULT_VARIANT_CHOICE_ID, getModelSelectionStringOptionValue, getProviderOptionCurrentLabel, getProviderOptionStatedValue, @@ -269,7 +270,7 @@ export function formatSubagentEffort(input: { } return ( REASONING_EFFORT_OPTION_IDS.map((id) => getModelSelectionStringOptionValue(selection, id)).find( - Boolean, + (value) => value && value !== DEFAULT_VARIANT_CHOICE_ID, ) ?? null ); } diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts index 88ff243eccd4..650dbd7ab0db 100644 --- a/packages/provider-opencode/src/server/OpenCodeRuntime.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts @@ -1,7 +1,7 @@ import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; import * as NodeURL from "node:url"; -import type { ChatAttachment, ProviderApprovalDecision } from "@t3tools/contracts"; +import type { ChatAttachment, ModelSelection, ProviderApprovalDecision } from "@t3tools/contracts"; import { createOpencodeClient, type Agent, @@ -37,6 +37,10 @@ import { collectStreamAsString } from "@t3tools/provider-core/server/snapshotPro import * as NetService from "@t3tools/shared/Net"; import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { + DEFAULT_VARIANT_CHOICE_ID, + getModelSelectionStringOptionValue, +} from "@t3tools/shared/model"; import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); @@ -382,6 +386,11 @@ export function parseOpenCodeModelSlug( }; } +export function openCodeVariant(selection: ModelSelection): string | undefined { + const variant = getModelSelectionStringOptionValue(selection, "variant"); + return variant === DEFAULT_VARIANT_CHOICE_ID ? undefined : variant; +} + export function openCodeQuestionId( index: number, question: QuestionRequest["questions"][number], diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 4459a219aa7d..a0741a9c453a 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -2983,7 +2983,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const agent = getModelSelectionStringOptionValue(turn.modelSelection, "agent") ?? (turn.runtimePolicy.interactionMode === "plan" ? "plan" : undefined); - const variant = getModelSelectionStringOptionValue(turn.modelSelection, "variant"); + const variant = OpenCodeRuntime.openCodeVariant(turn.modelSelection); return { ...(agent === undefined ? {} : { agent }), ...(variant === undefined ? {} : { variant }), diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index 4c4f61f03d68..c6be92ac1bd8 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -36,6 +36,7 @@ import { OPENCODE_2_RESPONSES, replayOpenCodeServer, } from "./probeResponses.fixture.ts"; +import { DEFAULT_VARIANT_CHOICE_ID } from "@t3tools/shared/model"; const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; @@ -687,6 +688,68 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { }), ); + it.effect.each(["1.x", "2.x"] as const)( + "offers a Default reasoning choice for a %s custom model with no inferable default", + (generation) => + Effect.gen(function* () { + const variants = ["low", "medium", "high"]; + runtimeMock.state.versionStdout = + generation === "2.x" ? "opencode v2.0.18\n" : DEFAULT_VERSION_STDOUT; + runtimeMock.state.inventory = { + providerList: { + connected: ["acme"], + all: [ + { + id: "acme", + name: "Acme", + models: { + "acme-chat": { + id: "acme-chat", + name: "Acme Chat", + variants: Object.fromEntries(variants.map((id) => [id, {}])), + }, + }, + }, + ], + default: {}, + }, + agents: [], + }; + + const snapshot = yield* checkProvider( + makeOpenCodeSettings(), + process.cwd(), + undefined, + undefined, + Effect.succeed([ + { + providerID: "acme", + id: "acme-chat", + name: "Acme Chat", + variants: variants.map((id) => ({ id })), + }, + ]), + ); + const variant = snapshot.models + .find((model) => model.slug === "acme/acme-chat") + ?.capabilities?.optionDescriptors?.find((descriptor) => descriptor.id === "variant"); + + NodeAssert.ok(variant?.type === "select"); + NodeAssert.deepEqual( + variant.options.find((option) => option.id === DEFAULT_VARIANT_CHOICE_ID), + { id: DEFAULT_VARIANT_CHOICE_ID, label: "Default" }, + "OpenCode cannot infer a default variant, so the picker needs a choice that sends none", + ); + NodeAssert.equal(variant.currentValue, DEFAULT_VARIANT_CHOICE_ID); + NodeAssert.deepEqual( + variant.options + .map((option) => option.id) + .filter((id) => id !== DEFAULT_VARIANT_CHOICE_ID), + variants, + ); + }), + ); + it.effect("keeps Agent without inventing Reasoning for models with no variants", () => Effect.gen(function* () { runtimeMock.state.inventory = { diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index 0f90b9df2894..6a8daccc184e 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -1,6 +1,7 @@ import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe"; import { type ModelCapabilities, + type ProviderOptionDescriptor, type ServerProviderModel, type ServerProviderSkill, type ServerProviderSlashCommand, @@ -14,7 +15,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; -import { createModelCapabilities } from "@t3tools/shared/model"; +import { createModelCapabilities, DEFAULT_VARIANT_CHOICE_ID } from "@t3tools/shared/model"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { buildServerProvider, @@ -200,6 +201,32 @@ function inferDefaultVariant( return undefined; } +/** Without an inferable default, a Default choice lets the user go back to sending no variant. */ +function openCodeVariantDescriptors( + providerID: string, + variants: ReadonlyArray, +): Array { + if (variants.length === 0) return []; + const inferred = inferDefaultVariant(providerID, variants); + const options = variants.map((id) => ({ + id, + label: titleCaseSlug(id), + ...(id === inferred ? { isDefault: true } : {}), + })); + return [ + { + id: "variant", + label: "Reasoning", + type: "select", + options: + inferred === undefined + ? [{ id: DEFAULT_VARIANT_CHOICE_ID, label: "Default" }, ...options] + : options, + currentValue: inferred ?? DEFAULT_VARIANT_CHOICE_ID, + }, + ]; +} + function inferDefaultAgent(agents: ReadonlyArray): string | undefined { return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; } @@ -237,12 +264,6 @@ function openCodeCapabilitiesForModel(input: { readonly agents: ReadonlyArray; }): ModelCapabilities { const variantValues = Object.keys(input.model.variants ?? {}); - const defaultVariant = inferDefaultVariant(input.providerID, variantValues); - const variantOptions = variantValues.map((value) => - defaultVariant === value - ? { id: value, label: titleCaseSlug(value), isDefault: true as const } - : { id: value, label: titleCaseSlug(value) }, - ); const primaryAgents = input.agents.filter( (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), ); @@ -254,17 +275,7 @@ function openCodeCapabilitiesForModel(input: { ); return createModelCapabilities({ optionDescriptors: [ - ...(variantOptions.length > 0 - ? [ - { - id: "variant", - label: "Reasoning", - type: "select" as const, - options: variantOptions, - ...(defaultVariant ? { currentValue: defaultVariant } : {}), - }, - ] - : []), + ...openCodeVariantDescriptors(input.providerID, variantValues), ...(agentOptions.length > 0 ? [ { @@ -651,25 +662,11 @@ const OPENCODE_2_PRESENTATION = { } as const; function openCode2ModelCapabilities(model: OpenCode2Model): ModelCapabilities { - const variants = model.variants.map((variant) => variant.id); - const defaultVariant = inferDefaultVariant(model.providerID, variants); return createModelCapabilities({ - optionDescriptors: - variants.length === 0 - ? [] - : [ - { - id: "variant", - label: "Reasoning", - type: "select", - options: variants.map((id) => ({ - id, - label: titleCaseSlug(id), - ...(id === defaultVariant ? { isDefault: true } : {}), - })), - ...(defaultVariant === undefined ? {} : { currentValue: defaultVariant }), - }, - ], + optionDescriptors: openCodeVariantDescriptors( + model.providerID, + model.variants.map((variant) => variant.id), + ), }); } diff --git a/packages/provider-opencode/src/server/textGeneration.test.ts b/packages/provider-opencode/src/server/textGeneration.test.ts index b29e2fb9f86d..70d45d58105c 100644 --- a/packages/provider-opencode/src/server/textGeneration.test.ts +++ b/packages/provider-opencode/src/server/textGeneration.test.ts @@ -20,6 +20,7 @@ import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; import * as OpenCodeTextGeneration from "./textGeneration.ts"; import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; +import { DEFAULT_VARIANT_CHOICE_ID } from "@t3tools/shared/model"; const runtimeMock = { state: { @@ -569,6 +570,30 @@ it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => { ), ); + it.effect( + "sends no variant for a custom model's Default choice and keeps a variant named default", + () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + for (const value of [DEFAULT_VARIANT_CHOICE_ID, "default"]) { + yield* textGeneration.generateCommitMessage({ + ...DEFAULT_COMMIT_MESSAGE_INPUT, + modelSelection: { + ...DEFAULT_TEST_MODEL_SELECTION, + model: "acme/custom-chat", + options: [{ id: "variant", value }], + }, + }); + } + + expect(runtimeMock.state.promptInputs.map((input) => input.variant)).toEqual([ + undefined, + "default", + ]); + }), + ), + ); + it.effect("parses JSON returned as plain text output", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { diff --git a/packages/provider-opencode/src/server/textGeneration.ts b/packages/provider-opencode/src/server/textGeneration.ts index bbb02d36e632..d6acdd2081c0 100644 --- a/packages/provider-opencode/src/server/textGeneration.ts +++ b/packages/provider-opencode/src/server/textGeneration.ts @@ -243,7 +243,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" const selectedVariant = yield* OpenCodeRuntime.resolveOpenCodeVariant( client, parsedModel, - getModelSelectionStringOptionValue(input.modelSelection, "variant"), + OpenCodeRuntime.openCodeVariant(input.modelSelection), ); const promptContext = { operation: input.operation, diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 612dfd54d73e..59f2c28bc72b 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -72,7 +72,7 @@ import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orche import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { compareSemverVersions } from "@t3tools/shared/semver"; import * as KeyedLock from "@t3tools/shared/KeyedLock"; -import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; +import { modelSelectionsEqual } from "@t3tools/shared/model"; import { causeErrorTag } from "@t3tools/shared/observability"; import { @@ -707,7 +707,7 @@ const modelRef = ( ) => { const parsed = OpenCodeRuntime.parseOpenCodeModelSlug(selection.model); if (parsed === null) return undefined; - const selected = getModelSelectionStringOptionValue(selection, "variant"); + const selected = OpenCodeRuntime.openCodeVariant(selection); const variant = selected === undefined || listedVariants === undefined || listedVariants.has(selected) ? selected @@ -720,6 +720,7 @@ const modelRef = ( }; const malformedModel = (model: string) => `OpenCode model '${model}' must use provider/model format`; +// OpenCode 2 reports a session created without a variant as variant "default". const sameModel = (left: ModelRef, right: ModelRef | undefined) => left.providerID === right?.providerID && left.id === right?.id && diff --git a/packages/provider-opencode/src/server/v2/textGeneration.ts b/packages/provider-opencode/src/server/v2/textGeneration.ts index 64c829377195..c7664c630508 100644 --- a/packages/provider-opencode/src/server/v2/textGeneration.ts +++ b/packages/provider-opencode/src/server/v2/textGeneration.ts @@ -1,7 +1,6 @@ // Generate endpoints return 403 on the free tier; use and remove a temporary prompt session. import { AbsolutePath, Location, Model, Provider, Session } from "@opencode/client/effect"; import { TextGenerationError } from "@t3tools/contracts"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as NodeURL from "node:url"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -41,7 +40,7 @@ const runOnServer = Effect.fn("Opencode2TextGeneration.runOnServer")(function* ( detail: "OpenCode model selection must use the 'provider/model' format.", }); } - const variant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const variant = OpenCodeRuntime.openCodeVariant(input.modelSelection); const outcome = yield* Deferred.make(); let sessionId: string | undefined; const texts = new Map(); diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 7d504feab1c0..f9a4ed0b3108 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -7,7 +7,9 @@ import { buildProviderOptionSelectionsFromDescriptors, createModelCapabilities, createModelSelection, + DEFAULT_VARIANT_CHOICE_ID, getProviderOptionCurrentLabel, + getProviderOptionCurrentValue, getProviderOptionDescriptors, readCustomModelEntries, modelSelectionsEqual, @@ -220,6 +222,22 @@ describe("provider-reported option display", () => { ).toBe("Unknown"); }); + it("shows the catalog's Default choice while no variant is chosen", () => { + const withDefault = { + ...descriptor, + options: [{ id: DEFAULT_VARIANT_CHOICE_ID, label: "Default" }, ...descriptor.options], + currentValue: DEFAULT_VARIANT_CHOICE_ID, + }; + expect(getProviderOptionCurrentValue(withDefault, selection)).toBe(DEFAULT_VARIANT_CHOICE_ID); + expect(getProviderOptionCurrentLabel(withDefault, selection)).toBe("Default"); + expect( + getProviderOptionCurrentLabel(withDefault, selection, { + ...reported, + options: [{ id: "variant", value: "thinking" }], + }), + ).toBe("Thinking"); + }); + it.each([ { ...selection, model: "other" }, { ...selection, instanceId: ProviderInstanceId.make("other") }, diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 6714bae9b8de..48f2363b3fe5 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -284,6 +284,9 @@ export function getProviderOptionStatedValue( ); } +/** The variant choice that sends no variant; distinct from any variant name a provider defines. */ +export const DEFAULT_VARIANT_CHOICE_ID = "t3:default"; + export function getProviderOptionCurrentValue( descriptor: ProviderOptionDescriptor | null | undefined, selection?: ModelSelection | null, @@ -296,6 +299,16 @@ export function getProviderOptionCurrentValue( // Reported values are display-only; callers that build dispatch options omit this context. const reportedValue = getReportedOptionValue(descriptor.id, selection, reportedSelection); if (reportedValue !== undefined) return reportedValue; + // Without an explicit variant none is sent, which is exactly the catalog's Default choice. + if ( + descriptor.type === "select" && + descriptor.id === "variant" && + selection && + !hasExplicitOption && + descriptor.options.some((option) => option.id === DEFAULT_VARIANT_CHOICE_ID) + ) { + return DEFAULT_VARIANT_CHOICE_ID; + } if (descriptor.id === "variant" && selection && !hasExplicitOption) return undefined; if (descriptor.type === "boolean") { return descriptor.currentValue;