Files
PaperClipAI/server/src/__tests__/adapter-models.test.ts
T
d295251550 feat(adapter-claude): add Claude Sonnet 5 to the static model fallback (#10280)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents pick their model from a dropdown in agent config, populated
per-adapter by `listAdapterModels()` → each adapter's live provider
catalog merged over a static fallback list
> - For `claude_local`, newer model ids only reach the dropdown via the
live Anthropic `/v1/models` fetch, which needs a server
`ANTHROPIC_API_KEY`, a <5s round-trip, non-Bedrock mode, and account
entitlement; on any miss it silently falls back to the static `models`
array
> - Claude Sonnet 5 (`claude-sonnet-5`) is a current flagship but was
absent from that static fallback, so it appeared only when live
discovery happened to succeed — i.e. "the newest model doesn't
consistently show up"
> - This pull request adds `claude-sonnet-5` to the `claude_local`
static model list so it is selectable regardless of the live-discovery
path
> - The benefit is a consistent, reliable dropdown that no longer
depends on a flaky live fetch to surface a shipped flagship model

## Linked Issues or Issue Description

No public GitHub issue. The bug is described inline following the
bug-report template:

**What happened**
The `claude_local` agent-config model dropdown intermittently omitted
Claude Sonnet 5. `claude-sonnet-5` was missing from the adapter's static
fallback `models` array (`packages/adapters/claude-local/src/index.ts`),
so it only surfaced when the live Anthropic `/v1/models` discovery
happened to succeed.

**Expected behavior**
Claude Sonnet 5 is a shipped flagship model and should always be
selectable in the dropdown, independent of whether live discovery
succeeds.

**Steps to reproduce**
1. Run the server without a working live Anthropic `/v1/models` path (no
`ANTHROPIC_API_KEY`, Bedrock mode, a discovery timeout, or a cache
miss).
2. Open agent config for a `claude_local` agent and inspect the model
dropdown.
3. Observe that `claude-sonnet-5` is absent because the static fallback
list omitted it.

**Deployment mode**
Self-hosted / local adapter (`claude_local`); the server process reads
`ANTHROPIC_API_KEY` from its environment.

## What Changed

- Added `{ id: "claude-sonnet-5", label: "Claude Sonnet 5" }` to the
`claude_local` static `models` fallback, immediately after
`claude-opus-4-8` (so Opus 4.8 stays the default first option).
- Added an explicit regression assertion in
`server/src/__tests__/adapter-models.test.ts` that `claude-sonnet-5` is
present in the `claude_local` fallback when live discovery is
unavailable.

## Verification

- `pnpm -C server exec vitest run src/__tests__/adapter-models.test.ts
-t "claude fallback"` — **passes** (the new `claude-sonnet-5` assertion
included).
- Reviewed the consuming tests: the fallback test also asserts
`models[0]?.id === "claude-opus-4-8"` (still index 0 — Sonnet 5 is index
1, unaffected); `adapter-registry.test.ts` reads `builtIn?.models`
dynamically, so no exact-array snapshot breaks.
- Change is a single static-data addition plus a test assertion; no
control-flow change.

## Risks

- Low risk. Pure additive change to a fallback list; no control-flow
change. Worst case is an id that a given account isn't entitled to,
which the existing "current"/manual-model UI paths already tolerate.

## Model Used

Claude (Anthropic), model id `claude-opus-4-8` (Opus 4.8), extended
thinking + tool use, run as the Paperclip CTO agent.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [ ] My branch name describes the change (branch is the assigned
execution-workspace branch and cannot be renamed this run)
- [x] I have run tests locally and they pass (server adapter-models
"claude fallback" case)
- [x] I have added or updated tests where applicable (explicit
`claude-sonnet-5` fallback assertion)
- [x] I have updated relevant documentation to reflect my changes (n/a —
no docs reference this list)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending CI)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending review)
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-31 13:15:42 -05:00

292 lines
11 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { models as claudeFallbackModels } from "@paperclipai/adapter-claude-local";
import { resetClaudeModelsCacheForTests } from "@paperclipai/adapter-claude-local/server";
import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local";
import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local";
import { models as opencodeFallbackModels } from "@paperclipai/adapter-opencode-local";
import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server";
import { listAdapterModels, listServerAdapters, refreshAdapterModels } from "../adapters/index.js";
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js";
vi.mock("acpx/runtime", () => ({
createAcpRuntime: vi.fn(),
createAgentRegistry: vi.fn(),
createRuntimeStore: vi.fn(),
isAcpRuntimeError: vi.fn(() => false),
}));
describe("adapter model listing", () => {
beforeEach(() => {
delete process.env.OPENAI_API_KEY;
delete process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_BASE_URL;
delete process.env.ANTHROPIC_BEDROCK_BASE_URL;
delete process.env.CLAUDE_CODE_USE_BEDROCK;
delete process.env.PAPERCLIP_OPENCODE_COMMAND;
resetClaudeModelsCacheForTests();
resetCodexModelsCacheForTests();
resetCursorModelsCacheForTests();
setCursorModelsRunnerForTests(null);
resetOpenCodeModelsCacheForTests();
vi.restoreAllMocks();
});
it("returns an empty list for unknown adapters", async () => {
const models = await listAdapterModels("unknown_adapter");
expect(models).toEqual([]);
});
it("does not expose models for the retired acpx_local tombstone", () => {
const adapter = listServerAdapters().find((candidate) => candidate.type === "acpx_local");
expect(adapter?.models).toEqual([]);
});
it("returns codex fallback models when no OpenAI key is available", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const models = await listAdapterModels("codex_local");
expect(models).toEqual(codexFallbackModels);
// The bare gpt-5.6 alias is intentionally not advertised (Codex has no metadata for it).
expect(models.some((model) => model.id === "gpt-5.6")).toBe(false);
expect(models.some((model) => model.id === "gpt-5.6-sol")).toBe(true);
expect(models.some((model) => model.id === "gpt-5.6-terra")).toBe(true);
expect(models.some((model) => model.id === "gpt-5.6-luna")).toBe(true);
expect(models.some((model) => model.id === "gpt-5.3-codex-spark")).toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("returns claude fallback models including the latest Opus alias when no Anthropic key is available", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const models = await listAdapterModels("claude_local");
expect(models).toEqual(claudeFallbackModels);
expect(models.some((model) => model.id === "claude-opus-4-8")).toBe(true);
// Newer flagship models are offered, but Opus 4.8 stays the default (first) option.
expect(models[0]?.id).toBe("claude-opus-4-8");
expect(models.some((model) => model.id === "claude-sonnet-5")).toBe(true);
expect(models.some((model) => model.id === "claude-fable-5")).toBe(true);
expect(models.some((model) => model.id === "claude-mythos-5")).toBe(true);
// Opus 5 is a current GA flagship and must be offered even when live discovery is unavailable.
expect(models.some((model) => model.id === "claude-opus-5")).toBe(true);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("loads claude models dynamically and merges fallback options", async () => {
process.env.ANTHROPIC_API_KEY = "sk-ant-test";
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
data: [
{ id: "claude-sonnet-4-20250514", display_name: "Claude Sonnet 4" },
{ id: "claude-opus-4-8-20260529", display_name: "Claude Opus 4.8" },
],
}),
} as Response);
const first = await listAdapterModels("claude_local");
const second = await listAdapterModels("claude_local");
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(first).toEqual(second);
expect(first.some((model) => model.id === "claude-opus-4-8-20260529")).toBe(true);
expect(first.some((model) => model.id === "claude-opus-4-8")).toBe(true);
});
it("refreshes cached claude models on demand", async () => {
process.env.ANTHROPIC_API_KEY = "sk-ant-test";
const fetchSpy = vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ id: "claude-sonnet-4-20250514", display_name: "Claude Sonnet 4" }],
}),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ id: "claude-opus-4-8-20260529", display_name: "Claude Opus 4.8" }],
}),
} as Response);
const initial = await listAdapterModels("claude_local");
const refreshed = await refreshAdapterModels("claude_local");
expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(initial.some((model) => model.id === "claude-sonnet-4-20250514")).toBe(true);
expect(refreshed.some((model) => model.id === "claude-opus-4-8-20260529")).toBe(true);
});
it("falls back to static claude models when Anthropic model discovery fails", async () => {
process.env.ANTHROPIC_API_KEY = "sk-ant-test";
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 401,
json: async () => ({}),
} as Response);
const models = await listAdapterModels("claude_local");
expect(models).toEqual(claudeFallbackModels);
});
it("loads codex models dynamically and merges fallback options", async () => {
process.env.OPENAI_API_KEY = "sk-test";
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
data: [
{ id: "gpt-5-pro" },
{ id: "gpt-5" },
],
}),
} as Response);
const first = await listAdapterModels("codex_local");
const second = await listAdapterModels("codex_local");
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(first).toEqual(second);
expect(first.some((model) => model.id === "gpt-5-pro")).toBe(true);
expect(first.some((model) => model.id === "codex-mini-latest")).toBe(true);
});
it("refreshes cached codex models on demand", async () => {
process.env.OPENAI_API_KEY = "sk-test";
const fetchSpy = vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ id: "gpt-5" }],
}),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ id: "gpt-5.6-terra" }],
}),
} as Response);
const initial = await listAdapterModels("codex_local");
const refreshed = await refreshAdapterModels("codex_local");
expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(initial.some((model) => model.id === "gpt-5")).toBe(true);
expect(refreshed.some((model) => model.id === "gpt-5.6-terra")).toBe(true);
expect(refreshed.some((model) => model.id === "gpt-5.6-luna")).toBe(true);
});
it("falls back to static codex models when OpenAI model discovery fails", async () => {
process.env.OPENAI_API_KEY = "sk-test";
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 401,
json: async () => ({}),
} as Response);
const models = await listAdapterModels("codex_local");
expect(models).toEqual(codexFallbackModels);
});
it("returns cursor fallback models when CLI discovery is unavailable", async () => {
setCursorModelsRunnerForTests(() => ({
status: null,
stdout: "",
stderr: "",
hasError: true,
}));
const models = await listAdapterModels("cursor");
expect(models).toEqual(cursorFallbackModels);
});
it("returns opencode fallback models including gpt-5.4", async () => {
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
const models = await listAdapterModels("opencode_local");
expect(models).toEqual(opencodeFallbackModels);
});
it("loads cursor models dynamically and caches them", async () => {
const runner = vi.fn(() => ({
status: 0,
stdout: "Available models: auto, composer-1.5, gpt-5.3-codex-high, sonnet-4.6",
stderr: "",
hasError: false,
}));
setCursorModelsRunnerForTests(runner);
const first = await listAdapterModels("cursor");
const second = await listAdapterModels("cursor");
expect(runner).toHaveBeenCalledTimes(1);
expect(first).toEqual(second);
expect(first.some((model) => model.id === "auto")).toBe(true);
expect(first.some((model) => model.id === "gpt-5.3-codex-high")).toBe(true);
expect(first.some((model) => model.id === "composer-1")).toBe(true);
});
describe("PAPERCLIP_ADAPTER_MODELS declared models", () => {
afterEach(() => {
delete process.env.PAPERCLIP_ADAPTER_MODELS;
});
it("prefers declared env models over adapter discovery", async () => {
process.env.PAPERCLIP_ADAPTER_MODELS = JSON.stringify({
opencode_local: [
{ id: "tensorix/deepseek/deepseek-chat-v3.1", label: "DeepSeek v3.1" },
{ id: "tensorix/z-ai/glm-4.7" },
],
});
const models = await listAdapterModels("opencode_local");
expect(models).toEqual([
{ id: "tensorix/deepseek/deepseek-chat-v3.1", label: "DeepSeek v3.1" },
{ id: "tensorix/z-ai/glm-4.7", label: "tensorix/z-ai/glm-4.7" },
]);
});
it("observes env changes between calls (memo keyed by raw env value)", async () => {
process.env.PAPERCLIP_ADAPTER_MODELS = JSON.stringify({
opencode_local: [{ id: "model-a" }],
});
expect(await listAdapterModels("opencode_local")).toEqual([
{ id: "model-a", label: "model-a" },
]);
process.env.PAPERCLIP_ADAPTER_MODELS = JSON.stringify({
opencode_local: [{ id: "model-b" }],
});
expect(await listAdapterModels("opencode_local")).toEqual([
{ id: "model-b", label: "model-b" },
]);
});
it("fails soft on malformed values: falls back to adapter models instead of throwing", async () => {
process.env.PAPERCLIP_ADAPTER_MODELS = "{not json";
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const models = await listAdapterModels("opencode_local");
expect(models).toEqual(opencodeFallbackModels);
// Parsing is memoized per raw value: a second call must not re-log.
const callsAfterFirst = errorSpy.mock.calls.length;
expect(callsAfterFirst).toBeGreaterThan(0);
await listAdapterModels("opencode_local");
expect(errorSpy.mock.calls.length).toBe(callsAfterFirst);
});
it("ignores declared models for adapters not in the map", async () => {
process.env.PAPERCLIP_ADAPTER_MODELS = JSON.stringify({
opencode_local: [{ id: "model-a" }],
});
const models = await listAdapterModels("codex_local");
expect(models).toEqual(codexFallbackModels);
});
});
});