From 5153b01ada2625f202d83b46b1179232f6c940f4 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 29 May 2026 07:03:07 -1000 Subject: [PATCH] [codex] Add Claude model refresh (#6953) ## Thinking Path > - Paperclip orchestrates AI-agent companies through adapter-backed local and external runtimes. > - The agent configuration UI lets operators choose adapter models and refresh model lists when adapters support live discovery. > - Codex already had a live refresh path, but Claude Local only exposed static fallback models and the UI hid the refresh action for Claude. > - A newly available Claude Opus model should not require a code release every time the model catalog changes. > - This pull request adds Anthropic model discovery for Claude Local, keeps the static fallback current with Claude Opus 4.8, and exposes the existing refresh button in the Claude Local dropdown. > - The benefit is that operators can refresh Claude models from the same model selector flow they already use for Codex. ## What Changed - Added `claude-opus-4-8` to the Claude Local fallback model list. - Added Claude model discovery through Anthropic-compatible `GET /v1/models` when `ANTHROPIC_API_KEY` is available. - Added normal cache reuse, forced refresh support, a SHA-256-based API-key fingerprint for cache keys, and warning logging for discovery errors before fallback. - Wired `claude_local.refreshModels` into the server adapter registry. - Enabled the existing `Refresh models` dropdown action for `claude_local` in `AgentConfigForm`. - Added tests for Claude fallback, live discovery, API-failure fallback, forced refresh, and the UI refresh-button gate. ## Verification - `pnpm exec vitest run server/src/__tests__/adapter-models.test.ts` - `pnpm exec vitest run ui/src/components/AgentConfigForm.test.ts` - `pnpm --filter @paperclipai/adapter-claude-local typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` - Greptile review reached Confidence Score: 5/5 on commit `b796cf4f1` with addressed threads resolved. UI note: the visible change is a conditional action row inside the existing model dropdown; the regression test covers that `claude_local` now receives the refresh action. ## Risks - Low risk. Without `ANTHROPIC_API_KEY`, Claude Local still uses the static fallback list. - If Anthropic model discovery fails or times out, Paperclip falls back to the existing cached or static list. - Bedrock environments remain on Bedrock-native model IDs. ## Model Used OpenAI GPT-5 via Codex local coding agent, with repository file access, shell command execution, git operations, and targeted test/typecheck verification. Exact context window is not exposed by the runtime. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- packages/adapters/claude-local/src/index.ts | 1 + .../adapters/claude-local/src/server/index.ts | 2 +- .../claude-local/src/server/models.ts | 132 +++++++++++++++++- server/src/__tests__/adapter-models.test.ts | 73 ++++++++++ server/src/adapters/registry.ts | 2 + ui/src/components/AgentConfigForm.test.ts | 15 ++ ui/src/components/AgentConfigForm.tsx | 6 +- 7 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 ui/src/components/AgentConfigForm.test.ts diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 8ad60fa5..ecfaa2fe 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -6,6 +6,7 @@ export const label = "Claude Code (local)"; export const SANDBOX_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code"; export const models = [ + { id: "claude-opus-4-8", label: "Claude Opus 4.8" }, { id: "claude-opus-4-7", label: "Claude Opus 4.7" }, { id: "claude-opus-4-6", label: "Claude Opus 4.6" }, { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, diff --git a/packages/adapters/claude-local/src/server/index.ts b/packages/adapters/claude-local/src/server/index.ts index 7fcf0c11..e55a65cd 100644 --- a/packages/adapters/claude-local/src/server/index.ts +++ b/packages/adapters/claude-local/src/server/index.ts @@ -1,6 +1,6 @@ export { claudeSessionCwdMatchesExecutionTarget, execute, runClaudeLogin } from "./execute.js"; export { listClaudeSkills, syncClaudeSkills } from "./skills.js"; -export { listClaudeModels } from "./models.js"; +export { listClaudeModels, refreshClaudeModels, resetClaudeModelsCacheForTests } from "./models.js"; export { testEnvironment } from "./test.js"; export { parseClaudeStreamJson, diff --git a/packages/adapters/claude-local/src/server/models.ts b/packages/adapters/claude-local/src/server/models.ts index 044fc3d2..cbb8553d 100644 --- a/packages/adapters/claude-local/src/server/models.ts +++ b/packages/adapters/claude-local/src/server/models.ts @@ -1,13 +1,22 @@ +import { createHash } from "node:crypto"; import type { AdapterModel } from "@paperclipai/adapter-utils"; import { models as DIRECT_MODELS } from "../index.js"; +const ANTHROPIC_MODELS_ENDPOINT = "/v1/models"; +const ANTHROPIC_MODELS_TIMEOUT_MS = 5000; +const ANTHROPIC_MODELS_CACHE_TTL_MS = 60_000; +const ANTHROPIC_API_VERSION = "2023-06-01"; + /** AWS Bedrock model IDs — region-qualified identifiers required by the Bedrock API. */ const BEDROCK_MODELS: AdapterModel[] = [ + { id: "us.anthropic.claude-opus-4-8-v1", label: "Bedrock Opus 4.8" }, { id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" }, { id: "us.anthropic.claude-sonnet-4-5-20250929-v2:0", label: "Bedrock Sonnet 4.5" }, { id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Bedrock Haiku 4.5" }, ]; +let cached: { keyFingerprint: string; baseUrl: string; expiresAt: number; models: AdapterModel[] } | null = null; + function isBedrockEnv(): boolean { return ( process.env.CLAUDE_CODE_USE_BEDROCK === "1" || @@ -17,13 +26,134 @@ function isBedrockEnv(): boolean { ); } +function fingerprint(apiKey: string): string { + const digest = createHash("sha256").update(apiKey).digest("base64url").slice(0, 16); + return `${apiKey.length}:${digest}`; +} + +function dedupeModels(models: AdapterModel[]): AdapterModel[] { + const seen = new Set(); + const deduped: AdapterModel[] = []; + for (const model of models) { + const id = model.id.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + deduped.push({ id, label: model.label.trim() || id }); + } + return deduped; +} + +function mergedWithFallback(models: AdapterModel[]): AdapterModel[] { + return dedupeModels([ + ...models, + ...DIRECT_MODELS, + ]); +} + +function resolveAnthropicApiKey(): string | null { + const apiKey = process.env.ANTHROPIC_API_KEY?.trim(); + return apiKey && apiKey.length > 0 ? apiKey : null; +} + +function resolveAnthropicBaseUrl(): string { + const baseUrl = process.env.ANTHROPIC_BASE_URL?.trim(); + return baseUrl && baseUrl.length > 0 ? baseUrl.replace(/\/+$/, "") : "https://api.anthropic.com"; +} + +async function fetchAnthropicModels(apiKey: string, baseUrl: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), ANTHROPIC_MODELS_TIMEOUT_MS); + try { + const response = await fetch(`${baseUrl}${ANTHROPIC_MODELS_ENDPOINT}`, { + headers: { + "anthropic-version": ANTHROPIC_API_VERSION, + "x-api-key": apiKey, + }, + signal: controller.signal, + }); + if (!response.ok) return []; + + const payload = (await response.json()) as { data?: unknown }; + const data = Array.isArray(payload.data) ? payload.data : []; + const models: AdapterModel[] = []; + for (const item of data) { + if (typeof item !== "object" || item === null) continue; + const record = item as { id?: unknown; display_name?: unknown }; + if (typeof record.id !== "string" || record.id.trim().length === 0) continue; + const displayName = + typeof record.display_name === "string" && record.display_name.trim().length > 0 + ? record.display_name + : record.id; + models.push({ + id: record.id, + label: displayName, + }); + } + return dedupeModels(models); + } catch (error) { + console.warn("[paperclip] Claude model discovery failed", { + error: error instanceof Error ? error.message : String(error), + }); + return []; + } finally { + clearTimeout(timeout); + } +} + +async function loadClaudeModels(options?: { forceRefresh?: boolean }): Promise { + if (isBedrockEnv()) return dedupeModels(BEDROCK_MODELS); + + const fallback = dedupeModels(DIRECT_MODELS); + const apiKey = resolveAnthropicApiKey(); + if (!apiKey) return fallback; + + const now = Date.now(); + const baseUrl = resolveAnthropicBaseUrl(); + const keyFingerprint = fingerprint(apiKey); + if ( + options?.forceRefresh !== true && + cached && + cached.keyFingerprint === keyFingerprint && + cached.baseUrl === baseUrl && + cached.expiresAt > now + ) { + return cached.models; + } + + const fetched = await fetchAnthropicModels(apiKey, baseUrl); + if (fetched.length > 0) { + const merged = mergedWithFallback(fetched); + cached = { + keyFingerprint, + baseUrl, + expiresAt: now + ANTHROPIC_MODELS_CACHE_TTL_MS, + models: merged, + }; + return merged; + } + + if (cached && cached.keyFingerprint === keyFingerprint && cached.baseUrl === baseUrl && cached.models.length > 0) { + return cached.models; + } + + return fallback; +} + /** * Return the model list appropriate for the current auth mode. * When Bedrock env vars are detected, returns Bedrock-native model IDs; * otherwise returns standard Anthropic API model IDs. */ export async function listClaudeModels(): Promise { - return isBedrockEnv() ? BEDROCK_MODELS : DIRECT_MODELS; + return loadClaudeModels(); +} + +export async function refreshClaudeModels(): Promise { + return loadClaudeModels({ forceRefresh: true }); +} + +export function resetClaudeModelsCacheForTests() { + cached = null; } /** Check whether a model ID is a Bedrock-native identifier (not an Anthropic API short name). */ diff --git a/server/src/__tests__/adapter-models.test.ts b/server/src/__tests__/adapter-models.test.ts index b920de22..ed6232a5 100644 --- a/server/src/__tests__/adapter-models.test.ts +++ b/server/src/__tests__/adapter-models.test.ts @@ -1,4 +1,6 @@ import { 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"; @@ -17,7 +19,12 @@ vi.mock("acpx/runtime", () => ({ 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); @@ -45,6 +52,72 @@ describe("adapter model listing", () => { 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); + 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({ diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index abe73ea0..5bb261b8 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -25,6 +25,7 @@ import { listClaudeSkills, syncClaudeSkills, listClaudeModels, + refreshClaudeModels, testEnvironment as claudeTestEnvironment, sessionCodec as claudeSessionCodec, getQuotaWindows as claudeGetQuotaWindows, @@ -255,6 +256,7 @@ const claudeLocalAdapter: ServerAdapterModule = { models: claudeModels, modelProfiles: claudeModelProfiles, listModels: listClaudeModels, + refreshModels: refreshClaudeModels, supportsLocalAgentJwt: true, supportsInstructionsBundle: true, instructionsPathKey: "instructionsFilePath", diff --git a/ui/src/components/AgentConfigForm.test.ts b/ui/src/components/AgentConfigForm.test.ts new file mode 100644 index 00000000..e532c8eb --- /dev/null +++ b/ui/src/components/AgentConfigForm.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { supportsAdapterModelRefresh } from "./AgentConfigForm"; + +describe("supportsAdapterModelRefresh", () => { + it("enables the model refresh action for Claude, Codex, and ACPX adapters", () => { + expect(supportsAdapterModelRefresh("claude_local")).toBe(true); + expect(supportsAdapterModelRefresh("codex_local")).toBe(true); + expect(supportsAdapterModelRefresh("acpx_local")).toBe(true); + }); + + it("keeps the refresh action hidden for adapters without a live refresh hook", () => { + expect(supportsAdapterModelRefresh("opencode_local")).toBe(false); + expect(supportsAdapterModelRefresh("process")).toBe(false); + }); +}); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 59c967c8..e07e9734 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -114,6 +114,10 @@ const emptyOverlay: AgentConfigOverlay = { /** Stable empty object used as fallback for missing env config to avoid new-object-per-render. */ const EMPTY_ENV: Record = {}; +export function supportsAdapterModelRefresh(adapterType: string): boolean { + return adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "acpx_local"; +} + function isOverlayDirty(o: AgentConfigOverlay): boolean { return ( Object.keys(o.identity).length > 0 || @@ -1006,7 +1010,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { return result.data?.model ?? null; }} onRefreshModels={ - adapterType === "codex_local" || adapterType === "acpx_local" + supportsAdapterModelRefresh(adapterType) ? handleRefreshModels : undefined }