Preserve Claude model and thinking preferences (#2912)
* fix(agents): preserve Claude thinking selections Model switches now resolve thinking against the target model's saved preference instead of resetting to the catalog default. Claude owns its configured-model defaults and legacy Fable alias handling, while persisted permission modes remain unchanged. * chore(claude): tag Fable compatibility shim * fix(app): resolve persisted model aliases * fix(models): preserve aliases for older clients * fix(models): hide compatibility aliases from listings * fix(settings): hide compatibility model aliases * test(claude): cover legacy catalog entry * test(app): expect mock thinking controls in schedules * fix(app): preserve queued thinking preferences * fix(app): reconcile schedule provider snapshots * fix(app): preserve schedule model selections * fix(claude): preserve exact configured model ids * fix(app): preserve committed thinking preferences * fix(app): preserve aliased schedule thinking
This commit is contained in:
@@ -696,7 +696,7 @@ Each entry in the `models` array:
|
|||||||
|
|
||||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||||
|
|
||||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
||||||
|
|
||||||
### Gotcha: `extends: "claude"` with third-party endpoints
|
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ test.describe("Schedules", () => {
|
|||||||
await expectSettled(projectTrigger);
|
await expectSettled(projectTrigger);
|
||||||
await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 });
|
await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 });
|
||||||
await expectSettled(modelTrigger);
|
await expectSettled(modelTrigger);
|
||||||
await expect(thinkingTrigger).toHaveCount(0);
|
await expect(thinkingTrigger).toContainText("Low");
|
||||||
|
await expectSettled(thinkingTrigger);
|
||||||
await expect(modeTrigger).toBeVisible({ timeout: 30_000 });
|
await expect(modeTrigger).toBeVisible({ timeout: 30_000 });
|
||||||
await expectSettled(modeTrigger);
|
await expectSettled(modeTrigger);
|
||||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||||
|
|||||||
@@ -217,7 +217,8 @@ test.describe("Schedules project target", () => {
|
|||||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
||||||
await expect(modelTrigger).toContainText("Ten second stream");
|
await expect(modelTrigger).toContainText("Ten second stream");
|
||||||
await expectSettled(modelTrigger);
|
await expectSettled(modelTrigger);
|
||||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
await expect(page.getByTestId("schedule-thinking-trigger")).toContainText("Low");
|
||||||
|
await expectSettled(page.getByTestId("schedule-thinking-trigger"));
|
||||||
await expect(page.getByTestId("schedule-mode-trigger")).toBeVisible();
|
await expect(page.getByTestId("schedule-mode-trigger")).toBeVisible();
|
||||||
await expectSettled(page.getByTestId("schedule-mode-trigger"));
|
await expectSettled(page.getByTestId("schedule-mode-trigger"));
|
||||||
await expect(page.getByTestId("schedule-isolation-trigger")).toHaveCount(0);
|
await expect(page.getByTestId("schedule-isolation-trigger")).toHaveCount(0);
|
||||||
@@ -327,7 +328,8 @@ test.describe("Schedules project target", () => {
|
|||||||
await selectModelByLabel(page, "Ten second stream");
|
await selectModelByLabel(page, "Ten second stream");
|
||||||
await expect(modelTrigger).toContainText("Ten second stream");
|
await expect(modelTrigger).toContainText("Ten second stream");
|
||||||
await expectSettled(modelTrigger);
|
await expectSettled(modelTrigger);
|
||||||
await expect(thinkingTrigger).toHaveCount(0);
|
await expect(thinkingTrigger).toContainText("Low");
|
||||||
|
await expectSettled(thinkingTrigger);
|
||||||
await expect(modeTrigger).toBeVisible();
|
await expect(modeTrigger).toBeVisible();
|
||||||
await expectSettled(modeTrigger);
|
await expectSettled(modeTrigger);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
|
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import {
|
||||||
|
chooseCommandCenterAgentControl,
|
||||||
|
submitDraftAgent,
|
||||||
|
waitForDraftComposer,
|
||||||
|
} from "../support/helpers/command-center-agent-controls";
|
||||||
|
import { openCommandCenter } from "../support/helpers/command-center";
|
||||||
|
import { selectModel } from "../support/helpers/app";
|
||||||
|
|
||||||
|
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||||
|
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||||
|
|
||||||
|
async function openNewAgentTab(page: Page): Promise<void> {
|
||||||
|
await clickNewChat(page);
|
||||||
|
await waitForDraftComposer(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseDraftControl(page: Page, query: string, choice: string): Promise<void> {
|
||||||
|
await openCommandCenter(page);
|
||||||
|
await chooseCommandCenterAgentControl({ page, query, choice });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectThinkingSelected(page: Page, label: string): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByRole("button", { name: `Select thinking option (${label})` })
|
||||||
|
.filter({ visible: true }),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectModeSelected(page: Page, label: string): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: `Select agent mode (${label})` }).filter({ visible: true }),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadWithPersistedPreferences(page: Page): Promise<void> {
|
||||||
|
await page.evaluate(
|
||||||
|
({ disableKey, nonceKey }) => {
|
||||||
|
const nonce = localStorage.getItem(nonceKey);
|
||||||
|
if (!nonce) throw new Error("Expected the Playwright seed nonce before reload");
|
||||||
|
localStorage.setItem(disableKey, nonce);
|
||||||
|
},
|
||||||
|
{ disableKey: DISABLE_DEFAULT_SEED_ONCE_KEY, nonceKey: SEED_NONCE_KEY },
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
await waitForDraftComposer(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRememberedThinking(page: Page, modelId: string): Promise<string | null> {
|
||||||
|
return page.evaluate((selectedModelId) => {
|
||||||
|
const raw = localStorage.getItem("@paseo:create-agent-preferences");
|
||||||
|
if (!raw) return null;
|
||||||
|
const preferences = JSON.parse(raw) as {
|
||||||
|
providerPreferences?: { mock?: { thinkingByModel?: Record<string, string> } };
|
||||||
|
};
|
||||||
|
return preferences.providerPreferences?.mock?.thinkingByModel?.[selectedModelId] ?? null;
|
||||||
|
}, modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedLegacyModelPreference(page: Page): Promise<void> {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const storageKey = "@paseo:create-agent-preferences";
|
||||||
|
const raw = localStorage.getItem(storageKey);
|
||||||
|
const preferences = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
||||||
|
localStorage.setItem(
|
||||||
|
storageKey,
|
||||||
|
JSON.stringify({
|
||||||
|
...preferences,
|
||||||
|
provider: "mock",
|
||||||
|
providerPreferences: {
|
||||||
|
...(preferences.providerPreferences as Record<string, unknown> | undefined),
|
||||||
|
mock: {
|
||||||
|
model: "legacy-five-minute-stream",
|
||||||
|
thinkingByModel: { "legacy-five-minute-stream": "medium" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Workspace draft thinking preferences", () => {
|
||||||
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
|
test("restores a model's remembered thinking after another model and a reload", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "draft-thinking-preferences-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await openNewAgentTab(page);
|
||||||
|
await chooseDraftControl(
|
||||||
|
page,
|
||||||
|
"five minute stream",
|
||||||
|
"Model › Mock Load Test › Five minute stream",
|
||||||
|
);
|
||||||
|
await chooseDraftControl(page, "medium", "Thinking › Medium");
|
||||||
|
await expectThinkingSelected(page, "Medium");
|
||||||
|
await chooseDraftControl(
|
||||||
|
page,
|
||||||
|
"ten second stream",
|
||||||
|
"Model › Mock Load Test › Ten second stream",
|
||||||
|
);
|
||||||
|
await chooseDraftControl(
|
||||||
|
page,
|
||||||
|
"five minute stream",
|
||||||
|
"Model › Mock Load Test › Five minute stream",
|
||||||
|
);
|
||||||
|
await expectThinkingSelected(page, "Medium");
|
||||||
|
await submitDraftAgent(page, "Remember medium thinking for this model");
|
||||||
|
|
||||||
|
await openNewAgentTab(page);
|
||||||
|
await expectThinkingSelected(page, "Medium");
|
||||||
|
await chooseDraftControl(
|
||||||
|
page,
|
||||||
|
"ten second stream",
|
||||||
|
"Model › Mock Load Test › Ten second stream",
|
||||||
|
);
|
||||||
|
await submitDraftAgent(page, "Hello from the other model");
|
||||||
|
|
||||||
|
await openNewAgentTab(page);
|
||||||
|
await reloadWithPersistedPreferences(page);
|
||||||
|
await expect.poll(() => readRememberedThinking(page, "five-minute-stream")).toBe("medium");
|
||||||
|
await chooseDraftControl(
|
||||||
|
page,
|
||||||
|
"five minute stream",
|
||||||
|
"Model › Mock Load Test › Five minute stream",
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectThinkingSelected(page, "Medium");
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restores a provider's permission mode after another provider and a reload", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "draft-mode-preferences-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await openNewAgentTab(page);
|
||||||
|
await chooseDraftControl(
|
||||||
|
page,
|
||||||
|
"five minute stream",
|
||||||
|
"Model › Mock Load Test › Five minute stream",
|
||||||
|
);
|
||||||
|
await chooseDraftControl(page, "approval test", "Mode › Approval test");
|
||||||
|
await expectModeSelected(page, "Approval test");
|
||||||
|
await submitDraftAgent(page, "Remember this permission mode");
|
||||||
|
|
||||||
|
await openNewAgentTab(page);
|
||||||
|
await expectModeSelected(page, "Approval test");
|
||||||
|
await selectModel(page, "gpt-5.4-mini");
|
||||||
|
|
||||||
|
await openNewAgentTab(page);
|
||||||
|
await reloadWithPersistedPreferences(page);
|
||||||
|
await selectModel(page, "Five minute stream");
|
||||||
|
|
||||||
|
await expectModeSelected(page, "Approval test");
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("canonicalizes a retired model preference and restores its thinking option", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "draft-model-alias-preferences-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await openNewAgentTab(page);
|
||||||
|
await seedLegacyModelPreference(page);
|
||||||
|
await reloadWithPersistedPreferences(page);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Select model (Five minute stream)" }),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expectThinkingSelected(page, "Medium");
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,6 +42,23 @@ describe("resolveProviderDiscoveredModels", () => {
|
|||||||
expect(refreshing.models).toEqual([grokModel]);
|
expect(refreshing.models).toEqual([grokModel]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("excludes compatibility-only models from display and cache", () => {
|
||||||
|
const compatibilityModel: AgentModelDefinition = {
|
||||||
|
...piModel,
|
||||||
|
id: "pi/model-legacy",
|
||||||
|
label: "Pi Model legacy",
|
||||||
|
isSelectable: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = resolveModels({
|
||||||
|
provider: "pi",
|
||||||
|
currentModels: [piModel, compatibilityModel],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.models).toEqual([piModel]);
|
||||||
|
expect(result.cache?.models).toEqual([piModel]);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not show one provider's cached models while another provider loads", () => {
|
it("does not show one provider's cached models while another provider loads", () => {
|
||||||
const ready = resolveModels({ provider: "pi", currentModels: [piModel] });
|
const ready = resolveModels({ provider: "pi", currentModels: [piModel] });
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||||
|
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||||
|
|
||||||
export interface ProviderDiscoveredModelsCache {
|
export interface ProviderDiscoveredModelsCache {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -26,9 +27,10 @@ export function resolveProviderDiscoveredModels({
|
|||||||
providerSnapshotRefreshing,
|
providerSnapshotRefreshing,
|
||||||
previousCache,
|
previousCache,
|
||||||
}: ResolveProviderDiscoveredModelsInput): ResolveProviderDiscoveredModelsResult {
|
}: ResolveProviderDiscoveredModelsInput): ResolveProviderDiscoveredModelsResult {
|
||||||
if (currentModels && currentModels.length > 0) {
|
const selectableModels = filterSelectableModels(currentModels ?? null) ?? [];
|
||||||
const cache = { serverId, provider, models: currentModels };
|
if (selectableModels.length > 0) {
|
||||||
return { models: currentModels, cache };
|
const cache = { serverId, provider, models: selectableModels };
|
||||||
|
return { models: selectableModels, cache };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
buildSelectableProviderSelectorProviders,
|
buildSelectableProviderSelectorProviders,
|
||||||
type ProviderSelectorProvider,
|
type ProviderSelectorProvider,
|
||||||
} from "@/provider-selection/provider-selection";
|
} from "@/provider-selection/provider-selection";
|
||||||
|
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||||
@@ -1448,7 +1449,7 @@ export const AgentControls = memo(function AgentControls({
|
|||||||
[snapshotEntries, agent?.provider],
|
[snapshotEntries, agent?.provider],
|
||||||
);
|
);
|
||||||
|
|
||||||
const models = snapshotSelectedEntry?.models ?? null;
|
const models = filterSelectableModels(snapshotSelectedEntry?.models ?? null);
|
||||||
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
|
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
|
||||||
|
|
||||||
const agentProviderDefinitions = useMemo(
|
const agentProviderDefinitions = useMemo(
|
||||||
|
|||||||
@@ -53,6 +53,28 @@ describe("normalizeModelId", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveAgentModelSelection", () => {
|
describe("resolveAgentModelSelection", () => {
|
||||||
|
it("resolves a configured model alias to its canonical catalog model", () => {
|
||||||
|
const selection = resolveAgentModelSelection({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
id: "claude-fable-5",
|
||||||
|
aliases: ["claude-fable-5[1m]"],
|
||||||
|
label: "Fable 5",
|
||||||
|
thinkingOptions: [{ id: "high", label: "High" }],
|
||||||
|
defaultThinkingOptionId: "high",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
runtimeModelId: null,
|
||||||
|
configuredModelId: "claude-fable-5[1m]",
|
||||||
|
explicitThinkingOptionId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selection.activeModelId).toBe("claude-fable-5");
|
||||||
|
expect(selection.displayModel).toBe("Fable 5");
|
||||||
|
expect(selection.selectedThinkingId).toBe("high");
|
||||||
|
});
|
||||||
|
|
||||||
it("prefers runtime model over configured model", () => {
|
it("prefers runtime model over configured model", () => {
|
||||||
const selection = resolveAgentModelSelection({
|
const selection = resolveAgentModelSelection({
|
||||||
models: [
|
models: [
|
||||||
|
|||||||
@@ -55,7 +55,11 @@ function findModelById(
|
|||||||
if (!models || !modelId) {
|
if (!models || !modelId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return models.find((model) => model.id === modelId) ?? null;
|
return (
|
||||||
|
models.find((model) => model.id === modelId) ??
|
||||||
|
models.find((model) => model.aliases?.includes(modelId)) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFallbackModel(models: AgentModelDefinition[] | null): AgentModelDefinition | null {
|
function getFallbackModel(models: AgentModelDefinition[] | null): AgentModelDefinition | null {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { OptimisticFormPreferences } from "./optimistic-preferences";
|
||||||
|
|
||||||
|
describe("optimistic form preferences", () => {
|
||||||
|
it("removes a rejected update while preserving later queued updates", () => {
|
||||||
|
const preferences = new OptimisticFormPreferences({});
|
||||||
|
const failed = preferences.begin({ provider: "claude" });
|
||||||
|
const pending = preferences.begin({ isolation: "worktree" });
|
||||||
|
|
||||||
|
expect(preferences.current()).toEqual({ provider: "claude", isolation: "worktree" });
|
||||||
|
|
||||||
|
preferences.reject(failed);
|
||||||
|
expect(preferences.current()).toEqual({ isolation: "worktree" });
|
||||||
|
|
||||||
|
preferences.commit(pending, { isolation: "worktree" });
|
||||||
|
expect(preferences.current()).toEqual({ isolation: "worktree" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not replace pending optimistic state with a stale external snapshot", () => {
|
||||||
|
const preferences = new OptimisticFormPreferences({ provider: "codex" });
|
||||||
|
const pending = preferences.begin({ provider: "claude" });
|
||||||
|
|
||||||
|
preferences.reconcile({ provider: "codex", isolation: "local" });
|
||||||
|
expect(preferences.current()).toEqual({ provider: "claude" });
|
||||||
|
|
||||||
|
preferences.commit(pending, { provider: "claude" });
|
||||||
|
preferences.reconcile({ provider: "claude", isolation: "worktree" });
|
||||||
|
expect(preferences.current()).toEqual({ provider: "claude", isolation: "worktree" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { FormPreferences } from "./preferences";
|
||||||
|
import type { FormPreferenceUpdate } from "./service";
|
||||||
|
|
||||||
|
type PendingUpdateId = number;
|
||||||
|
|
||||||
|
export class OptimisticFormPreferences {
|
||||||
|
private committed: FormPreferences;
|
||||||
|
private readonly pending = new Map<PendingUpdateId, FormPreferenceUpdate>();
|
||||||
|
private nextId = 0;
|
||||||
|
|
||||||
|
constructor(initial: FormPreferences) {
|
||||||
|
this.committed = initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
current(): FormPreferences {
|
||||||
|
let current = this.committed;
|
||||||
|
for (const update of this.pending.values()) {
|
||||||
|
current = typeof update === "function" ? update(current) : { ...current, ...update };
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
begin(update: FormPreferenceUpdate): PendingUpdateId {
|
||||||
|
const id = this.nextId++;
|
||||||
|
this.pending.set(id, update);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
commit(id: PendingUpdateId, persisted: FormPreferences): void {
|
||||||
|
this.pending.delete(id);
|
||||||
|
this.committed = persisted;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(id: PendingUpdateId): void {
|
||||||
|
this.pending.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
reconcile(preferences: FormPreferences): void {
|
||||||
|
if (this.pending.size === 0) {
|
||||||
|
this.committed = preferences;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,25 @@ describe("create agent preferences", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not commit a failed write or leak it into the next update", async () => {
|
||||||
|
const storage = new FakeCreateAgentPreferenceStorage();
|
||||||
|
const preferences = new CreateAgentPreferencesService(storage);
|
||||||
|
|
||||||
|
const failedWrite = preferences.update({ provider: "claude" });
|
||||||
|
await storage.nextWrite();
|
||||||
|
storage.failOldestWrite(new Error("disk full"));
|
||||||
|
await expect(failedWrite).rejects.toThrow("disk full");
|
||||||
|
|
||||||
|
expect(await preferences.load()).toEqual({});
|
||||||
|
|
||||||
|
const successfulWrite = preferences.update({ isolation: "worktree" });
|
||||||
|
await storage.nextWrite();
|
||||||
|
storage.finishOldestWrite();
|
||||||
|
await successfulWrite;
|
||||||
|
|
||||||
|
expect(storage.savedPreferences()).toEqual({ isolation: "worktree" });
|
||||||
|
});
|
||||||
|
|
||||||
it("flushes the full create-agent selection into provider preferences", async () => {
|
it("flushes the full create-agent selection into provider preferences", async () => {
|
||||||
const storage = new FakeCreateAgentPreferenceStorage();
|
const storage = new FakeCreateAgentPreferenceStorage();
|
||||||
const preferences = new CreateAgentPreferencesService(storage);
|
const preferences = new CreateAgentPreferencesService(storage);
|
||||||
|
|||||||
@@ -50,9 +50,10 @@ export class CreateAgentPreferencesService {
|
|||||||
await previousWrite;
|
await previousWrite;
|
||||||
const current = await this.load();
|
const current = await this.load();
|
||||||
const next = typeof update === "function" ? update(current) : { ...current, ...update };
|
const next = typeof update === "function" ? update(current) : { ...current, ...update };
|
||||||
this.preferences = parseFormPreferences(next);
|
const parsed = parseFormPreferences(next);
|
||||||
|
await this.storage.write(parsed);
|
||||||
|
this.preferences = parsed;
|
||||||
this.isLoaded = true;
|
this.isLoaded = true;
|
||||||
await this.storage.write(this.preferences);
|
|
||||||
return this.preferences;
|
return this.preferences;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { CreateAgentPreferenceStorage } from "../storage";
|
|||||||
interface PendingWrite {
|
interface PendingWrite {
|
||||||
preferences: FormPreferences;
|
preferences: FormPreferences;
|
||||||
finish: () => void;
|
finish: () => void;
|
||||||
|
fail: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage {
|
export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage {
|
||||||
@@ -20,13 +21,16 @@ export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
write(preferences: FormPreferences): Promise<void> {
|
write(preferences: FormPreferences): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve, reject) => {
|
||||||
const write = {
|
const write = {
|
||||||
preferences,
|
preferences,
|
||||||
finish: () => {
|
finish: () => {
|
||||||
this.stored = clone(preferences);
|
this.stored = clone(preferences);
|
||||||
resolve();
|
resolve();
|
||||||
},
|
},
|
||||||
|
fail: (error: Error) => {
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
this.pendingWrites.push(write);
|
this.pendingWrites.push(write);
|
||||||
this.pendingWriteWaiters.shift()?.(write);
|
this.pendingWriteWaiters.shift()?.(write);
|
||||||
@@ -55,6 +59,14 @@ export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceSt
|
|||||||
write.finish();
|
write.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
failOldestWrite(error: Error): void {
|
||||||
|
const write = this.pendingWrites.shift();
|
||||||
|
if (!write) {
|
||||||
|
throw new Error("No pending create-agent preference write");
|
||||||
|
}
|
||||||
|
write.fail(error);
|
||||||
|
}
|
||||||
|
|
||||||
savedPreferences(): unknown {
|
savedPreferences(): unknown {
|
||||||
return this.stored;
|
return this.stored;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
buildSelectableProviderSelectorProviders,
|
buildSelectableProviderSelectorProviders,
|
||||||
type ProviderSelectorProvider,
|
type ProviderSelectorProvider,
|
||||||
} from "@/provider-selection/provider-selection";
|
} from "@/provider-selection/provider-selection";
|
||||||
|
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||||
|
import { OptimisticFormPreferences } from "@/create-agent-preferences/optimistic-preferences";
|
||||||
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
||||||
import {
|
import {
|
||||||
useFormPreferences,
|
useFormPreferences,
|
||||||
@@ -156,7 +158,7 @@ function buildAllProviderModels(
|
|||||||
): Map<string, AgentModelDefinition[]> {
|
): Map<string, AgentModelDefinition[]> {
|
||||||
const map = new Map<string, AgentModelDefinition[]>();
|
const map = new Map<string, AgentModelDefinition[]>();
|
||||||
for (const entry of snapshotEntries ?? []) {
|
for (const entry of snapshotEntries ?? []) {
|
||||||
map.set(entry.provider, entry.models ?? []);
|
map.set(entry.provider, filterSelectableModels(entry.models ?? []) ?? []);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -166,7 +168,7 @@ function buildProviderModelsByProvider(
|
|||||||
): ProviderModelsByProvider {
|
): ProviderModelsByProvider {
|
||||||
const map: ProviderModelsByProvider = new Map();
|
const map: ProviderModelsByProvider = new Map();
|
||||||
for (const entry of snapshotEntries ?? []) {
|
for (const entry of snapshotEntries ?? []) {
|
||||||
map.set(entry.provider, entry.models ?? null);
|
map.set(entry.provider, filterSelectableModels(entry.models ?? null));
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -177,7 +179,7 @@ async function persistProviderPreferences(input: {
|
|||||||
availableModels: AgentModelDefinition[] | null;
|
availableModels: AgentModelDefinition[] | null;
|
||||||
updatePreferences: (
|
updatePreferences: (
|
||||||
updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences),
|
updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences),
|
||||||
) => Promise<void>;
|
) => Promise<FormPreferences>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { provider, formState, availableModels, updatePreferences } = input;
|
const { provider, formState, availableModels, updatePreferences } = input;
|
||||||
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||||
@@ -208,6 +210,28 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const { preferences, isLoading: isPreferencesLoading, updatePreferences } = useFormPreferences();
|
const { preferences, isLoading: isPreferencesLoading, updatePreferences } = useFormPreferences();
|
||||||
|
const preferenceOverlayRef = useRef(new OptimisticFormPreferences(preferences));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
preferenceOverlayRef.current.reconcile(preferences);
|
||||||
|
}, [preferences]);
|
||||||
|
|
||||||
|
const updateCurrentPreferences = useCallback(
|
||||||
|
async (
|
||||||
|
updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences),
|
||||||
|
): Promise<FormPreferences> => {
|
||||||
|
const pendingId = preferenceOverlayRef.current.begin(updates);
|
||||||
|
try {
|
||||||
|
const persisted = await updatePreferences(updates);
|
||||||
|
preferenceOverlayRef.current.commit(pendingId, persisted);
|
||||||
|
return persisted;
|
||||||
|
} catch (error) {
|
||||||
|
preferenceOverlayRef.current.reject(pendingId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updatePreferences],
|
||||||
|
);
|
||||||
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
|
|
||||||
@@ -294,7 +318,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
: null,
|
: null,
|
||||||
[formState.provider, snapshotEntries],
|
[formState.provider, snapshotEntries],
|
||||||
);
|
);
|
||||||
const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null;
|
const snapshotSelectedProviderModels = filterSelectableModels(
|
||||||
|
snapshotSelectedEntry?.models ?? null,
|
||||||
|
);
|
||||||
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
|
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
|
||||||
const snapshotSelectedProviderModes = resolveSelectedProviderModes({
|
const snapshotSelectedProviderModes = resolveSelectedProviderModes({
|
||||||
selectedEntry: snapshotSelectedEntry,
|
selectedEntry: snapshotSelectedEntry,
|
||||||
@@ -404,7 +430,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
}
|
}
|
||||||
const providerModels = allProviderModels.get(provider) ?? null;
|
const providerModels = allProviderModels.get(provider) ?? null;
|
||||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider];
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_PROVIDER_FROM_USER",
|
type: "SET_PROVIDER_FROM_USER",
|
||||||
@@ -413,14 +439,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
providerDef,
|
providerDef,
|
||||||
providerPrefs,
|
providerPrefs,
|
||||||
});
|
});
|
||||||
void updatePreferences({ provider });
|
void updateCurrentPreferences({ provider });
|
||||||
},
|
},
|
||||||
[
|
[allProviderModels, selectableProviderDefinitionMap, updateCurrentPreferences],
|
||||||
allProviderModels,
|
|
||||||
preferences?.providerPreferences,
|
|
||||||
selectableProviderDefinitionMap,
|
|
||||||
updatePreferences,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const setProviderAndModelFromUser = useCallback(
|
const setProviderAndModelFromUser = useCallback(
|
||||||
@@ -430,7 +451,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
}
|
}
|
||||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||||
const providerModels = allProviderModels.get(provider) ?? null;
|
const providerModels = allProviderModels.get(provider) ?? null;
|
||||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider];
|
||||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||||
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
||||||
|
|
||||||
@@ -442,7 +463,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
providerModels,
|
providerModels,
|
||||||
providerPrefs,
|
providerPrefs,
|
||||||
});
|
});
|
||||||
void updatePreferences((current) =>
|
void updateCurrentPreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
mergeSelectedComposerPreferences({
|
||||||
preferences: current,
|
preferences: current,
|
||||||
provider,
|
provider,
|
||||||
@@ -452,12 +473,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[
|
[allProviderModels, selectableProviderDefinitionMap, updateCurrentPreferences],
|
||||||
allProviderModels,
|
|
||||||
preferences?.providerPreferences,
|
|
||||||
selectableProviderDefinitionMap,
|
|
||||||
updatePreferences,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearProviderSelectionFromUser = useCallback(() => {
|
const clearProviderSelectionFromUser = useCallback(() => {
|
||||||
@@ -469,7 +485,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
dispatch({ type: "SET_MODE_FROM_USER", modeId });
|
dispatch({ type: "SET_MODE_FROM_USER", modeId });
|
||||||
const provider = reducerStateRef.current.form.provider;
|
const provider = reducerStateRef.current.form.provider;
|
||||||
if (provider) {
|
if (provider) {
|
||||||
void updatePreferences((current) =>
|
void updateCurrentPreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
mergeSelectedComposerPreferences({
|
||||||
preferences: current,
|
preferences: current,
|
||||||
provider,
|
provider,
|
||||||
@@ -480,17 +496,25 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updatePreferences],
|
[updateCurrentPreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setModelFromUser = useCallback(
|
const setModelFromUser = useCallback(
|
||||||
(modelId: string) => {
|
(modelId: string) => {
|
||||||
dispatch({ type: "SET_MODEL_FROM_USER", modelId, availableModels });
|
|
||||||
const provider = reducerStateRef.current.form.provider;
|
const provider = reducerStateRef.current.form.provider;
|
||||||
|
const providerPrefs = provider
|
||||||
|
? preferenceOverlayRef.current.current().providerPreferences?.[provider]
|
||||||
|
: undefined;
|
||||||
|
dispatch({
|
||||||
|
type: "SET_MODEL_FROM_USER",
|
||||||
|
modelId,
|
||||||
|
availableModels,
|
||||||
|
providerPrefs,
|
||||||
|
});
|
||||||
if (provider) {
|
if (provider) {
|
||||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||||
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
|
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
|
||||||
void updatePreferences((current) =>
|
void updateCurrentPreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
mergeSelectedComposerPreferences({
|
||||||
preferences: current,
|
preferences: current,
|
||||||
provider,
|
provider,
|
||||||
@@ -501,7 +525,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[availableModels, updatePreferences],
|
[availableModels, updateCurrentPreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setThinkingOptionFromUser = useCallback(
|
const setThinkingOptionFromUser = useCallback(
|
||||||
@@ -509,7 +533,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId });
|
dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId });
|
||||||
const { provider, model: modelId } = reducerStateRef.current.form;
|
const { provider, model: modelId } = reducerStateRef.current.form;
|
||||||
if (provider && modelId) {
|
if (provider && modelId) {
|
||||||
void updatePreferences((current) =>
|
void updateCurrentPreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
mergeSelectedComposerPreferences({
|
||||||
preferences: current,
|
preferences: current,
|
||||||
provider,
|
provider,
|
||||||
@@ -522,7 +546,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updatePreferences],
|
[updateCurrentPreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setWorkingDir = useCallback((value: string) => {
|
const setWorkingDir = useCallback((value: string) => {
|
||||||
@@ -556,9 +580,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
provider: formState.provider,
|
provider: formState.provider,
|
||||||
formState,
|
formState,
|
||||||
availableModels,
|
availableModels,
|
||||||
updatePreferences,
|
updatePreferences: updateCurrentPreferences,
|
||||||
});
|
});
|
||||||
}, [availableModels, formState, updatePreferences]);
|
}, [availableModels, formState, updateCurrentPreferences]);
|
||||||
|
|
||||||
const agentDefinition = formState.provider
|
const agentDefinition = formState.provider
|
||||||
? providerDefinitionMap.get(formState.provider)
|
? providerDefinitionMap.get(formState.provider)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ async function loadFormPreferences(): Promise<FormPreferences> {
|
|||||||
export interface UseFormPreferencesReturn {
|
export interface UseFormPreferencesReturn {
|
||||||
preferences: FormPreferences;
|
preferences: FormPreferences;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
updatePreferences: (updates: FormPreferenceUpdate) => Promise<void>;
|
updatePreferences: (updates: FormPreferenceUpdate) => Promise<FormPreferences>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFormPreferences(): UseFormPreferencesReturn {
|
export function useFormPreferences(): UseFormPreferencesReturn {
|
||||||
@@ -47,6 +47,7 @@ export function useFormPreferences(): UseFormPreferencesReturn {
|
|||||||
async (updates: FormPreferenceUpdate) => {
|
async (updates: FormPreferenceUpdate) => {
|
||||||
const next = await createAgentPreferencesService.update(updates);
|
const next = await createAgentPreferencesService.update(updates);
|
||||||
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
|
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
|
||||||
|
return next;
|
||||||
},
|
},
|
||||||
[queryClient],
|
[queryClient],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { findModelByReference } from "./model-catalog";
|
||||||
|
|
||||||
|
describe("findModelByReference", () => {
|
||||||
|
it("prefers an exact model id over another model's alias", () => {
|
||||||
|
const models: AgentModelDefinition[] = [
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
id: "canonical-model",
|
||||||
|
label: "Canonical model",
|
||||||
|
aliases: ["gateway-model"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
id: "gateway-model",
|
||||||
|
label: "Exact gateway model",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(findModelByReference(models, "gateway-model")?.label).toBe("Exact gateway model");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||||
|
|
||||||
|
export function findModelByReference(
|
||||||
|
models: AgentModelDefinition[] | null,
|
||||||
|
modelId: string,
|
||||||
|
): AgentModelDefinition | null {
|
||||||
|
if (!models || models.length === 0) return null;
|
||||||
|
const normalizedModelId = modelId.trim();
|
||||||
|
if (!normalizedModelId) return null;
|
||||||
|
return (
|
||||||
|
models.find((model) => model.id === normalizedModelId) ??
|
||||||
|
models.find((model) => model.aliases?.includes(normalizedModelId)) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterSelectableModels(
|
||||||
|
models: AgentModelDefinition[] | null,
|
||||||
|
): AgentModelDefinition[] | null {
|
||||||
|
return models?.filter((model) => model.isSelectable !== false) ?? null;
|
||||||
|
}
|
||||||
@@ -66,6 +66,24 @@ describe("combined model selector data", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides compatibility-only model entries from new clients", () => {
|
||||||
|
const compatibilityModel: AgentModelDefinition = {
|
||||||
|
...codexModel,
|
||||||
|
id: "gpt-5.4-legacy",
|
||||||
|
label: "GPT-5.4 legacy",
|
||||||
|
isSelectable: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [provider] = buildSelectableProviderSelectorProviders([
|
||||||
|
snapshotEntry({ provider: "codex", models: [codexModel, compatibilityModel] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(provider?.modelSelection).toMatchObject({
|
||||||
|
kind: "models",
|
||||||
|
rows: [{ modelId: "gpt-5.4" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("synthesizes a default model row for ready enabled providers without explicit models", () => {
|
it("synthesizes a default model row for ready enabled providers without explicit models", () => {
|
||||||
expect(
|
expect(
|
||||||
buildSelectableProviderSelectorProviders([
|
buildSelectableProviderSelectorProviders([
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
|||||||
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
|
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||||
import { i18n } from "@/i18n/i18next";
|
import { i18n } from "@/i18n/i18next";
|
||||||
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
|
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
|
||||||
|
import { filterSelectableModels } from "./model-catalog";
|
||||||
|
|
||||||
export type ProviderSelectionModelRow = FavoriteModelRow & { isDefault?: boolean };
|
export type ProviderSelectionModelRow = FavoriteModelRow & { isDefault?: boolean };
|
||||||
|
|
||||||
@@ -76,10 +77,11 @@ function buildModelSelection(
|
|||||||
if (models === null) {
|
if (models === null) {
|
||||||
return { kind: "loading" };
|
return { kind: "loading" };
|
||||||
}
|
}
|
||||||
if (models.length === 0) {
|
const selectableModels = filterSelectableModels(models) ?? [];
|
||||||
|
if (selectableModels.length === 0) {
|
||||||
return { kind: "models", rows: [buildSyntheticDefaultRow(provider, providerLabel)] };
|
return { kind: "models", rows: [buildSyntheticDefaultRow(provider, providerLabel)] };
|
||||||
}
|
}
|
||||||
return { kind: "models", rows: buildModelRows(provider, providerLabel, models) };
|
return { kind: "models", rows: buildModelRows(provider, providerLabel, selectableModels) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEntryModelSelection(
|
function buildEntryModelSelection(
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ const CODEX_MODELS: AgentModelDefinition[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ALIASED_CODEX_MODELS: AgentModelDefinition[] = [
|
||||||
|
{ ...CODEX_MODELS[0], aliases: ["gpt-5.3-codex-legacy"] },
|
||||||
|
];
|
||||||
|
|
||||||
function makeProviderMap(
|
function makeProviderMap(
|
||||||
...definitions: AgentProviderDefinition[]
|
...definitions: AgentProviderDefinition[]
|
||||||
): Map<AgentProvider, AgentProviderDefinition> {
|
): Map<AgentProvider, AgentProviderDefinition> {
|
||||||
@@ -120,6 +124,79 @@ describe("resolveDefaultModel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("model aliases", () => {
|
||||||
|
it("canonicalizes a retired preferred model and restores thinking from its alias key", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
model: "gpt-5.3-codex-legacy",
|
||||||
|
thinkingByModel: { "gpt-5.3-codex-legacy": "low" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ALIASED_CODEX_MODELS,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState().form,
|
||||||
|
codexProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers thinking stored under the canonical model id over an alias", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
model: "gpt-5.3-codex-legacy",
|
||||||
|
thinkingByModel: {
|
||||||
|
"gpt-5.3-codex": "xhigh",
|
||||||
|
"gpt-5.3-codex-legacy": "low",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ALIASED_CODEX_MODELS,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState().form,
|
||||||
|
codexProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers an exact configured model id over another model's alias", () => {
|
||||||
|
const configuredAlias: AgentModelDefinition = {
|
||||||
|
provider: "codex",
|
||||||
|
id: "gpt-5.3-codex-legacy",
|
||||||
|
label: "Gateway legacy model",
|
||||||
|
defaultThinkingOptionId: "medium",
|
||||||
|
thinkingOptions: [{ id: "medium", label: "medium", isDefault: true }],
|
||||||
|
};
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: { codex: { model: configuredAlias.id } },
|
||||||
|
},
|
||||||
|
[...ALIASED_CODEX_MODELS, configuredAlias],
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState().form,
|
||||||
|
codexProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex-legacy");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("medium");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveThinkingOptionId", () => {
|
describe("resolveThinkingOptionId", () => {
|
||||||
it("returns empty string when model has no thinking options", () => {
|
it("returns empty string when model has no thinking options", () => {
|
||||||
const modelsWithoutThinking: AgentModelDefinition[] = [
|
const modelsWithoutThinking: AgentModelDefinition[] = [
|
||||||
@@ -843,6 +920,7 @@ describe("resolveAgentForm", () => {
|
|||||||
type: "SET_MODEL_FROM_USER",
|
type: "SET_MODEL_FROM_USER",
|
||||||
modelId: "gpt-5.4-codex",
|
modelId: "gpt-5.4-codex",
|
||||||
availableModels: alternateModels,
|
availableModels: alternateModels,
|
||||||
|
providerPrefs: undefined,
|
||||||
});
|
});
|
||||||
const next = resolveAgentForm(userChanged, {
|
const next = resolveAgentForm(userChanged, {
|
||||||
type: "COMPLETE_RESOLUTION",
|
type: "COMPLETE_RESOLUTION",
|
||||||
@@ -925,6 +1003,22 @@ describe("resolveAgentForm", () => {
|
|||||||
expect(next.form.modeId).toBe("auto");
|
expect(next.form.modeId).toBe("auto");
|
||||||
expect(next.form.model).toBe("gpt-5.3-codex");
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("canonicalizes an aliased preferred model and restores its thinking option", () => {
|
||||||
|
const next = resolveAgentForm(makeState(), {
|
||||||
|
type: "SET_PROVIDER_FROM_USER",
|
||||||
|
provider: "codex",
|
||||||
|
providerModels: ALIASED_CODEX_MODELS,
|
||||||
|
providerDef: TEST_CODEX_DEFINITION,
|
||||||
|
providerPrefs: {
|
||||||
|
model: "gpt-5.3-codex-legacy",
|
||||||
|
thinkingByModel: { "gpt-5.3-codex-legacy": "low" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(next.form.thinkingOptionId).toBe("low");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("SET_PROVIDER_AND_MODEL_FROM_USER", () => {
|
describe("SET_PROVIDER_AND_MODEL_FROM_USER", () => {
|
||||||
@@ -981,9 +1075,10 @@ describe("resolveAgentForm", () => {
|
|||||||
modelId: "gpt-5.3-codex",
|
modelId: "gpt-5.3-codex",
|
||||||
providerDef: TEST_CODEX_DEFINITION,
|
providerDef: TEST_CODEX_DEFINITION,
|
||||||
providerModels: CODEX_MODELS,
|
providerModels: CODEX_MODELS,
|
||||||
|
providerPrefs: { thinkingByModel: { "gpt-5.3-codex": "low" } },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(next.form.thinkingOptionId).toBe("xhigh");
|
expect(next.form.thinkingOptionId).toBe("low");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1004,6 +1099,7 @@ describe("resolveAgentForm", () => {
|
|||||||
type: "SET_MODEL_FROM_USER",
|
type: "SET_MODEL_FROM_USER",
|
||||||
modelId: "gpt-5.3-codex",
|
modelId: "gpt-5.3-codex",
|
||||||
availableModels: CODEX_MODELS,
|
availableModels: CODEX_MODELS,
|
||||||
|
providerPrefs: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(next.form.model).toBe("gpt-5.3-codex");
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
@@ -1020,6 +1116,7 @@ describe("resolveAgentForm", () => {
|
|||||||
type: "SET_MODEL_FROM_USER",
|
type: "SET_MODEL_FROM_USER",
|
||||||
modelId: "gpt-5.3-codex",
|
modelId: "gpt-5.3-codex",
|
||||||
availableModels: CODEX_MODELS,
|
availableModels: CODEX_MODELS,
|
||||||
|
providerPrefs: { thinkingByModel: { "gpt-5.3-codex": "xhigh" } },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(next.form.thinkingOptionId).toBe("low");
|
expect(next.form.thinkingOptionId).toBe("low");
|
||||||
@@ -1031,10 +1128,37 @@ describe("resolveAgentForm", () => {
|
|||||||
type: "SET_MODEL_FROM_USER",
|
type: "SET_MODEL_FROM_USER",
|
||||||
modelId: " ",
|
modelId: " ",
|
||||||
availableModels: CODEX_MODELS,
|
availableModels: CODEX_MODELS,
|
||||||
|
providerPrefs: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(next.form.model).toBe("gpt-5.3-codex");
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores the target model's saved thinking option", () => {
|
||||||
|
const models = [
|
||||||
|
...CODEX_MODELS,
|
||||||
|
{
|
||||||
|
provider: "codex" as const,
|
||||||
|
id: "gpt-other",
|
||||||
|
label: "Other",
|
||||||
|
defaultThinkingOptionId: "xhigh",
|
||||||
|
thinkingOptions: CODEX_MODELS[0].thinkingOptions,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const state = makeState({
|
||||||
|
provider: "codex",
|
||||||
|
model: "gpt-other",
|
||||||
|
thinkingOptionId: "xhigh",
|
||||||
|
});
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_MODEL_FROM_USER",
|
||||||
|
modelId: "gpt-5.3-codex",
|
||||||
|
availableModels: models,
|
||||||
|
providerPrefs: { thinkingByModel: { "gpt-5.3-codex": "low" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.thinkingOptionId).toBe("low");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("SET_THINKING_OPTION_FROM_USER", () => {
|
describe("SET_THINKING_OPTION_FROM_USER", () => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type FormPreferences,
|
type FormPreferences,
|
||||||
type ProviderPreferences,
|
type ProviderPreferences,
|
||||||
} from "@/hooks/use-form-preferences";
|
} from "@/hooks/use-form-preferences";
|
||||||
|
import { findModelByReference } from "./model-catalog";
|
||||||
|
|
||||||
export interface FormInitialValues {
|
export interface FormInitialValues {
|
||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
@@ -98,6 +99,7 @@ export type AgentFormAction =
|
|||||||
type: "SET_MODEL_FROM_USER";
|
type: "SET_MODEL_FROM_USER";
|
||||||
modelId: string;
|
modelId: string;
|
||||||
availableModels: AgentModelDefinition[] | null;
|
availableModels: AgentModelDefinition[] | null;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
}
|
}
|
||||||
| { type: "CLEAR_PROVIDER_SELECTION_FROM_USER" }
|
| { type: "CLEAR_PROVIDER_SELECTION_FROM_USER" }
|
||||||
| { type: "SET_THINKING_OPTION_FROM_USER"; thinkingOptionId: string }
|
| { type: "SET_THINKING_OPTION_FROM_USER"; thinkingOptionId: string }
|
||||||
@@ -123,17 +125,36 @@ export function resolveDefaultModelId(availableModels: AgentModelDefinition[] |
|
|||||||
return resolveDefaultModel(availableModels)?.id ?? "";
|
return resolveDefaultModel(availableModels)?.id ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCanonicalModelId(
|
||||||
|
availableModels: AgentModelDefinition[] | null,
|
||||||
|
modelId: string,
|
||||||
|
): string {
|
||||||
|
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||||
|
if (!normalizedModelId || !availableModels) return normalizedModelId;
|
||||||
|
return findModelByReference(availableModels, normalizedModelId)?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveEffectiveModel(
|
export function resolveEffectiveModel(
|
||||||
availableModels: AgentModelDefinition[] | null,
|
availableModels: AgentModelDefinition[] | null,
|
||||||
modelId: string,
|
modelId: string,
|
||||||
): AgentModelDefinition | null {
|
): AgentModelDefinition | null {
|
||||||
if (!availableModels || availableModels.length === 0) return null;
|
if (!availableModels || availableModels.length === 0) return null;
|
||||||
const normalizedModelId = modelId.trim();
|
if (!normalizeSelectedModelId(modelId)) return null;
|
||||||
if (!normalizedModelId) return null;
|
return findModelByReference(availableModels, modelId) ?? resolveDefaultModel(availableModels);
|
||||||
return (
|
}
|
||||||
availableModels.find((model) => model.id === normalizedModelId) ??
|
|
||||||
resolveDefaultModel(availableModels)
|
function resolvePreferredThinkingOptionId(input: {
|
||||||
);
|
availableModels: AgentModelDefinition[] | null;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
modelId: string;
|
||||||
|
}): string {
|
||||||
|
const model = findModelByReference(input.availableModels, input.modelId);
|
||||||
|
const modelReferences = model ? [model.id, ...(model.aliases ?? [])] : [input.modelId];
|
||||||
|
for (const modelReference of modelReferences) {
|
||||||
|
const thinkingOptionId = input.providerPrefs?.thinkingByModel?.[modelReference]?.trim();
|
||||||
|
if (thinkingOptionId) return thinkingOptionId;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveThinkingOptionId(args: {
|
export function resolveThinkingOptionId(args: {
|
||||||
@@ -313,15 +334,18 @@ function resolveModelField(input: {
|
|||||||
input;
|
input;
|
||||||
if (userModified) return currentModel;
|
if (userModified) return currentModel;
|
||||||
if (!provider) return "";
|
if (!provider) return "";
|
||||||
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
|
|
||||||
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
||||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||||
const defaultModelId = resolveDefaultModelId(availableModels);
|
const defaultModelId = resolveDefaultModelId(availableModels);
|
||||||
if (initialModel) {
|
if (initialModel) {
|
||||||
return !availableModels || isValidModel(initialModel) ? initialModel : defaultModelId;
|
return !availableModels
|
||||||
|
? initialModel
|
||||||
|
: resolveCanonicalModelId(availableModels, initialModel) || defaultModelId;
|
||||||
}
|
}
|
||||||
if (preferredModel) {
|
if (preferredModel) {
|
||||||
return !availableModels || isValidModel(preferredModel) ? preferredModel : defaultModelId;
|
return !availableModels
|
||||||
|
? preferredModel
|
||||||
|
: resolveCanonicalModelId(availableModels, preferredModel) || defaultModelId;
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -333,19 +357,28 @@ function resolveThinkingOption(input: {
|
|||||||
modelId: string;
|
modelId: string;
|
||||||
initialValues: FormInitialValues | undefined;
|
initialValues: FormInitialValues | undefined;
|
||||||
providerPrefs: ProviderPrefs | undefined;
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
availableModels: AgentModelDefinition[] | null;
|
||||||
}): string {
|
}): string {
|
||||||
const { provider, userModified, currentThinkingOptionId, modelId, initialValues, providerPrefs } =
|
const {
|
||||||
input;
|
provider,
|
||||||
|
userModified,
|
||||||
|
currentThinkingOptionId,
|
||||||
|
modelId,
|
||||||
|
initialValues,
|
||||||
|
providerPrefs,
|
||||||
|
availableModels,
|
||||||
|
} = input;
|
||||||
if (!provider) return "";
|
if (!provider) return "";
|
||||||
if (userModified) return currentThinkingOptionId;
|
if (userModified) return currentThinkingOptionId;
|
||||||
const initialThinkingOptionId =
|
const initialThinkingOptionId =
|
||||||
typeof initialValues?.thinkingOptionId === "string"
|
typeof initialValues?.thinkingOptionId === "string"
|
||||||
? initialValues.thinkingOptionId.trim()
|
? initialValues.thinkingOptionId.trim()
|
||||||
: "";
|
: "";
|
||||||
const effectiveModelId = modelId.trim();
|
const preferredThinking = resolvePreferredThinkingOptionId({
|
||||||
const preferredThinking = effectiveModelId
|
availableModels,
|
||||||
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
|
providerPrefs,
|
||||||
: "";
|
modelId,
|
||||||
|
});
|
||||||
if (initialThinkingOptionId.length > 0) return initialThinkingOptionId;
|
if (initialThinkingOptionId.length > 0) return initialThinkingOptionId;
|
||||||
if (preferredThinking.length > 0) return preferredThinking;
|
if (preferredThinking.length > 0) return preferredThinking;
|
||||||
return "";
|
return "";
|
||||||
@@ -399,6 +432,7 @@ export function resolveFormState(
|
|||||||
modelId: result.model,
|
modelId: result.model,
|
||||||
initialValues,
|
initialValues,
|
||||||
providerPrefs,
|
providerPrefs,
|
||||||
|
availableModels,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.provider && availableModels) {
|
if (result.provider && availableModels) {
|
||||||
@@ -455,11 +489,11 @@ function pickNextModelForProvider(input: {
|
|||||||
providerPrefs: ProviderPrefs | undefined;
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
}): string {
|
}): string {
|
||||||
const { providerModels, providerPrefs } = input;
|
const { providerModels, providerPrefs } = input;
|
||||||
const isValidModel = (m: string) => providerModels?.some((am) => am.id === m) ?? false;
|
|
||||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||||
const defaultModelId = resolveDefaultModelId(providerModels);
|
const defaultModelId = resolveDefaultModelId(providerModels);
|
||||||
if (preferredModel && (!providerModels || isValidModel(preferredModel))) {
|
if (preferredModel) {
|
||||||
return preferredModel;
|
if (!providerModels) return preferredModel;
|
||||||
|
return resolveCanonicalModelId(providerModels, preferredModel) || defaultModelId;
|
||||||
}
|
}
|
||||||
return defaultModelId;
|
return defaultModelId;
|
||||||
}
|
}
|
||||||
@@ -496,9 +530,11 @@ function pickNextThinkingOptionForProvider(input: {
|
|||||||
modelId: string;
|
modelId: string;
|
||||||
}): string {
|
}): string {
|
||||||
const { providerModels, providerPrefs, modelId } = input;
|
const { providerModels, providerPrefs, modelId } = input;
|
||||||
const preferredThinking = modelId
|
const preferredThinking = resolvePreferredThinkingOptionId({
|
||||||
? (providerPrefs?.thinkingByModel?.[modelId]?.trim() ?? "")
|
availableModels: providerModels,
|
||||||
: "";
|
providerPrefs,
|
||||||
|
modelId,
|
||||||
|
});
|
||||||
return resolveThinkingOptionId({
|
return resolveThinkingOptionId({
|
||||||
availableModels: providerModels,
|
availableModels: providerModels,
|
||||||
modelId,
|
modelId,
|
||||||
@@ -506,6 +542,30 @@ function pickNextThinkingOptionForProvider(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickNextThinkingOptionForTarget(input: {
|
||||||
|
availableModels: AgentModelDefinition[] | null;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
modelId: string;
|
||||||
|
currentModelId: string;
|
||||||
|
currentThinkingOptionId: string;
|
||||||
|
isSameProvider: boolean;
|
||||||
|
}): string {
|
||||||
|
const requestedThinkingOptionId =
|
||||||
|
input.isSameProvider &&
|
||||||
|
resolveCanonicalModelId(input.availableModels, input.currentModelId) === input.modelId
|
||||||
|
? input.currentThinkingOptionId
|
||||||
|
: resolvePreferredThinkingOptionId({
|
||||||
|
availableModels: input.availableModels,
|
||||||
|
providerPrefs: input.providerPrefs,
|
||||||
|
modelId: input.modelId,
|
||||||
|
});
|
||||||
|
return resolveThinkingOptionId({
|
||||||
|
availableModels: input.availableModels,
|
||||||
|
modelId: input.modelId,
|
||||||
|
requestedThinkingOptionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function completeResolution(
|
function completeResolution(
|
||||||
state: AgentFormReducerState,
|
state: AgentFormReducerState,
|
||||||
action: CompleteResolutionAction,
|
action: CompleteResolutionAction,
|
||||||
@@ -579,12 +639,15 @@ export function resolveAgentForm(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "SET_PROVIDER_AND_MODEL_FROM_USER": {
|
case "SET_PROVIDER_AND_MODEL_FROM_USER": {
|
||||||
const normalizedModelId = normalizeSelectedModelId(action.modelId);
|
const normalizedModelId = resolveCanonicalModelId(action.providerModels, action.modelId);
|
||||||
const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels);
|
const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels);
|
||||||
const nextThinkingOptionId = resolveThinkingOptionId({
|
const nextThinkingOptionId = pickNextThinkingOptionForTarget({
|
||||||
availableModels: action.providerModels,
|
availableModels: action.providerModels,
|
||||||
modelId: nextModelId,
|
modelId: nextModelId,
|
||||||
requestedThinkingOptionId: "",
|
providerPrefs: action.providerPrefs,
|
||||||
|
currentModelId: state.form.model,
|
||||||
|
currentThinkingOptionId: state.form.thinkingOptionId,
|
||||||
|
isSameProvider: state.form.provider === action.provider,
|
||||||
});
|
});
|
||||||
const nextModeId = pickNextModeForProviderAndModel({
|
const nextModeId = pickNextModeForProviderAndModel({
|
||||||
currentProvider: state.form.provider,
|
currentProvider: state.form.provider,
|
||||||
@@ -614,14 +677,15 @@ export function resolveAgentForm(
|
|||||||
};
|
};
|
||||||
|
|
||||||
case "SET_MODEL_FROM_USER": {
|
case "SET_MODEL_FROM_USER": {
|
||||||
const normalizedModelId = normalizeSelectedModelId(action.modelId);
|
const normalizedModelId = resolveCanonicalModelId(action.availableModels, action.modelId);
|
||||||
const nextModelId = normalizedModelId || resolveDefaultModelId(action.availableModels);
|
const nextModelId = normalizedModelId || resolveDefaultModelId(action.availableModels);
|
||||||
const nextThinkingOptionId = resolveThinkingOptionId({
|
const nextThinkingOptionId = pickNextThinkingOptionForTarget({
|
||||||
availableModels: action.availableModels,
|
availableModels: action.availableModels,
|
||||||
modelId: nextModelId,
|
modelId: nextModelId,
|
||||||
requestedThinkingOptionId: state.userModified.thinkingOptionId
|
providerPrefs: action.providerPrefs,
|
||||||
? state.form.thinkingOptionId
|
currentModelId: state.form.model,
|
||||||
: "",
|
currentThinkingOptionId: state.form.thinkingOptionId,
|
||||||
|
isSameProvider: true,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const HOST_B_MODELS: AgentModelDefinition[] = [
|
|||||||
provider: "mock",
|
provider: "mock",
|
||||||
id: "model-b",
|
id: "model-b",
|
||||||
label: "Model B",
|
label: "Model B",
|
||||||
|
aliases: ["model-b-legacy"],
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
defaultThinkingOptionId: "high",
|
defaultThinkingOptionId: "high",
|
||||||
thinkingOptions: [
|
thinkingOptions: [
|
||||||
@@ -41,6 +42,20 @@ const HOST_B_MODELS: AgentModelDefinition[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const THINKING_MODELS: AgentModelDefinition[] = [
|
||||||
|
HOST_B_MODELS[0]!,
|
||||||
|
{
|
||||||
|
provider: "mock",
|
||||||
|
id: "model-c",
|
||||||
|
label: "Model C",
|
||||||
|
defaultThinkingOptionId: "low",
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low", isDefault: true },
|
||||||
|
{ id: "high", label: "High" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const ALL_MODELS = [...HOST_A_MODELS, ...HOST_B_MODELS];
|
const ALL_MODELS = [...HOST_A_MODELS, ...HOST_B_MODELS];
|
||||||
|
|
||||||
function target(input: {
|
function target(input: {
|
||||||
@@ -81,6 +96,7 @@ function scheduleOnHost(input: {
|
|||||||
serverName: string;
|
serverName: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
thinkingOptionId?: string | null;
|
||||||
cadence?: ScheduleSummary["cadence"];
|
cadence?: ScheduleSummary["cadence"];
|
||||||
}): TestSchedule {
|
}): TestSchedule {
|
||||||
return {
|
return {
|
||||||
@@ -97,7 +113,8 @@ function scheduleOnHost(input: {
|
|||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
modeId: "load-test",
|
modeId: "load-test",
|
||||||
thinkingOptionId: "high",
|
thinkingOptionId:
|
||||||
|
input.thinkingOptionId === undefined ? "high" : (input.thinkingOptionId ?? undefined),
|
||||||
archiveOnFinish: false,
|
archiveOnFinish: false,
|
||||||
isolation: "worktree",
|
isolation: "worktree",
|
||||||
},
|
},
|
||||||
@@ -133,13 +150,16 @@ function heartbeatOnHost(cadence: ScheduleSummary["cadence"]): TestSchedule {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function providerSnapshot(models: AgentModelDefinition[]): { entries: ProviderSnapshotEntry[] } {
|
function providerSnapshot(
|
||||||
|
models: AgentModelDefinition[],
|
||||||
|
status: ProviderSnapshotEntry["status"] = "ready",
|
||||||
|
): { entries: ProviderSnapshotEntry[] } {
|
||||||
return {
|
return {
|
||||||
entries: [
|
entries: [
|
||||||
{
|
{
|
||||||
provider: "mock",
|
provider: "mock",
|
||||||
label: "Mock",
|
label: "Mock",
|
||||||
status: "ready",
|
status,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
fetchedAt: "2026-07-01T00:00:00.000Z",
|
fetchedAt: "2026-07-01T00:00:00.000Z",
|
||||||
models,
|
models,
|
||||||
@@ -223,6 +243,69 @@ describe("schedule form model", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reconciles untouched thinking when a loading provider snapshot becomes ready", () => {
|
||||||
|
const form = open({
|
||||||
|
mode: "edit",
|
||||||
|
schedule: scheduleOnHost({
|
||||||
|
serverId: "host-b",
|
||||||
|
serverName: "Host B",
|
||||||
|
cwd: "/repo/b",
|
||||||
|
model: "model-b",
|
||||||
|
thinkingOptionId: null,
|
||||||
|
}),
|
||||||
|
defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} },
|
||||||
|
});
|
||||||
|
const loadingModel = { ...HOST_B_MODELS[0] };
|
||||||
|
delete loadingModel.thinkingOptions;
|
||||||
|
delete loadingModel.defaultThinkingOptionId;
|
||||||
|
|
||||||
|
form.applyProviderSnapshot("host-b", providerSnapshot([loadingModel], "loading"));
|
||||||
|
expect(form.getState().selectedThinkingOptionId).toBe("");
|
||||||
|
|
||||||
|
form.applyProviderSnapshot("host-b", providerSnapshot(HOST_B_MODELS));
|
||||||
|
|
||||||
|
expect(form.getState()).toMatchObject({
|
||||||
|
selectedModel: "model-b",
|
||||||
|
selectedThinkingOptionId: "high",
|
||||||
|
selectedThinkingDisplay: { label: "High" },
|
||||||
|
});
|
||||||
|
|
||||||
|
form.setThinking("low");
|
||||||
|
form.applyProviderSnapshot("host-b", providerSnapshot(HOST_B_MODELS));
|
||||||
|
expect(form.getState().selectedThinkingOptionId).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves per-model thinking across model switches and snapshot updates", () => {
|
||||||
|
const form = open({
|
||||||
|
mode: "edit",
|
||||||
|
schedule: scheduleOnHost({
|
||||||
|
serverId: "host-b",
|
||||||
|
serverName: "Host B",
|
||||||
|
cwd: "/repo/b",
|
||||||
|
model: "model-b-legacy",
|
||||||
|
thinkingOptionId: "low",
|
||||||
|
}),
|
||||||
|
defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} },
|
||||||
|
});
|
||||||
|
form.applyProviderSnapshot("host-b", providerSnapshot(THINKING_MODELS));
|
||||||
|
|
||||||
|
form.setModel("mock", "model-b");
|
||||||
|
expect(form.getState().selectedThinkingOptionId).toBe("low");
|
||||||
|
|
||||||
|
form.setThinking("high");
|
||||||
|
form.setModel("mock", "model-c");
|
||||||
|
expect(form.getState().selectedThinkingOptionId).toBe("low");
|
||||||
|
|
||||||
|
form.setModel("mock", "model-b");
|
||||||
|
expect(form.getState().selectedThinkingOptionId).toBe("high");
|
||||||
|
|
||||||
|
form.setModel("mock", "model-b");
|
||||||
|
expect(form.getState().selectedThinkingOptionId).toBe("high");
|
||||||
|
|
||||||
|
form.applyProviderSnapshot("host-b", providerSnapshot(THINKING_MODELS));
|
||||||
|
expect(form.getState().selectedThinkingOptionId).toBe("high");
|
||||||
|
});
|
||||||
|
|
||||||
it("opens create pristine after an edit instance closes", () => {
|
it("opens create pristine after an edit instance closes", () => {
|
||||||
const edit = open({
|
const edit = open({
|
||||||
mode: "edit",
|
mode: "edit",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
buildSelectableProviderSelectorProviders,
|
buildSelectableProviderSelectorProviders,
|
||||||
type ProviderSelectorProvider,
|
type ProviderSelectorProvider,
|
||||||
} from "@/provider-selection/provider-selection";
|
} from "@/provider-selection/provider-selection";
|
||||||
|
import { filterSelectableModels, findModelByReference } from "@/provider-selection/model-catalog";
|
||||||
import {
|
import {
|
||||||
buildProviderDefinitionMapForStatuses,
|
buildProviderDefinitionMapForStatuses,
|
||||||
INITIAL_USER_MODIFIED,
|
INITIAL_USER_MODIFIED,
|
||||||
@@ -221,7 +222,7 @@ function resolveProjectDisplay(input: {
|
|||||||
function buildProviderModelsByProvider(entries: ProviderSnapshotEntry[]): ProviderModelsByProvider {
|
function buildProviderModelsByProvider(entries: ProviderSnapshotEntry[]): ProviderModelsByProvider {
|
||||||
const map: ProviderModelsByProvider = new Map();
|
const map: ProviderModelsByProvider = new Map();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
map.set(entry.provider, entry.models ?? null);
|
map.set(entry.provider, filterSelectableModels(entry.models ?? null));
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -247,7 +248,7 @@ function resolveAvailableModels(
|
|||||||
entries: readonly ProviderSnapshotEntry[],
|
entries: readonly ProviderSnapshotEntry[],
|
||||||
provider: AgentProvider | null,
|
provider: AgentProvider | null,
|
||||||
): AgentModelDefinition[] | null {
|
): AgentModelDefinition[] | null {
|
||||||
return resolveSelectedEntry(entries, provider)?.models ?? null;
|
return filterSelectableModels(resolveSelectedEntry(entries, provider)?.models ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveEffectiveModel(
|
function resolveEffectiveModel(
|
||||||
@@ -789,6 +790,58 @@ function pickModelForProvider(input: {
|
|||||||
return resolveDefaultModelId(resolveAvailableModels(input.entries, input.provider));
|
return resolveDefaultModelId(resolveAvailableModels(input.entries, input.provider));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function thinkingDraftKey(provider: AgentProvider, modelId: string): string {
|
||||||
|
return `${provider}:${modelId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedThinkingDrafts(
|
||||||
|
drafts: Map<string, string>,
|
||||||
|
preferences: FormPreferences | null,
|
||||||
|
): void {
|
||||||
|
for (const [provider, providerPreferences] of Object.entries(
|
||||||
|
preferences?.providerPreferences ?? {},
|
||||||
|
)) {
|
||||||
|
for (const [modelId, thinkingOptionId] of Object.entries(
|
||||||
|
providerPreferences?.thinkingByModel ?? {},
|
||||||
|
)) {
|
||||||
|
const key = thinkingDraftKey(provider as AgentProvider, modelId);
|
||||||
|
if (!drafts.has(key)) {
|
||||||
|
drafts.set(key, thinkingOptionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalizeThinkingDrafts(
|
||||||
|
drafts: Map<string, string>,
|
||||||
|
entries: readonly ProviderSnapshotEntry[],
|
||||||
|
): void {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const models = filterSelectableModels(entry.models ?? null);
|
||||||
|
if (!models) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const model of models) {
|
||||||
|
const canonicalKey = thinkingDraftKey(entry.provider, model.id);
|
||||||
|
if (drafts.has(canonicalKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const resolvedAlias = model.aliases?.find(
|
||||||
|
(alias) =>
|
||||||
|
findModelByReference(models, alias)?.id === model.id &&
|
||||||
|
drafts.has(thinkingDraftKey(entry.provider, alias)),
|
||||||
|
);
|
||||||
|
if (!resolvedAlias) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const aliasedThinking = drafts.get(thinkingDraftKey(entry.provider, resolvedAlias));
|
||||||
|
if (aliasedThinking !== undefined) {
|
||||||
|
drafts.set(canonicalKey, aliasedThinking);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormModel {
|
export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormModel {
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
const initialValues = normalizeInitialValues({
|
const initialValues = normalizeInitialValues({
|
||||||
@@ -799,6 +852,19 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo
|
|||||||
let hosts = snapshot.hosts;
|
let hosts = snapshot.hosts;
|
||||||
let projectTargets = snapshot.defaults.projectTargets;
|
let projectTargets = snapshot.defaults.projectTargets;
|
||||||
let preferences = snapshot.defaults.preferences ?? null;
|
let preferences = snapshot.defaults.preferences ?? null;
|
||||||
|
const thinkingDrafts = new Map<string, string>();
|
||||||
|
seedThinkingDrafts(thinkingDrafts, preferences);
|
||||||
|
const initialAgentConfig = newAgentConfig(snapshot.schedule);
|
||||||
|
if (
|
||||||
|
initialAgentConfig?.provider &&
|
||||||
|
initialAgentConfig.model &&
|
||||||
|
initialAgentConfig.thinkingOptionId
|
||||||
|
) {
|
||||||
|
thinkingDrafts.set(
|
||||||
|
thinkingDraftKey(initialAgentConfig.provider, initialAgentConfig.model),
|
||||||
|
initialAgentConfig.thinkingOptionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
let providerEntries: ProviderSnapshotEntry[] = [];
|
let providerEntries: ProviderSnapshotEntry[] = [];
|
||||||
let userModified = { ...INITIAL_USER_MODIFIED, isolation: false };
|
let userModified = { ...INITIAL_USER_MODIFIED, isolation: false };
|
||||||
const timezone = snapshot.defaults.timezone ?? DEFAULT_TIMEZONE;
|
const timezone = snapshot.defaults.timezone ?? DEFAULT_TIMEZONE;
|
||||||
@@ -913,6 +979,7 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
preferences = normalizedPreferences;
|
preferences = normalizedPreferences;
|
||||||
|
seedThinkingDrafts(thinkingDrafts, preferences);
|
||||||
publish(resolvePreferences(state));
|
publish(resolvePreferences(state));
|
||||||
},
|
},
|
||||||
applyProviderSnapshot(serverId, providerSnapshot) {
|
applyProviderSnapshot(serverId, providerSnapshot) {
|
||||||
@@ -920,17 +987,19 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
providerEntries = providerSnapshot.entries;
|
providerEntries = providerSnapshot.entries;
|
||||||
|
canonicalizeThinkingDrafts(thinkingDrafts, providerEntries);
|
||||||
const isPendingResolution = state.providerSnapshotRequest?.serverId === serverId;
|
const isPendingResolution = state.providerSnapshotRequest?.serverId === serverId;
|
||||||
const resolved = isPendingResolution
|
const resolved =
|
||||||
? resolveSnapshotSelection({
|
state.targetKind === "new-agent"
|
||||||
state,
|
? resolveSnapshotSelection({
|
||||||
snapshot,
|
state,
|
||||||
initialValues,
|
snapshot,
|
||||||
preferences: preferencesForSnapshotResolution(snapshot, preferences),
|
initialValues,
|
||||||
providerEntries,
|
preferences: preferencesForSnapshotResolution(snapshot, preferences),
|
||||||
userModified,
|
providerEntries,
|
||||||
})
|
userModified,
|
||||||
: state;
|
})
|
||||||
|
: state;
|
||||||
const providerResolutionByServerId: Record<string, ProviderResolutionStatus> = {
|
const providerResolutionByServerId: Record<string, ProviderResolutionStatus> = {
|
||||||
...state.providerResolutionByServerId,
|
...state.providerResolutionByServerId,
|
||||||
};
|
};
|
||||||
@@ -1000,9 +1069,19 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo
|
|||||||
const selectedThinkingOptionId = resolveThinkingOptionId({
|
const selectedThinkingOptionId = resolveThinkingOptionId({
|
||||||
availableModels,
|
availableModels,
|
||||||
modelId: selectedModel,
|
modelId: selectedModel,
|
||||||
requestedThinkingOptionId: "",
|
requestedThinkingOptionId:
|
||||||
|
thinkingDrafts.get(thinkingDraftKey(provider, selectedModel)) ?? "",
|
||||||
});
|
});
|
||||||
userModified = { ...userModified, provider: true, model: true };
|
if (selectedModel && selectedThinkingOptionId) {
|
||||||
|
thinkingDrafts.set(thinkingDraftKey(provider, selectedModel), selectedThinkingOptionId);
|
||||||
|
}
|
||||||
|
userModified = {
|
||||||
|
...userModified,
|
||||||
|
provider: true,
|
||||||
|
model: true,
|
||||||
|
modeId: true,
|
||||||
|
thinkingOptionId: true,
|
||||||
|
};
|
||||||
publish({
|
publish({
|
||||||
...state,
|
...state,
|
||||||
selectedProvider: provider,
|
selectedProvider: provider,
|
||||||
@@ -1020,6 +1099,12 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo
|
|||||||
if (closed) {
|
if (closed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (state.selectedProvider && state.selectedModel) {
|
||||||
|
thinkingDrafts.set(
|
||||||
|
thinkingDraftKey(state.selectedProvider, state.selectedModel),
|
||||||
|
thinkingOptionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
userModified = { ...userModified, thinkingOptionId: true };
|
userModified = { ...userModified, thinkingOptionId: true };
|
||||||
publish({ ...state, selectedThinkingOptionId: thinkingOptionId });
|
publish({ ...state, selectedThinkingOptionId: thinkingOptionId });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
|
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||||
import { ChevronRight, MoreHorizontal, Trash2 } from "lucide-react-native";
|
import { ChevronRight, MoreHorizontal, Trash2 } from "lucide-react-native";
|
||||||
|
|
||||||
type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
|
type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
|
||||||
@@ -188,7 +189,7 @@ function ProviderRow({
|
|||||||
entry.error.trim().length > 0
|
entry.error.trim().length > 0
|
||||||
? entry.error.trim()
|
? entry.error.trim()
|
||||||
: null;
|
: null;
|
||||||
const modelCount = entry.models?.length ?? 0;
|
const modelCount = filterSelectableModels(entry.models ?? null)?.length ?? 0;
|
||||||
const providerStatus = getProviderStatus(entry.status, enabled, modelCount, t);
|
const providerStatus = getProviderStatus(entry.status, enabled, modelCount, t);
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
|
|||||||
@@ -108,11 +108,6 @@ const EXPECTED_CLAUDE_MODELS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const EXPECTED_CLAUDE_CONTEXT_MODELS = [
|
const EXPECTED_CLAUDE_CONTEXT_MODELS = [
|
||||||
{
|
|
||||||
id: "claude-fable-5[1m]",
|
|
||||||
model: "Fable 5 1M",
|
|
||||||
descriptionFragment: "1M context window",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "claude-sonnet-5[1m]",
|
id: "claude-sonnet-5[1m]",
|
||||||
model: "Sonnet 5 1M",
|
model: "Sonnet 5 1M",
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export type ProviderStatus = "ready" | "loading" | "error" | "unavailable";
|
|||||||
export interface AgentModelDefinition {
|
export interface AgentModelDefinition {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
id: string;
|
id: string;
|
||||||
|
aliases?: string[];
|
||||||
|
isSelectable?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
|
|||||||
@@ -259,6 +259,8 @@ export const AgentFeatureSchema = z.discriminatedUnion("type", [
|
|||||||
const AgentModelDefinitionSchema = z.object({
|
const AgentModelDefinitionSchema = z.object({
|
||||||
provider: AgentProviderSchema,
|
provider: AgentProviderSchema,
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
aliases: z.array(z.string()).optional(),
|
||||||
|
isSelectable: z.boolean().optional(),
|
||||||
label: z.string(),
|
label: z.string(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
isDefault: z.boolean().optional(),
|
isDefault: z.boolean().optional(),
|
||||||
|
|||||||
@@ -175,6 +175,13 @@ const MOCK_LOAD_TEST_MODES: AgentProviderModeDefinition[] = [
|
|||||||
icon: "ShieldOff",
|
icon: "ShieldOff",
|
||||||
colorTier: "dangerous",
|
colorTier: "dangerous",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "approval-test",
|
||||||
|
label: "Approval Test",
|
||||||
|
description: "Alternate development-only permission mode for preference tests",
|
||||||
|
icon: "ShieldCheck",
|
||||||
|
colorTier: "safe",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const MOCK_SLOW_MODES: AgentProviderModeDefinition[] = [
|
const MOCK_SLOW_MODES: AgentProviderModeDefinition[] = [
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { ProviderSnapshotEntry } from "./agent-types.js";
|
import type { ProviderSnapshotEntry } from "./agent-types.js";
|
||||||
|
import { CompactProviderSnapshotSchema } from "./messages.js";
|
||||||
import { compactProviderSnapshot, expandProviderSnapshot } from "./provider-snapshot-codec.js";
|
import { compactProviderSnapshot, expandProviderSnapshot } from "./provider-snapshot-codec.js";
|
||||||
|
|
||||||
const sharedThinking = [
|
const sharedThinking = [
|
||||||
@@ -21,6 +22,8 @@ function providerEntry(): ProviderSnapshotEntry {
|
|||||||
{
|
{
|
||||||
provider: "pi",
|
provider: "pi",
|
||||||
id: "openai/gpt-a",
|
id: "openai/gpt-a",
|
||||||
|
aliases: ["openai/gpt-a-legacy"],
|
||||||
|
isSelectable: false,
|
||||||
label: "GPT A",
|
label: "GPT A",
|
||||||
description: "openai/gpt-a",
|
description: "openai/gpt-a",
|
||||||
metadata: { provider: "openai", modelId: "gpt-a" },
|
metadata: { provider: "openai", modelId: "gpt-a" },
|
||||||
@@ -48,6 +51,9 @@ describe("provider snapshot codec", () => {
|
|||||||
|
|
||||||
expect(compact.thinkingSets).toHaveLength(1);
|
expect(compact.thinkingSets).toHaveLength(1);
|
||||||
expect(compact.entries[0]?.models?.map((model) => model.thinkingSet)).toEqual([0, 0]);
|
expect(compact.entries[0]?.models?.map((model) => model.thinkingSet)).toEqual([0, 0]);
|
||||||
|
expect(CompactProviderSnapshotSchema.parse(compact)).toEqual(compact);
|
||||||
|
expect(compact.entries[0]?.models?.[0]?.aliases).toEqual(["openai/gpt-a-legacy"]);
|
||||||
|
expect(compact.entries[0]?.models?.[0]?.isSelectable).toBe(false);
|
||||||
expect(expanded).toEqual(original);
|
expect(expanded).toEqual(original);
|
||||||
expect(expanded[0]?.models?.[0]?.thinkingOptions).toBe(
|
expect(expanded[0]?.models?.[0]?.thinkingOptions).toBe(
|
||||||
expanded[0]?.models?.[1]?.thinkingOptions,
|
expanded[0]?.models?.[1]?.thinkingOptions,
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export type ProviderStatus = "ready" | "loading" | "error" | "unavailable";
|
|||||||
export interface AgentModelDefinition {
|
export interface AgentModelDefinition {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
id: string;
|
id: string;
|
||||||
|
aliases?: string[];
|
||||||
|
isSelectable?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
@@ -101,6 +103,12 @@ export function normalizeAgentModelDefinition(model: AgentModelDefinition): Agen
|
|||||||
return { ...model, defaultThinkingOptionId };
|
return { ...model, defaultThinkingOptionId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function filterSelectableAgentModels(
|
||||||
|
models: AgentModelDefinition[] | undefined,
|
||||||
|
): AgentModelDefinition[] {
|
||||||
|
return models?.filter((model) => model.isSelectable !== false) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProviderSnapshotEntry {
|
export interface ProviderSnapshotEntry {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
status: ProviderStatus;
|
status: ProviderStatus;
|
||||||
@@ -703,6 +711,8 @@ export interface AgentClient {
|
|||||||
* The registry is responsible for merging configured model overrides.
|
* The registry is responsible for merging configured model overrides.
|
||||||
*/
|
*/
|
||||||
fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog>;
|
fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog>;
|
||||||
|
/** Apply provider-owned defaults to a model supplied through provider configuration. */
|
||||||
|
resolveConfiguredModel?(model: AgentModelDefinition): AgentModelDefinition;
|
||||||
resolveDefaultModeId?(input: ResolveAgentDefaultModeInput): Promise<string | undefined>;
|
resolveDefaultModeId?(input: ResolveAgentDefaultModeInput): Promise<string | undefined>;
|
||||||
resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult;
|
resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult;
|
||||||
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
|
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ import type {
|
|||||||
ProviderCatalog,
|
ProviderCatalog,
|
||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
|
|
||||||
|
const CLAUDE_CUSTOM_THINKING_FIELDS = {
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low" },
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
{ id: "high", label: "High", isDefault: true },
|
||||||
|
{ id: "max", label: "Max" },
|
||||||
|
],
|
||||||
|
defaultThinkingOptionId: "high",
|
||||||
|
} satisfies Partial<AgentModelDefinition>;
|
||||||
|
|
||||||
const mockState = vi.hoisted(() => {
|
const mockState = vi.hoisted(() => {
|
||||||
interface ConstructorEntry {
|
interface ConstructorEntry {
|
||||||
runtimeSettings?: unknown;
|
runtimeSettings?: unknown;
|
||||||
@@ -64,55 +74,62 @@ vi.mock("../../executable-resolution/executable-resolution.js", () => ({
|
|||||||
isCommandAvailable: mockState.isCommandAvailable,
|
isCommandAvailable: mockState.isCommandAvailable,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./providers/claude/agent.js", () => ({
|
vi.mock("./providers/claude/agent.js", async () => {
|
||||||
ClaudeAgentClient: class ClaudeAgentClient {
|
const { resolveConfiguredClaudeModel } = await import("./providers/claude/models.js");
|
||||||
readonly capabilities = {
|
return {
|
||||||
supportsStreaming: true,
|
ClaudeAgentClient: class ClaudeAgentClient {
|
||||||
supportsSessionPersistence: true,
|
readonly capabilities = {
|
||||||
supportsDynamicModes: true,
|
supportsStreaming: true,
|
||||||
supportsMcpServers: true,
|
supportsSessionPersistence: true,
|
||||||
supportsReasoningStream: true,
|
supportsDynamicModes: true,
|
||||||
supportsToolInvocations: true,
|
supportsMcpServers: true,
|
||||||
};
|
supportsReasoningStream: true,
|
||||||
readonly provider = "claude";
|
supportsToolInvocations: true,
|
||||||
readonly runtimeSettings?: unknown;
|
|
||||||
|
|
||||||
constructor(options: { runtimeSettings?: unknown }) {
|
|
||||||
this.runtimeSettings = options.runtimeSettings;
|
|
||||||
mockState.constructorArgs.claude.push({
|
|
||||||
runtimeSettings: options.runtimeSettings,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async createSession(): Promise<never> {
|
|
||||||
throw new Error("not implemented");
|
|
||||||
}
|
|
||||||
|
|
||||||
async resumeSession(): Promise<never> {
|
|
||||||
throw new Error("not implemented");
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchCatalog(): Promise<ProviderCatalog> {
|
|
||||||
return {
|
|
||||||
models: mockState.runtimeModels.get(this.provider) ?? [],
|
|
||||||
modes: [],
|
|
||||||
};
|
};
|
||||||
}
|
readonly provider = "claude";
|
||||||
|
readonly runtimeSettings?: unknown;
|
||||||
|
|
||||||
async isAvailable(): Promise<boolean> {
|
constructor(options: { runtimeSettings?: unknown }) {
|
||||||
const command: { mode?: string; argv?: string[] } | undefined =
|
this.runtimeSettings = options.runtimeSettings;
|
||||||
typeof this.runtimeSettings === "object" && this.runtimeSettings !== null
|
mockState.constructorArgs.claude.push({
|
||||||
? Reflect.get(this.runtimeSettings, "command")
|
runtimeSettings: options.runtimeSettings,
|
||||||
: undefined;
|
});
|
||||||
if (command?.mode === "replace") {
|
|
||||||
const { isCommandAvailable } =
|
|
||||||
await import("../../executable-resolution/executable-resolution.js");
|
|
||||||
return await isCommandAvailable(command.argv?.[0] ?? "");
|
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
async createSession(): Promise<never> {
|
||||||
},
|
throw new Error("not implemented");
|
||||||
}));
|
}
|
||||||
|
|
||||||
|
async resumeSession(): Promise<never> {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchCatalog(): Promise<ProviderCatalog> {
|
||||||
|
return {
|
||||||
|
models: mockState.runtimeModels.get(this.provider) ?? [],
|
||||||
|
modes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveConfiguredModel(model: AgentModelDefinition): AgentModelDefinition {
|
||||||
|
return resolveConfiguredClaudeModel(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
const command: { mode?: string; argv?: string[] } | undefined =
|
||||||
|
typeof this.runtimeSettings === "object" && this.runtimeSettings !== null
|
||||||
|
? Reflect.get(this.runtimeSettings, "command")
|
||||||
|
: undefined;
|
||||||
|
if (command?.mode === "replace") {
|
||||||
|
const { isCommandAvailable } =
|
||||||
|
await import("../../executable-resolution/executable-resolution.js");
|
||||||
|
return await isCommandAvailable(command.argv?.[0] ?? "");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("./providers/codex-app-server-agent.js", () => ({
|
vi.mock("./providers/codex-app-server-agent.js", () => ({
|
||||||
CodexAppServerAgentClient: class CodexAppServerAgentClient {
|
CodexAppServerAgentClient: class CodexAppServerAgentClient {
|
||||||
@@ -1114,6 +1131,7 @@ describe("model merging", () => {
|
|||||||
provider: "claude",
|
provider: "claude",
|
||||||
id: "profile-fast",
|
id: "profile-fast",
|
||||||
label: "Profile Fast",
|
label: "Profile Fast",
|
||||||
|
...CLAUDE_CUSTOM_THINKING_FIELDS,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -1160,11 +1178,13 @@ describe("model merging", () => {
|
|||||||
provider: "claude",
|
provider: "claude",
|
||||||
id: "shared-model",
|
id: "shared-model",
|
||||||
label: "Profile Label",
|
label: "Profile Label",
|
||||||
|
...CLAUDE_CUSTOM_THINKING_FIELDS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
id: "profile-model",
|
id: "profile-model",
|
||||||
label: "Profile Model",
|
label: "Profile Model",
|
||||||
|
...CLAUDE_CUSTOM_THINKING_FIELDS,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -1249,6 +1269,7 @@ describe("model merging", () => {
|
|||||||
id: "shared-model",
|
id: "shared-model",
|
||||||
label: "Profile Label",
|
label: "Profile Label",
|
||||||
description: "Runtime description",
|
description: "Runtime description",
|
||||||
|
...CLAUDE_CUSTOM_THINKING_FIELDS,
|
||||||
metadata: {
|
metadata: {
|
||||||
source: "runtime",
|
source: "runtime",
|
||||||
},
|
},
|
||||||
@@ -1314,6 +1335,7 @@ describe("model merging", () => {
|
|||||||
id: "profile-default",
|
id: "profile-default",
|
||||||
label: "Profile Default",
|
label: "Profile Default",
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
|
...CLAUDE_CUSTOM_THINKING_FIELDS,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -1345,6 +1367,45 @@ describe("model merging", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Claude configured models can override or disable inferred thinking options", async () => {
|
||||||
|
const registry = buildProviderRegistry(logger, {
|
||||||
|
providerOverrides: {
|
||||||
|
claude: {
|
||||||
|
models: [
|
||||||
|
{ id: "custom-defaults", label: "Defaults" },
|
||||||
|
{ id: "claude-sonnet-5", label: "Known" },
|
||||||
|
{ id: "claude-opus-5", label: "Disabled", thinkingOptions: [] },
|
||||||
|
{
|
||||||
|
id: "custom-explicit",
|
||||||
|
label: "Explicit",
|
||||||
|
thinkingOptions: [{ id: "bespoke", label: "Bespoke", isDefault: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { models } = await registry.claude.fetchCatalog({
|
||||||
|
scope: "workspace",
|
||||||
|
cwd: "/tmp/registry-models",
|
||||||
|
force: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(models.find((model) => model.id === "custom-defaults")).toMatchObject(
|
||||||
|
CLAUDE_CUSTOM_THINKING_FIELDS,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
models
|
||||||
|
.find((model) => model.id === "claude-sonnet-5")
|
||||||
|
?.thinkingOptions?.map((option) => option.id),
|
||||||
|
).toEqual(["off", "low", "medium", "high", "xhigh", "max", "ultracode"]);
|
||||||
|
expect(models.find((model) => model.id === "claude-opus-5")?.thinkingOptions).toEqual([]);
|
||||||
|
expect(models.find((model) => model.id === "custom-explicit")).toMatchObject({
|
||||||
|
thinkingOptions: [{ id: "bespoke", label: "Bespoke", isDefault: true }],
|
||||||
|
defaultThinkingOptionId: "bespoke",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("built-in createClient().fetchCatalog() honors profile model replacement (issue #579)", async () => {
|
test("built-in createClient().fetchCatalog() honors profile model replacement (issue #579)", async () => {
|
||||||
mockState.runtimeModels.set("codex", [
|
mockState.runtimeModels.set("codex", [
|
||||||
{
|
{
|
||||||
@@ -1415,6 +1476,39 @@ describe("model merging", () => {
|
|||||||
expect(defaultModel?.id).toBe("profile-default");
|
expect(defaultModel?.id).toBe("profile-default");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("explicit additional models override hidden compatibility entries", async () => {
|
||||||
|
mockState.runtimeModels.set("claude", [
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
id: "claude-fable-5[1m]",
|
||||||
|
label: "Fable 5",
|
||||||
|
isSelectable: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const registry = buildProviderRegistry(logger, {
|
||||||
|
providerOverrides: {
|
||||||
|
claude: {
|
||||||
|
additionalModels: [{ id: "claude-fable-5[1m]", label: "Gateway Fable 5" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { models } = await registry.claude.fetchCatalog({
|
||||||
|
scope: "workspace",
|
||||||
|
cwd: "/tmp/registry-models",
|
||||||
|
force: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(models).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "claude-fable-5[1m]",
|
||||||
|
label: "Gateway Fable 5",
|
||||||
|
isSelectable: true,
|
||||||
|
defaultThinkingOptionId: "high",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("built-in Claude models override replaces hardcoded first-party models (issue #1299)", async () => {
|
test("built-in Claude models override replaces hardcoded first-party models (issue #1299)", async () => {
|
||||||
mockState.runtimeModels.set("claude", [
|
mockState.runtimeModels.set("claude", [
|
||||||
{ provider: "claude", id: "claude-opus-4-8", label: "Opus 4.8", isDefault: true },
|
{ provider: "claude", id: "claude-opus-4-8", label: "Opus 4.8", isDefault: true },
|
||||||
|
|||||||
@@ -287,6 +287,17 @@ function mapModel(
|
|||||||
return normalizeAgentModelDefinition({ ...model, provider });
|
return normalizeAgentModelDefinition({ ...model, provider });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveConfiguredModels(
|
||||||
|
provider: AgentProvider,
|
||||||
|
client: AgentClient,
|
||||||
|
models: ProviderProfileModel[],
|
||||||
|
): AgentModelDefinition[] {
|
||||||
|
return models.map((model) => {
|
||||||
|
const mapped = mapModel(provider, model);
|
||||||
|
return client.resolveConfiguredModel?.(mapped) ?? mapped;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function mergeModels(
|
function mergeModels(
|
||||||
provider: AgentProvider,
|
provider: AgentProvider,
|
||||||
profileModels: ProviderProfileModel[],
|
profileModels: ProviderProfileModel[],
|
||||||
@@ -309,7 +320,7 @@ function mergeModels(
|
|||||||
function mergeModelAdditions(
|
function mergeModelAdditions(
|
||||||
provider: AgentProvider,
|
provider: AgentProvider,
|
||||||
baseModels: AgentModelDefinition[],
|
baseModels: AgentModelDefinition[],
|
||||||
modelAdditions: ProviderProfileModel[],
|
modelAdditions: Array<ProviderProfileModel | AgentModelDefinition>,
|
||||||
): AgentModelDefinition[] {
|
): AgentModelDefinition[] {
|
||||||
if (modelAdditions.length === 0) {
|
if (modelAdditions.length === 0) {
|
||||||
return baseModels;
|
return baseModels;
|
||||||
@@ -328,9 +339,13 @@ function mergeModelAdditions(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingModel = mergedModels[existingIndex];
|
||||||
|
const explicitlyEnablesCompatibilityModel =
|
||||||
|
existingModel?.isSelectable === false && additionalModel.isSelectable === undefined;
|
||||||
mergedModels[existingIndex] = {
|
mergedModels[existingIndex] = {
|
||||||
...mergedModels[existingIndex],
|
...existingModel,
|
||||||
...additionalModel,
|
...additionalModel,
|
||||||
|
...(explicitlyEnablesCompatibilityModel ? { isSelectable: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,6 +459,7 @@ function wrapClientProvider(
|
|||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
|
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
|
||||||
|
resolveConfiguredModel: inner.resolveConfiguredModel?.bind(inner),
|
||||||
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
|
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
|
||||||
listFeatures: listFeatures
|
listFeatures: listFeatures
|
||||||
? async (config) => await listFeatures({ ...config, provider: inner.provider })
|
? async (config) => await listFeatures({ ...config, provider: inner.provider })
|
||||||
@@ -490,10 +506,15 @@ function createRegistryEntry(
|
|||||||
resolved: ResolvedProvider,
|
resolved: ResolvedProvider,
|
||||||
): ProviderDefinition {
|
): ProviderDefinition {
|
||||||
const modelClient = resolved.createBaseClient(logger);
|
const modelClient = resolved.createBaseClient(logger);
|
||||||
const hasReplacementModels =
|
const profileModels = resolveConfiguredModels(provider, modelClient, resolved.profileModels);
|
||||||
resolved.profileModels.length > 0 && !resolved.profileModelsAreAdditive;
|
const additionalModels = resolveConfiguredModels(
|
||||||
|
provider,
|
||||||
|
modelClient,
|
||||||
|
resolved.additionalModels,
|
||||||
|
);
|
||||||
|
const hasReplacementModels = profileModels.length > 0 && !resolved.profileModelsAreAdditive;
|
||||||
const replacementModels = hasReplacementModels
|
const replacementModels = hasReplacementModels
|
||||||
? resolved.profileModels.map((model) => mapModel(provider, model))
|
? profileModels.map((model) => mapModel(provider, model))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const decorateModes = (modes: AgentMode[]): AgentMode[] =>
|
const decorateModes = (modes: AgentMode[]): AgentMode[] =>
|
||||||
@@ -524,7 +545,7 @@ function createRegistryEntry(
|
|||||||
// Replacement models skip runtime model discovery, but additionalModels
|
// Replacement models skip runtime model discovery, but additionalModels
|
||||||
// must still be merged on top. If modes are dynamic, probe for modes via
|
// must still be merged on top. If modes are dynamic, probe for modes via
|
||||||
// the single catalog API; otherwise use static/empty modes with no runtime.
|
// the single catalog API; otherwise use static/empty modes with no runtime.
|
||||||
const models = mergeModelAdditions(provider, replacementModels, resolved.additionalModels);
|
const models = mergeModelAdditions(provider, replacementModels, additionalModels);
|
||||||
if (hasStaticModes) {
|
if (hasStaticModes) {
|
||||||
const defaultModeId = await catalogClient.resolveDefaultModeId?.({
|
const defaultModeId = await catalogClient.resolveDefaultModeId?.({
|
||||||
config: {
|
config: {
|
||||||
@@ -545,15 +566,9 @@ function createRegistryEntry(
|
|||||||
const catalog = await catalogClient.fetchCatalog(options);
|
const catalog = await catalogClient.fetchCatalog(options);
|
||||||
return {
|
return {
|
||||||
...catalog,
|
...catalog,
|
||||||
models: mergeModels(
|
models: mergeModels(provider, profileModels, additionalModels, catalog.models, {
|
||||||
provider,
|
profileModelsAreAdditive: resolved.profileModelsAreAdditive,
|
||||||
resolved.profileModels,
|
}),
|
||||||
resolved.additionalModels,
|
|
||||||
catalog.models,
|
|
||||||
{
|
|
||||||
profileModelsAreAdditive: resolved.profileModelsAreAdditive,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
modes: decorateModes(catalog.modes),
|
modes: decorateModes(catalog.modes),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -566,16 +581,17 @@ function createResolvedProviderClient(
|
|||||||
resolved: ResolvedProvider,
|
resolved: ResolvedProvider,
|
||||||
): AgentClient {
|
): AgentClient {
|
||||||
const inner = resolved.createBaseClient(logger);
|
const inner = resolved.createBaseClient(logger);
|
||||||
const hasModelOverrides =
|
const profileModels = resolveConfiguredModels(provider, inner, resolved.profileModels);
|
||||||
resolved.profileModels.length > 0 || resolved.additionalModels.length > 0;
|
const additionalModels = resolveConfiguredModels(provider, inner, resolved.additionalModels);
|
||||||
|
const hasModelOverrides = profileModels.length > 0 || additionalModels.length > 0;
|
||||||
if (inner.provider === provider && !hasModelOverrides) {
|
if (inner.provider === provider && !hasModelOverrides) {
|
||||||
return inner;
|
return inner;
|
||||||
}
|
}
|
||||||
return wrapClientProvider(
|
return wrapClientProvider(
|
||||||
provider,
|
provider,
|
||||||
inner,
|
inner,
|
||||||
resolved.profileModels,
|
profileModels,
|
||||||
resolved.additionalModels,
|
additionalModels,
|
||||||
resolved.profileModelsAreAdditive,
|
resolved.profileModelsAreAdditive,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -440,6 +440,38 @@ describe("ProviderSnapshotManager public surface", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("listModels excludes compatibility-only catalog entries", async () => {
|
||||||
|
const client = createExtraClient("codex", {
|
||||||
|
isAvailable: async () => true,
|
||||||
|
fetchCatalog: async () => ({
|
||||||
|
models: [
|
||||||
|
{ provider: "codex", id: "gpt-5.4", label: "GPT 5.4" },
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "gpt-5.4-legacy",
|
||||||
|
label: "GPT 5.4 legacy",
|
||||||
|
isSelectable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
modes: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const manager = new ProviderSnapshotManager({
|
||||||
|
logger: createTestLogger(),
|
||||||
|
extraClients: { codex: client },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const models = await manager.listModels({
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
provider: "codex",
|
||||||
|
wait: true,
|
||||||
|
});
|
||||||
|
expect(models.map((model) => model.id)).toEqual(["gpt-5.4"]);
|
||||||
|
} finally {
|
||||||
|
manager.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("listModes rejects when the provider is disabled", async () => {
|
test("listModes rejects when the provider is disabled", async () => {
|
||||||
const manager = new ProviderSnapshotManager({
|
const manager = new ProviderSnapshotManager({
|
||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
|
|||||||
@@ -6,14 +6,15 @@ import type { Logger } from "pino";
|
|||||||
|
|
||||||
import { expandTilde } from "../../utils/path.js";
|
import { expandTilde } from "../../utils/path.js";
|
||||||
import { withTimeout } from "../../utils/promise-timeout.js";
|
import { withTimeout } from "../../utils/promise-timeout.js";
|
||||||
import type {
|
import {
|
||||||
AgentClient,
|
filterSelectableAgentModels,
|
||||||
AgentCreateConfigParent,
|
type AgentClient,
|
||||||
AgentMode,
|
type AgentCreateConfigParent,
|
||||||
AgentModelDefinition,
|
type AgentMode,
|
||||||
AgentProvider,
|
type AgentModelDefinition,
|
||||||
FetchCatalogOptions,
|
type AgentProvider,
|
||||||
ProviderSnapshotEntry,
|
type FetchCatalogOptions,
|
||||||
|
type ProviderSnapshotEntry,
|
||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
import type { ManagedAgent } from "./agent-manager.js";
|
import type { ManagedAgent } from "./agent-manager.js";
|
||||||
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||||
@@ -317,7 +318,7 @@ export class ProviderSnapshotManager {
|
|||||||
|
|
||||||
async listModels(input: ProviderSnapshotProviderOptions): Promise<AgentModelDefinition[]> {
|
async listModels(input: ProviderSnapshotProviderOptions): Promise<AgentModelDefinition[]> {
|
||||||
const entry = await this.getReadyProvider(input);
|
const entry = await this.getReadyProvider(input);
|
||||||
return entry.models ?? [];
|
return filterSelectableAgentModels(entry.models);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listModes(input: ProviderSnapshotProviderOptions): Promise<AgentMode[]> {
|
async listModes(input: ProviderSnapshotProviderOptions): Promise<AgentMode[]> {
|
||||||
|
|||||||
@@ -417,8 +417,8 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
|||||||
|
|
||||||
expect(models.map((m) => m.id)).toEqual([
|
expect(models.map((m) => m.id)).toEqual([
|
||||||
"claude-opus-5",
|
"claude-opus-5",
|
||||||
"claude-fable-5[1m]",
|
|
||||||
"claude-fable-5",
|
"claude-fable-5",
|
||||||
|
"claude-fable-5[1m]",
|
||||||
"claude-opus-4-8[1m]",
|
"claude-opus-4-8[1m]",
|
||||||
"claude-opus-4-8",
|
"claude-opus-4-8",
|
||||||
"claude-sonnet-5",
|
"claude-sonnet-5",
|
||||||
@@ -431,6 +431,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
|||||||
"claude-sonnet-4-6",
|
"claude-sonnet-4-6",
|
||||||
"claude-haiku-4-5",
|
"claude-haiku-4-5",
|
||||||
]);
|
]);
|
||||||
|
expect(models.find((model) => model.id === "claude-fable-5[1m]")?.isSelectable).toBe(false);
|
||||||
|
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
expect(model.provider).toBe("claude");
|
expect(model.provider).toBe("claude");
|
||||||
@@ -461,7 +462,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5");
|
expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5");
|
||||||
expect(models.map((model) => model.id)).toContain("claude-fable-5[1m]");
|
expect(models.map((model) => model.id)).toContain("claude-fable-5");
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(emptyConfigDir, { recursive: true, force: true });
|
await fs.rm(emptyConfigDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -626,6 +627,31 @@ describe("ClaudeAgentSession features", () => {
|
|||||||
return { queryFactory, queryMock, launches };
|
return { queryFactory, queryMock, launches };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("passes exact configured Fable 5 IDs through to Claude Code", async () => {
|
||||||
|
const { queryFactory, queryMock } = createQueryMock();
|
||||||
|
const client = new ClaudeAgentClient({
|
||||||
|
logger,
|
||||||
|
queryFactory,
|
||||||
|
resolveBinary: async () => "/test/claude/bin",
|
||||||
|
});
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "claude",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
model: "claude-fable-5[1m]",
|
||||||
|
});
|
||||||
|
|
||||||
|
await (
|
||||||
|
session as unknown as {
|
||||||
|
ensureQuery(): Promise<unknown>;
|
||||||
|
}
|
||||||
|
).ensureQuery();
|
||||||
|
expect(queryFactory.mock.calls[0]?.[0].options.model).toBe("claude-fable-5[1m]");
|
||||||
|
|
||||||
|
await session.setModel?.("claude-fable-5[1m]");
|
||||||
|
expect(queryMock.setModel).toHaveBeenCalledWith("claude-fable-5[1m]");
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("lists fast mode only for supported Opus models", async () => {
|
test("lists fast mode only for supported Opus models", async () => {
|
||||||
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
|
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
|
||||||
|
|
||||||
@@ -751,7 +777,7 @@ describe("ClaudeAgentSession features", () => {
|
|||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
["supported model", "claude-opus-4-8", { type: "disabled" }, undefined],
|
["supported model", "claude-opus-4-8", { type: "disabled" }, undefined],
|
||||||
["unsupported model", "claude-fable-5", { type: "adaptive" }, "low"],
|
["unsupported model", "claude-fable-5", { type: "adaptive" }, "high"],
|
||||||
["custom model", "openrouter/anthropic/claude-opus-4-8", undefined, undefined],
|
["custom model", "openrouter/anthropic/claude-opus-4-8", undefined, undefined],
|
||||||
["provider default", null, undefined, undefined],
|
["provider default", null, undefined, undefined],
|
||||||
])("reconciles Off when switching to a %s", async (_label, modelId, thinking, effort) => {
|
])("reconciles Off when switching to a %s", async (_label, modelId, thinking, effort) => {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
findClaudeModel,
|
findClaudeModel,
|
||||||
getClaudeModelsWithSettings,
|
getClaudeModelsWithSettings,
|
||||||
normalizeClaudeRuntimeModelId,
|
normalizeClaudeRuntimeModelId,
|
||||||
|
resolveConfiguredClaudeModel,
|
||||||
} from "./models.js";
|
} from "./models.js";
|
||||||
import {
|
import {
|
||||||
CLAUDE_DISABLED_THINKING_OPTION_ID,
|
CLAUDE_DISABLED_THINKING_OPTION_ID,
|
||||||
@@ -85,6 +86,7 @@ import {
|
|||||||
type AgentLaunchContext,
|
type AgentLaunchContext,
|
||||||
type AgentMetadata,
|
type AgentMetadata,
|
||||||
type AgentMode,
|
type AgentMode,
|
||||||
|
type AgentModelDefinition,
|
||||||
type AgentPermissionRequest,
|
type AgentPermissionRequest,
|
||||||
type AgentPermissionRequestKind,
|
type AgentPermissionRequestKind,
|
||||||
type AgentPermissionResponse,
|
type AgentPermissionResponse,
|
||||||
@@ -1467,6 +1469,10 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
this.configDir = options.configDir;
|
this.configDir = options.configDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolveConfiguredModel(model: AgentModelDefinition): AgentModelDefinition {
|
||||||
|
return resolveConfiguredClaudeModel(model);
|
||||||
|
}
|
||||||
|
|
||||||
async createSession(
|
async createSession(
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
launchContext?: AgentLaunchContext,
|
launchContext?: AgentLaunchContext,
|
||||||
@@ -1629,7 +1635,12 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
if (config.provider !== "claude") {
|
if (config.provider !== "claude") {
|
||||||
throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`);
|
throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`);
|
||||||
}
|
}
|
||||||
return { ...config, provider: "claude" } as ClaudeAgentConfig;
|
const model = config.model?.trim();
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
provider: "claude",
|
||||||
|
model: model || undefined,
|
||||||
|
} as ClaudeAgentConfig;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2287,7 +2298,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
async setModel(modelId: string | null): Promise<void> {
|
async setModel(modelId: string | null): Promise<void> {
|
||||||
const normalizedModelId =
|
const normalizedModelId =
|
||||||
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
|
typeof modelId === "string" && modelId.trim().length > 0 ? modelId.trim() : null;
|
||||||
const activeQuery = await this.ensureQuery();
|
const activeQuery = await this.ensureQuery();
|
||||||
await activeQuery.setModel(normalizedModelId ?? undefined);
|
await activeQuery.setModel(normalizedModelId ?? undefined);
|
||||||
this.config.model = normalizedModelId ?? undefined;
|
this.config.model = normalizedModelId ?? undefined;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ type ClaudeEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
|
|||||||
|
|
||||||
interface ClaudeModelManifestEntry {
|
interface ClaudeModelManifestEntry {
|
||||||
id: string;
|
id: string;
|
||||||
|
aliases?: readonly string[];
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
defaultPriority?: number;
|
defaultPriority?: number;
|
||||||
@@ -27,6 +28,8 @@ const CLAUDE_EFFORT_LABELS = {
|
|||||||
max: "Max",
|
max: "Max",
|
||||||
} as const satisfies Record<ClaudeEffortLevel, string>;
|
} as const satisfies Record<ClaudeEffortLevel, string>;
|
||||||
|
|
||||||
|
export const CLAUDE_DEFAULT_THINKING_OPTION_ID = "high";
|
||||||
|
|
||||||
export const CLAUDE_DISABLED_THINKING_OPTION_ID = "off";
|
export const CLAUDE_DISABLED_THINKING_OPTION_ID = "off";
|
||||||
export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode";
|
export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode";
|
||||||
|
|
||||||
@@ -41,20 +44,14 @@ export const CLAUDE_MODEL_MANIFEST = [
|
|||||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||||
supportsThinkingDisabled: true,
|
supportsThinkingDisabled: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "claude-fable-5[1m]",
|
|
||||||
label: "Fable 5 1M",
|
|
||||||
description: "Fable 5 with 1M context window",
|
|
||||||
minimumClaudeCodeVersion: "2.1.169",
|
|
||||||
contextWindowMaxTokens: 1_000_000,
|
|
||||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "claude-fable-5",
|
id: "claude-fable-5",
|
||||||
|
// COMPAT(claudeFable5OneMillionId): added in v0.3.0, remove after 2027-02-06 once pre-v0.3.0 app preferences are outside support.
|
||||||
|
aliases: ["claude-fable-5[1m]"],
|
||||||
label: "Fable 5",
|
label: "Fable 5",
|
||||||
description: "Fable 5 · Most powerful model",
|
description: "Fable 5 · Most powerful model",
|
||||||
minimumClaudeCodeVersion: "2.1.169",
|
minimumClaudeCodeVersion: "2.1.169",
|
||||||
contextWindowMaxTokens: 200_000,
|
contextWindowMaxTokens: 1_000_000,
|
||||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -165,6 +162,7 @@ function buildThinkingOptions(
|
|||||||
...effortLevels.map((id) => ({
|
...effortLevels.map((id) => ({
|
||||||
id,
|
id,
|
||||||
label: CLAUDE_EFFORT_LABELS[id],
|
label: CLAUDE_EFFORT_LABELS[id],
|
||||||
|
...(id === CLAUDE_DEFAULT_THINKING_OPTION_ID ? { isDefault: true } : {}),
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -185,7 +183,8 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD
|
|||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
return availableModels.map((model) => {
|
const definitions: AgentModelDefinition[] = [];
|
||||||
|
for (const model of availableModels) {
|
||||||
const thinkingOptions = buildThinkingOptions(
|
const thinkingOptions = buildThinkingOptions(
|
||||||
model.effortLevels,
|
model.effortLevels,
|
||||||
model.supportsThinkingDisabled === true,
|
model.supportsThinkingDisabled === true,
|
||||||
@@ -196,6 +195,9 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD
|
|||||||
label: model.label,
|
label: model.label,
|
||||||
description: model.description,
|
description: model.description,
|
||||||
};
|
};
|
||||||
|
if ("aliases" in model && model.aliases) {
|
||||||
|
definition.aliases = [...model.aliases];
|
||||||
|
}
|
||||||
if (model === defaultModel) {
|
if (model === defaultModel) {
|
||||||
definition.isDefault = true;
|
definition.isDefault = true;
|
||||||
}
|
}
|
||||||
@@ -204,10 +206,24 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD
|
|||||||
}
|
}
|
||||||
if (thinkingOptions) {
|
if (thinkingOptions) {
|
||||||
definition.thinkingOptions = thinkingOptions;
|
definition.thinkingOptions = thinkingOptions;
|
||||||
definition.defaultThinkingOptionId = model.effortLevels?.[0];
|
definition.defaultThinkingOptionId = CLAUDE_DEFAULT_THINKING_OPTION_ID;
|
||||||
}
|
}
|
||||||
return definition;
|
definitions.push(definition);
|
||||||
});
|
if (!("aliases" in model) || !model.aliases) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// COMPAT(claudeFable5LegacyCatalogEntry): added in v0.3.0, remove after 2027-02-06 once pre-v0.3.0 apps are outside support.
|
||||||
|
for (const alias of model.aliases) {
|
||||||
|
definitions.push({
|
||||||
|
...definition,
|
||||||
|
id: alias,
|
||||||
|
aliases: undefined,
|
||||||
|
isDefault: undefined,
|
||||||
|
isSelectable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return definitions;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isModelAvailableInClaudeCode(
|
function isModelAvailableInClaudeCode(
|
||||||
@@ -262,7 +278,7 @@ export function resolveClaudeDisabledThinkingForModel(
|
|||||||
supported:
|
supported:
|
||||||
!!model && "supportsThinkingDisabled" in model && model.supportsThinkingDisabled === true,
|
!!model && "supportsThinkingDisabled" in model && model.supportsThinkingDisabled === true,
|
||||||
fallbackThinkingOptionId:
|
fallbackThinkingOptionId:
|
||||||
model && "effortLevels" in model ? model.effortLevels?.[0] : undefined,
|
model && "effortLevels" in model ? CLAUDE_DEFAULT_THINKING_OPTION_ID : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,6 +384,14 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getClaudeCustomModelThinkingOptions(): AgentSelectOption[] {
|
||||||
|
return CLAUDE_EFFORT_LEVELS.standard.map((id) => {
|
||||||
|
const option: AgentSelectOption = { id, label: CLAUDE_EFFORT_LABELS[id] };
|
||||||
|
if (id === CLAUDE_DEFAULT_THINKING_OPTION_ID) option.isDefault = true;
|
||||||
|
return option;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSingleSegmentClaudeModelId(
|
function normalizeSingleSegmentClaudeModelId(
|
||||||
familyValue: string,
|
familyValue: string,
|
||||||
major: string,
|
major: string,
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ describe("getClaudeModels", () => {
|
|||||||
const models = getClaudeModels();
|
const models = getClaudeModels();
|
||||||
expect(models.map((m) => m.id)).toEqual([
|
expect(models.map((m) => m.id)).toEqual([
|
||||||
"claude-opus-5",
|
"claude-opus-5",
|
||||||
"claude-fable-5[1m]",
|
|
||||||
"claude-fable-5",
|
"claude-fable-5",
|
||||||
|
"claude-fable-5[1m]",
|
||||||
"claude-opus-4-8[1m]",
|
"claude-opus-4-8[1m]",
|
||||||
"claude-opus-4-8",
|
"claude-opus-4-8",
|
||||||
"claude-sonnet-5",
|
"claude-sonnet-5",
|
||||||
@@ -82,8 +82,8 @@ describe("getClaudeModels", () => {
|
|||||||
expect(contextWindows).toEqual(
|
expect(contextWindows).toEqual(
|
||||||
new Map([
|
new Map([
|
||||||
["claude-opus-5", 1_000_000],
|
["claude-opus-5", 1_000_000],
|
||||||
|
["claude-fable-5", 1_000_000],
|
||||||
["claude-fable-5[1m]", 1_000_000],
|
["claude-fable-5[1m]", 1_000_000],
|
||||||
["claude-fable-5", 200_000],
|
|
||||||
["claude-opus-4-8[1m]", 1_000_000],
|
["claude-opus-4-8[1m]", 1_000_000],
|
||||||
["claude-opus-4-8", 200_000],
|
["claude-opus-4-8", 200_000],
|
||||||
["claude-sonnet-5", 200_000],
|
["claude-sonnet-5", 200_000],
|
||||||
@@ -105,9 +105,7 @@ describe("getClaudeModels", () => {
|
|||||||
expect(oldVersionModels.find((model) => model.isDefault)?.id).toBe("claude-opus-4-8");
|
expect(oldVersionModels.find((model) => model.isDefault)?.id).toBe("claude-opus-4-8");
|
||||||
expect(getClaudeModels("2.1.219").map((model) => model.id)).toContain("claude-opus-5");
|
expect(getClaudeModels("2.1.219").map((model) => model.id)).toContain("claude-opus-5");
|
||||||
|
|
||||||
expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5[1m]");
|
|
||||||
expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5");
|
expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5");
|
||||||
expect(getClaudeModels("2.1.169").map((model) => model.id)).toContain("claude-fable-5[1m]");
|
|
||||||
expect(getClaudeModels("2.1.169").map((model) => model.id)).toContain("claude-fable-5");
|
expect(getClaudeModels("2.1.169").map((model) => model.id)).toContain("claude-fable-5");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,7 +136,7 @@ describe("getClaudeModels", () => {
|
|||||||
?.thinkingOptions?.find((option) => option.id === CLAUDE_ULTRACODE_THINKING_OPTION_ID)
|
?.thinkingOptions?.find((option) => option.id === CLAUDE_ULTRACODE_THINKING_OPTION_ID)
|
||||||
?.label,
|
?.label,
|
||||||
).toBe("Ultra Code");
|
).toBe("Ultra Code");
|
||||||
expect(models.get("claude-sonnet-5")?.defaultThinkingOptionId).toBe("low");
|
expect(models.get("claude-sonnet-5")?.defaultThinkingOptionId).toBe("high");
|
||||||
expect(models.get("claude-sonnet-5[1m]")?.thinkingOptions).toEqual(
|
expect(models.get("claude-sonnet-5[1m]")?.thinkingOptions).toEqual(
|
||||||
models.get("claude-sonnet-5")?.thinkingOptions,
|
models.get("claude-sonnet-5")?.thinkingOptions,
|
||||||
);
|
);
|
||||||
@@ -162,19 +160,16 @@ describe("getClaudeModels", () => {
|
|||||||
expect(models.get("claude-fable-5")?.thinkingOptions?.map((option) => option.id)).not.toContain(
|
expect(models.get("claude-fable-5")?.thinkingOptions?.map((option) => option.id)).not.toContain(
|
||||||
CLAUDE_DISABLED_THINKING_OPTION_ID,
|
CLAUDE_DISABLED_THINKING_OPTION_ID,
|
||||||
);
|
);
|
||||||
expect(models.get("claude-fable-5[1m]")?.thinkingOptions).toEqual(
|
|
||||||
models.get("claude-fable-5")?.thinkingOptions,
|
|
||||||
);
|
|
||||||
expect(models.get("claude-haiku-4-5")?.thinkingOptions).toBeUndefined();
|
expect(models.get("claude-haiku-4-5")?.thinkingOptions).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["claude-opus-5", true, "low"],
|
["claude-opus-5", true, "high"],
|
||||||
["claude-opus-5-20260724", true, "low"],
|
["claude-opus-5-20260724", true, "high"],
|
||||||
["claude-sonnet-5", true, "low"],
|
["claude-sonnet-5", true, "high"],
|
||||||
["claude-sonnet-5[1m]", true, "low"],
|
["claude-sonnet-5[1m]", true, "high"],
|
||||||
["claude-sonnet-5-20260101", true, "low"],
|
["claude-sonnet-5-20260101", true, "high"],
|
||||||
["claude-fable-5", false, "low"],
|
["claude-fable-5", false, "high"],
|
||||||
["claude-haiku-4-5", false, undefined],
|
["claude-haiku-4-5", false, undefined],
|
||||||
["openrouter/anthropic/claude-opus-4-8", false, undefined],
|
["openrouter/anthropic/claude-opus-4-8", false, undefined],
|
||||||
[null, false, undefined],
|
[null, false, undefined],
|
||||||
@@ -327,6 +322,26 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lets an exact settings model override the hidden Fable compatibility entry", async () => {
|
||||||
|
const configDir = await createClaudeConfigDir({ model: "claude-fable-5[1m]" });
|
||||||
|
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||||
|
const client = createCatalogClient();
|
||||||
|
|
||||||
|
const { models } = await client.fetchCatalog({
|
||||||
|
scope: "workspace",
|
||||||
|
cwd: os.tmpdir(),
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const configured = models.filter((model) => model.id === "claude-fable-5[1m]");
|
||||||
|
expect(configured).toHaveLength(1);
|
||||||
|
expect(configured[0]).toMatchObject({
|
||||||
|
id: "claude-fable-5[1m]",
|
||||||
|
isSelectable: true,
|
||||||
|
defaultThinkingOptionId: "high",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("omits models that require a newer Claude Code version", async () => {
|
it("omits models that require a newer Claude Code version", async () => {
|
||||||
const client = createCatalogClient("2.1.218");
|
const client = createCatalogClient("2.1.218");
|
||||||
|
|
||||||
@@ -345,7 +360,7 @@ describe("normalizeClaudeRuntimeModelId", () => {
|
|||||||
it("returns exact match for known model IDs", () => {
|
it("returns exact match for known model IDs", () => {
|
||||||
expect(normalizeClaudeRuntimeModelId("claude-opus-5")).toBe("claude-opus-5");
|
expect(normalizeClaudeRuntimeModelId("claude-opus-5")).toBe("claude-opus-5");
|
||||||
expect(normalizeClaudeRuntimeModelId("claude-fable-5")).toBe("claude-fable-5");
|
expect(normalizeClaudeRuntimeModelId("claude-fable-5")).toBe("claude-fable-5");
|
||||||
expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5[1m]");
|
expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5");
|
||||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5")).toBe("claude-sonnet-5");
|
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5")).toBe("claude-sonnet-5");
|
||||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5[1m]")).toBe("claude-sonnet-5[1m]");
|
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5[1m]")).toBe("claude-sonnet-5[1m]");
|
||||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6")).toBe("claude-opus-4-6");
|
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6")).toBe("claude-opus-4-6");
|
||||||
@@ -361,14 +376,14 @@ describe("normalizeClaudeRuntimeModelId", () => {
|
|||||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6-20260101")).toBe("claude-opus-4-6");
|
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6-20260101")).toBe("claude-opus-4-6");
|
||||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6-20260101")).toBe("claude-sonnet-4-6");
|
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6-20260101")).toBe("claude-sonnet-4-6");
|
||||||
expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5");
|
expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5");
|
||||||
expect(normalizeClaudeRuntimeModelId("claude-fable-5-20260301[1m]")).toBe("claude-fable-5[1m]");
|
expect(normalizeClaudeRuntimeModelId("claude-fable-5-20260301[1m]")).toBe("claude-fable-5");
|
||||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5-20260101[1m]")).toBe(
|
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5-20260101[1m]")).toBe(
|
||||||
"claude-sonnet-5[1m]",
|
"claude-sonnet-5[1m]",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves [1m] suffix from runtime model strings", () => {
|
it("preserves [1m] only when it identifies a distinct catalog entry", () => {
|
||||||
expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5[1m]");
|
expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5");
|
||||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5[1m]")).toBe("claude-sonnet-5[1m]");
|
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5[1m]")).toBe("claude-sonnet-5[1m]");
|
||||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
|
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
|
||||||
});
|
});
|
||||||
@@ -435,11 +450,47 @@ describe("Claude Opus 5 catalog", () => {
|
|||||||
it("keeps disabled thinking available for agents persisted on the retired 1M ID", () => {
|
it("keeps disabled thinking available for agents persisted on the retired 1M ID", () => {
|
||||||
expect(resolveClaudeDisabledThinkingForModel("claude-opus-5[1m]")).toEqual({
|
expect(resolveClaudeDisabledThinkingForModel("claude-opus-5[1m]")).toEqual({
|
||||||
supported: true,
|
supported: true,
|
||||||
fallbackThinkingOptionId: "low",
|
fallbackThinkingOptionId: "high",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Claude Fable 5 catalog", () => {
|
||||||
|
it("offers one selectable Fable 5 entry and a compatibility entry for old apps", () => {
|
||||||
|
const fable5Models = getClaudeModels()
|
||||||
|
.filter((model) => model.id.startsWith("claude-fable-5"))
|
||||||
|
.map(({ id, aliases, isSelectable, label, contextWindowMaxTokens }) => ({
|
||||||
|
id,
|
||||||
|
aliases,
|
||||||
|
isSelectable,
|
||||||
|
label,
|
||||||
|
contextWindowMaxTokens,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(fable5Models).toEqual([
|
||||||
|
{
|
||||||
|
id: "claude-fable-5",
|
||||||
|
aliases: ["claude-fable-5[1m]"],
|
||||||
|
isSelectable: undefined,
|
||||||
|
label: "Fable 5",
|
||||||
|
contextWindowMaxTokens: 1_000_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-fable-5[1m]",
|
||||||
|
aliases: undefined,
|
||||||
|
isSelectable: false,
|
||||||
|
label: "Fable 5",
|
||||||
|
contextWindowMaxTokens: 1_000_000,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves retired Fable 5 IDs to the canonical catalog entry", () => {
|
||||||
|
expect(findClaudeModel("claude-fable-5[1m]")?.id).toBe("claude-fable-5");
|
||||||
|
expect(findClaudeModel("claude-fable-5-20260301[1m]")?.id).toBe("claude-fable-5");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("claudeManifestModelSupportsFastMode", () => {
|
describe("claudeManifestModelSupportsFastMode", () => {
|
||||||
it("keeps fast mode strict to first-party manifest model IDs", () => {
|
it("keeps fast mode strict to first-party manifest model IDs", () => {
|
||||||
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
|
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import type { Logger } from "pino";
|
|||||||
|
|
||||||
import type { AgentModelDefinition } from "../../agent-sdk-types.js";
|
import type { AgentModelDefinition } from "../../agent-sdk-types.js";
|
||||||
import {
|
import {
|
||||||
|
getClaudeCustomModelThinkingOptions,
|
||||||
getClaudeManifestModels,
|
getClaudeManifestModels,
|
||||||
|
normalizeClaudeManifestModelId,
|
||||||
normalizeClaudeRuntimeModelId as normalizeClaudeManifestRuntimeModelId,
|
normalizeClaudeRuntimeModelId as normalizeClaudeManifestRuntimeModelId,
|
||||||
} from "./model-manifest.js";
|
} from "./model-manifest.js";
|
||||||
|
|
||||||
@@ -21,6 +23,21 @@ export function getClaudeModels(claudeCodeVersion?: string): AgentModelDefinitio
|
|||||||
return getClaudeManifestModels(claudeCodeVersion);
|
return getClaudeManifestModels(claudeCodeVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveConfiguredClaudeModel(model: AgentModelDefinition): AgentModelDefinition {
|
||||||
|
if (model.thinkingOptions !== undefined) return model;
|
||||||
|
|
||||||
|
const manifestModelId = normalizeClaudeManifestModelId(model.id);
|
||||||
|
const manifestModel = manifestModelId
|
||||||
|
? getClaudeModels().find((candidate) => candidate.id === manifestModelId)
|
||||||
|
: undefined;
|
||||||
|
if (manifestModel) {
|
||||||
|
return manifestModel.thinkingOptions
|
||||||
|
? { ...model, thinkingOptions: manifestModel.thinkingOptions }
|
||||||
|
: model;
|
||||||
|
}
|
||||||
|
return { ...model, thinkingOptions: getClaudeCustomModelThinkingOptions() };
|
||||||
|
}
|
||||||
|
|
||||||
export function findClaudeModel(
|
export function findClaudeModel(
|
||||||
modelId: string | null | undefined,
|
modelId: string | null | undefined,
|
||||||
): AgentModelDefinition | undefined {
|
): AgentModelDefinition | undefined {
|
||||||
@@ -42,14 +59,17 @@ export async function getClaudeModelsWithSettings(
|
|||||||
return hardcodedModels;
|
return hardcodedModels;
|
||||||
}
|
}
|
||||||
|
|
||||||
const seenModelIds = new Set(hardcodedModels.map((model) => model.id));
|
|
||||||
const models = [...hardcodedModels];
|
const models = [...hardcodedModels];
|
||||||
|
|
||||||
for (const model of settingsModels) {
|
for (const model of settingsModels) {
|
||||||
if (seenModelIds.has(model.id)) {
|
const existingIndex = models.findIndex((candidate) => candidate.id === model.id);
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
const existing = models[existingIndex];
|
||||||
|
if (existing?.isSelectable === false) {
|
||||||
|
models[existingIndex] = { ...existing, ...model, isSelectable: true };
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
seenModelIds.add(model.id);
|
|
||||||
models.push(model);
|
models.push(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,8 +169,8 @@ const CLAUDE_PLACEHOLDER_MODEL_IDS = new Set(["<synthetic>"]);
|
|||||||
* (docs/custom-providers.md) among them. Manifest-only resolution would blank the model for
|
* (docs/custom-providers.md) among them. Manifest-only resolution would blank the model for
|
||||||
* exactly those users.
|
* exactly those users.
|
||||||
*
|
*
|
||||||
* Note a `[1m]` suffix is preserved where it names its own manifest entry: a 1M-context variant
|
* A `[1m]` suffix is preserved where it names its own manifest entry. Models such as Fable 5
|
||||||
* is a distinct model with a distinct context window, not a spelling of the 200K one.
|
* that only have a 1M entry normalize the retired suffixed spelling to the canonical ID.
|
||||||
*
|
*
|
||||||
* Returns null for placeholders and empty values, meaning "not observed".
|
* Returns null for placeholders and empty values, meaning "not observed".
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -57,10 +57,17 @@ const MODELS: AgentModelDefinition[] = [
|
|||||||
{
|
{
|
||||||
provider: MOCK_LOAD_TEST_PROVIDER_ID,
|
provider: MOCK_LOAD_TEST_PROVIDER_ID,
|
||||||
id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID,
|
id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID,
|
||||||
|
aliases: ["legacy-five-minute-stream"],
|
||||||
label: "Five minute stream",
|
label: "Five minute stream",
|
||||||
description:
|
description:
|
||||||
"Realistic agent flow streamed as sub-word tokens for five minutes (good for scroll/coalesce debugging).",
|
"Realistic agent flow streamed as sub-word tokens for five minutes (good for scroll/coalesce debugging).",
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low", isDefault: true },
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
{ id: "high", label: "High" },
|
||||||
|
],
|
||||||
|
defaultThinkingOptionId: "low",
|
||||||
metadata: {
|
metadata: {
|
||||||
durationMs: MOCK_LOAD_TEST_DURATION_MS,
|
durationMs: MOCK_LOAD_TEST_DURATION_MS,
|
||||||
intervalMs: MOCK_LOAD_TEST_INTERVAL_MS,
|
intervalMs: MOCK_LOAD_TEST_INTERVAL_MS,
|
||||||
@@ -76,6 +83,22 @@ const MODELS: AgentModelDefinition[] = [
|
|||||||
intervalMs: 50,
|
intervalMs: 50,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provider: MOCK_LOAD_TEST_PROVIDER_ID,
|
||||||
|
id: "legacy-five-minute-stream",
|
||||||
|
label: "Legacy five minute stream",
|
||||||
|
isSelectable: false,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low", isDefault: true },
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
{ id: "high", label: "High" },
|
||||||
|
],
|
||||||
|
defaultThinkingOptionId: "low",
|
||||||
|
metadata: {
|
||||||
|
durationMs: MOCK_LOAD_TEST_DURATION_MS,
|
||||||
|
intervalMs: MOCK_LOAD_TEST_INTERVAL_MS,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provider: MOCK_LOAD_TEST_PROVIDER_ID,
|
provider: MOCK_LOAD_TEST_PROVIDER_ID,
|
||||||
id: "one-minute-stream",
|
id: "one-minute-stream",
|
||||||
@@ -91,6 +114,12 @@ const MODELS: AgentModelDefinition[] = [
|
|||||||
id: "ten-second-stream",
|
id: "ten-second-stream",
|
||||||
label: "Ten second stream",
|
label: "Ten second stream",
|
||||||
description: "Fast realistic stream for tests and smoke checks.",
|
description: "Fast realistic stream for tests and smoke checks.",
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low", isDefault: true },
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
{ id: "high", label: "High" },
|
||||||
|
],
|
||||||
|
defaultThinkingOptionId: "low",
|
||||||
metadata: {
|
metadata: {
|
||||||
durationMs: 10_000,
|
durationMs: 10_000,
|
||||||
intervalMs: 5,
|
intervalMs: 5,
|
||||||
|
|||||||
@@ -217,6 +217,38 @@ describe("ProviderCatalogSession", () => {
|
|||||||
expect(res?.payload.error).toBe("Provider codex is disabled");
|
expect(res?.payload.error).toBe("Provider codex is disabled");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides compatibility-only entries from list_provider_models", async () => {
|
||||||
|
const { subsystem, emitted } = makeSubsystem({
|
||||||
|
snapshot: {
|
||||||
|
getSnapshot: () => [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
models: [
|
||||||
|
{ provider: "codex", id: "gpt-5.4", label: "GPT 5.4" },
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "gpt-5.4-legacy",
|
||||||
|
label: "GPT 5.4 legacy",
|
||||||
|
isSelectable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await subsystem.handleListProviderModelsRequest({
|
||||||
|
type: "list_provider_models_request",
|
||||||
|
provider: "codex",
|
||||||
|
requestId: "m-selectable",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = findByType(emitted, "list_provider_models_response");
|
||||||
|
expect(response?.payload.models?.map((model) => model.id)).toEqual(["gpt-5.4"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves missing cwd as the semantic global snapshot for model list reads", async () => {
|
it("preserves missing cwd as the semantic global snapshot for model list reads", async () => {
|
||||||
const getSnapshot = vi.fn(() => [{ provider: "codex", status: "loading", enabled: true }]);
|
const getSnapshot = vi.fn(() => [{ provider: "codex", status: "loading", enabled: true }]);
|
||||||
const warmUpSnapshotForCwd = vi.fn(async () => {});
|
const warmUpSnapshotForCwd = vi.fn(async () => {});
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ import {
|
|||||||
isGlobalProviderSnapshotKey,
|
isGlobalProviderSnapshotKey,
|
||||||
type ProviderSnapshotManager,
|
type ProviderSnapshotManager,
|
||||||
} from "../../agent/provider-snapshot-manager.js";
|
} from "../../agent/provider-snapshot-manager.js";
|
||||||
import type {
|
import {
|
||||||
AgentFeature,
|
filterSelectableAgentModels,
|
||||||
AgentProvider,
|
type AgentFeature,
|
||||||
AgentSessionConfig,
|
type AgentProvider,
|
||||||
ProviderSnapshotEntry,
|
type AgentSessionConfig,
|
||||||
|
type ProviderSnapshotEntry,
|
||||||
} from "../../agent/agent-sdk-types.js";
|
} from "../../agent/agent-sdk-types.js";
|
||||||
import type { ProviderAvailability } from "../../agent/agent-manager.js";
|
import type { ProviderAvailability } from "../../agent/agent-manager.js";
|
||||||
import type { ProviderUsageService } from "../../../services/quota-fetcher/service.js";
|
import type { ProviderUsageService } from "../../../services/quota-fetcher/service.js";
|
||||||
@@ -202,7 +203,7 @@ export class ProviderCatalogSession {
|
|||||||
type: "list_provider_models_response",
|
type: "list_provider_models_response",
|
||||||
payload: {
|
payload: {
|
||||||
provider: msg.provider,
|
provider: msg.provider,
|
||||||
models: entry.models ?? [],
|
models: filterSelectableAgentModels(entry.models),
|
||||||
error: null,
|
error: null,
|
||||||
fetchedAt: entry.fetchedAt ?? fetchedAt,
|
fetchedAt: entry.fetchedAt ?? fetchedAt,
|
||||||
requestId: msg.requestId,
|
requestId: msg.requestId,
|
||||||
|
|||||||
Reference in New Issue
Block a user