/** * @license * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ import { AuthType, MODEL_GENERATION_CONFIG_FIELDS, type ContentGeneratorConfig, type ContentGeneratorConfigSources, normalizeReasoningEffort, REASONING_EFFORT_TIERS, resolveModelConfig, resolveModelSelectionAuthType, tryResolveModelProtocol, resolveProviderProtocol, type ModelConfigSourcesInput, type ModelProvidersConfig, type ProviderModelConfig, type ProviderProtocolConfig, stripRuntimeSnapshotPrefix, } from '@qwen-code/qwen-code-core'; import type { Settings } from '../config/settings.js'; import { getBuiltinDeepSeekReasoning } from '@qwen-code/qwen-code-core/core/deepseek-reasoning.js'; import { sanitizeProviderBaseUrl } from './acpModelUtils.js'; /** * Env var names that hold model selections for each auth type. * Mirrors the model-var mappings in core's AUTH_ENV_MAPPINGS. */ const AUTH_ENV_MODEL_VARS: Record = { [AuthType.USE_OPENAI]: ['OPENAI_MODEL', 'QWEN_MODEL'], [AuthType.USE_OPENAI_RESPONSES]: ['OPENAI_MODEL'], [AuthType.USE_GEMINI]: ['GEMINI_MODEL'], [AuthType.USE_VERTEX_AI]: ['GOOGLE_MODEL'], [AuthType.USE_ANTHROPIC]: ['ANTHROPIC_MODEL'], [AuthType.QWEN_OAUTH]: [], [AuthType.QWEN_SUBSCRIPTION]: [], }; /** * Collect every modelProviders entry whose provider id resolves (via * providerProtocol) to the given protocol, in declaration order. Mirrors * {@link ModelRegistry}: a built-in key resolves to itself, a custom id resolves * through providerProtocol, with each model's api selecting its OpenAI route. * Lets credential/metadata lookups find a custom * provider's models under their resolved protocol instead of only the protocol * key. */ export function collectProviderModelsForProtocol( modelProviders: ModelProvidersConfig | undefined, providerProtocol: ProviderProtocolConfig | undefined, protocol: string, ): ProviderModelConfig[] { if (!modelProviders) { return []; } const out: ProviderModelConfig[] = []; for (const [providerId, models] of Object.entries(modelProviders)) { if (!Array.isArray(models)) { continue; } out.push( ...models.filter( // tryResolveModelProtocol: one entry with an invalid `wireApi` (a // hand-edited settings file surviving a hot reload) must not take // down every read that walks the providers map — skip that entry. // Startup validation still classifies it as a FatalConfigError. (model) => tryResolveModelProtocol(providerId, model, providerProtocol) === protocol, ), ); } return out; } function findProviderIdForModel( modelProviders: ModelProvidersConfig | undefined, providerProtocol: ProviderProtocolConfig | undefined, protocol: string, modelProvider: ProviderModelConfig | undefined, ): string | undefined { if (!modelProviders || !modelProvider) { return undefined; } for (const [providerId, models] of Object.entries(modelProviders)) { if (!Array.isArray(models)) { continue; } if ( models.includes(modelProvider) && tryResolveModelProtocol(providerId, modelProvider, providerProtocol) === protocol ) { return providerId; } } return undefined; } /** * Build user-visible warnings for modelProviders entries that carry models but * are dropped at registration: an unknown provider id with no providerProtocol * mapping, or a mapping to an unknown protocol. Without this the models silently * vanish from selection (the registry only logs at debug level). */ function buildSkippedProviderWarnings( modelProviders: ModelProvidersConfig | undefined, providerProtocol: ProviderProtocolConfig | undefined, ): string[] { if (!modelProviders) { return []; } const warnings: string[] = []; const known = Object.values(AuthType) .filter((value) => value !== AuthType.USE_OPENAI_RESPONSES) .join(', '); for (const [providerId, models] of Object.entries(modelProviders)) { if (!Array.isArray(models) || models.length === 0) { continue; } const protocol = resolveProviderProtocol(providerId, providerProtocol); if ( protocol === AuthType.QWEN_OAUTH && providerId !== AuthType.QWEN_OAUTH ) { warnings.push( `Warning: modelProviders provider "${providerId}" maps to "qwen-oauth" via providerProtocol, but qwen-oauth uses hard-coded models only; its ${models.length} model(s) are ignored.`, ); continue; } if (protocol !== undefined) { continue; } const mapped = providerProtocol && Object.hasOwn(providerProtocol, providerId) ? providerProtocol[providerId] : undefined; warnings.push( mapped !== undefined ? `Warning: providerProtocol["${providerId}"] = "${mapped}" is not a known protocol (expected one of: ${known}); its ${models.length} model(s) are ignored.` : `Warning: modelProviders provider "${providerId}" is not a built-in protocol (${known}) and has no providerProtocol mapping; its ${models.length} model(s) are ignored. Add providerProtocol["${providerId}"] (e.g. "openai") to enable them.`, ); } return warnings; } function getIgnoredTopLevelGenerationConfigFields( settingsGenerationConfig: Partial | undefined, modelProvider: ProviderModelConfig | undefined, ): string[] { if (!settingsGenerationConfig || !modelProvider) { return []; } const providerGenerationConfig = modelProvider.generationConfig ?? {}; return MODEL_GENERATION_CONFIG_FIELDS.filter( (field) => Object.hasOwn(settingsGenerationConfig, field) && !Object.hasOwn(providerGenerationConfig, field), ); } function buildIgnoredTopLevelGenerationConfigWarning( providerId: string, modelProvider: ProviderModelConfig, ignoredFields: string[], ): string | undefined { if (ignoredFields.length === 0) { return undefined; } const fieldList = ignoredFields .map((field) => `model.generationConfig.${field}`) .join(', '); const isSingular = ignoredFields.length === 1; const verb = isSingular ? 'is' : 'are'; const fieldReference = isSingular ? 'this field' : 'these fields'; const pronoun = isSingular ? 'it' : 'them'; return `Warning: ${fieldList} ${verb} ignored for provider model "${modelProvider.id}" from modelProviders.${providerId}. Move ${fieldReference} to modelProviders.${providerId}[].generationConfig for that model if you want ${pronoun} to apply.`; } export interface CliGenerationConfigInputs { argv: { model?: string | undefined; openaiApiKey?: string | undefined; openaiBaseUrl?: string | undefined; openaiLogging?: boolean | undefined; openaiLoggingDir?: string | undefined; }; settings: Settings; selectedAuthType: AuthType | undefined; /** * Injectable env for testability. Defaults to process.env at callsites. */ env?: Record; } export interface ResolvedCliGenerationConfig { /** Effective protocol after applying the selected model's API. */ authType: AuthType | undefined; /** The resolved model id (may be empty string if not resolvable at CLI layer) */ model: string; /** API key for OpenAI-compatible auth */ apiKey: string; /** Base URL for OpenAI-compatible auth */ baseUrl: string; /** The full generation config to pass to core Config */ generationConfig: Partial; /** Exact selected modelProviders baseUrl; null selects an implicit route. */ registryBaseUrl?: string | null; /** Source attribution for each resolved field */ sources: ContentGeneratorConfigSources; /** Warnings generated during resolution */ warnings: string[]; } export function getAuthTypeFromEnv( env: Record = process.env, ): AuthType | undefined { if (env['QWEN_OAUTH']) { return AuthType.QWEN_OAUTH; } if ( env['OPENAI_API_KEY'] && (env['OPENAI_MODEL'] || env['QWEN_MODEL']) && env['OPENAI_BASE_URL'] ) { return AuthType.USE_OPENAI; } if (env['GEMINI_API_KEY'] && env['GEMINI_MODEL']) { return AuthType.USE_GEMINI; } if (env['GOOGLE_API_KEY'] && env['GOOGLE_MODEL']) { return AuthType.USE_VERTEX_AI; } if (env['GOOGLE_CLOUD_PROJECT'] && env['GOOGLE_MODEL']) { return AuthType.USE_VERTEX_AI; } if ( env['ANTHROPIC_API_KEY'] && env['ANTHROPIC_MODEL'] && env['ANTHROPIC_BASE_URL'] ) { return AuthType.USE_ANTHROPIC; } return undefined; } /** * Unified resolver for CLI generation config. * * Model precedence (all auth types): * - argv.model > settings.model.name > auth-specific env model vars * * Env var mapping by auth type (mirrors core's AUTH_ENV_MAPPINGS): * - USE_OPENAI: OPENAI_MODEL, QWEN_MODEL * - USE_GEMINI: GEMINI_MODEL * - USE_VERTEX_AI: GOOGLE_MODEL * - USE_ANTHROPIC: ANTHROPIC_MODEL * * When model is resolved from argv or settings, all model env vars are stripped * from the env passed to core's resolveModelConfig to prevent incorrect overrides. * When model is resolved from an auth-specific env var, only that env var is * kept in the filtered env so core can access the provider metadata. */ export function resolveCliGenerationConfig( inputs: CliGenerationConfigInputs, ): ResolvedCliGenerationConfig { const { argv, settings, selectedAuthType } = inputs; const env = inputs.env ?? (process.env as Record); // Resolve the target model based on strict precedence: // argv.model > settings.model.name > auth-specific env model vars // Env vars are ONLY considered when neither argv.model nor settings.model.name is set. let resolvedModel: string | undefined; let sourceEnvVar: string | undefined; // Whether the model came from settings.model.name (vs argv/env). The persisted // settings.model.baseUrl disambiguator only applies to this case. let resolvedFromSettings = false; if (argv.model) { resolvedModel = argv.model; } else if (settings.model?.name) { // Self-heal configs already corrupted by older builds. resolvedModel = stripRuntimeSnapshotPrefix(settings.model.name); resolvedFromSettings = true; } else if (selectedAuthType && AUTH_ENV_MODEL_VARS[selectedAuthType]) { // Only check env vars for the current auth type for (const envVar of AUTH_ENV_MODEL_VARS[selectedAuthType]) { if (env[envVar]) { resolvedModel = env[envVar]; sourceEnvVar = envVar; break; } } } // Only derive the wire when a model selection exists to derive it from: // the resolver's no-model branch would let an unrelated `wireApi: 'responses'` // entry anywhere in the map flip an `openai` selection to openai-responses, // which has no DEFAULT_MODELS entry — the session would then start on a // wire and a fallback model id no config file contains. const authType = selectedAuthType && resolvedModel !== undefined ? resolveModelSelectionAuthType( selectedAuthType, resolvedModel, settings.modelProviders, settings.providerProtocol, resolvedFromSettings ? settings.model?.baseUrl : undefined, ) : selectedAuthType; // Find a matching provider for the resolved model (for metadata: generationConfig, envKey, etc.) // When resolvedModel is from settings and matches a provider, modelProvider.id == settings.model.name, // so the resolver correctly uses the settings-selected model (no override occurs). // The old candidate-loop code that fell through to OPENAI_MODEL is gone. let modelProvider: ProviderModelConfig | undefined; let disambiguationWarning: string | undefined; if (resolvedModel && authType && settings.modelProviders) { // Merge across all provider ids that route to this protocol (built-in key // or custom id via providerProtocol), so a custom provider's envKey/metadata // is honored, not just entries stored under the protocol key itself. const providers = collectProviderModelsForProtocol( settings.modelProviders, settings.providerProtocol, authType, ); if (providers.length > 0) { // When multiple providers share the same id, disambiguate by the // persisted settings.model.baseUrl (written by the model picker). This // only applies when the model itself came from settings.model.name. // Fall back to the first id match if the paired provider was edited or // removed (and for the legacy id-only case where no baseUrl was saved), // mirroring auth.ts:findModelConfig. // // Note: `settings` is already merged across user/workspace/system scopes. // Every writer of model.name (the picker, /model, ACP, provider install) // also writes model.baseUrl in the SAME scope — a real URL, or an empty // string tombstone when there is none. The tombstone matters because an // omitted key cannot override a stale model.baseUrl in a lower-priority // scope on merge, but '' (a present value) can. Empty string is treated // as "no disambiguator" here. The only remaining desync is a hand-edited // config that sets model.name in a higher scope with no baseUrl key at // all; the id-only fallback bounds the blast radius to a same-id provider. const persistedBaseUrl = settings.model?.baseUrl; if (resolvedFromSettings && persistedBaseUrl) { const exactMatch = providers.find( (p) => p.id === resolvedModel && p.baseUrl === persistedBaseUrl, ); modelProvider = exactMatch ?? providers.find((p) => p.id === resolvedModel); // Surface the silent fallback: the paired provider was removed or its // baseUrl changed, so traffic now routes to a different same-id provider. if (!exactMatch && modelProvider) { const fallbackBaseUrl = modelProvider.baseUrl === undefined ? '(default baseUrl)' : sanitizeProviderBaseUrl(modelProvider.baseUrl); disambiguationWarning = `Persisted model.baseUrl '${sanitizeProviderBaseUrl(persistedBaseUrl)}' no longer matches any provider ` + `for model '${resolvedModel}' (authType '${authType}'); using the first id match ` + `('${fallbackBaseUrl}'). Re-select the model to update it.`; } } else { modelProvider = providers.find((p) => p.id === resolvedModel); } } } // Filter env to prevent auth-specific model env vars from overriding higher-priority sources. // sourceEnvVar is only set when the model was actually resolved from an env var (lines 119-128), // so this is source-based filtering, not value-based. If model came from argv or settings, // sourceEnvVar is undefined and ALL model env vars are stripped. // Build a list of ALL model env vars across all auth types. const allModelEnvVars = Object.values(AUTH_ENV_MODEL_VARS).flat(); const filteredEnv = { ...env }; if (sourceEnvVar) { // Keep only the env var that was actually used for (const envVar of allModelEnvVars) { if (envVar !== sourceEnvVar) { delete filteredEnv[envVar]; } } } else { // Model was not resolved from env - strip ALL model env vars for (const envVar of allModelEnvVars) { delete filteredEnv[envVar]; } } const configSources: ModelConfigSourcesInput = { authType, cli: { model: argv.model, apiKey: argv.openaiApiKey, baseUrl: argv.openaiBaseUrl, }, settings: { model: settings.model?.name ? stripRuntimeSnapshotPrefix(settings.model.name) : undefined, apiKey: settings.security?.auth?.apiKey, baseUrl: settings.security?.auth?.baseUrl, generationConfig: settings.model?.generationConfig as | Partial | undefined, }, modelProvider, env: filteredEnv, }; const resolved = resolveModelConfig(configSources); // Provider-backed models are synced again during Config.refreshAuth(), which // reapplies provider defaults after the initial resolver fallback. const ignoredGenerationConfigWarning = authType && modelProvider ? buildIgnoredTopLevelGenerationConfigWarning( findProviderIdForModel( settings.modelProviders, settings.providerProtocol, authType, modelProvider, ) ?? authType, modelProvider, getIgnoredTopLevelGenerationConfigFields( settings.model?.generationConfig as | Partial | undefined, modelProvider, ), ) : undefined; // Resolve OpenAI logging config (CLI-specific, not part of core resolver) const enableOpenAILogging = (typeof argv.openaiLogging === 'undefined' ? settings.model?.enableOpenAILogging : argv.openaiLogging) ?? false; const openAILoggingDir = argv.openaiLoggingDir || settings.model?.openAILoggingDir; // Build the full generation config // Note: we merge the resolved config with logging settings const generationConfig: Partial = { ...resolved.config, enableOpenAILogging, openAILoggingDir, }; // Apply the global reasoning-effort preference (settings.model.reasoningEffort, // set via /effort) onto the unified reasoning config. Skip when thinking is // explicitly disabled (reasoning === false) so effort never silently // re-enables it; provider adapters clamp the tier to the active model. const rawReasoningEffort = settings.model?.reasoningEffort; const reasoningDisabled = rawReasoningEffort === 'none'; const reasoningEffort = reasoningDisabled ? undefined : normalizeReasoningEffort(rawReasoningEffort); // A configured-but-unrecognized value (e.g. a "hihg" typo in settings.json) // normalizes to undefined and is silently skipped below. Surface it as a // warning so the user isn't left wondering why /effort had no effect. const invalidReasoningEffortWarning = rawReasoningEffort && !reasoningDisabled && !reasoningEffort ? `Ignoring invalid model.reasoningEffort "${rawReasoningEffort}"; expected one of: none, ${REASONING_EFFORT_TIERS.join(', ')}.` : undefined; if (reasoningDisabled && generationConfig.thinkingMandatory !== true) { generationConfig.reasoning = false; if (getBuiltinDeepSeekReasoning({ ...generationConfig, authType })) { resolved.sources['reasoning'] = { kind: 'settings', settingsPath: 'model.reasoningEffort', }; } } else if (reasoningEffort && generationConfig.reasoning !== false) { generationConfig.reasoning = { ...(generationConfig.reasoning ?? {}), effort: reasoningEffort, }; } return { authType, model: resolved.config.model || '', apiKey: resolved.config.apiKey || '', baseUrl: resolved.config.baseUrl || '', generationConfig, ...(modelProvider ? { registryBaseUrl: modelProvider.baseUrl ?? null } : {}), sources: resolved.sources, warnings: [ ...resolved.warnings, ...(invalidReasoningEffortWarning ? [invalidReasoningEffortWarning] : []), ...(disambiguationWarning ? [disambiguationWarning] : []), ...(ignoredGenerationConfigWarning ? [ignoredGenerationConfigWarning] : []), ...buildSkippedProviderWarnings( settings.modelProviders, settings.providerProtocol, ), ], }; }