mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Claude local adapter lets operators select a Claude model for an agent. > - Claude Fable 5.1 was absent from the adapter model lists. > - The adapter runtime also used a Claude Code build that rejected Fable 5.1. > - This pull request adds the direct Anthropic ID and the AWS Bedrock inference profile ID. > - It also updates the Claude ACP runtime and keeps the Paperclip usage and isolation patches. > - The benefit is that operators can select and run Claude Fable 5.1 through the Claude adapter. ## Linked Issues or Issue Description Refs #8810. That issue covers related model ID handling. This change does not change provider-prefixed model IDs. **Agent or provider** Claude Code through the built-in `claude_local` adapter. The requested model is Claude Fable 5.1. **Why this adapter is useful** Operators can use Fable 5.1 without entering an undocumented model ID. The configured model also reaches both supported Claude execution lanes. **How the agent is invoked** The CLI lane sends `--model claude-fable-5-1`. The ACP lane sends `ANTHROPIC_MODEL=claude-fable-5-1` to `@agentclientprotocol/claude-agent-acp`. **Are you willing to implement it?** Yes. This pull request includes the implementation and tests. **Additional context** Claude Code 2.1.232 rejected Fable 5.1 and required version 2.1.251 or newer. ACP package 0.73.0 includes Claude Code 2.1.257. The update keeps Paperclip's usage metadata and isolated-context behavior. ## What Changed - Added `claude-fable-5-1` to the direct Claude fallback list. - Added `us.anthropic.claude-fable-5-1` to the AWS Bedrock list. - Kept the existing default model at the first position in each list. - Updated the Claude ACP dependency from 0.70 to 0.73. - Carried the Paperclip usage and isolated-context changes into the 0.73 patch. - Added a Claude Code 2.1.251 minimum-version preflight for Fable 5.1 when using the standard `claude` executable, surfaced in both adapter Test and execution. Explicit custom wrappers retain their existing compatibility contract. - Kept local adapter Tests from executing caller-selected binaries: when runtime `PATH` selects a different Claude executable than the trusted probe, the Test warns and defers the authoritative version check to execution instead of approving or rejecting the alternate installation. - Added tests for model listing, discovery deduplication, Bedrock filtering, model pass-through in both execution lanes, old-CLI rejection before launch, custom-wrapper compatibility, and local runtime-PATH mismatch handling. ## Verification - `pnpm --filter @paperclipai/adapter-claude-local typecheck` - `pnpm exec vitest run packages/adapters/claude-local/src/server/execute.remote.test.ts packages/adapters/claude-local/src/server/test.remote.test.ts packages/adapters/claude-local/src/server/test.probe.test.ts packages/adapters/claude-local/src/server/acp.test.ts server/src/__tests__/adapter-models.test.ts` (72 tests passed) - `node --test scripts/acpx-patch-packaging.test.mjs` (13 tests passed) - `pnpm -r typecheck` - `pnpm build` - A local Paperclip agent run completed with `usageJson.model` set to `claude-fable-5-1` through ACP 0.73.0 and its bundled Claude Code 2.1.257. - `pnpm test:run` completed 5,638 passing tests and 24 skipped tests. It also found 24 failures in unrelated workspace-runtime, path-canonicalization, and runtime-exposure tests on macOS with Node 26. These failures do not touch this diff. Clean pull request CI is the final full-suite gate. ## Risks - The ACP dependency update can change Claude runtime behavior outside model selection. Focused ACP tests, the full typecheck, the production build, and a real local Fable run reduce this risk. - The 0.73 patch must stay aligned with the installed ACP version. Dependency-resolution CI verifies the manifest and patch pair. - Fable 5.1 adds a short `claude --version` preflight to standard CLI-lane Tests and runs. The result is intentionally not cached so an in-place Claude Code upgrade takes effect without restarting Paperclip. Explicit custom wrappers are not version-probed because their output and compatibility contract can differ from the standard executable. - Local Tests preserve the existing deny-by-default probe boundary and do not execute a binary selected by caller-controlled `PATH`. A mismatched runtime binary produces an explicit warning without blocking an otherwise valid setup; execution independently validates the actual runtime-selected CLI before launch. - The AWS Bedrock identifier differs from earlier IDs because Fable 5.1 has no `-v1` suffix. The model-list test locks this exact value. - There is no schema change or migration. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used Provider: OpenAI. Model: GPT-5 Codex. The host did not expose a more specific model ID or context-window size. Capabilities used: agentic reasoning, repository editing, shell execution, web research, and local runtime verification. ## 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) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
323 lines
13 KiB
TypeScript
323 lines
13 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-1")).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("does not duplicate claude-fable-5-1 when discovery returns the identical ID", async () => {
|
|
process.env.ANTHROPIC_API_KEY = "sk-ant-test";
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
data: [{ id: "claude-fable-5-1", display_name: "Claude Fable 5.1" }],
|
|
}),
|
|
} as Response);
|
|
|
|
const models = await listAdapterModels("claude_local");
|
|
|
|
expect(models.filter((model) => model.id === "claude-fable-5-1")).toHaveLength(1);
|
|
// Curated fallbacks discovery did not return are still merged in.
|
|
expect(models.some((model) => model.id === "claude-fable-5")).toBe(true);
|
|
expect(models.some((model) => model.id === "claude-opus-4-8")).toBe(true);
|
|
});
|
|
|
|
it("exposes the Bedrock-native Fable 5.1 ID (never the direct ID) in Bedrock mode", async () => {
|
|
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
|
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
|
|
const models = await listAdapterModels("claude_local");
|
|
|
|
// The Bedrock default (first entry) is unchanged.
|
|
expect(models[0]?.id).toBe("us.anthropic.claude-opus-4-8-v1");
|
|
expect(models.some((model) => model.id === "us.anthropic.claude-fable-5-1")).toBe(true);
|
|
expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(false);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|