diff --git a/docs/model-token-limits.md b/docs/model-token-limits.md new file mode 100644 index 000000000..282eeab5b --- /dev/null +++ b/docs/model-token-limits.md @@ -0,0 +1,116 @@ +# Model token limit resolution (#685) + +Context and output limits resolve independently in this order: + +1. Explicit user configuration. +2. Positive metadata from the configured provider endpoint. +3. The engine model catalog, including aliases. +4. Protocol defaults. + +`src/model/metadata/tokenLimits.ts` is a browser-safe shared normalizer and +resolver. Input-only limits from Anthropic and Gemini are used directly as +`maxContextTokens`; output is not added to input. Missing, null, fractional, +non-finite and non-positive metadata fields do not override the next layer. + +## Discovery and offline behavior + +`/api/config/models` retains the token metadata instead of dropping everything +except ID and name. Anthropic and Gemini lists follow cursors with a bounded +page count and reject repeated cursors. Requests retain the existing credential +scope checks and use the provider URL for all pagination requests. + +DashScope's official hosts additionally query `/api/v1/models` for `model_info`. +An unavailable supplemental endpoint does not break ID discovery. Custom proxies +use the same field map without making requests to an unrelated official host. + +Opening one model's settings calls `/api/config/model-limits`. For Ollama this +performs one `/api/show` request, with `num_ctx` taking precedence over the model +architecture's context length. Listing Ollama models never calls show per model. +No output limit is invented from an Ollama context limit. + +Successful discoveries are cached in +`$PILOT_HOME/cache/provider-model-limits.json`, separately from `pilotdeck.yaml`. +The cache contains only model IDs and validated numeric limits, scoped by +provider ID, protocol and endpoint. Inference and discovery URLs such as `/v1` +and `/models` share a scope. Configuration parsing reads this cache synchronously +without network access, so server restarts and headless use retain the metadata. +A successful cache update requests a gateway configuration reload. A later +discovery merges into the existing per-model record: only validated fields in +the new response replace stored values, and an omitted field keeps its last +known value. Offline or damaged-cache reads fall back to the model catalog and +protocol defaults. + +## Saving and legacy configuration + +The dialog labels each limit's source and updates untouched fields when provider +metadata arrives. It preserves edits made during the lookup. Saving compares the +submitted values to the resolved non-user values. Equal values are omitted from +`capabilities`; differing values are stored with a per-field marker: + +```yaml +models: + deepseek-chat: + capabilities: + maxContextTokens: 128000 + maxOutputTokens: 32768 + tokenLimitOverrides: + maxContextTokens: true + maxOutputTokens: true +``` + +An old configuration containing exactly both protocol defaults, without an +explicit marker, stops overriding a differing provider/catalog value. This is +an in-memory interpretation, not an automatic rewrite of the user's YAML. +Other user values, including a single overridden field, retain priority. Users +can deliberately save the old default pair again after upgrading, or use +“Use automatic token limits” to remove their overrides. + +## Relationship to #682 + +This change does not import or replace #682's remote catalog store, generated +catalog, refresh hooks, model overrides, or selection rules. The provider +metadata cache is a separate higher-priority layer and must remain separate +from #682's `model-catalog.json` cache. + +Before #682 lands, UI model capabilities and aliases are derived from the engine +catalog instead of copied into a hand-maintained UI subset. Provider presentation +fields remain local. OpenRouter's small fallback suggestion list references +engine entries rather than duplicating their token limits. + +Both changes touch config parsing, ProviderCard, modelListApi and the UI catalog +adapter. When merging them, retain #682's catalog snapshot injection and refresh +hooks, call the shared provider normalizer, and retain #685's metadata resolver, +cache input, explicit override markers and non-user-value save comparison. +`/model-limits` must use #682's active catalog snapshot when that store is present. +No #682 commit is required for this branch to build or run. + +## Validation + +Targeted tests cover provider field maps, per-field fallback, user priority, +legacy-pair recovery and deliberate pins, metadata persistence and endpoint +isolation, damaged/offline caches, pagination, DashScope enrichment, on-demand +Ollama metadata, the show-then-list runtime round trip, partial cache updates, +source labels, asynchronous dialog updates, and automatic-value +omission on save. Tests inject provider responses; they do not make paid model +requests or claim validation against every live provider. + +### Compatibility rehearsal + +A temporary worktree combined this fix with #682 at +`5f5f9186c4774b8770acdedda2e9566d60e64085`. The four overlapping files above +required manual merge resolution. Both sets of imports and parsing inputs were +retained; #682's UI catalog adapter and refresh hooks were kept, and the new +`/model-limits` lookup was changed to pass `readCatalogSnapshot().catalog` to +`lookupCatalogModel`. The combined engine type check, 145 model tests, 149 config +route tests, 74 UI tests and Web production build passed. This is an integration rehearsal, +not a merge of #682 into the repair branch; later changes to that PR need a new +check. + +### Main-branch checks + +The repair branch passed 135 model-layer tests, 149 config-route tests and 89 +settings/onboarding/shared-API UI tests, engine TypeScript compilation, and engine +and Web production builds. The full UI type check still reports 546 existing +errors, mainly React 18/19 type incompatibilities. Running the same check on +unmodified `main` at `5862db79` produced the identical diagnostic set after +normalizing source paths and line numbers; this fix adds no UI type errors. diff --git a/src/model/config/parseModelConfig.ts b/src/model/config/parseModelConfig.ts index cd4f18ad4..e92222242 100644 --- a/src/model/config/parseModelConfig.ts +++ b/src/model/config/parseModelConfig.ts @@ -1,3 +1,5 @@ +import { readProviderModelLimits } from "../metadata/store.js"; +import { resolveTokenLimits, type TokenLimits, type TokenLimitOverrides } from "../metadata/tokenLimits.js"; import { sanitizeProviderBody } from "../request/sanitizeProviderBody.js"; import { parseThinkingSettings } from "../thinking/settings.js"; import { ANTHROPIC_DEFAULT_CAPABILITIES } from "../providers/anthropic/defaults.js"; @@ -99,9 +101,10 @@ function parseProvider(providerId: string, rawProvider: unknown, env?: Credentia }); } + const providerLimits = readProviderModelLimits({ providerId, protocol, baseUrl: rawUrl }, env); const models: Record = {}; for (const [modelId, rawModel] of Object.entries(provider.models)) { - models[modelId] = parseModelDefinition(modelId, protocol, rawModel, providerId); + models[modelId] = parseModelDefinition(modelId, protocol, rawModel, providerId, providerLimits[modelId]); } return { @@ -195,6 +198,7 @@ function parseModelDefinition( protocol: ModelProtocol, rawModel: unknown, providerId: string, + providerLimits?: TokenLimits, ): ModelDefinition { const effectiveRaw = rawModel ?? {}; if (!isRecord(effectiveRaw)) { @@ -210,6 +214,8 @@ function parseModelDefinition( model.capabilities, catalogModel?.capabilities, providerId, + providerLimits, + parseTokenLimitOverrides(model.tokenLimitOverrides), ); // Cross-provider model-name matches are useful for token/capability hints, // but they must not silently opt a custom model into image delivery. Aliases @@ -240,6 +246,8 @@ function parseCapabilities( rawCapabilities: unknown, catalogCapabilities?: ModelCapabilities, providerId?: string, + providerLimits?: TokenLimits, + explicit?: TokenLimitOverrides, ): ModelCapabilities { const protocolDefaults = protocol === "anthropic" @@ -249,15 +257,11 @@ function parseCapabilities( : OPENAI_DEFAULT_CAPABILITIES; const defaults = catalogCapabilities ?? protocolDefaults; - if (rawCapabilities === undefined) { - return applyOfficialSpeedDefault(defaults, providerId); - } - - if (!isRecord(rawCapabilities)) { + if (rawCapabilities !== undefined && !isRecord(rawCapabilities)) { throw new ModelConfigError("invalid_capabilities", "Model capabilities must be an object."); } - const capabilities = rawCapabilities as RawCapabilities; + const capabilities = (rawCapabilities ?? {}) as RawCapabilities; const overrides: Partial = {}; for (const key of [ @@ -280,7 +284,7 @@ function parseCapabilities( // Accept `contextWindow` as an alias for `maxContextTokens` so that // YAML configs using the friendlier name are not silently ignored. - const raw = rawCapabilities as Record; + const raw = capabilities as Record; if (raw.contextWindow !== undefined && capabilities.maxContextTokens === undefined) { overrides.maxContextTokens = readPositiveNumber(raw.contextWindow, "contextWindow"); } @@ -291,14 +295,25 @@ function parseCapabilities( } } + const limits = resolveTokenLimits({ protocol, user: overrides, explicit, provider: providerLimits, catalog: catalogCapabilities }); return applyOfficialSpeedDefault({ ...mergeCapabilities(defaults, overrides), + maxContextTokens: limits.maxContextTokens.value, + maxOutputTokens: limits.maxOutputTokens.value, ...(capabilities.supportsThinking !== undefined ? { supportsThinkingExplicit: capabilities.supportsThinking } : {}), } as ModelCapabilities, providerId, overrides.supportsSpeed); } +function parseTokenLimitOverrides(value: unknown): TokenLimitOverrides | undefined { + if (value === undefined) return undefined; + if (!isRecord(value) || Object.values(value).some(v => typeof v !== "boolean")) { + throw new ModelConfigError("invalid_capabilities", "tokenLimitOverrides must contain boolean values."); + } + return value as TokenLimitOverrides; +} + function applyOfficialSpeedDefault( capabilities: ModelCapabilities, providerId: string | undefined, diff --git a/src/model/config/schema.ts b/src/model/config/schema.ts index 8ea982beb..0ff0d0f6d 100644 --- a/src/model/config/schema.ts +++ b/src/model/config/schema.ts @@ -20,6 +20,7 @@ export type RawProviderConfig = { }; export type RawModelDefinition = { + tokenLimitOverrides?: unknown; thinking?: unknown; displayName?: unknown; capabilities?: unknown; diff --git a/src/model/metadata/discovery.ts b/src/model/metadata/discovery.ts new file mode 100644 index 000000000..55f96061d --- /dev/null +++ b/src/model/metadata/discovery.ts @@ -0,0 +1,90 @@ +import { networkFetch } from "../../network/fetch.js"; +import { buildProviderModelsEndpointCandidates, isExpectedProviderModelsResponseShape } from "../providerEndpoint.js"; +import type { ModelProtocol } from "../protocol/canonical.js"; +import { normalizeModelListItem, parseModelListResponse, positiveTokenLimit, type ProviderModelMetadata } from "./tokenLimits.js"; + +type Options = { protocol: ModelProtocol; baseUrl: string; apiKey?: string; providerId?: string; signal?: AbortSignal; fetchImpl?: typeof fetch }; + +export class ModelDiscoveryError extends Error { + constructor(message: string, readonly status: number) { super(message); } +} + +function headers(options: Options): Record { + const key = options.apiKey; + if (options.protocol === "anthropic") return { "anthropic-version": "2023-06-01", ...(key ? { "x-api-key": key } : {}) }; + return key ? options.protocol === "google" ? { "x-goog-api-key": key } : { Authorization: `Bearer ${key}` } : {}; +} + +async function request(url: string, options: Options, body?: unknown): Promise> { + const response = await networkFetch(url, { + method: body === undefined ? "GET" : "POST", headers: { ...headers(options), ...(body === undefined ? {} : { "Content-Type": "application/json" }) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: options.signal, + }, { signal: options.signal, fetchImpl: options.fetchImpl, retry: { maxRetries: 2, baseDelayMs: 500, maxDelayMs: 5000, retryOnPost: body !== undefined } }); + const text = await response.text(); + let parsed; + try { parsed = text ? JSON.parse(text) : {}; } catch { /* Non-JSON error responses still keep the upstream HTTP status. */ } + if (!response.ok) throw new ModelDiscoveryError(parsed?.error?.message || parsed?.message || text || `HTTP ${response.status}`, response.status); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new ModelDiscoveryError(`Expected JSON from ${url}, but received non-JSON content.`, 502); + return parsed; +} + +/** Follow only provider-issued cursors on the same URL; never forward keys to a next-link host. */ +async function listPages(options: Options): Promise { + const urls = buildProviderModelsEndpointCandidates(options); + let body: Record | undefined, endpoint = urls[0]; + for (const url of urls) { + try { + const candidate = await request(url, options); + if (isExpectedProviderModelsResponseShape(options.protocol, candidate)) { body = candidate; endpoint = url; break; } + } catch (error) { + if (!(error instanceof ModelDiscoveryError) || ![400, 404, 405].includes(error.status) || url === urls.at(-1)) throw error; + } + } + if (!body) throw new ModelDiscoveryError("Expected a provider model list.", 502); + const models = new Map(), cursors = new Set(); + for (let page = 0; ; page++) { + for (const model of parseModelListResponse(body)) models.set(model.id, { ...models.get(model.id), ...model }); + const cursor = options.protocol === "google" ? body.nextPageToken : options.protocol === "anthropic" && body.has_more === true ? body.last_id : undefined; + if (!cursor) { + if (options.protocol === "anthropic" && body.has_more === true) throw new ModelDiscoveryError("Provider omitted its pagination cursor.", 502); + return [...models.values()]; + } + if (typeof cursor !== "string" || cursors.has(cursor) || page >= 99) throw new ModelDiscoveryError("Provider model pagination did not finish.", 502); + cursors.add(cursor); + const url = new URL(endpoint); + url.searchParams.set(options.protocol === "google" ? "pageToken" : "after_id", cursor); + body = await request(url.toString(), options); + if (!isExpectedProviderModelsResponseShape(options.protocol, body)) throw new ModelDiscoveryError("Expected a provider model list.", 502); + } +} + +export async function discoverProviderModels(options: Options): Promise { + const models = await listPages(options); + const url = new URL(options.baseUrl); + // This API is separate from the compatible-mode discovery endpoint. A proxy + // must never cause an additional request to the official host with its key. + if (["dashscope.aliyuncs.com", "dashscope-intl.aliyuncs.com", "dashscope-us.aliyuncs.com"].includes(url.hostname)) { + try { + const details = parseModelListResponse(await request(`${url.origin}/api/v1/models`, options)); + const byId = new Map(details.map(model => [model.id, model])); + return models.map(model => ({ ...model, ...byId.get(model.id) })); + } catch { /* Discovery still works when the optional metadata endpoint is unavailable. */ } + } + return models; +} + +/** Ollama's loaded window is available only in /api/show, requested for one opened model. */ +export async function discoverOllamaModel(options: Options, modelId: string): Promise { + const url = new URL(options.baseUrl); + url.pathname = url.pathname.replace(/\/+$/, "").replace(/\/models$/, "").replace(/\/v1$/, "").replace(/\/api$/, "") + "/api/show"; + url.search = ""; url.hash = ""; + const body = await request(url.toString(), options, { model: modelId }); + const info = body.model_info && typeof body.model_info === "object" ? body.model_info as Record : {}; + const parameters = typeof body.parameters === "string" ? body.parameters : ""; + const configured = /^\s*num_ctx\s+(\d+)\s*$/m.exec(parameters); + const configuredValue = configured ? positiveTokenLimit(Number(configured[1])) : undefined; + const architecture = typeof info["general.architecture"] === "string" ? info["general.architecture"] : undefined; + const context = configuredValue ?? positiveTokenLimit(architecture ? info[`${architecture}.context_length`] : undefined) + ?? Object.entries(info).filter(([key]) => key.endsWith(".context_length")).map(([, value]) => positiveTokenLimit(value)).find(v => v !== undefined); + return { ...normalizeModelListItem({ id: modelId })!, ...(context !== undefined ? { maxContextTokens: context } : {}) }; +} diff --git a/src/model/metadata/store.ts b/src/model/metadata/store.ts new file mode 100644 index 000000000..b3fb4d247 --- /dev/null +++ b/src/model/metadata/store.ts @@ -0,0 +1,84 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { resolvePilotHome } from "../../pilot/paths.js"; +import type { ModelProtocol } from "../protocol/canonical.js"; +import { + normalizeProviderTokenLimits, + TOKEN_LIMIT_KEYS, + type ProviderModelMetadata, + type TokenLimits, +} from "./tokenLimits.js"; + +type Scope = { providerId: string; protocol: ModelProtocol; baseUrl: string }; +type Cache = Record>; +type Env = Record; + +/** Discovery URLs can be siblings of the inference URL, e.g. /models and /v1. */ +export function metadataEndpointScope(value: string): string { + const url = new URL(value); + url.username = ""; url.password = ""; url.search = ""; url.hash = ""; + url.pathname = url.pathname.replace(/\/+$/, "").replace(/\/models$/, "").replace(/\/v1(?:beta)?$/, ""); + return url.toString().replace(/\/+$/, ""); +} + +function scopeKey(scope: Scope): string { + return JSON.stringify([scope.providerId, scope.protocol, metadataEndpointScope(scope.baseUrl)]); +} + +function cachePath(env?: Env): string { + return join(resolvePilotHome(env), "cache", "provider-model-limits.json"); +} + +function readCache(env?: Env): Cache { + try { + const file = cachePath(env); + if (statSync(file).size > 10 * 1024 * 1024) return {}; + const body = JSON.parse(readFileSync(file, "utf8")); + return body?.version === 1 && body.scopes && typeof body.scopes === "object" && !Array.isArray(body.scopes) ? body.scopes : {}; + } catch { return {}; } +} + +/** Read-only, synchronous fallback for config parsing, including headless/offline starts. */ +export function readProviderModelLimits(scope: Scope, env?: Env): Record { + try { + const models = readCache(env)[scopeKey(scope)]; + if (!models || typeof models !== "object" || Array.isArray(models)) return {}; + return Object.fromEntries(Object.entries(models).map(([id, value]) => [id, normalizeProviderTokenLimits(value)])); + } catch { return {}; } +} + +/** Keep a known limit when a later response omits it. A present, validated value replaces that field. */ +function mergeValidatedTokenLimits(current: TokenLimits | undefined, incoming: TokenLimits): TokenLimits { + const merged = normalizeProviderTokenLimits(current); + for (const key of TOKEN_LIMIT_KEYS) { + const value = incoming[key]; + if (value !== undefined) merged[key] = value; + } + return merged; +} + +function mergeCachedModelLimits( + existing: Record | undefined, + models: ProviderModelMetadata[], +): Record { + const merged: Record = { ...existing }; + for (const model of models) { + merged[model.id] = mergeValidatedTokenLimits(merged[model.id], normalizeProviderTokenLimits(model)); + } + return merged; +} + +/** Persist metadata separately from user configuration; never store credentials or raw responses. */ +export function writeProviderModelLimits(scope: Scope, models: ProviderModelMetadata[], env?: Env): void { + const file = cachePath(env), temporary = `${file}.${randomUUID()}.tmp`; + try { + const cache = readCache(env), key = scopeKey(scope); + cache[key] = mergeCachedModelLimits(cache[key], models); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(temporary, JSON.stringify({ version: 1, scopes: cache }), { mode: 0o600 }); + renameSync(temporary, file); + } finally { + try { unlinkSync(temporary); } catch { /* Already renamed, or the directory was read-only. */ } + } +} diff --git a/src/model/metadata/tokenLimits.ts b/src/model/metadata/tokenLimits.ts new file mode 100644 index 000000000..f7197f8dc --- /dev/null +++ b/src/model/metadata/tokenLimits.ts @@ -0,0 +1,105 @@ +import type { ModelProtocol } from "../protocol/canonical.js"; +import { ANTHROPIC_DEFAULT_CAPABILITIES } from "../providers/anthropic/defaults.js"; +import { GOOGLE_DEFAULT_CAPABILITIES } from "../providers/google/defaults.js"; +import { OPENAI_DEFAULT_CAPABILITIES } from "../providers/openai/defaults.js"; + +export const TOKEN_LIMIT_KEYS = ["maxContextTokens", "maxOutputTokens"] as const; +export type TokenLimitKey = typeof TOKEN_LIMIT_KEYS[number]; +export type TokenLimits = Partial>; +export type TokenLimitSource = "user" | "provider" | "catalog" | "protocol"; +export type ResolvedTokenLimits = Record; +export type TokenLimitOverrides = Partial>; +export type ProviderModelMetadata = TokenLimits & { id: string; displayName: string }; + +export function positiveTokenLimit(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +/** Shared by discovery and the browser. Input limits are not added to output limits. */ +export function normalizeProviderTokenLimits(value: unknown): TokenLimits { + const item = record(value); + const info = record(item.model_info); + const top = record(item.top_provider); + const first = (...values: unknown[]) => values.map(positiveTokenLimit).find(v => v !== undefined); + const context = first( + item.maxContextTokens, item.context_window, item.context_length, + item.max_input_tokens, item.inputTokenLimit, info.context_window, + ); + const output = first( + item.maxOutputTokens, item.max_output_tokens, item.max_tokens, + item.outputTokenLimit, top.max_completion_tokens, info.max_output_tokens, + ); + return { ...(context !== undefined ? { maxContextTokens: context } : {}), ...(output !== undefined ? { maxOutputTokens: output } : {}) }; +} + +export function normalizeModelListItem(value: unknown): ProviderModelMetadata | null { + const item = record(value); + const id = (typeof item.id === "string" ? item.id : typeof item.name === "string" ? item.name : "").replace(/^models\//, "").trim(); + if (!id) return null; + const displayName = [item.displayName, item.display_name].find(v => typeof v === "string" && v.trim()); + return { id, displayName: typeof displayName === "string" ? displayName.trim() : id, ...normalizeProviderTokenLimits(item) }; +} + +export function parseModelListResponse(value: unknown): ProviderModelMetadata[] { + const body = record(value); + const items = Array.isArray(body.data) ? body.data : Array.isArray(body.models) ? body.models : []; + const models = new Map(); + for (const item of items) { + const normalized = normalizeModelListItem(item); + if (normalized) models.set(normalized.id, { ...models.get(normalized.id), ...normalized }); + } + return [...models.values()]; +} + +export function protocolTokenLimits(protocol: ModelProtocol): Required { + const defaults = protocol === "anthropic" ? ANTHROPIC_DEFAULT_CAPABILITIES : protocol === "google" ? GOOGLE_DEFAULT_CAPABILITIES : OPENAI_DEFAULT_CAPABILITIES; + return { maxContextTokens: defaults.maxContextTokens, maxOutputTokens: defaults.maxOutputTokens }; +} + +export function resolveTokenLimits(input: { + protocol: ModelProtocol; + user?: TokenLimits; + explicit?: TokenLimitOverrides; + provider?: TokenLimits; + catalog?: TokenLimits; +}): ResolvedTokenLimits { + const defaults = protocolTokenLimits(input.protocol); + const automatic = (key: TokenLimitKey) => { + for (const source of ["provider", "catalog"] as const) { + const value = positiveTokenLimit(input[source]?.[key]); + if (value !== undefined) return { value, source }; + } + return { value: defaults[key], source: "protocol" as const }; + }; + // Old dialogs persisted this exact pair even without an edit. A marker lets + // users deliberately pin the same numbers after upgrading, without changing YAML on read. + const legacyPair = TOKEN_LIMIT_KEYS.every(key => input.user?.[key] === defaults[key] && !input.explicit?.[key]); + const migrate = legacyPair && TOKEN_LIMIT_KEYS.some(key => automatic(key).value !== defaults[key]); + const resolve = (key: TokenLimitKey) => { + const user = positiveTokenLimit(input.user?.[key]); + return user !== undefined && !migrate ? { value: user, source: "user" as const } : automatic(key); + }; + return { maxContextTokens: resolve("maxContextTokens"), maxOutputTokens: resolve("maxOutputTokens") }; +} + +/** Keep other capabilities intact, while persisting only real user overrides. */ +export function tokenLimitSettingsPatch(input: { + capabilities: Record; + values: Required; + automatic: ResolvedTokenLimits; +}): { capabilities: Record; tokenLimitOverrides: TokenLimitOverrides } { + const capabilities = { ...input.capabilities }; + delete capabilities.contextWindow; + const tokenLimitOverrides: TokenLimitOverrides = {}; + for (const key of TOKEN_LIMIT_KEYS) { + if (input.values[key] !== input.automatic[key].value) { + capabilities[key] = input.values[key]; + tokenLimitOverrides[key] = true; + } else delete capabilities[key]; + } + return { capabilities, tokenLimitOverrides }; +} diff --git a/tests/model/token-limits.spec.ts b/tests/model/token-limits.spec.ts new file mode 100644 index 000000000..ab65f0b11 --- /dev/null +++ b/tests/model/token-limits.spec.ts @@ -0,0 +1,170 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { parseModelConfig } from "../../src/model/config/parseModelConfig.js"; +import { normalizeProviderTokenLimits, parseModelListResponse, protocolTokenLimits, resolveTokenLimits, tokenLimitSettingsPatch } from "../../src/model/metadata/tokenLimits.js"; +import { metadataEndpointScope, readProviderModelLimits, writeProviderModelLimits } from "../../src/model/metadata/store.js"; +import { discoverProviderModels, discoverOllamaModel } from "../../src/model/metadata/discovery.js"; + +test("provider field map resolves each positive limit independently", () => { + for (const [raw, expected] of [ + [{ context_window: 1048576, max_output_tokens: 393216 }, { maxContextTokens: 1048576, maxOutputTokens: 393216 }], + [{ max_input_tokens: 200000, max_tokens: 64000 }, { maxContextTokens: 200000, maxOutputTokens: 64000 }], + [{ inputTokenLimit: 1000000, outputTokenLimit: 65536 }, { maxContextTokens: 1000000, maxOutputTokens: 65536 }], + [{ context_length: 1000000, top_provider: { max_completion_tokens: 32768 } }, { maxContextTokens: 1000000, maxOutputTokens: 32768 }], + [{ context_length: 262144 }, { maxContextTokens: 262144 }], + [{ model_info: { context_window: 1000000, max_output_tokens: 65536 } }, { maxContextTokens: 1000000, maxOutputTokens: 65536 }], + [{ context_window: null, max_output_tokens: 0, context_length: 123 }, { maxContextTokens: 123 }], + [{ max_input_tokens: -1, max_tokens: "123", outputTokenLimit: Infinity }, {}], + [{ id: "model", created: 1 }, {}], + ]) assert.deepEqual(normalizeProviderTokenLimits(raw), expected); + assert.deepEqual(parseModelListResponse({ models: [{ name: "models/gemini", inputTokenLimit: 123 }, { id: "gemini", outputTokenLimit: 45 }, null] }), [{ id: "gemini", displayName: "gemini", maxContextTokens: 123, maxOutputTokens: 45 }]); +}); + +test("user > provider > catalog > protocol, independently, with legacy-pair recovery", () => { + const catalog = { maxContextTokens: 1048576, maxOutputTokens: 393216 }; + const resolved = resolveTokenLimits({ protocol: "openai", catalog, provider: { maxContextTokens: 2000000 }, user: { maxOutputTokens: 1024 } }); + assert.deepEqual(resolved, { maxContextTokens: { value: 2000000, source: "provider" }, maxOutputTokens: { value: 1024, source: "user" } }); + const legacy = { ...protocolTokenLimits("openai") }; + assert.equal(resolveTokenLimits({ protocol: "openai", catalog, user: legacy }).maxContextTokens.source, "catalog"); + assert.equal(resolveTokenLimits({ protocol: "openai", catalog, user: legacy, explicit: { maxOutputTokens: true } }).maxOutputTokens.source, "user"); + assert.equal(resolveTokenLimits({ protocol: "openai", user: legacy }).maxContextTokens.value, legacy.maxContextTokens); + assert.equal(resolveTokenLimits({ protocol: "openai", user: { maxContextTokens: 128000 }, catalog }).maxContextTokens.source, "user"); + assert.equal(resolveTokenLimits({ protocol: "openai" }).maxOutputTokens.source, "protocol"); +}); + +test("settings save omits automatic limits and keeps explicit default pins and other capabilities", () => { + const automatic = resolveTokenLimits({ protocol: "openai", provider: { maxContextTokens: 1048576, maxOutputTokens: 393216 } }); + assert.deepEqual(tokenLimitSettingsPatch({ capabilities: { supportsToolUse: false, maxContextTokens: 128000, maxOutputTokens: 32768 }, values: { maxContextTokens: 1048576, maxOutputTokens: 393216 }, automatic }), { capabilities: { supportsToolUse: false }, tokenLimitOverrides: {} }); + assert.deepEqual(tokenLimitSettingsPatch({ capabilities: {}, values: { maxContextTokens: 128000, maxOutputTokens: 32768 }, automatic }), { capabilities: { maxContextTokens: 128000, maxOutputTokens: 32768 }, tokenLimitOverrides: { maxContextTokens: true, maxOutputTokens: true } }); +}); + +test("metadata persists across config reloads without polluting configuration, and is endpoint scoped", () => { + const directory = mkdtempSync(join(tmpdir(), "pilot-limits-")), env = { PILOT_HOME: directory }; + try { + const scope = { providerId: "deepseek", protocol: "openai" as const, baseUrl: "https://api.deepseek.com/models" }; + const raw = { providers: { deepseek: { apiKey: "dummy", models: { "deepseek-flash": {}, "deepseek-chat": { capabilities: { ...protocolTokenLimits("openai") } } } } } }; + const before = JSON.stringify(raw); + writeProviderModelLimits(scope, [{ id: "deepseek-flash", displayName: "Flash", maxContextTokens: 1048576, maxOutputTokens: 393216 }], env); + const parsed = parseModelConfig(raw, { env }); + for (const id of ["deepseek-flash", "deepseek-chat"]) { + assert.equal(parsed.providers.deepseek.models[id].capabilities.maxContextTokens, 1048576); + assert.equal(parsed.providers.deepseek.models[id].capabilities.maxOutputTokens, 393216); + } + assert.equal(JSON.stringify(raw), before); + assert.deepEqual(readProviderModelLimits({ ...scope, baseUrl: "https://proxy.example/v1" }, env), {}); + assert.equal(metadataEndpointScope(scope.baseUrl), metadataEndpointScope("https://api.deepseek.com/v1")); + assert.equal(parseModelConfig(raw, { env }).providers.deepseek.models["deepseek-flash"].capabilities.maxOutputTokens, 393216); + const file = join(directory, "cache", "provider-model-limits.json"); + assert.ok(!readFileSync(file, "utf8").includes("dummy")); + writeFileSync(file, "broken"); + assert.deepEqual(readProviderModelLimits(scope, env), {}); + assert.equal(parseModelConfig(raw, { env }).providers.deepseek.models["deepseek-chat"].capabilities.maxContextTokens, 1048576); + const pinned = { providers: { deepseek: { apiKey: "dummy", models: { "deepseek-chat": { capabilities: { maxContextTokens: 128000, maxOutputTokens: 32768 }, tokenLimitOverrides: { maxContextTokens: true, maxOutputTokens: true } } } } } }; + assert.equal(parseModelConfig(pinned, { env }).providers.deepseek.models["deepseek-chat"].capabilities.maxContextTokens, 128000); + } finally { rmSync(directory, { recursive: true, force: true }); } +}); + +const reply = (body: unknown) => new Response(JSON.stringify(body), { headers: { "Content-Type": "application/json" } }); + +test("Anthropic and Gemini pagination collects later metadata, without adding input and output", async () => { + for (const protocol of ["anthropic", "google"] as const) { + const calls: string[] = []; + const fetchImpl = (async (url: string | URL | Request) => { + calls.push(String(url)); + return reply(protocol === "google" + ? calls.length === 1 ? { models: [{ name: "models/one" }], nextPageToken: "next" } : { models: [{ name: "models/two", inputTokenLimit: 200000, outputTokenLimit: 64000 }] } + : calls.length === 1 ? { data: [{ id: "one" }], has_more: true, last_id: "one" } : { data: [{ id: "two", max_input_tokens: 200000, max_tokens: 64000 }], has_more: false }); + }) as typeof fetch; + const models = await discoverProviderModels({ protocol, baseUrl: "https://example.test", fetchImpl }); + assert.equal(models.length, 2); + assert.equal(models[1].maxContextTokens, 200000); + assert.equal(models[1].maxOutputTokens, 64000); + assert.ok(calls[1].includes(protocol === "google" ? "pageToken=next" : "after_id=one")); + } +}); + +test("repeated pagination cursors fail instead of caching a partial list", async () => { + await assert.rejects(discoverProviderModels({ protocol: "google", baseUrl: "https://example.test", fetchImpl: (async () => reply({ models: [{ name: "models/one" }], nextPageToken: "same" })) as typeof fetch }), /pagination did not finish/); +}); + +test("DashScope enriches only its official endpoint; a proxy receives no extra official lookup", async () => { + for (const host of ["dashscope.aliyuncs.com", "proxy.example"]) { + const calls: string[] = []; + const models = await discoverProviderModels({ protocol: "openai", baseUrl: `https://${host}/compatible-mode/v1`, fetchImpl: (async url => { + calls.push(String(url)); + return reply({ data: [{ id: "qwen", ...(String(url).includes("/api/v1/models") ? { model_info: { context_window: 1000000, max_output_tokens: 65536 } } : {}) }] }); + }) as typeof fetch }); + assert.equal(calls.length, host === "proxy.example" ? 1 : 2); + assert.equal(models[0].maxContextTokens, host === "proxy.example" ? undefined : 1000000); + } +}); + +test("Ollama show is on demand and num_ctx wins over architecture context, without inventing output", async () => { + const calls: string[] = []; + const fetchImpl = (async (url, init) => { + calls.push(String(url)); + if (init?.method === "GET") return reply({ data: [{ id: "local" }] }); + assert.deepEqual(JSON.parse(String(init?.body)), { model: "local" }); + return reply({ parameters: "temperature 0.7\nnum_ctx 8192\n", model_info: { "general.architecture": "llama", "llama.context_length": 131072 } }); + }) as typeof fetch; + await discoverProviderModels({ protocol: "openai", providerId: "ollama", baseUrl: "http://localhost:11434/v1", fetchImpl }); + assert.equal(calls.length, 1); + assert.deepEqual(await discoverOllamaModel({ protocol: "openai", baseUrl: "http://localhost:11434/v1", fetchImpl }, "local"), { id: "local", displayName: "local", maxContextTokens: 8192 }); + assert.equal(calls[1], "http://localhost:11434/api/show"); +}); + +test("Ollama show limits survive an ID-only model list through runtime config", async () => { + const directory = mkdtempSync(join(tmpdir(), "pilot-limits-")); + const env = { PILOT_HOME: directory }; + const scope = { providerId: "ollama", protocol: "openai" as const, baseUrl: "http://localhost:11434/v1" }; + const calls: string[] = []; + const fetchImpl = (async (url, init) => { + calls.push(String(url)); + if (init?.method === "GET") return reply({ data: [{ id: "synthetic-local" }] }); + return reply({ parameters: "num_ctx 8192\n", model_info: { "general.architecture": "llama", "llama.context_length": 131072 } }); + }) as typeof fetch; + try { + writeProviderModelLimits(scope, [await discoverOllamaModel({ ...scope, fetchImpl }, "synthetic-local")], env); + calls.length = 0; + const listed = await discoverProviderModels({ ...scope, fetchImpl }); + assert.equal(listed.find(model => model.id === "synthetic-local")?.maxContextTokens, undefined); + assert.ok(calls.every(call => !call.includes("/api/show"))); + writeProviderModelLimits(scope, listed, env); + const model = parseModelConfig({ + providers: { ollama: { url: scope.baseUrl, models: { "synthetic-local": {} } } }, + }, { env }).providers.ollama.models["synthetic-local"]; + assert.equal(model.capabilities.maxContextTokens, 8192); + assert.equal(model.capabilities.maxOutputTokens, protocolTokenLimits("openai").maxOutputTokens); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("a partial metadata update replaces only the validated limit it contains", () => { + const directory = mkdtempSync(join(tmpdir(), "pilot-limits-")); + const env = { PILOT_HOME: directory }; + const scope = { providerId: "ollama", protocol: "openai" as const, baseUrl: "http://localhost:11434/v1" }; + const model = (id: string, limits: { maxContextTokens?: number; maxOutputTokens?: number }) => ({ id, displayName: id, ...limits }); + try { + writeProviderModelLimits(scope, [ + model("synthetic-local", { maxContextTokens: 8192, maxOutputTokens: 2048 }), + model("other-local", { maxContextTokens: 4096 }), + ], env); + writeProviderModelLimits(scope, [model("synthetic-local", { maxContextTokens: 0, maxOutputTokens: 4096 })], env); + assert.deepEqual(readProviderModelLimits(scope, env), { + "synthetic-local": { maxContextTokens: 8192, maxOutputTokens: 4096 }, + "other-local": { maxContextTokens: 4096 }, + }); + writeProviderModelLimits(scope, [model("synthetic-local", { maxContextTokens: 16384 })], env); + const parsed = parseModelConfig({ + providers: { ollama: { url: scope.baseUrl, models: { "synthetic-local": {} } } }, + }, { env }).providers.ollama.models["synthetic-local"]; + assert.equal(parsed.capabilities.maxContextTokens, 16384); + assert.equal(parsed.capabilities.maxOutputTokens, 4096); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/ui/server/routes/config.js b/ui/server/routes/config.js index 725e7acda..4470d6b11 100644 --- a/ui/server/routes/config.js +++ b/ui/server/routes/config.js @@ -1,5 +1,8 @@ import { imageApiBaseUrl } from "../../../src/pilot/config/imageGenerationConfig.js"; import { customSearchRequest, customSearchResults } from "../../../src/pilot/config/customSearch.js"; +import { discoverProviderModels, discoverOllamaModel, ModelDiscoveryError } from '../../../src/model/metadata/discovery.js'; +import { readProviderModelLimits, writeProviderModelLimits } from '../../../src/model/metadata/store.js'; +import { resolveTokenLimits } from '../../../src/model/metadata/tokenLimits.js'; import { WEB_SEARCH_ENDPOINTS, isWebSearchProvider, isSerpApiEngine } from "../../../src/pilot/config/webSearchProviders.js"; import { getWebSearchProviderConfig, migrateWebSearchProviderConfigs, resolveWebSearchConfig } from "../../../src/pilot/config/webSearchConfig.js"; import { additionalSearchRequest, additionalSearchResults, isAdditionalSearchProvider, redactSearchError } from "../../../src/tool/builtin/additionalWebSearch.js"; @@ -31,13 +34,8 @@ import { import { reloadPilotDeckConfig } from '../services/pilotdeckConfigReloader.js'; import { suppressNextWatchEvent } from '../services/pilotdeckConfigWatcher.js'; import { getPilotDeckGateway } from '../pilotdeck-bridge.js'; -import { - buildProviderModelsEndpointCandidates, - isExpectedProviderModelsResponseShape, -} from '../../../src/model/providerEndpoint.js'; -import { lookupCatalogProvider, PROVIDER_CATALOG } from '../../../src/model/catalog/index.js'; +import { lookupCatalogModel, lookupCatalogProvider, PROVIDER_CATALOG } from '../../../src/model/catalog/index.js'; import { NetworkFetchError, networkFetch } from '../../../src/network/fetch.js'; -import { lookupCatalogModel } from '../../../src/model/catalog/lookup.js'; import { probeModelConnection } from '../services/modelConnectionProbe.js'; import { configuredModelIds, @@ -636,40 +634,6 @@ function broadcastConfigEvent(payload) { process.emit('pilotdeck:config-broadcast', payload); } -function normalizeModelListItem(item) { - if (!item || typeof item !== 'object') return null; - const rawId = typeof item.id === 'string' - ? item.id - : typeof item.name === 'string' - ? item.name - : ''; - const id = rawId.replace(/^models\//, '').trim(); - if (!id) return null; - const displayName = typeof item.display_name === 'string' - ? item.display_name - : typeof item.displayName === 'string' - ? item.displayName - : id; - return { id, displayName }; -} - -function parseModelListResponse(body) { - const rawModels = Array.isArray(body?.data) - ? body.data - : Array.isArray(body?.models) - ? body.models - : []; - const seen = new Set(); - const models = []; - for (const item of rawModels) { - const model = normalizeModelListItem(item); - if (!model || seen.has(model.id)) continue; - seen.add(model.id); - models.push(model); - } - return models; -} - function isEndpointFallbackStatus(status) { return status === 400 || status === 404 || status === 405; } @@ -707,14 +671,6 @@ async function fetchWithEndpointFallback(urls, options, isExpectedOkBody = null) return lastResult; } -function isExpectedModelsJsonBody(protocol, responseText) { - try { - return isExpectedProviderModelsResponseShape(protocol, responseText ? JSON.parse(responseText) : {}); - } catch { - return false; - } -} - router.get('/', (_req, res) => { try { const record = readPilotDeckConfigFile(); @@ -1154,7 +1110,7 @@ router.get('/provider', (_req, res) => { } }); -router.post('/models', async (req, res) => { +async function providerModelsHandler(req, res, resolveSingle = false) { const { providerId, providerType, baseUrl, apiKey } = req.body || {}; if (!baseUrl) { return res.status(400).json({ ok: false, error: 'baseUrl is required' }); @@ -1193,45 +1149,40 @@ router.post('/models', async (req, res) => { const timer = setTimeout(() => controller.abort(new NetworkFetchError('network_timeout', 'Model list request timed out after 10s.')), 10_000); try { - const urls = buildProviderModelsEndpointCandidates({ protocol, baseUrl: normalizedBaseUrl }); - const headers = isGoogle - ? (effectiveApiKey && effectiveApiKey !== '********' ? { 'x-goog-api-key': effectiveApiKey } : {}) - : isAnthropic - ? { - ...(effectiveApiKey && effectiveApiKey !== '********' ? { 'x-api-key': effectiveApiKey } : {}), - 'anthropic-version': '2023-06-01', - } - : (effectiveApiKey && effectiveApiKey !== '********' ? { Authorization: `Bearer ${effectiveApiKey}` } : {}); - const { url, response, responseText } = await fetchWithEndpointFallback( - urls, - { method: 'GET', headers, signal: controller.signal }, - (text) => isExpectedModelsJsonBody(protocol, text), - ); - clearTimeout(timer); - if (!response.ok) { - let body = {}; - try { - body = responseText ? JSON.parse(responseText) : {}; - } catch { /* Use the upstream response text below. */ } - const message = body?.error?.message || body?.message || responseText || `HTTP ${response.status}`; - return res.status(response.status).json({ ok: false, error: message }); - } - let body; + const options = { protocol, baseUrl: normalizedBaseUrl, apiKey: effectiveApiKey, providerId, signal: controller.signal, fetchImpl: fetch }; + const scope = { providerId: String(providerId || ''), protocol, baseUrl: normalizedBaseUrl }; + const modelId = typeof req.body?.modelId === 'string' ? req.body.modelId.trim() : ''; + if (resolveSingle && !modelId) return res.status(400).json({ ok: false, error: 'modelId is required' }); + let warning; + let models; try { - body = responseText ? JSON.parse(responseText) : {}; - } catch { - return res.status(502).json({ ok: false, error: `Expected JSON from ${url}, but received non-JSON content.` }); + models = resolveSingle && providerId === 'ollama' + ? [await discoverOllamaModel(options, modelId)] + : await discoverProviderModels(options); + try { writeProviderModelLimits(scope, models); } catch { warning = 'Model metadata could not be cached.'; } + await notifyGatewayConfigReload(); + } catch (error) { + if (!resolveSingle) throw error; + // A failed lookup must leave settings usable and keep the last known metadata. + warning = 'Model metadata is unavailable; using cached or catalog limits.'; } - - res.json({ ok: true, models: parseModelListResponse(body) }); + if (!resolveSingle) return res.json({ ok: true, models }); + const providerLimits = models?.find(model => model.id === modelId) ?? readProviderModelLimits(scope)[modelId]; + const catalog = lookupCatalogModel(String(providerId || ''), modelId).model?.capabilities; + res.json({ ok: true, limits: resolveTokenLimits({ protocol, provider: providerLimits, catalog }), ...(warning ? { warning } : {}) }); } catch (error) { clearTimeout(timer); const message = isNetworkTimeout(error) ? 'Model list request timed out after 10s.' : error instanceof Error ? error.message : String(error); - res.status(500).json({ ok: false, error: message }); + res.status(error instanceof ModelDiscoveryError ? error.status : 500).json({ ok: false, error: message }); + } finally { + clearTimeout(timer); } -}); +} + +router.post('/models', (req, res) => providerModelsHandler(req, res)); +router.post('/model-limits', (req, res) => providerModelsHandler(req, res, true)); router.post('/test-connection', async (req, res) => { const { providerId, providerType, baseUrl, apiKey, model } = req.body || {}; diff --git a/ui/server/routes/config.test.js b/ui/server/routes/config.test.js index 671b2f256..51bd87593 100644 --- a/ui/server/routes/config.test.js +++ b/ui/server/routes/config.test.js @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { validatePilotDeckConfig } from '../services/pilotdeckConfig.js'; import { rewriteModelReferences } from '../services/modelReferences.js'; import { resolveWebSearchConfig, updateWebSearchProviderConfig } from '../../../src/pilot/config/webSearchConfig.js'; @@ -497,6 +497,38 @@ describe('config test-connection route', () => { }); describe('config model-list route', () => { + beforeEach(() => { + const home = mkdtempSync(join(tmpdir(), 'pilotdeck-model-metadata-')); + tempDirs.push(home); + vi.stubEnv('PILOT_HOME', home); + }); + + it('shares provider limits with runtime and serves cached limits when discovery fails', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ data: [{ id: 'deepseek-flash', context_window: 1048576, max_output_tokens: 393216 }] }))); + const { requestStatus, writePilotDeckConfig } = await createConfigApp(); + const input = { providerId: 'deepseek', providerType: 'openai', baseUrl: 'https://api.deepseek.com/models', apiKey: 'dummy' }; + const listed = await requestStatus('/api/config/models', { method: 'POST', body: JSON.stringify(input) }); + expect(listed.body.models[0]).toMatchObject({ maxContextTokens: 1048576, maxOutputTokens: 393216 }); + const { parseModelConfig } = await import('../../../src/model/config/parseModelConfig.js'); + expect(parseModelConfig({ providers: { deepseek: { apiKey: 'dummy', models: { 'deepseek-flash': {} } } } }).providers.deepseek.models['deepseek-flash'].capabilities.maxContextTokens).toBe(1048576); + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); })); + const settings = await requestStatus('/api/config/model-limits', { method: 'POST', body: JSON.stringify({ ...input, baseUrl: 'https://api.deepseek.com/v1', modelId: 'deepseek-flash' }) }); + expect(settings.body.limits).toEqual({ maxContextTokens: { value: 1048576, source: 'provider' }, maxOutputTokens: { value: 393216, source: 'provider' } }); + expect(settings.body.warning).toBeTruthy(); + expect(writePilotDeckConfig).not.toHaveBeenCalled(); + }); + + it('looks up only the opened Ollama model using show', async () => { + const calls = []; + vi.stubGlobal('fetch', vi.fn(async (url, init) => { + calls.push([String(url), init.method, JSON.parse(init.body)]); + return jsonResponse({ parameters: 'num_ctx 8192', model_info: { 'llama.context_length': 131072 } }); + })); + const { requestStatus } = await createConfigApp(); + const response = await requestStatus('/api/config/model-limits', { method: 'POST', body: JSON.stringify({ providerId: 'ollama', providerType: 'openai', baseUrl: 'http://localhost:11434/v1', modelId: 'local' }) }); + expect(response.body.limits).toEqual({ maxContextTokens: { value: 8192, source: 'provider' }, maxOutputTokens: { value: 32768, source: 'protocol' } }); + expect(calls).toEqual([['http://localhost:11434/api/show', 'POST', { model: 'local' }]]); + }); it('uses the catalog environment API key fallback', async () => { const authHeaders = []; vi.stubEnv('ANTHROPIC_API_KEY', 'sk-from-env'); diff --git a/ui/src/components/settings/view/modelPool/components/ModelSettingsModal.test.tsx b/ui/src/components/settings/view/modelPool/components/ModelSettingsModal.test.tsx index b060e69d0..a5fbfaa60 100644 --- a/ui/src/components/settings/view/modelPool/components/ModelSettingsModal.test.tsx +++ b/ui/src/components/settings/view/modelPool/components/ModelSettingsModal.test.tsx @@ -99,3 +99,17 @@ it('keeps the draft open when saving before a test fails', async () => { expect(screen.getByText('Save conflict')).toBeTruthy(); expect(p.onClose).not.toHaveBeenCalled(); }); + +it('applies refreshed token limits only to untouched fields and can reset to automatic values', () => { + const p = props(); + const automatic = { maxContextTokens: { value: 1048576, source: 'provider' as const }, maxOutputTokens: { value: 393216, source: 'provider' as const } }; + const view = render(); + const output = screen.getByLabelText('pilotDeckConfig.panels.models.maxOutputTokens') as HTMLInputElement; + fireEvent.change(output, { target: { value: '1024' } }); + view.rerender(); + expect(output.value).toBe('1024'); + expect((screen.getByLabelText('pilotDeckConfig.panels.models.maxContextTokens') as HTMLInputElement).value).toBe('1048576'); + fireEvent.click(screen.getByRole('button', { name: 'pilotDeckConfig.panels.models.modelSettings.resetTokenLimits' })); + expect(output.value).toBe('393216'); + expect(screen.getAllByText('pilotDeckConfig.panels.models.modelSettings.tokenSources.provider')).toHaveLength(2); +}); diff --git a/ui/src/components/settings/view/modelPool/components/ModelSettingsModal.tsx b/ui/src/components/settings/view/modelPool/components/ModelSettingsModal.tsx index 0e4edd9d5..2cdafdd51 100644 --- a/ui/src/components/settings/view/modelPool/components/ModelSettingsModal.tsx +++ b/ui/src/components/settings/view/modelPool/components/ModelSettingsModal.tsx @@ -1,3 +1,4 @@ +import type { ResolvedTokenLimits } from "../../../../../../../src/model/metadata/tokenLimits.js"; import { THINKING_EFFORTS, thinkingFormatsForProtocol, type ModelThinkingSettings, type ThinkingFormat } from '../../../../../../../src/model/thinking/settings.js'; import { useEffect, useId, useRef, useState } from 'react'; import { CheckCircle2, CircleHelp, Loader2, Plug, XCircle } from 'lucide-react'; @@ -8,6 +9,10 @@ import type { ConnectionTestTask } from '../hooks/useConnectionTestTasks'; export type ModelSettingsPatch = { maxOutputTokens: number; maxContextTokens: number; supportsImage: boolean; thinking?: ModelThinkingSettings }; type Props = { + tokenSources?: ResolvedTokenLimits; + automaticLimits?: ResolvedTokenLimits; + limitsLoading?: boolean; + limitsError?: boolean; modelId: string; protocol?: string; initial: ModelSettingsPatch; @@ -26,7 +31,7 @@ type Props = { onClose: () => void; }; -export default function ModelSettingsModal({ modelId, protocol = 'openai', initial, task, testDisabled, testDisabledReason, saveBeforeTest = false, blockingTask, onCancelBlockingTest, cancellingBlockedTest, applyTestResult = true, testError, onTest, onCancelTest, onSave, onClose }: Props) { +export default function ModelSettingsModal({ tokenSources, automaticLimits, limitsLoading = false, limitsError = false, modelId, protocol = 'openai', initial, task, testDisabled, testDisabledReason, saveBeforeTest = false, blockingTask, onCancelBlockingTest, cancellingBlockedTest, applyTestResult = true, testError, onTest, onCancelTest, onSave, onClose }: Props) { const { t } = useTranslation('settings'); const label = (key: string) => t(`pilotDeckConfig.panels.models.modelSettings.${key}`); const [output, setOutput] = useState(String(initial.maxOutputTokens)); @@ -39,6 +44,17 @@ export default function ModelSettingsModal({ modelId, protocol = 'openai', initi const [submittingTest, setSubmittingTest] = useState(false); const [error, setError] = useState(''); const reasonId = useId(); + const editedTokens = useRef({ maxOutputTokens: false, maxContextTokens: false }); + useEffect(() => { + if (!editedTokens.current.maxOutputTokens) setOutput(String(initial.maxOutputTokens)); + if (!editedTokens.current.maxContextTokens) setContext(String(initial.maxContextTokens)); + }, [initial.maxOutputTokens, initial.maxContextTokens]); + const resetTokens = () => { + if (!automaticLimits) return; + editedTokens.current = { maxOutputTokens: true, maxContextTokens: true }; + setOutput(String(automaticLimits.maxOutputTokens.value)); + setContext(String(automaticLimits.maxContextTokens.value)); + }; const manual = useRef(false); const applied = useRef(null); const saveLock = useRef(false); @@ -85,17 +101,26 @@ export default function ModelSettingsModal({ modelId, protocol = 'openai', initi ? label('imageNotDescribed') : result?.error?.message; const imageUnconfirmed = result?.textInput === 'supported' && result?.imageInput === 'unknown'; + const tokenSource = (key: 'maxContextTokens' | 'maxOutputTokens', value: string) => + editedTokens.current[key] + ? automaticLimits && Number(value) === automaticLimits[key].value ? automaticLimits[key].source : 'user' + : tokenSources?.[key].source; const inputClass = 'mt-2 h-10 w-full rounded-lg border border-input bg-background px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary'; - return void save()}> + return void save()}>

{modelId}

+ {limitsLoading &&

{label('limitsLoading')}

} + {limitsError &&

{label('limitsUnavailable')}

} + {automaticLimits && }