0055aef618
* feat: add Claude Code agent for Anthropic model users Re-adds Claude Code support (removed in #478) so users with Anthropic API keys or Claude Code OAuth tokens can use their Claude subscriptions directly. When an Anthropic model is selected and Claude Code credentials are available, the system auto-selects the Claude agent instead of OpenCode. The harness mirrors opentoad's security model: native Bash blocked via --disallowedTools, MCP ShellTool for restricted shell, ASKPASS for git auth. Includes NDJSON streaming, provider error detection, cache/cost tracking, browser skill, and todo progress tracking. Key changes: - action/agents/claude.ts: full Claude Code harness - action/utils/agent.ts: auto-select Claude for anthropic/* models - action/utils/providerErrors.ts: extracted shared provider error detection - action/utils/skills.ts: extracted shared skill installation (agent-aware) - action/models.ts: add CLAUDE_CODE_OAUTH_TOKEN to anthropic envVars - action/utils/docker.ts: add CLAUDE_CODE_OAUTH_TOKEN to test env allowlist - CI: add claude to test matrix, pass CLAUDE_CODE_OAUTH_TOKEN secret Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove unused toolId variable, fix apiKeys test env cleanup The apiKeys test cleanup stripped *_API_KEY vars but missed CLAUDE_CODE_OAUTH_TOKEN which doesn't match that pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: strip provider prefix from PULLFROG_MODEL in Claude agent the env override path was returning the raw value (e.g. "anthropic/claude-sonnet-4-5") without stripping the provider prefix, causing the Claude CLI to receive an invalid model ID. Made-with: Cursor * fix: remove dead cliPath field, add CLAUDE_CODE_OAUTH_TOKEN to workflows remove unused cliPath from Claude agent RunParams, and pass CLAUDE_CODE_OAUTH_TOKEN through all pullfrog.yml workflow templates so users with Claude Pro/Team subscriptions can use their membership. Made-with: Cursor * fix: block Bash subagent in Claude Code disallowedTools Made-with: Cursor * chore: update model snapshot (opencode/openrouter latest → qwen3.6-plus-free) Made-with: Cursor --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
142 lines
4.6 KiB
TypeScript
142 lines
4.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
getModelEnvVars,
|
|
getModelProvider,
|
|
modelAliases,
|
|
parseModel,
|
|
providers,
|
|
resolveCliModel,
|
|
resolveModelSlug,
|
|
} from "./models.ts";
|
|
|
|
describe("parseModel", () => {
|
|
it("parses provider/model format", () => {
|
|
const result = parseModel("anthropic/claude-opus");
|
|
expect(result).toEqual({ provider: "anthropic", model: "claude-opus" });
|
|
});
|
|
|
|
it("handles nested slashes (openrouter format)", () => {
|
|
const result = parseModel("openrouter/anthropic/claude-opus-4.6");
|
|
expect(result).toEqual({ provider: "openrouter", model: "anthropic/claude-opus-4.6" });
|
|
});
|
|
|
|
it("throws on invalid slug without slash", () => {
|
|
expect(() => parseModel("invalid")).toThrow("invalid model slug");
|
|
});
|
|
});
|
|
|
|
describe("getModelProvider", () => {
|
|
it("extracts provider from slug", () => {
|
|
expect(getModelProvider("anthropic/claude-opus")).toBe("anthropic");
|
|
expect(getModelProvider("openai/gpt-codex")).toBe("openai");
|
|
expect(getModelProvider("google/gemini-pro")).toBe("google");
|
|
});
|
|
});
|
|
|
|
describe("getModelEnvVars", () => {
|
|
it("returns correct env vars for anthropic", () => {
|
|
expect(getModelEnvVars("anthropic/claude-opus")).toEqual([
|
|
"ANTHROPIC_API_KEY",
|
|
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
]);
|
|
});
|
|
|
|
it("returns correct env vars for google (multiple)", () => {
|
|
const envVars = getModelEnvVars("google/gemini-pro");
|
|
expect(envVars).toContain("GOOGLE_GENERATIVE_AI_API_KEY");
|
|
expect(envVars).toContain("GEMINI_API_KEY");
|
|
});
|
|
|
|
it("returns empty array for unknown provider", () => {
|
|
expect(getModelEnvVars("unknown/model")).toEqual([]);
|
|
});
|
|
|
|
it("returns empty env vars for free opencode models", () => {
|
|
expect(getModelEnvVars("opencode/big-pickle")).toEqual([]);
|
|
expect(getModelEnvVars("opencode/gpt-5-nano")).toEqual([]);
|
|
expect(getModelEnvVars("opencode/mimo-v2-pro-free")).toEqual([]);
|
|
expect(getModelEnvVars("opencode/minimax-m2.5-free")).toEqual([]);
|
|
expect(getModelEnvVars("opencode/nemotron-3-super-free")).toEqual([]);
|
|
});
|
|
|
|
it("still requires OPENCODE_API_KEY for non-free opencode models", () => {
|
|
expect(getModelEnvVars("opencode/claude-opus")).toEqual(["OPENCODE_API_KEY"]);
|
|
});
|
|
});
|
|
|
|
describe("resolveModelSlug", () => {
|
|
it("resolves known alias to concrete specifier", () => {
|
|
const resolved = resolveModelSlug("anthropic/claude-opus");
|
|
expect(resolved).toBe("anthropic/claude-opus-4-6");
|
|
});
|
|
|
|
it("resolves openai alias", () => {
|
|
const resolved = resolveModelSlug("openai/gpt-codex");
|
|
expect(resolved).toBe("openai/gpt-5.3-codex");
|
|
});
|
|
|
|
it("returns undefined for unknown slug", () => {
|
|
expect(resolveModelSlug("unknown/model")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("resolveCliModel", () => {
|
|
it("returns same as resolveModelSlug (models.dev specifier)", () => {
|
|
const slug = "anthropic/claude-opus";
|
|
expect(resolveCliModel(slug)).toBe(resolveModelSlug(slug));
|
|
});
|
|
|
|
it("returns undefined for unknown slug", () => {
|
|
expect(resolveCliModel("bogus/nope")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("modelAliases registry", () => {
|
|
it("has at least one model per provider", () => {
|
|
for (const providerKey of Object.keys(providers)) {
|
|
const providerModels = modelAliases.filter((a) => a.provider === providerKey);
|
|
expect(providerModels.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it("has exactly one preferred model per provider", () => {
|
|
for (const providerKey of Object.keys(providers)) {
|
|
const preferred = modelAliases.filter((a) => a.provider === providerKey && a.preferred);
|
|
expect(preferred.length, `${providerKey} should have exactly 1 preferred model`).toBe(1);
|
|
}
|
|
});
|
|
|
|
it("all slugs follow provider/model format", () => {
|
|
for (const alias of modelAliases) {
|
|
expect(alias.slug).toContain("/");
|
|
const parsed = parseModel(alias.slug);
|
|
expect(parsed.provider).toBe(alias.provider);
|
|
}
|
|
});
|
|
|
|
it("all resolve values follow provider/model format", () => {
|
|
for (const alias of modelAliases) {
|
|
expect(alias.resolve).toContain("/");
|
|
}
|
|
});
|
|
|
|
it("slugs are unique", () => {
|
|
const slugs = modelAliases.map((a) => a.slug);
|
|
expect(new Set(slugs).size).toBe(slugs.length);
|
|
});
|
|
});
|
|
|
|
describe("providers registry", () => {
|
|
it("every provider has envVars", () => {
|
|
for (const [key, config] of Object.entries(providers)) {
|
|
expect(config.envVars.length, `${key} should have env vars`).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it("every provider has a displayName", () => {
|
|
for (const [key, config] of Object.entries(providers)) {
|
|
expect(config.displayName, `${key} should have a displayName`).toBeTruthy();
|
|
}
|
|
});
|
|
});
|