feat: layer runtime and model selection across conversation, project and global
- Store runtime selection as { provider?, model? } layers and resolve them per
field (conversation -> project -> global); drop the legacy auto provider.
- Migrate stored selections (database v49) and clear deleted profile ids.
- Re-resolve selections in Main per request and at queue dispatch.
- Add ProjectRuntimeSelector and a two-column RuntimeModelPicker; manage
link opens model connections; restore per-runtime default model tabs.
- Stop duplicating trusted instructions and history across OpenCode,
Continue and DeepSeek Harness runtimes.
- Document the layered design, global provider removal plan and planned
purpose-specific models.
This commit is contained in:
@@ -1,64 +1,155 @@
|
||||
import { z } from 'zod'
|
||||
import { isDeepSeekHarnessModelProfile } from './deepseek-harness-compatibility'
|
||||
|
||||
const runtimeSelectionProfileIdSchema = z.string().uuid()
|
||||
/**
|
||||
* Runtime selection is layered: global settings, then an optional project
|
||||
* layer, then an optional conversation layer. Each layer may independently
|
||||
* leave the execution mode (`provider`) or the model unset, which means
|
||||
* "inherit from the layer above". Only the resolver turns layers into a
|
||||
* concrete `AgentRuntimeSelection` that a runtime can be created from.
|
||||
*/
|
||||
|
||||
export const agentRuntimeSelectionSchema = z.discriminatedUnion(
|
||||
'provider',
|
||||
[
|
||||
z.object({ provider: z.literal('auto') }).strict(),
|
||||
export const agentRuntimeProviders = [
|
||||
'model',
|
||||
'opencode',
|
||||
'continue',
|
||||
'deepseek-harness'
|
||||
] as const
|
||||
|
||||
export const agentRuntimeProviderSchema = z.enum(agentRuntimeProviders)
|
||||
export type AgentRuntimeProvider = z.infer<typeof agentRuntimeProviderSchema>
|
||||
|
||||
const profileIdSchema = z.string().uuid()
|
||||
|
||||
export const runtimeModelChoiceSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('profile'), profileId: profileIdSchema }).strict(),
|
||||
z.object({ kind: z.literal('runtime-config') }).strict()
|
||||
])
|
||||
export type RuntimeModelChoice = z.infer<typeof runtimeModelChoiceSchema>
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** Accepts the pre-layer `{ provider, profileId? }` and `{ provider: 'auto' }` shapes. */
|
||||
function upgradeLegacyLayer(value: unknown): unknown {
|
||||
if (!isRecord(value) || 'model' in value || !('provider' in value)) {
|
||||
return value
|
||||
}
|
||||
if (value.provider === 'auto') return {}
|
||||
const { profileId, ...rest } = value
|
||||
return typeof profileId === 'string'
|
||||
? { ...rest, model: { kind: 'profile', profileId } }
|
||||
: rest
|
||||
}
|
||||
|
||||
export const runtimeSelectionLayerSchema = z.preprocess(
|
||||
upgradeLegacyLayer,
|
||||
z
|
||||
.object({
|
||||
provider: agentRuntimeProviderSchema.optional(),
|
||||
model: runtimeModelChoiceSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(layer) =>
|
||||
layer.provider !== 'model' || layer.model?.kind !== 'runtime-config',
|
||||
{ message: '直连模型不能使用 Runtime 自有配置', path: ['model'] }
|
||||
)
|
||||
)
|
||||
export type RuntimeSelectionLayer = z.infer<typeof runtimeSelectionLayerSchema>
|
||||
|
||||
export function isEmptyRuntimeSelectionLayer(
|
||||
layer: RuntimeSelectionLayer | undefined
|
||||
): boolean {
|
||||
return !layer || (layer.provider === undefined && layer.model === undefined)
|
||||
}
|
||||
|
||||
/** Normalizes an empty layer to `undefined` so storage keeps "inherit" as NULL. */
|
||||
export function compactRuntimeSelectionLayer(
|
||||
layer: RuntimeSelectionLayer | undefined
|
||||
): RuntimeSelectionLayer | undefined {
|
||||
if (isEmptyRuntimeSelectionLayer(layer)) return undefined
|
||||
return {
|
||||
...(layer!.provider ? { provider: layer!.provider } : {}),
|
||||
...(layer!.model ? { model: layer!.model } : {})
|
||||
}
|
||||
}
|
||||
|
||||
const concreteRuntimeSelectionShape = {
|
||||
profileId: profileIdSchema.optional(),
|
||||
runtimeConfig: z.literal(true).optional()
|
||||
}
|
||||
|
||||
/** A fully resolved selection. `profileId` and `runtimeConfig` are exclusive. */
|
||||
export const agentRuntimeSelectionSchema = z
|
||||
.discriminatedUnion('provider', [
|
||||
z
|
||||
.object({
|
||||
provider: z.literal('model'),
|
||||
profileId: runtimeSelectionProfileIdSchema.optional()
|
||||
profileId: profileIdSchema.optional()
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
provider: z.literal('opencode'),
|
||||
profileId: runtimeSelectionProfileIdSchema.optional()
|
||||
})
|
||||
.object({ provider: z.literal('opencode'), ...concreteRuntimeSelectionShape })
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
provider: z.literal('continue'),
|
||||
profileId: runtimeSelectionProfileIdSchema.optional()
|
||||
})
|
||||
.object({ provider: z.literal('continue'), ...concreteRuntimeSelectionShape })
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
provider: z.literal('deepseek-harness'),
|
||||
profileId: runtimeSelectionProfileIdSchema.optional()
|
||||
...concreteRuntimeSelectionShape
|
||||
})
|
||||
.strict()
|
||||
]
|
||||
)
|
||||
])
|
||||
.refine(
|
||||
(selection) =>
|
||||
!('runtimeConfig' in selection && selection.runtimeConfig && selection.profileId),
|
||||
{ message: '模型连接与 Runtime 自有配置不能同时选择' }
|
||||
)
|
||||
|
||||
export type AgentRuntimeSelection = z.infer<
|
||||
typeof agentRuntimeSelectionSchema
|
||||
>
|
||||
export type AgentRuntimeSelection = z.infer<typeof agentRuntimeSelectionSchema>
|
||||
|
||||
export type RuntimeSelectionRepairSettings = {
|
||||
modelProfiles: ReadonlyArray<{
|
||||
id: string
|
||||
baseUrl?: string
|
||||
protocol?: string
|
||||
authentication?: 'api-key' | 'none'
|
||||
apiKeyConfigured?: boolean
|
||||
}>
|
||||
defaultModelProfileId: string
|
||||
opencodeModelSource:
|
||||
| { kind: 'default' }
|
||||
| { kind: 'platform' }
|
||||
| { kind: 'profile'; profileId: string }
|
||||
continueModelSource:
|
||||
| { kind: 'default' }
|
||||
| { kind: 'platform' }
|
||||
| { kind: 'profile'; profileId: string }
|
||||
deepseekHarnessModelSource?:
|
||||
| { kind: 'default' }
|
||||
| { kind: 'platform' }
|
||||
| { kind: 'profile'; profileId: string }
|
||||
/**
|
||||
* Stored queue items, schedules and older renderers may still send
|
||||
* `{ provider: 'auto' }`. It now means "not specified" so Main resolves
|
||||
* the layers; any other invalid shape is still rejected.
|
||||
*/
|
||||
export const optionalAgentRuntimeSelectionSchema = agentRuntimeSelectionSchema.optional()
|
||||
|
||||
/** Maps legacy `{ provider: 'auto' }` to "not specified"; other values pass through for validation. */
|
||||
export function withoutLegacyAutoSelection<T>(value: T): T {
|
||||
if (!isRecord(value)) return value
|
||||
if (value.provider === 'auto') return undefined as T
|
||||
if (isRecord(value.runtimeSelection) && value.runtimeSelection.provider === 'auto') {
|
||||
const { runtimeSelection: _legacy, ...rest } = value
|
||||
void _legacy
|
||||
return rest as T
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function agentRuntimeSelectionKey(
|
||||
selection: AgentRuntimeSelection
|
||||
): string {
|
||||
const model =
|
||||
selection.profileId ??
|
||||
('runtimeConfig' in selection && selection.runtimeConfig
|
||||
? 'runtime-config'
|
||||
: 'default')
|
||||
return `${selection.provider}:${model}`
|
||||
}
|
||||
|
||||
export function runtimeSelectionFromChoice(
|
||||
provider: AgentRuntimeProvider,
|
||||
model: RuntimeModelChoice | undefined
|
||||
): AgentRuntimeSelection {
|
||||
if (model?.kind === 'profile') return { provider, profileId: model.profileId }
|
||||
if (model?.kind === 'runtime-config' && provider !== 'model') {
|
||||
return { provider, runtimeConfig: true }
|
||||
}
|
||||
return { provider }
|
||||
}
|
||||
|
||||
type RuntimeModelSource =
|
||||
@@ -66,178 +157,295 @@ type RuntimeModelSource =
|
||||
| { kind: 'platform' }
|
||||
| { kind: 'profile'; profileId: string }
|
||||
|
||||
export type RuntimeSelectionDefaultSettings = {
|
||||
provider: AgentRuntimeSelection['provider']
|
||||
export type RuntimeResolutionProfile = {
|
||||
id: string
|
||||
name?: string
|
||||
modelName?: string
|
||||
baseUrl?: string
|
||||
protocol?: string
|
||||
authentication?: 'api-key' | 'none'
|
||||
apiKeyConfigured?: boolean
|
||||
}
|
||||
|
||||
export type RuntimeResolutionSettings = {
|
||||
/** Global default execution mode. Legacy `auto` is treated as the direct model. */
|
||||
provider: string
|
||||
defaultModelProfileId: string
|
||||
modelProfiles?: RuntimeSelectionRepairSettings['modelProfiles']
|
||||
opencodeBaseUrl: string
|
||||
opencodeEmbedded: boolean
|
||||
modelProfiles: ReadonlyArray<RuntimeResolutionProfile>
|
||||
opencodeModelSource?: RuntimeModelSource
|
||||
continueModelSource?: RuntimeModelSource
|
||||
deepseekHarnessModelSource?: RuntimeModelSource
|
||||
opencodeModelProfile?: { id: string }
|
||||
continueModelProfile?: { id: string }
|
||||
deepseekHarnessModelProfile?: { id: string }
|
||||
/** An external OpenCode server can only use its own configuration. */
|
||||
opencodeBaseUrl?: string
|
||||
}
|
||||
|
||||
type ChannelModelProfile = RuntimeSelectionRepairSettings['modelProfiles'][number]
|
||||
|
||||
export function getRuntimeSelectionForProvider(
|
||||
provider: Exclude<AgentRuntimeSelection['provider'], 'auto'>,
|
||||
_settings: RuntimeSelectionDefaultSettings
|
||||
): AgentRuntimeSelection {
|
||||
void _settings
|
||||
return { provider }
|
||||
export type RuntimeResolutionOptions = {
|
||||
/** Managed SSH projects: OpenCode or Continue with a GoodBuddy model connection. */
|
||||
remote?: boolean
|
||||
}
|
||||
|
||||
export function getDefaultRuntimeSelection(
|
||||
settings: RuntimeSelectionDefaultSettings
|
||||
): AgentRuntimeSelection {
|
||||
const provider = settings.provider
|
||||
if (provider !== 'auto') {
|
||||
return getRuntimeSelectionForProvider(provider, settings)
|
||||
export type RuntimeSelectionLayerName = 'global' | 'project' | 'conversation'
|
||||
|
||||
export type RuntimeModelChoiceStatus =
|
||||
| 'ok'
|
||||
| 'missing'
|
||||
| 'incompatible'
|
||||
| 'unavailable'
|
||||
|
||||
export type ResolvedRuntimeChoice = {
|
||||
selection: AgentRuntimeSelection
|
||||
provider: AgentRuntimeProvider
|
||||
providerSource: RuntimeSelectionLayerName
|
||||
model?: RuntimeModelChoice
|
||||
modelSource: RuntimeSelectionLayerName
|
||||
/** A project or conversation choice that could not be used. */
|
||||
ignored?: {
|
||||
layer: Exclude<RuntimeSelectionLayerName, 'global'>
|
||||
kind: 'provider' | 'model'
|
||||
reason: Exclude<RuntimeModelChoiceStatus, 'ok'>
|
||||
}
|
||||
return settings.opencodeBaseUrl || settings.opencodeEmbedded
|
||||
? getRuntimeSelectionForProvider('opencode', settings)
|
||||
: getRuntimeSelectionForProvider('model', settings)
|
||||
}
|
||||
|
||||
// Resolve only for presentation or request diagnostics, never for persisted defaults.
|
||||
export function getRuntimeSelectionProfileId(
|
||||
selection: AgentRuntimeSelection,
|
||||
settings: RuntimeSelectionDefaultSettings
|
||||
): string | undefined {
|
||||
if (selection.provider === 'auto') return undefined
|
||||
if (selection.profileId) return selection.profileId
|
||||
if (selection.provider === 'model') return settings.defaultModelProfileId
|
||||
const source = selection.provider === 'opencode' ? settings.opencodeModelSource
|
||||
: selection.provider === 'continue' ? settings.continueModelSource : settings.deepseekHarnessModelSource
|
||||
if (source?.kind === 'profile') return source.profileId
|
||||
if (source?.kind !== 'default') return undefined
|
||||
const profiles = settings.modelProfiles ?? []
|
||||
const isCompatible = (profile: ChannelModelProfile): boolean =>
|
||||
selection.provider === 'deepseek-harness'
|
||||
? profile.protocol === 'openai-chat-completions' &&
|
||||
profile.authentication === 'api-key' &&
|
||||
Boolean(profile.baseUrl) &&
|
||||
isDeepSeekHarnessModelProfile({
|
||||
baseUrl: profile.baseUrl!,
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication
|
||||
})
|
||||
: Boolean(profile.protocol) &&
|
||||
profile.protocol !== 'openai-images-generations'
|
||||
return profiles.find(
|
||||
(profile) =>
|
||||
profile.id === settings.defaultModelProfileId && isCompatible(profile)
|
||||
)?.id ?? profiles.find(isCompatible)?.id
|
||||
export function allowedRuntimeProviders(
|
||||
options: RuntimeResolutionOptions = {}
|
||||
): readonly AgentRuntimeProvider[] {
|
||||
return options.remote ? ['opencode', 'continue'] : agentRuntimeProviders
|
||||
}
|
||||
|
||||
function normalizedGlobalProvider(
|
||||
settings: RuntimeResolutionSettings,
|
||||
options: RuntimeResolutionOptions
|
||||
): AgentRuntimeProvider {
|
||||
const parsed = agentRuntimeProviderSchema.safeParse(settings.provider)
|
||||
const provider = parsed.success ? parsed.data : 'model'
|
||||
return allowedRuntimeProviders(options).includes(provider)
|
||||
? provider
|
||||
: 'opencode'
|
||||
}
|
||||
|
||||
function isProfileCredentialReady(profile: RuntimeResolutionProfile): boolean {
|
||||
return !(profile.authentication === 'api-key' && profile.apiKeyConfigured === false)
|
||||
}
|
||||
|
||||
export function isChannelModelProfileUsable(
|
||||
profile: ChannelModelProfile
|
||||
profile: RuntimeResolutionProfile
|
||||
): boolean {
|
||||
return (
|
||||
profile.protocol !== 'openai-images-generations' &&
|
||||
!(
|
||||
profile.authentication === 'api-key' &&
|
||||
profile.apiKeyConfigured === false
|
||||
)
|
||||
isProfileCredentialReady(profile)
|
||||
)
|
||||
}
|
||||
|
||||
function isDeepSeekHarnessRepairProfileUsable(
|
||||
profile: ChannelModelProfile
|
||||
export function isProfileCompatibleWithProvider(
|
||||
provider: AgentRuntimeProvider,
|
||||
profile: RuntimeResolutionProfile
|
||||
): boolean {
|
||||
if (
|
||||
profile.protocol !== 'openai-chat-completions' ||
|
||||
profile.authentication !== 'api-key' ||
|
||||
profile.apiKeyConfigured === false ||
|
||||
!profile.baseUrl
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return isDeepSeekHarnessModelProfile({
|
||||
baseUrl: profile.baseUrl,
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication
|
||||
})
|
||||
}
|
||||
|
||||
export function repairChannelRuntimeSelection(
|
||||
selection: AgentRuntimeSelection,
|
||||
settings: RuntimeSelectionRepairSettings
|
||||
): AgentRuntimeSelection {
|
||||
const defaultDirectProfile =
|
||||
settings.modelProfiles.find(
|
||||
(profile) =>
|
||||
profile.id === settings.defaultModelProfileId &&
|
||||
isChannelModelProfileUsable(profile)
|
||||
) ??
|
||||
settings.modelProfiles.find(isChannelModelProfileUsable)
|
||||
const defaultDirectSelection: AgentRuntimeSelection =
|
||||
defaultDirectProfile &&
|
||||
defaultDirectProfile.id !== settings.defaultModelProfileId
|
||||
? { provider: 'model', profileId: defaultDirectProfile.id }
|
||||
: { provider: 'model' }
|
||||
if (selection.provider === 'auto') {
|
||||
return defaultDirectSelection
|
||||
}
|
||||
if (
|
||||
selection.provider === 'opencode' ||
|
||||
selection.provider === 'continue'
|
||||
) {
|
||||
return repairAgentRuntimeSelection(selection, settings)
|
||||
}
|
||||
if (selection.provider === 'deepseek-harness') {
|
||||
const repaired = repairAgentRuntimeSelection(selection, settings)
|
||||
if (
|
||||
repaired.provider === 'deepseek-harness' &&
|
||||
repaired.profileId
|
||||
) {
|
||||
const profile = settings.modelProfiles.find(
|
||||
(candidate) => candidate.id === repaired.profileId
|
||||
)
|
||||
if (profile && isDeepSeekHarnessRepairProfileUsable(profile)) {
|
||||
return repaired
|
||||
}
|
||||
}
|
||||
return { provider: 'deepseek-harness' }
|
||||
}
|
||||
const repaired = repairAgentRuntimeSelection(selection, settings)
|
||||
if (repaired.provider !== 'model') {
|
||||
return repaired
|
||||
}
|
||||
const profile = settings.modelProfiles.find(
|
||||
(candidate) => candidate.id === repaired.profileId
|
||||
if (!profile.protocol) return false
|
||||
// The direct model can also run image-generation connections.
|
||||
if (profile.protocol === 'openai-images-generations') return provider === 'model'
|
||||
if (provider !== 'deepseek-harness') return true
|
||||
return (
|
||||
Boolean(profile.baseUrl) &&
|
||||
isDeepSeekHarnessModelProfile({
|
||||
baseUrl: profile.baseUrl!,
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication ?? 'none'
|
||||
})
|
||||
)
|
||||
return profile && isChannelModelProfileUsable(profile)
|
||||
? repaired
|
||||
: defaultDirectSelection
|
||||
}
|
||||
|
||||
export function repairAgentRuntimeSelection(
|
||||
selection: AgentRuntimeSelection,
|
||||
settings: RuntimeSelectionRepairSettings
|
||||
): AgentRuntimeSelection {
|
||||
export function supportsRuntimeConfig(
|
||||
provider: AgentRuntimeProvider,
|
||||
options: RuntimeResolutionOptions = {}
|
||||
): boolean {
|
||||
return provider !== 'model' && !options.remote
|
||||
}
|
||||
|
||||
function supportsProfiles(
|
||||
provider: AgentRuntimeProvider,
|
||||
settings: RuntimeResolutionSettings
|
||||
): boolean {
|
||||
return !(provider === 'opencode' && settings.opencodeBaseUrl?.trim())
|
||||
}
|
||||
|
||||
export function runtimeModelChoiceStatus(
|
||||
provider: AgentRuntimeProvider,
|
||||
choice: RuntimeModelChoice,
|
||||
settings: RuntimeResolutionSettings,
|
||||
options: RuntimeResolutionOptions = {}
|
||||
): RuntimeModelChoiceStatus {
|
||||
if (choice.kind === 'runtime-config') {
|
||||
return supportsRuntimeConfig(provider, options) ? 'ok' : 'incompatible'
|
||||
}
|
||||
const profile = (settings.modelProfiles ?? []).find(
|
||||
(candidate) => candidate.id === choice.profileId
|
||||
)
|
||||
if (!profile) return 'missing'
|
||||
if (
|
||||
!('profileId' in selection) ||
|
||||
!selection.profileId ||
|
||||
settings.modelProfiles.some(
|
||||
(profile) => profile.id === selection.profileId
|
||||
)
|
||||
!supportsProfiles(provider, settings) ||
|
||||
!isProfileCompatibleWithProvider(provider, profile)
|
||||
) {
|
||||
return selection
|
||||
return 'incompatible'
|
||||
}
|
||||
if (selection.provider === 'model') {
|
||||
return { provider: 'model' }
|
||||
}
|
||||
return { provider: selection.provider }
|
||||
return isProfileCredentialReady(profile) ? 'ok' : 'unavailable'
|
||||
}
|
||||
|
||||
export function agentRuntimeSelectionKey(
|
||||
selection: AgentRuntimeSelection
|
||||
export function isResolvableStatus(status: RuntimeModelChoiceStatus): boolean {
|
||||
return status === 'ok' || status === 'unavailable'
|
||||
}
|
||||
|
||||
/** Profiles a user can pick for a provider, in configuration order. */
|
||||
export function selectableModelProfiles(
|
||||
provider: AgentRuntimeProvider,
|
||||
settings: RuntimeResolutionSettings
|
||||
): RuntimeResolutionProfile[] {
|
||||
if (!supportsProfiles(provider, settings)) return []
|
||||
return (settings.modelProfiles ?? []).filter((profile) =>
|
||||
isProfileCompatibleWithProvider(provider, profile)
|
||||
)
|
||||
}
|
||||
|
||||
function preferredUsableProfile(
|
||||
provider: AgentRuntimeProvider,
|
||||
settings: RuntimeResolutionSettings
|
||||
): RuntimeResolutionProfile | undefined {
|
||||
// Fallback defaults are text models; image generation is only chosen explicitly.
|
||||
const text = selectableModelProfiles(provider, settings).filter(
|
||||
(profile) => profile.protocol !== 'openai-images-generations'
|
||||
)
|
||||
const ready = text.filter(isProfileCredentialReady)
|
||||
return (
|
||||
ready.find((profile) => profile.id === settings.defaultModelProfileId) ??
|
||||
ready[0] ??
|
||||
text[0]
|
||||
)
|
||||
}
|
||||
|
||||
/** What the global settings configure for a provider, before any fallback. */
|
||||
export function configuredGlobalModel(
|
||||
provider: AgentRuntimeProvider,
|
||||
settings: RuntimeResolutionSettings
|
||||
): RuntimeModelChoice | undefined {
|
||||
if (provider === 'model') {
|
||||
// No connections yet (first run, tests): let the runtime use its configured model.
|
||||
return settings.defaultModelProfileId
|
||||
? { kind: 'profile', profileId: settings.defaultModelProfileId }
|
||||
: undefined
|
||||
}
|
||||
const source =
|
||||
provider === 'opencode'
|
||||
? settings.opencodeModelSource
|
||||
: provider === 'continue'
|
||||
? settings.continueModelSource
|
||||
: settings.deepseekHarnessModelSource
|
||||
if (!source || source.kind === 'platform') return { kind: 'runtime-config' }
|
||||
if (source.kind === 'profile') {
|
||||
return { kind: 'profile', profileId: source.profileId }
|
||||
}
|
||||
const preferred = preferredUsableProfile(provider, settings)
|
||||
return preferred
|
||||
? { kind: 'profile', profileId: preferred.id }
|
||||
: { kind: 'runtime-config' }
|
||||
}
|
||||
|
||||
/** The global default after skipping unusable connections. */
|
||||
export function globalDefaultModel(
|
||||
provider: AgentRuntimeProvider,
|
||||
settings: RuntimeResolutionSettings,
|
||||
options: RuntimeResolutionOptions = {}
|
||||
): RuntimeModelChoice | undefined {
|
||||
const configured = configuredGlobalModel(provider, settings)
|
||||
const configuredProfile =
|
||||
configured?.kind === 'profile'
|
||||
? settings.modelProfiles?.find((profile) => profile.id === configured.profileId)
|
||||
: undefined
|
||||
if (
|
||||
configured &&
|
||||
configuredProfile?.protocol !== 'openai-images-generations' &&
|
||||
isResolvableStatus(runtimeModelChoiceStatus(provider, configured, settings, options))
|
||||
) {
|
||||
return configured
|
||||
}
|
||||
const preferred = preferredUsableProfile(provider, settings)
|
||||
if (preferred) return { kind: 'profile', profileId: preferred.id }
|
||||
if (supportsRuntimeConfig(provider, options)) return { kind: 'runtime-config' }
|
||||
// Keep the broken choice so the run reports why it cannot start.
|
||||
return options.remote ? undefined : configured
|
||||
}
|
||||
|
||||
export function resolveRuntimeChoice(
|
||||
settings: RuntimeResolutionSettings,
|
||||
layers: {
|
||||
project?: RuntimeSelectionLayer
|
||||
conversation?: RuntimeSelectionLayer
|
||||
} = {},
|
||||
options: RuntimeResolutionOptions = {}
|
||||
): ResolvedRuntimeChoice {
|
||||
// Cached renderer state may still hold pre-layer shapes; read them as layers.
|
||||
const normalize = (layer: unknown): RuntimeSelectionLayer | undefined => {
|
||||
if (layer === undefined) return undefined
|
||||
const parsed = runtimeSelectionLayerSchema.safeParse(layer)
|
||||
return parsed.success ? parsed.data : undefined
|
||||
}
|
||||
layers = { project: normalize(layers.project), conversation: normalize(layers.conversation) }
|
||||
const allowed = allowedRuntimeProviders(options)
|
||||
let ignored: ResolvedRuntimeChoice['ignored']
|
||||
let provider = normalizedGlobalProvider(settings, options)
|
||||
let providerSource: RuntimeSelectionLayerName = 'global'
|
||||
for (const layer of ['project', 'conversation'] as const) {
|
||||
const candidate = layers[layer]?.provider
|
||||
if (!candidate) continue
|
||||
if (allowed.includes(candidate)) {
|
||||
provider = candidate
|
||||
providerSource = layer
|
||||
} else {
|
||||
ignored = { layer, kind: 'provider', reason: 'incompatible' }
|
||||
}
|
||||
}
|
||||
|
||||
let model: RuntimeModelChoice | undefined
|
||||
let modelSource: RuntimeSelectionLayerName = 'global'
|
||||
for (const layer of ['conversation', 'project'] as const) {
|
||||
const candidate = layers[layer]?.model
|
||||
if (!candidate) continue
|
||||
const status = runtimeModelChoiceStatus(provider, candidate, settings, options)
|
||||
// A missing key is reported when the run starts; only structural problems fall back.
|
||||
if (isResolvableStatus(status)) {
|
||||
model = candidate
|
||||
modelSource = layer
|
||||
break
|
||||
}
|
||||
ignored ??= { layer, kind: 'model', reason: status as 'missing' | 'incompatible' }
|
||||
}
|
||||
model ??= globalDefaultModel(provider, settings, options)
|
||||
|
||||
return {
|
||||
selection: runtimeSelectionFromChoice(provider, model),
|
||||
provider,
|
||||
providerSource,
|
||||
model,
|
||||
modelSource,
|
||||
...(ignored ? { ignored } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops references to deleted model connections without touching other choices. */
|
||||
export function repairRuntimeSelectionLayer(
|
||||
layer: RuntimeSelectionLayer | undefined,
|
||||
settings: Pick<RuntimeResolutionSettings, 'modelProfiles'>
|
||||
): RuntimeSelectionLayer | undefined {
|
||||
if (
|
||||
layer?.model?.kind !== 'profile' ||
|
||||
(settings.modelProfiles ?? []).some((profile) => profile.id === (layer.model as { profileId: string }).profileId)
|
||||
) {
|
||||
return compactRuntimeSelectionLayer(layer)
|
||||
}
|
||||
return compactRuntimeSelectionLayer({ ...layer, model: undefined })
|
||||
}
|
||||
|
||||
export function runtimeSelectionLayerKey(
|
||||
layer: RuntimeSelectionLayer | undefined
|
||||
): string {
|
||||
return `${selection.provider}:${'profileId' in selection
|
||||
? selection.profileId ?? 'default'
|
||||
: 'default'}`
|
||||
const compact = compactRuntimeSelectionLayer(layer)
|
||||
return compact ? JSON.stringify(compact) : ''
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user