diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index 81bf793d..28c74427 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -438,6 +438,14 @@ symlink with this checkout's own npm ci. Full type-check and lint then passed. - E2E chat-title expectations assume the deterministic chat-model route. On a Mac where the native Foundation Models helper reports `ready`, automatic titles come from Apple Intelligence instead, so `chat-message-queue` sidebar-title lookups fail locally while passing in CI; probe the helper or move it aside before treating those failures as regressions. - `git add` on the tracked-but-ignored `.papercuts/troubleshooting.md` still needs `-f` after conflict resolution. +# Custom model options + +- Fresh worktree has no `.memory/` or dependencies; inspected existing implementation and installed dependencies before validation. +- Video input is not supported by Aiden's chat attachment transport. The capability option must describe server support without advertising video uploads. +- Review found capability consumers outside the desktop/native picker (Bot inventory and Telegram) and assistant artifact images in raw history; added projection and role-aware image-limit regressions. +- Frozen runtime contribution snapshots require a copied tool policy; added a real harness test covering base and extension tools. +- Hosted verify hit a pre-existing Git cancellation fixture race: a short marker poll expired while push was still running, then cleanup removed its wrapper. Replaced delay/count coordination with a bounded marker handshake and awaited cancellation cleanup. + ## 2026-09-12 — MCP maintenance implementation - MCP SDK1.30.0 closes HTTP transports during OAuth redirection but expects finishAuth to reuse the same object and discovered metadata. Restart only its exchange request lifetime, retaining owner cancellation. diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenChatTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenChatTest.kt index 80bb1d14..092fa29e 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenChatTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenChatTest.kt @@ -19,6 +19,16 @@ import java.util.UUID class AidenChatTest { private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + @Test + fun testCustomModelOverridesPreserveImageAndVisibilityFlags() { + val catalog = json.decodeFromString(""" + {"providers":[{"id":"custom:tailnet","label":"Private","models":[{"id":"text","label":"Text","supportsImages":false},{"id":"vision","label":"Vision","supportsImages":true,"hidden":true}]}],"defaults":{}} + """.trimIndent()) + assertFalse(catalog.providers.first().models.first().acceptsImageInput) + assertTrue(catalog.providers.first().models.last().acceptsImageInput) + assertEquals(listOf("text"), catalog.visibleProviders.first().models.map { it.id }) + } + @Test fun testHiddenAndAllHiddenProviderModelsStayOutOfNewSelections() { val wire = """ diff --git a/ios/AidenOnTheGoTests/AidenChatTests.swift b/ios/AidenOnTheGoTests/AidenChatTests.swift index 6d6c1cae..88cf59fd 100644 --- a/ios/AidenOnTheGoTests/AidenChatTests.swift +++ b/ios/AidenOnTheGoTests/AidenChatTests.swift @@ -463,6 +463,15 @@ final class AidenChatTests: XCTestCase { XCTAssertEqual(catalog.visibleProviders.first?.models.map(\.id), ["gemini-flash"]) } + func testCustomModelOverridesPreserveImageAndVisibilityFlags() throws { + let catalog = try JSONDecoder().decode(AidenModelCatalog.self, from: Data( + #"{"providers":[{"id":"custom:tailnet","label":"Private","models":[{"id":"text","label":"Text","supportsImages":false},{"id":"vision","label":"Vision","supportsImages":true,"hidden":true}]}],"defaults":{}}"#.utf8 + )) + XCTAssertFalse(try XCTUnwrap(catalog.providers.first?.models.first).acceptsImageInput) + XCTAssertTrue(try XCTUnwrap(catalog.providers.first?.models.last).acceptsImageInput) + XCTAssertEqual(catalog.visibleProviders.first?.models.map(\.id), ["text"]) + } + func testModelCatalogPreservesThinkingDefaultAndRequiredThinkingPresentation() throws { let catalog = try JSONDecoder().decode( AidenModelCatalog.self, diff --git a/main/handlers/providers.ts b/main/handlers/providers.ts index 9dd9ef94..e033d149 100644 --- a/main/handlers/providers.ts +++ b/main/handlers/providers.ts @@ -1,3 +1,4 @@ +import { parseCustomModelOptions } from "../../renderer/shared/custom-model-options.js"; import { isCompactionEngine } from "../../renderer/shared/compaction.js"; // Provider configuration + API key IPC handlers. Thin — logic lives in services. @@ -114,6 +115,8 @@ function parseModelMetadata(value: unknown): Record model.supportsImages), [true, false, false]); assert.equal(files.fullMac.scopeFingerprint, HASH); assert.equal(files.approvedLocations[0]?.label, "Documents"); assert.equal(shell.shellFingerprint, HASH); diff --git a/main/services/bot-capability-inventory-ports.ts b/main/services/bot-capability-inventory-ports.ts index fc10af5d..ecb3851a 100644 --- a/main/services/bot-capability-inventory-ports.ts +++ b/main/services/bot-capability-inventory-ports.ts @@ -94,7 +94,7 @@ function providerInventory( sourceId: modelId, label: metadata?.name ?? modelId, available, - supportsImages: metadata?.vision === true, + supportsImages: (metadata?.overrides?.vision ?? metadata?.vision) === true && metadata?.overrides?.maxImages !== 0, modelFingerprint: botCapabilityFactsFingerprint({ providerId: provider.id, modelId, diff --git a/main/services/config-store-core.test.ts b/main/services/config-store-core.test.ts index f513d10a..4b65b123 100644 --- a/main/services/config-store-core.test.ts +++ b/main/services/config-store-core.test.ts @@ -2883,3 +2883,15 @@ test("global skills preference persists independently of individual skill choice assert.equal((await h.store.getSettings()).skillsEnabled, true); assert.deepEqual(await h.store.listSkills(), [skill]); }); + +test("custom model overrides survive restart and reset through the config store", async (t) => { + const h = await harness(t); + const configured = { ...provider, modelMetadata: { "qwen3-8b": { source: "lmstudio" as const, overrides: { vision: true, maxImages: 2 } } } }; + await h.store.saveProvider(configured); + const loaded = await h.store.getProvider(provider.id); + assert.deepEqual(loaded?.modelMetadata?.["qwen3-8b"].overrides, { vision: true, maxImages: 2 }); + await h.store.saveProvider({ ...loaded!, modelMetadata: { "qwen3-8b": { source: "lmstudio" } } }); + const reset = await h.store.getProvider(provider.id); + assert.equal(reset?.modelMetadata?.["qwen3-8b"].overrides, undefined); + assert.equal(reset?.customModelOptions, undefined); +}); diff --git a/main/services/config-store-core.ts b/main/services/config-store-core.ts index a44cdee1..ad5a237d 100644 --- a/main/services/config-store-core.ts +++ b/main/services/config-store-core.ts @@ -681,7 +681,13 @@ export function createConfigStore( const { intent, cache } = splitStoredProvider(provider); const stored = await mutatePortable((config) => { const idx = config.providers.findIndex((p) => p.id === intent.id); - if (idx >= 0) config.providers[idx] = { ...config.providers[idx], ...intent }; + if (idx >= 0) { + config.providers[idx] = { ...config.providers[idx], ...intent }; + // An explicit metadata save replaces user overrides, including reset. + if (provider.modelMetadata !== undefined && intent.customModelOptions === undefined) { + delete config.providers[idx].customModelOptions; + } + } else config.providers.push(intent); return structuredClone(config.providers.find((p) => p.id === intent.id)!); }, isCurrent); diff --git a/main/services/custom-model-options.test.ts b/main/services/custom-model-options.test.ts new file mode 100644 index 00000000..70e2fb27 --- /dev/null +++ b/main/services/custom-model-options.test.ts @@ -0,0 +1,272 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + parseCustomModelOptions, + prepareCustomModelToolContext, + mergeDiscoveredModelMetadata, + assertCustomModelImageLimit, +} from "../../renderer/shared/custom-model-options.js"; +import { resolveProviderRuntimeLimits } from "./models-catalog-core.js"; +import { createProviderModelInfo } from "./provider-model-info-core.js"; +import { AidenRemoteModelService } from "./aiden-remote-models.js"; +import { + composeStoredProvider, + splitStoredProvider, +} from "./portable-config-core.js"; +import type { Provider, ModelInfo } from "./types.js"; + +const provider: Provider = { + id: "custom:tailnet", + label: "Private server", + kind: "openai", + baseUrl: "http://server.ts.net:1234/v1", + needsKey: false, + hasKey: false, + models: ["custom-model"], + modelMetadata: { + "custom-model": { + source: "provider", + vision: true, + reasoning: true, + contextLength: 128000, + manuallyAdded: true, + overrides: { + vision: false, + reasoning: false, + toolCall: false, + contextLength: 4096, + outputLimit: 1024, + maxImages: 2, + video: true, + }, + }, + }, +}; + +test("custom options reject malformed values and preserve explicit false and zero", () => { + assert.deepEqual(parseCustomModelOptions({ vision: false, maxImages: 0 }), { + vision: false, + maxImages: 0, + }); + for (const value of [ + null, + [], + { vision: "true" }, + { maxImages: -1 }, + { contextLength: 0 }, + { outputLimit: 1.5 }, + { contextLength: Infinity }, + { maxImages: NaN }, + { contextLength: 1, outputLimit: 2 }, + ]) { + assert.throws(() => parseCustomModelOptions(value)); + } +}); + +test("discovery refresh preserves overrides and manually entered IDs' metadata", () => { + const merged = mergeDiscoveredModelMetadata( + { + "custom-model": { + source: "provider" as const, + vision: true, + contextLength: 256000, + }, + }, + provider.modelMetadata!, + ); + assert.deepEqual( + merged["custom-model"].overrides, + provider.modelMetadata!["custom-model"].overrides, + ); + assert.equal(merged["custom-model"].contextLength, 256000); + assert.equal(merged["custom-model"].manuallyAdded, true); +}); + +test("persistence split and composition retain custom model settings", () => { + const split = splitStoredProvider(provider); + assert.deepEqual( + composeStoredProvider(split.intent, split.cache).modelMetadata, + provider.modelMetadata, + ); +}); + +test("runtime uses custom limits and negative capability overrides", () => { + const limits = resolveProviderRuntimeLimits({}, provider, "custom-model"); + assert.equal(limits.contextWindow, 4096); + assert.equal(limits.maxTokens, 1024); + assert.equal(limits.reasoning, false); + assert.deepEqual(limits.input, ["text"]); +}); + +test("manual capability values win over matched display catalogs", async () => { + const catalog: ModelInfo = { + id: "custom-model", + vision: true, + reasoning: true, + contextLength: 128000, + matched: true, + metadataSource: "provider", + inputModalities: ["text", "image"], + }; + const service = createProviderModelInfo({ + legacyProvider: async () => provider, + modelsCatalog: { + info: async () => catalog, + infoMany: async () => ({ "custom-model": catalog }), + }, + codexModelInfo: () => undefined, + }); + for (const actual of [ + await service.info(provider.id, "custom-model"), + (await service.infoMany(provider.id, ["custom-model"]))["custom-model"], + ]) { + assert.equal(actual.vision, false); + assert.equal(actual.reasoning, false); + assert.equal(actual.contextLength, 4096); + assert.deepEqual(actual.inputModalities, ["text", "video"]); + } +}); + +test("native clients receive overridden image support and existing visibility policy", async () => { + const service = new AidenRemoteModelService({ + listProviders: async () => [provider], + getSettings: async () => ({ + hiddenModelsByProvider: { [provider.id]: ["custom-model"] }, + }), + }); + const result = await service.list(); + assert.equal(result.providers[0].models[0].supportsImages, false); + assert.equal(result.providers[0].models[0].hidden, true); +}); + +test("image count enforcement accepts the limit and rejects excess for native and desktop requests", () => { + const messages = [ + { role: "user", attachments: [{ kind: "image" }, { kind: "text" }] }, + ]; + assert.doesNotThrow(() => + assertCustomModelImageLimit({ maxImages: 1 }, messages), + ); + assert.throws( + () => assertCustomModelImageLimit({ maxImages: 0 }, messages), + /at most 0/, + ); + assert.doesNotThrow(() => assertCustomModelImageLimit(undefined, messages)); +}); + +test("portable intent restores manual IDs and overrides without a discovery cache", () => { + const { intent } = splitStoredProvider(provider); + const restored = composeStoredProvider(intent, undefined); + assert.deepEqual(restored.models, ["custom-model"]); + assert.deepEqual( + restored.modelMetadata?.["custom-model"].overrides, + provider.modelMetadata?.["custom-model"].overrides, + ); +}); + +test("reset removes portable overrides while keeping manual IDs", () => { + const reset = { + ...provider, + modelMetadata: { + "custom-model": { source: "provider" as const, manuallyAdded: true }, + }, + }; + const { intent } = splitStoredProvider(reset); + assert.deepEqual(intent.customModelOptions, { + "custom-model": { manuallyAdded: true }, + }); +}); + +test("zero images consistently disables desktop, runtime and native capability", async () => { + const zero = { + ...provider, + modelMetadata: { + "custom-model": { + source: "provider" as const, + vision: true, + overrides: { maxImages: 0 }, + }, + }, + }; + const catalog: ModelInfo = { + id: "custom-model", + vision: true, + matched: true, + metadataSource: "provider", + inputModalities: ["text", "image"], + }; + const info = createProviderModelInfo({ + legacyProvider: async () => zero, + modelsCatalog: { + info: async () => catalog, + infoMany: async () => ({ "custom-model": catalog }), + }, + codexModelInfo: () => undefined, + }); + const result = await info.info(zero.id, "custom-model"); + assert.equal(result.vision, false); + assert.deepEqual(result.inputModalities, ["text"]); + assert.equal(result.detectedCapabilities?.vision, true); + assert.equal( + (await info.infoMany(zero.id, ["custom-model"]))["custom-model"].vision, + false, + ); + assert.deepEqual( + resolveProviderRuntimeLimits({}, zero, "custom-model").input, + ["text"], + ); + const remote = new AidenRemoteModelService({ + listProviders: async () => [zero], + getSettings: async () => ({}), + }); + assert.equal( + (await remote.list()).providers[0].models[0].supportsImages, + false, + ); +}); + +test("portable intent clears cached overrides and handles prototype-shaped IDs as data", () => { + const { intent, cache } = splitStoredProvider(provider); + const cleared = composeStoredProvider( + { ...intent, customModelOptions: {} }, + cache, + ); + assert.equal(cleared.modelMetadata?.["custom-model"].overrides, undefined); + const manual = composeStoredProvider( + { + ...intent, + customModelOptions: JSON.parse( + '{"__proto__":{"vision":true,"manuallyAdded":true}}', + ), + }, + undefined, + ); + assert.ok( + Object.prototype.hasOwnProperty.call(manual.modelMetadata, "__proto__"), + ); + assert.equal(manual.modelMetadata?.["__proto__"].overrides?.vision, true); + assert.ok(manual.models.includes("__proto__")); +}); + +test("generated assistant images are not counted as model inputs", () => { + assert.doesNotThrow(() => + assertCustomModelImageLimit({ maxImages: 1 }, [ + { + role: "assistant", + attachments: [{ kind: "image" }, { kind: "image" }], + }, + { role: "user", attachments: [{ kind: "image" }] }, + ]), + ); +}); + + +test("disabled tools skip deferred browser discovery without altering context", async () => { + let calls = 0; + const context = { tools: [] as string[] }; + const prepare = async () => { calls += 1; return { tools: ["browser"] }; }; + assert.equal(await prepareCustomModelToolContext(context, prepare, { toolCall: false }), context); + assert.equal(calls, 0); + assert.deepEqual(await prepareCustomModelToolContext(context, prepare, { toolCall: true }), { tools: ["browser"] }); + assert.equal(calls, 1); + assert.equal(await prepareCustomModelToolContext(context, undefined, undefined), context); +}); diff --git a/main/services/git.test.ts b/main/services/git.test.ts index 28c3312f..50b3fb50 100644 --- a/main/services/git.test.ts +++ b/main/services/git.test.ts @@ -1434,7 +1434,7 @@ test("GitService does not mutate upstream configuration after a cancelled push r "const args = process.argv.slice(2);", "const result = spawnSync('git', args, { env: process.env, stdio: 'inherit' });", "if (result.error) throw result.error;", - "if (args[0] === 'push' && result.status === 0) { writeFileSync(marker, 'pushed\\n'); setTimeout(() => process.exit(0), 2000); }", + "if (args[0] === 'push' && result.status === 0) { writeFileSync(marker, 'pushed\\n'); setInterval(() => {}, 1000); }", "else process.exit(result.status ?? 1);", "", ].join("\n"), @@ -1443,7 +1443,7 @@ test("GitService does not mutate upstream configuration after a cancelled push r const service = new GitService({ cacheTtlMs: 0, gitBinary: wrapper, - pushTimeoutMs: 5_000, + pushTimeoutMs: 30_000, }); const capability = await service.pushCapability(repository); const controller = new AbortController(); @@ -1460,26 +1460,24 @@ test("GitService does not mutate upstream configuration after a cancelled push r controller.signal, ); - let pushed = false; - for (let attempt = 0; attempt < 150; attempt += 1) { - try { - await fs.access(marker); - pushed = true; - break; - } catch { - await new Promise((resolve) => setTimeout(resolve, 20)); - } + // Observe rejection immediately; fixture cleanup must not race a live push. + void operation.catch(() => {}); + try { + // Wait for the actual remote mutation, not a scheduler-dependent polling count. + await waitForFile(marker, 15_000); + controller.abort(); + const result = await operation; + assert.match(result.warning ?? "", /cancelled request did not change the local upstream/); + assert.equal(result.upstreamSet, false); + assert.equal(await git(remote, ["rev-parse", "refs/heads/main"]), capability.expectedHead); + assert.equal( + await git(repository, ["for-each-ref", "--format=%(upstream:short)", "refs/heads/main"]), + "", + ); + } finally { + controller.abort(); + await operation.catch(() => {}); } - assert.equal(pushed, true); - controller.abort(); - const result = await operation; - assert.match(result.warning ?? "", /cancelled request did not change the local upstream/); - assert.equal(result.upstreamSet, false); - assert.equal(await git(remote, ["rev-parse", "refs/heads/main"]), capability.expectedHead); - assert.equal( - await git(repository, ["for-each-ref", "--format=%(upstream:short)", "refs/heads/main"]), - "", - ); }); test("GitService rechecks cancellation after post-push branch reads before setting upstream", async (t) => { diff --git a/main/services/llm-client.ts b/main/services/llm-client.ts index 08dd4093..a2c52b8c 100644 --- a/main/services/llm-client.ts +++ b/main/services/llm-client.ts @@ -1,3 +1,4 @@ +import { assertCustomModelImageLimit, applyCustomModelToolPolicy, prepareCustomModelToolContext } from "../../renderer/shared/custom-model-options.js"; import { compactionEngineFrom } from "../../renderer/shared/compaction.js"; import { createVccRecallTool } from "./pi-vcc/recall.js"; // Chat generation via pi's embedded agent loop (@earendil-works/pi-agent-core + @@ -718,6 +719,8 @@ async function prepareGeneration( // The resolved runtime model is the connection-bound capability authority. // Display metadata must not re-enable an input that Pi or discovery rejected. const model = runtime.model; + assertCustomModelImageLimit(runtime.provider.modelMetadata?.[model.id]?.overrides, + runtimeSupportsImages(model) ? chat.messages : chat.messages.slice(-1)); if (assistantAutomationMode || params.mode === "assistant-unattended") { assertScheduledProviderFingerprint(runtime.provider, options.providerFingerprint); } @@ -2059,13 +2062,16 @@ export const llmClient = { throw new Error("Bot runtime authority was not prepared."); })() : baseSystemPrompt; - const runtimeContributions = resolvePiAgentRuntimeContributionSnapshot( + const resolvedContributions = resolvePiAgentRuntimeContributionSnapshot( botSystemPrompt, tools, piResourcesForSkillSnapshot(skillSnapshot), runtimeExtensions, runtimeExtensionSnapshot.revision, ); + const runtimeContributions = applyCustomModelToolPolicy( + resolvedContributions, runtime.provider.modelMetadata?.[model.id]?.overrides, + ); const { systemPrompt, tools: runtimeTools } = runtimeContributions; const generationContextOptions = { contextWindow: model.contextWindow, systemPrompt, tools: runtimeTools, @@ -2286,7 +2292,7 @@ export const llmClient = { messages: initialMessages, }, prepareNextTurnWithContext: async ({ toolResults, context }) => { - let nextContext = browserDiscovery ? await browserDiscovery.prepare(context) : context; + let nextContext = await prepareCustomModelToolContext(context, browserDiscovery?.prepare.bind(browserDiscovery), runtime.provider.modelMetadata?.[model.id]?.overrides); let changed = nextContext !== context; if (changed) { assertGenerationContextCapacity({ ...generationContextOptions, systemPrompt: nextContext.systemPrompt, tools: nextContext.tools ?? [] }); diff --git a/main/services/models-catalog-core.ts b/main/services/models-catalog-core.ts index 7ae2d283..252e3bad 100644 --- a/main/services/models-catalog-core.ts +++ b/main/services/models-catalog-core.ts @@ -463,10 +463,11 @@ function discoveredRuntimeMetadata( const metadata = provider.modelMetadata?.[modelId]; if (!metadata) return undefined; return { - contextWindow: metadata.contextLength, - reasoning: metadata.reasoning, + contextWindow: metadata.overrides?.contextLength ?? metadata.contextLength, + maxTokens: metadata.overrides?.outputLimit, + reasoning: metadata.overrides?.reasoning ?? metadata.reasoning, input: - metadata.vision === undefined ? undefined : metadata.vision ? ["text", "image"] : ["text"], + (metadata.overrides?.vision ?? metadata.vision) === undefined ? undefined : (metadata.overrides?.vision ?? metadata.vision) ? ["text", "image"] : ["text"], }; } @@ -501,12 +502,15 @@ export function resolveProviderRuntimeLimits( ): RuntimeModelLimits { const runtimeSlug = catalogProviderSlug(provider.id); const discovered = discoveredRuntimeMetadata(provider, modelId); - return resolveRuntimeLimits( + const limits = resolveRuntimeLimits( catalog, runtimeSlug ? provider.id : "", modelId, runtimeSlug ? mergeRuntimeMetadata(discovered, piExact) : discovered, ); + if (provider.modelMetadata?.[modelId]?.overrides?.maxImages === 0) limits.input = ["text"]; + if (provider.modelMetadata?.[modelId]?.overrides) limits.maxTokens = Math.min(limits.maxTokens, limits.contextWindow); + return limits; } function isLocalProvider(provider: ModelCatalogProvider): boolean { diff --git a/main/services/pi-agent-runtime-harness.test.ts b/main/services/pi-agent-runtime-harness.test.ts index c0645775..1a328c55 100644 --- a/main/services/pi-agent-runtime-harness.test.ts +++ b/main/services/pi-agent-runtime-harness.test.ts @@ -1,3 +1,4 @@ +import { applyCustomModelToolPolicy } from "../../renderer/shared/custom-model-options.js"; import assert from "node:assert/strict"; import test from "node:test"; import { mkdtemp, readFile, rm } from "node:fs/promises"; @@ -2719,6 +2720,30 @@ test("disclosed browser tools remain executable and budgeted after managed provi assert.equal(journal.messages.filter((message) => message.role === "toolResult" && message.toolName === "browser_status").length, 2); }); + +test("custom tool disabling removes base and extension tools from a frozen runtime snapshot", async () => { + const tool: AgentTool = { + name: "base-tool", label: "Base", description: "Base", parameters: Type.Object({}), + execute: async () => { throw new Error("Disabled tool must never execute"); }, + }; + const snapshot = resolvePiAgentRuntimeContributionSnapshot("base", [tool], {}, [{ id: "extension", tools: [{ ...tool, name: "extension-tool" }] }]); + assert.equal(Object.isFrozen(snapshot), true); + assert.equal(snapshot.tools.length, 2); + for (const setting of [undefined, { toolCall: true }]) { + const { harness } = testHarness([fauxAssistantMessage("ok")], { contributions: applyCustomModelToolPolicy(snapshot, setting) }); + assert.equal(harness.state.tools.length, 2); + } + const { harness } = testHarness([fauxAssistantMessage("ok")], { + contributions: applyCustomModelToolPolicy(snapshot, { toolCall: false }), + // The final contribution policy must win over the unfiltered initial tools. + initialState: { tools: [tool] }, + }); + assert.deepEqual(harness.state.tools, []); + await harness.prompt("Respond without tools"); + assert.deepEqual(harness.state.tools, []); + assert.equal(snapshot.tools.length, 2, "the shared frozen snapshot stays intact"); +}); + test("provider diagnostics classify before outcome redaction without exporting the raw error", async (t) => { const root = await mkdtemp(join(tmpdir(), "aiden-provider-diagnostics-")); t.after(async () => { await flushDiagnosticJournal(); await rm(root, { recursive: true, force: true }); }); diff --git a/main/services/portable-config-core.ts b/main/services/portable-config-core.ts index 25ea9247..a24e3e1d 100644 --- a/main/services/portable-config-core.ts +++ b/main/services/portable-config-core.ts @@ -1,3 +1,4 @@ +import { parseCustomModelOptions } from "../../renderer/shared/custom-model-options.js"; import { compactionEngineFrom } from "../../renderer/shared/compaction.js"; // Splits Aiden's persisted configuration into a portable half and a // machine-local half, and migrates existing installs into that layout once. @@ -368,6 +369,16 @@ function hasSensitiveProviderUrl(value: unknown): boolean { export function isPortableProvider(value: unknown): value is PortableProvider { if (!isRecord(value)) return false; const provider = value as Partial; + if (provider.customModelOptions !== undefined) { + if (!isRecord(provider.customModelOptions)) return false; + try { + for (const [id, options] of Object.entries(provider.customModelOptions)) { + if (!id.trim() || id.length > MAX_CONFIG_ID_LENGTH || !isRecord(options)) return false; + if (options.manuallyAdded !== undefined && typeof options.manuallyAdded !== "boolean") return false; + parseCustomModelOptions(options); + } + } catch { return false; } + } return ( typeof provider.id === "string" && provider.id.trim().length > 0 && @@ -394,9 +405,11 @@ export function isPortableProviderList(value: unknown): value is PortableProvide } function isProviderModelMetadata(value: unknown): value is ProviderModelMetadata { + try { if (isRecord(value)) parseCustomModelOptions(value.overrides); } catch { return false; } if (!isRecord(value)) return false; return ( (value.source === "lmstudio" || value.source === "ollama" || value.source === "provider") && + (value.manuallyAdded === undefined || typeof value.manuallyAdded === "boolean") && (value.name === undefined || typeof value.name === "string") && (value.type === undefined || value.type === "llm" || @@ -915,6 +928,13 @@ export function splitStoredProvider(provider: StoredProvider): { cache: ProviderModelCacheEntry; } { const { models, modelMetadata, ...intent } = provider; + if (modelMetadata !== undefined) { + const custom = Object.fromEntries(Object.entries(modelMetadata) + .filter(([, metadata]) => metadata.overrides !== undefined || metadata.manuallyAdded) + .map(([id, metadata]) => [id, { ...metadata.overrides, ...(metadata.manuallyAdded ? { manuallyAdded: true } : {}) }])); + if (Object.keys(custom).length) intent.customModelOptions = custom; + else delete intent.customModelOptions; + } const cache: ProviderModelCacheEntry = {}; if ("models" in provider) cache.models = models; if ("modelMetadata" in provider) cache.modelMetadata = modelMetadata; @@ -934,7 +954,19 @@ export function composeStoredProvider( ...safeIntent } = intent as PortableProvider & Partial; const composed: StoredProvider = { ...safeIntent, models: cache?.models ?? [] }; - if (cache?.modelMetadata) composed.modelMetadata = cache.modelMetadata; + if (cache?.modelMetadata) composed.modelMetadata = Object.fromEntries( + Object.entries(cache.modelMetadata).map(([id, metadata]) => { + const { overrides: _overrides, manuallyAdded: _manual, ...detected } = metadata; + return [id, detected]; + }), + ); + for (const [id, options] of Object.entries(safeIntent.customModelOptions ?? {})) { + const { manuallyAdded, ...overrides } = options; + composed.modelMetadata ??= {}; + const detected = Object.prototype.hasOwnProperty.call(composed.modelMetadata, id) ? composed.modelMetadata[id] : undefined; + composed.modelMetadata = { ...composed.modelMetadata, [id]: { source: "provider", ...detected, ...(Object.keys(overrides).length ? { overrides } : {}), ...(manuallyAdded ? { manuallyAdded: true } : {}) } }; + if (manuallyAdded && !composed.models.includes(id)) composed.models = [...composed.models, id]; + } return composed; } diff --git a/main/services/provider-model-info-core.ts b/main/services/provider-model-info-core.ts index 41e822ab..09c07d96 100644 --- a/main/services/provider-model-info-core.ts +++ b/main/services/provider-model-info-core.ts @@ -42,7 +42,15 @@ function withProviderFallback( catalog: ModelInfo, metadata: ProviderModelMetadata | undefined, ): ModelInfo { - return catalog.matched ? catalog : (providerInfo(modelId, metadata) ?? catalog); + const info = catalog.matched ? catalog : (providerInfo(modelId, metadata) ?? catalog); + if (!metadata?.overrides) return info; + const overrides = metadata.overrides; + const vision = overrides.maxImages === 0 ? false : overrides.vision ?? info.vision; + const modalities = new Set(info.inputModalities ?? ["text"]); + if (vision) modalities.add("image"); else modalities.delete("image"); + if (overrides.video === true) modalities.add("video"); + if (overrides.video === false) modalities.delete("video"); + return { ...info, ...overrides, detectedCapabilities: { vision: info.vision, reasoning: info.reasoning, toolCall: info.toolCall, openWeights: info.openWeights, contextLength: info.contextLength, outputLimit: info.outputLimit, video: info.inputModalities?.includes("video") }, vision, inputModalities: [...modalities], metadataSource: "provider", matched: true }; } const CODEX_CATALOG_PROVIDER: ModelCatalogProvider = { diff --git a/main/services/telegram/telegram-controls.test.ts b/main/services/telegram/telegram-controls.test.ts index 01d7e2c7..fd007638 100644 --- a/main/services/telegram/telegram-controls.test.ts +++ b/main/services/telegram/telegram-controls.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { TELEGRAM_COMMANDS, + telegramModelChoice, buildMainMenu, buildModelMenu, buildQueueItemMenu, @@ -92,3 +93,16 @@ test("Telegram settings expose native rendering and voice policy controls", () = assert.ok(callbacks.includes("settings:rendering:toggle")); assert.ok(callbacks.includes("settings:voice:next")); }); + + +test("Telegram choices honor explicit custom reasoning overrides", () => { + const provider = { + id: "custom:server", label: "Server", kind: "openai" as const, baseUrl: "http://localhost/v1", needsKey: false, hasKey: false, models: ["on", "off"], + modelMetadata: { + on: { source: "provider" as const, reasoning: false, overrides: { reasoning: true } }, + off: { source: "provider" as const, reasoning: true, overrides: { reasoning: false } }, + }, + }; + assert.equal(telegramModelChoice(provider, "on").reasoning, true); + assert.equal(telegramModelChoice(provider, "off").reasoning, false); +}); diff --git a/main/services/telegram/telegram-controls.ts b/main/services/telegram/telegram-controls.ts index dc2c474d..439e7fbe 100644 --- a/main/services/telegram/telegram-controls.ts +++ b/main/services/telegram/telegram-controls.ts @@ -1,3 +1,4 @@ +import type { Provider } from "../types.js"; // Telegram-native operator controls. // // Owns the stable command catalog and pure inline-keyboard renderers. Runtime @@ -39,6 +40,18 @@ export interface TelegramModelChoice { thinkingLevels?: readonly GenerationThinkingLevel[]; } +export function telegramModelChoice(provider: Provider, model: string): TelegramModelChoice { + const metadata = provider.modelMetadata?.[model]; + return { + providerId: provider.id, + providerLabel: provider.label, + model, + modelLabel: metadata?.name, + reasoning: metadata?.overrides?.reasoning ?? metadata?.reasoning ?? false, + thinkingLevels: metadata?.thinkingLevels, + }; +} + export function visibleTelegramModelChoices( models: readonly TelegramModelChoice[], hiddenModelsByProvider: HiddenModelsByProvider | undefined, diff --git a/main/services/telegram/telegram-service.ts b/main/services/telegram/telegram-service.ts index 765b7439..d1325c17 100644 --- a/main/services/telegram/telegram-service.ts +++ b/main/services/telegram/telegram-service.ts @@ -42,6 +42,7 @@ import type { TelegramModelChoice } from "./telegram-controls.js"; import { TELEGRAM_COMMANDS, visibleTelegramModelChoices, + telegramModelChoice, } from "./telegram-controls.js"; import { getTelegramExtensions } from "./telegram-extension-registry.js"; import { @@ -214,17 +215,7 @@ async function listTelegramModels(): Promise { }); } const models = [...byId.values()].flatMap((provider) => - provider.models.map((model) => { - const metadata = provider.modelMetadata?.[model]; - return { - providerId: provider.id, - providerLabel: provider.label, - model, - modelLabel: metadata?.name, - reasoning: metadata?.reasoning ?? false, - thinkingLevels: metadata?.thinkingLevels, - }; - }), + provider.models.map((model) => telegramModelChoice(provider, model)), ); return visibleTelegramModelChoices(models, settings.hiddenModelsByProvider); } diff --git a/main/services/types.ts b/main/services/types.ts index 2f0dc539..6a70c578 100644 --- a/main/services/types.ts +++ b/main/services/types.ts @@ -1,3 +1,4 @@ +import type { CustomModelOptions } from "../../renderer/shared/custom-model-options.js"; import type { CompactionEngine } from "../../renderer/shared/compaction.js"; // Shared backend/renderer data types for the AI chat client. @@ -25,6 +26,8 @@ export type ProviderModelType = "llm" | "embedding" | "reranker" | "image" | "au /** Metadata reported by the configured provider during explicit model discovery. */ export interface ProviderModelMetadata { + overrides?: CustomModelOptions; + manuallyAdded?: boolean; source: "lmstudio" | "ollama" | "provider"; name?: string; type?: ProviderModelType; @@ -53,6 +56,8 @@ export interface StoredProvider { models: string[]; /** Provider-reported metadata captured alongside the last explicit discovery. */ modelMetadata?: Record; + /** User-authored model intent, portable independently of discovery cache. */ + customModelOptions?: Record; defaultModel?: string; /** Whether this provider requires an API key (local backends often don't). */ needsKey: boolean; @@ -297,6 +302,9 @@ export type ModelMetadataSource = /** Normalized model metadata after applying local and bundled-source precedence. */ export interface ModelInfo { + detectedCapabilities?: CustomModelOptions; + maxImages?: number; + video?: boolean; id: string; name?: string; /** Accepts image input (vision). */ diff --git a/package.json b/package.json index 9e673fd5..67d532a4 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,7 @@ "test:voice": "tsx --test main/services/transcription-core.test.ts main/services/gemini-live-transcription-core.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/parakeet-transcription-lane.test.ts renderer/shared/voice-models.test.ts renderer/shared/gemini-usage-scope.test.ts renderer/components/settings/gemini-voice-setup.test.tsx renderer/lib/accessibility-permission-core.test.ts renderer/lib/accessibility-refresh.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/gemini-recorded-retry.test.ts renderer/lib/live-pcm-capture.test.ts renderer/lib/voice-recorder-core.test.ts renderer/lib/wav-audio.test.ts", "test:diagnostics": "tsx --test main/services/diagnostics-contract.test.ts main/services/diagnostic-health.test.ts main/services/diagnostic-journal.test.ts main/services/diagnostic-support.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/renderer-crash-recovery.test.ts main/services/renderer-diagnostic-rate.test.ts main/services/subagents/subagent-runtime-diagnostics.test.ts renderer/components/settings/diagnostics-settings.test.tsx && node --test scripts/diagnostic-policy.test.mjs", "diagnostics:failure-receipt": "node scripts/write-diagnostic-failure-receipt.mjs", - "test": "tsx --test main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/github-pull-request.test.ts main/services/model-runtime-core.test.ts main/services/opencode-session-attribution.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/provider-artwork.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/lib/chat-message-queue.test.ts renderer/lib/chat-draft.test.ts renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/components/interface-polish.test.tsx renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:telegram && npm run test:worktree-remover:native && npm run test:computer-use:native && npm run test:settings-design", + "test": "npm run test:custom-model-options && tsx --test main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/github-pull-request.test.ts main/services/model-runtime-core.test.ts main/services/opencode-session-attribution.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/provider-artwork.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/lib/chat-message-queue.test.ts renderer/lib/chat-draft.test.ts renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/components/interface-polish.test.tsx renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:custom-model-options test:telegram && npm run test:worktree-remover:native && npm run test:computer-use:native && npm run test:settings-design", "test:coverage": "tsx --test --experimental-test-coverage main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/github-pull-request.test.ts main/services/model-runtime-core.test.ts main/services/opencode-session-attribution.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/provider-artwork.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", "test:computer-use": "tsx --test main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/quit-barrier.test.ts main/services/tool-approval.test.ts scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:computer-use:native", "test:computer-use:packaged": "node scripts/computer-use-packaged-acceptance.mjs", @@ -140,7 +140,8 @@ "prevcc:evaluate": "npm run build:vcc", "vcc:evaluate": "node --import tsx scripts/pi-vcc-evaluation.mjs", "test:settings-design": "tsx --test renderer/lib/workspace-path-display.test.ts renderer/components/settings/settings-design.test.tsx renderer/shared/appearance.test.ts renderer/components/chat-sidebar.test.tsx renderer/components/settings/memory-settings.test.tsx", - "test:browser": "tsx --test main/services/browser/*.test.ts main/services/browser-tools.test.ts main/services/browser-discovery.test.ts renderer/lib/browser-*.test.ts renderer/components/browser-panel.test.tsx" + "test:browser": "tsx --test main/services/browser/*.test.ts main/services/browser-tools.test.ts main/services/browser-discovery.test.ts renderer/lib/browser-*.test.ts renderer/components/browser-panel.test.tsx", + "test:custom-model-options": "tsx --test main/services/custom-model-options.test.ts" }, "dependencies": { "@earendil-works/pi-agent-core": "0.84.4", diff --git a/renderer/components/onboarding-flow.tsx b/renderer/components/onboarding-flow.tsx index 5ed30634..980a2e54 100644 --- a/renderer/components/onboarding-flow.tsx +++ b/renderer/components/onboarding-flow.tsx @@ -152,12 +152,12 @@ const providerChoices: Array<{ { id: "custom", title: "Other Custom Provider", - description: "Connect your own model server or endpoint.", + description: "Connect your endpoint, choose models, and customize their capabilities.", }, { id: "tailscale", title: "Tailscale custom model", - description: "Connect to a private model on your tailnet.", + description: "Connect a private server and choose models and capabilities in More options.", }, ]; @@ -269,7 +269,7 @@ const featureBentos: FeatureBento[] = [ id: "models", group: "extend", title: "Model Freedom", - description: "Choose from 30+ Pi providers, ChatGPT sign-in, Apple models, or local endpoints.", + description: "Choose from 30+ Pi providers, ChatGPT sign-in, Apple models, or custom endpoints with model and capability controls.", icon: Blocks, imageUrl: FEATURE_ILLUSTRATIONS.models, size: "hero", diff --git a/renderer/components/settings/custom-model-options.tsx b/renderer/components/settings/custom-model-options.tsx new file mode 100644 index 00000000..21db868a --- /dev/null +++ b/renderer/components/settings/custom-model-options.tsx @@ -0,0 +1,139 @@ +import * as React from "react"; +import { Button, Field, FieldSet, Input, Switch, Text } from "../ui"; +import type { ModelInfo, ProviderModelMetadata } from "../../lib/types"; +import type { CustomModelOptions } from "../../shared/custom-model-options"; + +export function CustomModelOptionsEditor({ + models, + metadata, + info, + disabled, + modelsStale, + onChange, + onAdd, +}: { + models: string[]; + metadata: Record; + info?: Record; + disabled: boolean; + modelsStale: boolean; + onChange: (id: string, options: CustomModelOptions | undefined) => void; + onAdd: (id: string) => void; +}) { + const [modelId, setModelId] = React.useState(""); + return ( +
+ +
+ setModelId(event.target.value)} + /> + +
+
+ + Set the capabilities your server supports. Changes apply when you save. + Video records server support; Aiden chat currently accepts images and + text only. + + {models.map((id) => { + const overrides = metadata[id]?.overrides ?? {}; + const effective = { + ...metadata[id], + ...(info?.[id]?.detectedCapabilities ?? info?.[id]), + ...overrides, + }; + return ( +
+ + {metadata[id]?.name ?? id} + +
+ {( + [ + ["vision", "Vision"], + ["reasoning", "Reasoning"], + ["toolCall", "Tool calling"], + ["openWeights", "Open weights"], + ["video", "Video support (server)"], + ] as const + ).map(([key, label]) => ( + + + onChange(id, { ...overrides, [key]: checked }) + } + /> + + ))} + {( + [ + ["contextLength", "Context length (tokens)"], + ["outputLimit", "Maximum output tokens"], + ["maxImages", "Maximum images per message"], + ] as const + ).map(([key, label]) => ( + + { + const next = { ...overrides }; + if (event.target.value === "") delete next[key]; + else next[key] = Number(event.target.value); + onChange(id, next); + }} + /> + + ))} + +
+
+ ); + })} +
+ ); +} diff --git a/renderer/components/settings/provider-editor.tsx b/renderer/components/settings/provider-editor.tsx index 84f08677..444d5390 100644 --- a/renderer/components/settings/provider-editor.tsx +++ b/renderer/components/settings/provider-editor.tsx @@ -1,3 +1,8 @@ +import { CustomModelOptionsEditor } from "./custom-model-options"; +import { + mergeDiscoveredModelMetadata, + parseCustomModelOptions, +} from "../../shared/custom-model-options"; // Dialog to configure a custom provider: base URL, API key, connection test, // model discovery, and default model. Pi built-ins use a separate setup view. @@ -79,14 +84,16 @@ export function ProviderEditor({ ); const [keyDraft, setKeyDraft] = React.useState(""); const [models, setModels] = React.useState(provider.models); - const [modelMetadata, setModelMetadata] = React.useState>( - provider.modelMetadata ?? {}, - ); + const [modelMetadata, setModelMetadata] = React.useState< + Record + >(provider.modelMetadata ?? {}); const [defaultModel, setDefaultModel] = React.useState( provider.defaultModel ?? provider.models[0] ?? "", ); const [artwork, setArtwork] = React.useState(provider.artwork); const [artworkBusy, setArtworkBusy] = React.useState(false); + const [moreOptions, setMoreOptions] = React.useState(false); + const [manualModels, setManualModels] = React.useState([]); const [testing, setTesting] = React.useState(false); const [saving, setSaving] = React.useState(false); const [modelsStale, setModelsStale] = React.useState(false); @@ -99,16 +106,25 @@ export function ProviderEditor({ const visibleDefaultModels = settings.data ? models.filter( (modelId) => - !isModelHidden(settings.data?.hiddenModelsByProvider, provider.id, modelId), + !isModelHidden( + settings.data?.hiddenModelsByProvider, + provider.id, + modelId, + ), ) : []; const defaultModelIsHidden = Boolean( settings.data && defaultModel && - isModelHidden(settings.data.hiddenModelsByProvider, provider.id, defaultModel), + isModelHidden( + settings.data.hiddenModelsByProvider, + provider.id, + defaultModel, + ), ); const usesArtificialAnalysis = models.some( - (modelId) => modelInfo.data?.[modelId]?.metadataSource === "artificial-analysis", + (modelId) => + modelInfo.data?.[modelId]?.metadataSource === "artificial-analysis", ); // Reset drafts whenever a different provider is opened. @@ -121,6 +137,12 @@ export function ProviderEditor({ setDeployment(resolveProviderDeployment(provider)); setKeyDraft(""); setModels(provider.models); + setMoreOptions(false); + setManualModels( + provider.models.filter( + (id) => provider.modelMetadata?.[id]?.manuallyAdded, + ), + ); setModelMetadata(provider.modelMetadata ?? {}); setDefaultModel(provider.defaultModel ?? provider.models[0] ?? ""); setArtwork(provider.artwork); @@ -159,13 +181,20 @@ export function ProviderEditor({ typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("Aiden could not read that image.")); - reader.onerror = () => reject(reader.error ?? new Error("Aiden could not read that image.")); + reader.onerror = () => + reject(reader.error ?? new Error("Aiden could not read that image.")); reader.readAsDataURL(file); }); const dataBase64 = dataUrl.slice(dataUrl.indexOf(",") + 1); - setArtwork(await providersApi.normalizeArtwork({ name: file.name, dataBase64 })); + setArtwork( + await providersApi.normalizeArtwork({ name: file.name, dataBase64 }), + ); } catch (error) { - toast.error(error instanceof Error ? error.message : "Aiden could not use that provider icon."); + toast.error( + error instanceof Error + ? error.message + : "Aiden could not use that provider icon.", + ); } finally { artworkBusyRef.current = false; setArtworkBusy(false); @@ -178,8 +207,22 @@ export function ProviderEditor({ metadata: Record, recommendedModel?: string, ) => { + list = [...new Set([...list, ...manualModels])]; setModels(list); - setModelMetadata(metadata); + setModelMetadata((current) => + mergeDiscoveredModelMetadata( + { + ...Object.fromEntries( + manualModels.map((id) => [ + id, + current[id] ?? { source: "provider" as const }, + ]), + ), + ...metadata, + }, + current, + ), + ); setDefaultModel((current) => list.includes(current) ? current @@ -193,7 +236,8 @@ export function ProviderEditor({ const markDiscoveryStale = () => { setModelsStale(true); setConnectionNotice({ - message: "Connection settings changed. Discover models again before saving.", + message: + "Connection settings changed. Discover models again before saving.", error: true, }); }; @@ -201,8 +245,15 @@ export function ProviderEditor({ const handleTest = async () => { setTesting(true); try { - const result = await providersApi.test(buildDraft(), keyDraft.trim() || undefined); - applyDiscoveredModels(result.models, result.modelMetadata, result.recommendedModel); + const result = await providersApi.test( + buildDraft(), + keyDraft.trim() || undefined, + ); + applyDiscoveredModels( + result.models, + result.modelMetadata, + result.recommendedModel, + ); if (result.models.length > 0) { setConnectionNotice({ message: `${result.modelCount} model${result.modelCount === 1 ? "" : "s"} found. Save to use them.`, @@ -228,20 +279,35 @@ export function ProviderEditor({ const handleSave = async () => { if (modelsStale) { - const message = "Connection settings changed. Discover models again before saving."; + const message = + "Connection settings changed. Discover models again before saving."; setConnectionNotice({ message, error: true }); toast.error(message); return; } - if (requireReady && (models.length === 0 || !defaultModel || !models.includes(defaultModel) || defaultModelIsHidden)) { - setConnectionNotice({ message: "Discover models and choose an available default before continuing.", error: true }); + if ( + requireReady && + (models.length === 0 || + !defaultModel || + !models.includes(defaultModel) || + defaultModelIsHidden) + ) { + setConnectionNotice({ + message: + "Discover models and choose an available default before continuing.", + error: true, + }); return; } setSaving(true); try { + for (const metadata of Object.values(modelMetadata)) + parseCustomModelOptions(metadata.overrides); await providersApi.save(buildDraft(), keyDraft.trim() || undefined); if (models.length === 0) { - toast.info("Saved without models. Discover models before sending a chat."); + toast.info( + "Saved without models. Discover models before sending a chat.", + ); } await onSaved(); onOpenChange(false); @@ -302,7 +368,11 @@ export function ProviderEditor({ disabled={artworkBusy} onClick={() => artworkInputRef.current?.click()} > - {artworkBusy ? "Normalizing…" : artwork ? "Replace" : "Choose image"} + {artworkBusy + ? "Normalizing…" + : artwork + ? "Replace" + : "Choose image"} {artwork ? ( + {moreOptions ? ( +
+ { + if (modelsStale) { + const retained = [...new Set([...manualModels, id])]; + setModels(retained); + setManualModels(retained); + setModelMetadata( + Object.fromEntries( + retained.map((modelId) => [ + modelId, + { + ...modelMetadata[modelId], + source: modelMetadata[modelId]?.source ?? "provider", + manuallyAdded: true, + }, + ]), + ), + ); + if (!retained.includes(defaultModel)) setDefaultModel(id); + setModelsStale(false); + setConnectionNotice(null); + return; + } + setModels((current) => [...current, id]); + setManualModels((current) => [...current, id]); + setModelMetadata((current) => ({ + ...current, + [id]: { source: "provider", manuallyAdded: true }, + })); + if (!defaultModel) setDefaultModel(id); + }} + onChange={(id, overrides) => + setModelMetadata((current) => ({ + ...current, + [id]: { + ...current[id], + source: current[id]?.source ?? "provider", + overrides, + }, + })) + } + /> + +
+ ) : null} ); } diff --git a/renderer/lib/types.ts b/renderer/lib/types.ts index 1b1a359b..981da393 100644 --- a/renderer/lib/types.ts +++ b/renderer/lib/types.ts @@ -1,3 +1,4 @@ +import type { CustomModelOptions } from "../shared/custom-model-options"; import type { CompactionEngine } from "../shared/compaction"; // Renderer-side mirror of the backend data shapes (types only; no runtime import // across the process boundary). @@ -53,6 +54,8 @@ export type ProviderDeployment = "local" | "hosted"; export type ProviderModelType = "llm" | "embedding" | "reranker" | "image" | "audio" | "video"; export interface ProviderModelMetadata { + overrides?: CustomModelOptions; + manuallyAdded?: boolean; source: "lmstudio" | "ollama" | "provider"; name?: string; type?: ProviderModelType; @@ -74,6 +77,8 @@ export interface Provider { baseUrl: string; models: string[]; modelMetadata?: Record; + /** User-authored model intent, portable independently of discovery cache. */ + customModelOptions?: Record; defaultModel?: string; needsKey: boolean; /** Explicit local vs hosted; when unset, inferred from loopback base URL. */ @@ -540,6 +545,9 @@ export type ModelMetadataSource = | "fallback"; export interface ModelInfo { + detectedCapabilities?: CustomModelOptions; + maxImages?: number; + video?: boolean; id: string; name?: string; vision?: boolean; diff --git a/renderer/shared/custom-model-options.ts b/renderer/shared/custom-model-options.ts new file mode 100644 index 00000000..713e6400 --- /dev/null +++ b/renderer/shared/custom-model-options.ts @@ -0,0 +1,119 @@ +/** Explicit user overrides, separate from rediscovered provider metadata. */ +export interface CustomModelOptions { + vision?: boolean; + reasoning?: boolean; + toolCall?: boolean; + openWeights?: boolean; + video?: boolean; + contextLength?: number; + outputLimit?: number; + maxImages?: number; +} + +export function parseCustomModelOptions( + value: unknown, +): CustomModelOptions | undefined { + if (value === undefined) return undefined; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Invalid custom model options."); + } + const raw = value as Record; + const result: CustomModelOptions = {}; + for (const key of [ + "vision", + "reasoning", + "toolCall", + "openWeights", + "video", + ] as const) { + if (raw[key] === undefined) continue; + if (typeof raw[key] !== "boolean") + throw new Error(`Invalid ${key} option.`); + result[key] = raw[key]; + } + for (const key of ["contextLength", "outputLimit", "maxImages"] as const) { + if (raw[key] === undefined) continue; + const number = raw[key]; + if ( + typeof number !== "number" || + !Number.isSafeInteger(number) || + number < (key === "maxImages" ? 0 : 1) + ) { + throw new Error( + `Enter a whole ${key === "maxImages" ? "non-negative" : "positive"} number for ${key}.`, + ); + } + result[key] = number; + } + if ( + result.contextLength !== undefined && + result.outputLimit !== undefined && + result.outputLimit > result.contextLength + ) { + throw new Error("Maximum output tokens cannot exceed the context length."); + } + return result; +} + +export function mergeDiscoveredModelMetadata< + T extends { overrides?: CustomModelOptions; manuallyAdded?: boolean }, +>( + discovered: Record, + previous: Record, +): Record { + return Object.fromEntries( + Object.entries(discovered).map(([id, metadata]) => [ + id, + { + ...metadata, + ...(Object.prototype.hasOwnProperty.call(previous, id) && + previous[id].overrides + ? { overrides: previous[id].overrides } + : {}), + ...(Object.prototype.hasOwnProperty.call(previous, id) && + previous[id].manuallyAdded + ? { manuallyAdded: true } + : {}), + }, + ]), + ); +} + +/** Enforced in the shared generation path, including native-client requests. */ +export function assertCustomModelImageLimit( + options: CustomModelOptions | undefined, + messages: ReadonlyArray<{ + role: string; + attachments?: ReadonlyArray<{ kind: string }>; + }>, +): void { + if (options?.maxImages === undefined) return; + if ( + messages.some( + (message) => + message.role === "user" && + (message.attachments?.filter((item) => item.kind === "image").length ?? + 0) > options.maxImages!, + ) + ) { + throw new Error( + `This model supports at most ${options.maxImages} images per message. Remove images or choose another model.`, + ); + } +} + +/** Filter the final composed snapshot, including tools contributed by extensions. */ +export function applyCustomModelToolPolicy< + T extends { tools: readonly unknown[] }, +>(snapshot: T, options: CustomModelOptions | undefined): T { + return options?.toolCall === false ? { ...snapshot, tools: [] } : snapshot; +} + +/** Deferred discovery must respect the same policy as the initial tool snapshot. */ +export async function prepareCustomModelToolContext( + context: T, + prepare: ((context: T) => Promise) | undefined, + options: CustomModelOptions | undefined, +): Promise { + return options?.toolCall === false || !prepare ? context : prepare(context); +} diff --git a/tests/e2e/custom-model-options.spec.ts b/tests/e2e/custom-model-options.spec.ts new file mode 100644 index 00000000..894e9a2f --- /dev/null +++ b/tests/e2e/custom-model-options.spec.ts @@ -0,0 +1,165 @@ +import { + E2E_MODEL_ID, + E2E_MODEL_DISPLAY_NAME, + expect, + finishLmStudioOnboarding, + test, +} from "./fixtures"; + +test("custom model options survive save and rediscovery, and can be reset", async ({ + aiden, +}) => { + const { page } = aiden; + await finishLmStudioOnboarding(page); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page + .getByRole("navigation", { name: "Settings" }) + .getByRole("button", { name: "Providers", exact: true }) + .click(); + const configure = page + .getByText("LM Studio (local)", { exact: true }) + .locator("xpath=ancestor::div[.//button[normalize-space()='Configure']][1]") + .getByRole("button", { name: "Configure", exact: true }); + const dialog = page.getByRole("dialog", { + name: "Configure LM Studio (local)", + }); + await configure.click(); + await dialog + .getByRole("button", { name: "More options", exact: true }) + .click(); + await dialog + .locator("summary") + .filter({ hasText: E2E_MODEL_DISPLAY_NAME }) + .click(); + const vision = dialog.getByRole("switch", { + name: `${E2E_MODEL_ID}: Vision`, + exact: true, + }); + await expect(vision).toHaveAttribute("data-state", "checked"); + await vision.click(); + await dialog + .getByRole("switch", { name: `${E2E_MODEL_ID}: Open weights`, exact: true }) + .click(); + await dialog + .getByRole("spinbutton", { + name: `${E2E_MODEL_ID}: Context length (tokens)`, + exact: true, + }) + .fill("8192"); + await dialog + .getByRole("spinbutton", { + name: `${E2E_MODEL_ID}: Maximum images per message`, + exact: true, + }) + .fill("2"); + await dialog.getByRole("button", { name: "Save", exact: true }).click(); + await expect(dialog).toBeHidden(); + await configure.click(); + await dialog + .getByRole("button", { name: "More options", exact: true }) + .click(); + await dialog + .locator("summary") + .filter({ hasText: E2E_MODEL_DISPLAY_NAME }) + .click(); + await expect(vision).toHaveAttribute("data-state", "unchecked"); + await expect( + dialog.getByRole("spinbutton", { + name: `${E2E_MODEL_ID}: Context length (tokens)`, + exact: true, + }), + ).toHaveValue("8192"); + await dialog + .getByRole("button", { name: "Discover models", exact: true }) + .click(); + await expect( + dialog.getByRole("button", { name: "Discover models", exact: true }), + ).toBeEnabled(); + await expect(vision).toHaveAttribute("data-state", "unchecked"); + const contextInput = dialog.getByRole("spinbutton", { + name: `${E2E_MODEL_ID}: Context length (tokens)`, + exact: true, + }); + await contextInput.fill(""); + await expect(contextInput).toHaveAttribute("placeholder", "32768"); + await dialog + .getByRole("button", { name: "Use detected capabilities", exact: true }) + .click(); + await expect(vision).toHaveAttribute("data-state", "checked"); + await expect( + dialog.getByRole("switch", { + name: `${E2E_MODEL_ID}: Open weights`, + exact: true, + }), + ).toHaveAttribute("data-state", "unchecked"); + await dialog + .getByRole("textbox", { name: "Model ID", exact: true }) + .fill("manual-private-model"); + await dialog.getByRole("button", { name: "Add", exact: true }).click(); + await dialog + .locator("summary") + .filter({ hasText: "manual-private-model" }) + .click(); + await dialog + .getByRole("spinbutton", { + name: "manual-private-model: Context length (tokens)", + exact: true, + }) + .fill("7777"); + await dialog.getByRole("button", { name: "Save", exact: true }).click(); + await expect(dialog).toBeHidden(); + await configure.click(); + await dialog + .getByRole("button", { name: "More options", exact: true }) + .click(); + await dialog + .getByRole("button", { name: "Discover models", exact: true }) + .click(); + await expect( + dialog.getByRole("button", { name: "Discover models", exact: true }), + ).toBeEnabled(); + await expect( + dialog.locator("summary").filter({ hasText: "manual-private-model" }), + ).toBeVisible(); + await dialog + .getByRole("group", { name: "Base URL", exact: true }) + .locator("input") + .fill("http://127.0.0.1:1/v1"); + await dialog + .getByRole("textbox", { name: "Model ID", exact: true }) + .fill("manual-without-discovery"); + await dialog.getByRole("button", { name: "Add", exact: true }).click(); + await dialog.getByRole("button", { name: "Save", exact: true }).click(); + await expect(dialog).toBeHidden(); + await configure.click(); + await dialog + .getByRole("button", { name: "More options", exact: true }) + .click(); + await dialog + .getByRole("group", { name: "Base URL", exact: true }) + .locator("input") + .fill("http://127.0.0.1:2/v1"); + await dialog + .getByRole("textbox", { name: "Model ID", exact: true }) + .fill("manual-without-discovery"); + await dialog.getByRole("button", { name: "Add", exact: true }).click(); + await dialog.getByRole("button", { name: "Save", exact: true }).click(); + await expect(dialog).toBeHidden(); + await configure.click(); + await dialog + .getByRole("button", { name: "More options", exact: true }) + .click(); + await expect( + dialog.locator("summary").filter({ hasText: "manual-without-discovery" }), + ).toBeVisible(); + await dialog + .locator("summary") + .filter({ hasText: "manual-private-model" }) + .click(); + await expect( + dialog.getByRole("spinbutton", { + name: "manual-private-model: Context length (tokens)", + exact: true, + }), + ).toHaveValue("7777"); +});