mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
## 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>
292 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|