From a8189e4ab8d1111a5150de09cf421579860303da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 19:55:26 +0000 Subject: [PATCH] feat: auto-detect LiteLLM gateways, support ANTHROPIC_AUTH_TOKEN, add direct HTTP LLM calls Provider detection now recognizes ANTHROPIC_AUTH_TOKEN as a fallback auth credential and detects LiteLLM gateways from ANTHROPIC_BASE_URL patterns (URLs containing "gateway", "litellm", etc.). ANTHROPIC_MODEL env var overrides tier-based model routing when set. New src/llm.js provides direct HTTP LLM calls via child-process-fetch (same pattern as embed.js) so the LLM proposer works without the claude CLI. buildRunner() in adjudicate.js auto-selects: direct HTTP when claude CLI is missing or FORGE_LLM_HTTP=1, otherwise claude -p. Doctor now checks for claude CLI presence and surfaces model overrides. Route output includes modelOverride field when ANTHROPIC_MODEL is set. 26 new tests (500 total, 0 failures). Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_019PXuKmJp92Gdo2FudNjSx2 --- src/adjudicate.js | 29 ++++++- src/doctor.js | 14 +++- src/llm.js | 55 ++++++++++++ src/providers.js | 55 ++++++++++-- src/route.js | 4 +- test/adjudicate.test.js | 36 +++++++- test/llm.test.js | 104 +++++++++++++++++++++++ test/providers.test.js | 182 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 465 insertions(+), 14 deletions(-) create mode 100644 src/llm.js create mode 100644 test/llm.test.js diff --git a/src/adjudicate.js b/src/adjudicate.js index 0736246..53e042e 100644 --- a/src/adjudicate.js +++ b/src/adjudicate.js @@ -10,7 +10,10 @@ // - FAIL-SAFE. Any error/timeout/garble/secret → returns null. A null NEVER changes a verdict. // - ZERO-DEP. Access is a `claude -p` CLI shell-out; the runner is injectable so the pure // prompt/parse/verify logic is fully testable without the CLI or the network. -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; +import { buildHttpRunner as httpRunner } from "./llm.js"; +import { MODELS } from "./model_tiers.js"; +import { envModelOverride } from "./providers.js"; import { SECRET_RE } from "./recall.js"; /** @@ -22,10 +25,30 @@ export function llmEnabled(opts = {}) { return process.env.FORGE_LLM === "1"; } -/** Build an injectable `claude -p` runner. Pure config in, a (prompt)->string runner out. */ +let _claudeChecked = false; +let _claudeAvail = false; +function hasClaude() { + if (!_claudeChecked) { + _claudeChecked = true; + try { + const r = spawnSync("which", ["claude"], { encoding: "utf8", timeout: 2000, stdio: "pipe" }); + _claudeAvail = r.status === 0; + } catch { + _claudeAvail = false; + } + } + return _claudeAvail; +} + +/** Build an injectable LLM runner. Tries direct HTTP when `claude` CLI is unavailable + * or when FORGE_LLM_HTTP=1. Falls back to `claude -p` otherwise. */ export function buildRunner({ model = "haiku", timeoutMs = 20000 } = {}) { + const resolvedModel = envModelOverride() || MODELS[model]?.id || model; + if (process.env.FORGE_LLM_HTTP === "1" || !hasClaude()) { + return httpRunner({ model: resolvedModel, timeoutMs }); + } return (prompt) => - execFileSync("claude", ["-p", "--model", model], { + execFileSync("claude", ["-p", "--model", resolvedModel], { input: prompt, encoding: "utf8", timeout: timeoutMs, diff --git a/src/doctor.js b/src/doctor.js index 7548243..8bb1c86 100644 --- a/src/doctor.js +++ b/src/doctor.js @@ -9,7 +9,7 @@ import { summary as cortexSummary } from "./cortex.js"; import { extractHash, hashContent } from "./emit/_shared.js"; import { verify as ledgerVerify, repoLedger } from "./ledger_store.js"; import { PRICING_VERIFIED } from "./model_tiers.js"; -import { activeProvider } from "./providers.js"; +import { activeProvider, envModelOverride } from "./providers.js"; import { canonical } from "./sync.js"; const ok = (label, note = "") => ({ status: "ok", label, note }); @@ -31,6 +31,14 @@ function checkTooling(out) { out.push( hasBin("git") ? ok("git", "found") : warn("git", "not found — churn/impact/anchor need it"), ); + out.push( + hasBin("claude") + ? ok("claude CLI", "found — LLM proposer uses it (FORGE_LLM=1)") + : warn( + "claude CLI", + "not found — LLM proposer falls back to direct HTTP (needs ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN)", + ), + ); } // Every guard the manifests reference must exist and be executable, or a hook silently no-ops. @@ -293,6 +301,10 @@ function checkProvider(out, targetRoot) { } else { out.push(ok("provider", `${prov.name} (configured)`)); } + const override = envModelOverride(); + if (override) { + out.push(ok("model override", `${override} (all tiers resolve to this model)`)); + } } export function doctor({ targetRoot = process.cwd() } = {}) { diff --git a/src/llm.js b/src/llm.js new file mode 100644 index 0000000..5a3d67b --- /dev/null +++ b/src/llm.js @@ -0,0 +1,55 @@ +// forge llm — direct HTTP LLM call (Anthropic Messages API). Follows the embed.js +// child-process-fetch pattern: synchronous shell-out to a spawned node child so this +// module stays synchronous like every other forge faculty. The auth token travels via +// the child's env (_FORGE_LLM_KEY) — never in argv, never logged. +import { spawnSync } from "node:child_process"; + +const HTTP_CHILD = `let raw="";process.stdin.on("data",(d)=>{raw+=d;});process.stdin.on("end",async()=>{try{const{url,model,prompt,maxTokens}=JSON.parse(raw);const key=process.env._FORGE_LLM_KEY||"";const headers={"content-type":"application/json","anthropic-version":"2023-06-01"};if(key.startsWith("Bearer "))headers.authorization=key;else if(key)headers["x-api-key"]=key;const body=JSON.stringify({model,max_tokens:maxTokens||1024,messages:[{role:"user",content:prompt}]});const res=await fetch(url,{method:"POST",headers,body});if(!res.ok){process.stderr.write("llm: http "+res.status);process.exit(1);}const data=await res.json();const text=(data.content||[]).filter(b=>b.type==="text").map(b=>b.text).join("");process.stdout.write(text);}catch(e){process.stderr.write("llm: "+(e.message||e));process.exit(1);}});`; + +/** Resolve base URL, auth key, and model override from environment. Returns null if no auth is available. */ +export function resolveHttpProvider() { + const key = + process.env.ANTHROPIC_API_KEY || + process.env.ANTHROPIC_AUTH_TOKEN || + process.env.LITELLM_API_KEY || + ""; + if (!key) return null; + const baseUrl = ( + process.env.LITELLM_BASE_URL || + process.env.ANTHROPIC_BASE_URL || + "https://api.anthropic.com" + ).replace(/\/+$/, ""); + const model = process.env.ANTHROPIC_MODEL?.trim() || null; + return { baseUrl, key, model }; +} + +/** + * Build an HTTP-based LLM runner (Anthropic Messages API). + * Same contract as adjudicate.buildRunner: returns (prompt) => string. + * @param {{model?: string, timeoutMs?: number}} [opts] + */ +export function buildHttpRunner({ model = "claude-haiku-4-5-20251001", timeoutMs = 20000 } = {}) { + return (prompt) => { + const provider = resolveHttpProvider(); + if (!provider) + throw new Error("no LLM provider configured — set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN"); + const input = JSON.stringify({ + url: `${provider.baseUrl}/v1/messages`, + model: provider.model || model, + prompt, + maxTokens: 1024, + }); + const r = spawnSync(process.execPath, ["-e", HTTP_CHILD], { + input, + encoding: "utf8", + timeout: timeoutMs, + maxBuffer: 4 * 1024 * 1024, + env: { ...process.env, _FORGE_LLM_KEY: provider.key }, + stdio: ["pipe", "pipe", "pipe"], + }); + if (r.error || r.status !== 0 || !r.stdout) { + throw new Error(r.stderr?.trim() || r.error?.message || "llm call failed"); + } + return r.stdout; + }; +} diff --git a/src/providers.js b/src/providers.js index 7d77490..f7c37f3 100644 --- a/src/providers.js +++ b/src/providers.js @@ -13,6 +13,16 @@ const PROVIDERS_DIR = ".forge"; const ANTHROPIC_DEFAULT_URL = "https://api.anthropic.com"; +function anthropicKeyEnv() { + if (process.env.ANTHROPIC_API_KEY) return "ANTHROPIC_API_KEY"; + if (process.env.ANTHROPIC_AUTH_TOKEN) return "ANTHROPIC_AUTH_TOKEN"; + return "ANTHROPIC_API_KEY"; +} + +function isLikelyGateway(url) { + return /\b(gateway|litellm|aigateway|llmproxy|llm-proxy)\b/i.test(url); +} + function providersPath(root) { return join(root, PROVIDERS_DIR, PROVIDERS_FILE); } @@ -99,8 +109,15 @@ export function activeProvider(root = process.cwd()) { return { name: "anthropic", ...BUILTIN_PROVIDERS.anthropic }; } +/** Explicit model override from the environment — bypasses tier-based routing when set. */ +export function envModelOverride() { + return process.env.ANTHROPIC_MODEL?.trim() || process.env.FORGE_MODEL?.trim() || null; +} + /** Resolve a tier key (haiku/sonnet/opus/fable) to the active provider's model ID. */ export function resolveModel(root, tierKey) { + const override = envModelOverride(); + if (override) return override; const provider = activeProvider(root); return provider.models?.[tierKey] ?? MODELS[tierKey]?.id ?? null; } @@ -167,7 +184,7 @@ export function autoDetectProvider() { type: "litellm", label: "LiteLLM Gateway (hosted)", baseUrl: litellmUrl, - envKey: process.env.LITELLM_API_KEY ? "LITELLM_API_KEY" : "ANTHROPIC_API_KEY", + envKey: process.env.LITELLM_API_KEY ? "LITELLM_API_KEY" : anthropicKeyEnv(), models: Object.fromEntries(Object.entries(MODELS).map(([key, m]) => [key, m.id])), source: "LITELLM_BASE_URL", }; @@ -175,12 +192,13 @@ export function autoDetectProvider() { const baseUrl = (process.env.ANTHROPIC_BASE_URL || "").replace(/\/+$/, ""); if (baseUrl && baseUrl.toLowerCase() !== ANTHROPIC_DEFAULT_URL) { + const gateway = isLikelyGateway(baseUrl); return { - name: "anthropic-proxy", - type: "anthropic", - label: "Anthropic (via proxy)", + name: gateway ? "litellm-gateway" : "anthropic-proxy", + type: gateway ? "litellm" : "anthropic", + label: gateway ? "LiteLLM Gateway (via ANTHROPIC_BASE_URL)" : "Anthropic (via proxy)", baseUrl, - envKey: "ANTHROPIC_API_KEY", + envKey: anthropicKeyEnv(), models: Object.fromEntries(Object.entries(MODELS).map(([key, m]) => [key, m.id])), source: "ANTHROPIC_BASE_URL", }; @@ -194,6 +212,15 @@ export function autoDetectProvider() { return { name: "anthropic", ...BUILTIN_PROVIDERS.anthropic, source: "ANTHROPIC_API_KEY" }; } + if (process.env.ANTHROPIC_AUTH_TOKEN) { + return { + name: "anthropic", + ...BUILTIN_PROVIDERS.anthropic, + envKey: "ANTHROPIC_AUTH_TOKEN", + source: "ANTHROPIC_AUTH_TOKEN", + }; + } + return null; } @@ -212,10 +239,11 @@ export function listDetectedProviders() { } const baseUrl = (process.env.ANTHROPIC_BASE_URL || "").replace(/\/+$/, ""); if (baseUrl && baseUrl.toLowerCase() !== ANTHROPIC_DEFAULT_URL) { + const gateway = isLikelyGateway(baseUrl); detected.push({ - name: "anthropic-proxy", - type: "anthropic", - label: "Anthropic (via proxy)", + name: gateway ? "litellm-gateway" : "anthropic-proxy", + type: gateway ? "litellm" : "anthropic", + label: gateway ? "LiteLLM Gateway (via ANTHROPIC_BASE_URL)" : "Anthropic (via proxy)", source: "ANTHROPIC_BASE_URL", available: true, }); @@ -238,6 +266,15 @@ export function listDetectedProviders() { available: true, }); } + if (!process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_AUTH_TOKEN) { + detected.push({ + name: "anthropic", + type: "anthropic", + label: "Anthropic (via auth token)", + source: "ANTHROPIC_AUTH_TOKEN", + available: true, + }); + } return detected; } @@ -288,6 +325,8 @@ export function providerStatus(root = process.cwd()) { { key: "ANTHROPIC_BASE_URL", set: Boolean(process.env.ANTHROPIC_BASE_URL) }, { key: "OPENROUTER_API_KEY", set: Boolean(process.env.OPENROUTER_API_KEY) }, { key: "ANTHROPIC_API_KEY", set: Boolean(process.env.ANTHROPIC_API_KEY) }, + { key: "ANTHROPIC_AUTH_TOKEN", set: Boolean(process.env.ANTHROPIC_AUTH_TOKEN) }, + { key: "ANTHROPIC_MODEL", set: Boolean(process.env.ANTHROPIC_MODEL) }, ]; return { diff --git a/src/route.js b/src/route.js index c60db5d..b2d0db1 100644 --- a/src/route.js +++ b/src/route.js @@ -11,7 +11,7 @@ import { recordRoute } from "./cost_report.js"; import { mergedLessons } from "./ledger_read.js"; import { MODELS } from "./model_tiers.js"; import { preflightRepo, referencedEntities } from "./preflight.js"; -import { activeProvider } from "./providers.js"; +import { activeProvider, envModelOverride } from "./providers.js"; import { clamp01, contentHash, epochDay } from "./util.js"; // Weights sum to 1. Each raw signal is normalized by the point where it reads as "complex". @@ -232,6 +232,7 @@ export function routeTask( } } const recommended = recommend(score, norm); + const modelOvr = envModelOverride(); return { score, repoScore, @@ -242,6 +243,7 @@ export function routeTask( : null, provenance: { path }, ...recommended, + modelOverride: modelOvr || undefined, reasons: [ ...new Set([ ...(recommended.reasons || []), diff --git a/test/adjudicate.test.js b/test/adjudicate.test.js index 7bc8e4f..81e03c5 100644 --- a/test/adjudicate.test.js +++ b/test/adjudicate.test.js @@ -1,6 +1,13 @@ import assert from "node:assert/strict"; import { afterEach, test } from "node:test"; -import { adjudicate, asText, asUnit, extractJson, llmEnabled } from "../src/adjudicate.js"; +import { + adjudicate, + asText, + asUnit, + buildRunner, + extractJson, + llmEnabled, +} from "../src/adjudicate.js"; const parseScore = (o) => { const score = asUnit(o.score); @@ -107,3 +114,30 @@ test("asUnit clamps to [0,1]; asText trims and caps", () => { assert.equal(asText(null), ""); assert.equal(asText("x".repeat(500)).length, 200); }); + +test("buildRunner: returns a function regardless of transport (HTTP or CLI)", () => { + const runner = buildRunner({ model: "haiku" }); + assert.equal(typeof runner, "function"); +}); + +test("buildRunner: FORGE_LLM_HTTP=1 with no provider → runner throws descriptive error", () => { + const saved = { + FORGE_LLM_HTTP: process.env.FORGE_LLM_HTTP, + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, + LITELLM_API_KEY: process.env.LITELLM_API_KEY, + }; + process.env.FORGE_LLM_HTTP = "1"; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_AUTH_TOKEN; + delete process.env.LITELLM_API_KEY; + try { + const runner = buildRunner({ model: "haiku" }); + assert.throws(() => runner("test prompt"), /no LLM provider/); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +}); diff --git a/test/llm.test.js b/test/llm.test.js new file mode 100644 index 0000000..bb71807 --- /dev/null +++ b/test/llm.test.js @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { resolveHttpProvider } from "../src/llm.js"; + +function withEnv(overrides, fn) { + const saved = {}; + for (const k of Object.keys(overrides)) { + saved[k] = process.env[k]; + if (overrides[k] === undefined) delete process.env[k]; + else process.env[k] = overrides[k]; + } + try { + return fn(); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +} + +const CLEAR = { + ANTHROPIC_API_KEY: undefined, + ANTHROPIC_AUTH_TOKEN: undefined, + LITELLM_API_KEY: undefined, + ANTHROPIC_BASE_URL: undefined, + LITELLM_BASE_URL: undefined, + ANTHROPIC_MODEL: undefined, +}; + +test("resolveHttpProvider: ANTHROPIC_API_KEY + default URL", () => { + withEnv({ ...CLEAR, ANTHROPIC_API_KEY: "sk-test" }, () => { + const p = resolveHttpProvider(); + assert.equal(p.baseUrl, "https://api.anthropic.com"); + assert.equal(p.key, "sk-test"); + }); +}); + +test("resolveHttpProvider: ANTHROPIC_AUTH_TOKEN + custom URL", () => { + withEnv( + { + ...CLEAR, + ANTHROPIC_AUTH_TOKEN: "bearer-token", + ANTHROPIC_BASE_URL: "https://gw.example.com", + }, + () => { + const p = resolveHttpProvider(); + assert.equal(p.baseUrl, "https://gw.example.com"); + assert.equal(p.key, "bearer-token"); + }, + ); +}); + +test("resolveHttpProvider: LITELLM_BASE_URL takes precedence over ANTHROPIC_BASE_URL", () => { + withEnv( + { + ...CLEAR, + ANTHROPIC_API_KEY: "sk-test", + LITELLM_BASE_URL: "https://litellm.local", + ANTHROPIC_BASE_URL: "https://other.local", + }, + () => { + const p = resolveHttpProvider(); + assert.equal(p.baseUrl, "https://litellm.local"); + }, + ); +}); + +test("resolveHttpProvider: ANTHROPIC_MODEL override surfaces in result", () => { + withEnv({ ...CLEAR, ANTHROPIC_API_KEY: "sk-test", ANTHROPIC_MODEL: "claude-opus-4-7" }, () => { + const p = resolveHttpProvider(); + assert.equal(p.model, "claude-opus-4-7"); + }); +}); + +test("resolveHttpProvider: no model override → null", () => { + withEnv({ ...CLEAR, ANTHROPIC_API_KEY: "sk-test" }, () => { + const p = resolveHttpProvider(); + assert.equal(p.model, null); + }); +}); + +test("resolveHttpProvider: no credentials → null", () => { + withEnv(CLEAR, () => { + assert.equal(resolveHttpProvider(), null); + }); +}); + +test("resolveHttpProvider: LITELLM_API_KEY as fallback", () => { + withEnv({ ...CLEAR, LITELLM_API_KEY: "sk-lite" }, () => { + const p = resolveHttpProvider(); + assert.equal(p.key, "sk-lite"); + }); +}); + +test("resolveHttpProvider: strips trailing slash from URL", () => { + withEnv( + { ...CLEAR, ANTHROPIC_API_KEY: "sk-test", ANTHROPIC_BASE_URL: "https://gw.example.com/" }, + () => { + const p = resolveHttpProvider(); + assert.equal(p.baseUrl, "https://gw.example.com"); + }, + ); +}); diff --git a/test/providers.test.js b/test/providers.test.js index a13f60d..a1c3dd8 100644 --- a/test/providers.test.js +++ b/test/providers.test.js @@ -8,6 +8,7 @@ import { addProvider, applyRoute, autoDetectProvider, + envModelOverride, listDetectedProviders, listProviders, loadProviders, @@ -43,6 +44,9 @@ const CLEAR_ENV = { ANTHROPIC_BASE_URL: undefined, OPENROUTER_API_KEY: undefined, ANTHROPIC_API_KEY: undefined, + ANTHROPIC_AUTH_TOKEN: undefined, + ANTHROPIC_MODEL: undefined, + FORGE_MODEL: undefined, }; // --- existing tests --- @@ -373,3 +377,181 @@ test("listDetectedProviders: returns all available providers from env", () => { }, ); }); + +// --- ANTHROPIC_AUTH_TOKEN recognition --- + +test("autoDetectProvider: ANTHROPIC_AUTH_TOKEN → anthropic (fallback auth)", () => { + withEnv({ ...CLEAR_ENV, ANTHROPIC_AUTH_TOKEN: "token-test" }, () => { + const r = autoDetectProvider(); + assert.equal(r.name, "anthropic"); + assert.equal(r.type, "anthropic"); + assert.equal(r.envKey, "ANTHROPIC_AUTH_TOKEN"); + assert.equal(r.source, "ANTHROPIC_AUTH_TOKEN"); + }); +}); + +test("autoDetectProvider: ANTHROPIC_API_KEY wins over ANTHROPIC_AUTH_TOKEN", () => { + withEnv( + { ...CLEAR_ENV, ANTHROPIC_API_KEY: "sk-ant-test", ANTHROPIC_AUTH_TOKEN: "token-test" }, + () => { + const r = autoDetectProvider(); + assert.equal(r.envKey, "ANTHROPIC_API_KEY"); + assert.equal(r.source, "ANTHROPIC_API_KEY"); + }, + ); +}); + +// --- LiteLLM gateway detection from ANTHROPIC_BASE_URL --- + +test("autoDetectProvider: ANTHROPIC_BASE_URL with 'gateway' → litellm type", () => { + withEnv( + { + ...CLEAR_ENV, + ANTHROPIC_BASE_URL: "https://api-eu1.aigateway.emirates.group", + ANTHROPIC_AUTH_TOKEN: "token", + }, + () => { + const r = autoDetectProvider(); + assert.equal(r.type, "litellm"); + assert.equal(r.name, "litellm-gateway"); + assert.equal(r.envKey, "ANTHROPIC_AUTH_TOKEN"); + }, + ); +}); + +test("autoDetectProvider: ANTHROPIC_BASE_URL with 'litellm' → litellm type", () => { + withEnv( + { ...CLEAR_ENV, ANTHROPIC_BASE_URL: "https://litellm.company.com", ANTHROPIC_API_KEY: "sk" }, + () => { + const r = autoDetectProvider(); + assert.equal(r.type, "litellm"); + assert.equal(r.name, "litellm-gateway"); + }, + ); +}); + +test("autoDetectProvider: non-gateway proxy URL → anthropic-proxy (backward compat)", () => { + withEnv( + { ...CLEAR_ENV, ANTHROPIC_BASE_URL: "http://localhost:4000", ANTHROPIC_API_KEY: "sk-ant-test" }, + () => { + const r = autoDetectProvider(); + assert.equal(r.type, "anthropic"); + assert.equal(r.name, "anthropic-proxy"); + }, + ); +}); + +test("autoDetectProvider: ANTHROPIC_BASE_URL proxy uses ANTHROPIC_AUTH_TOKEN when no API_KEY", () => { + withEnv( + { ...CLEAR_ENV, ANTHROPIC_BASE_URL: "http://localhost:4000", ANTHROPIC_AUTH_TOKEN: "token" }, + () => { + const r = autoDetectProvider(); + assert.equal(r.envKey, "ANTHROPIC_AUTH_TOKEN"); + }, + ); +}); + +// --- Full LiteLLM gateway scenario (the Emirates env) --- + +test("autoDetectProvider: full LiteLLM gateway env (Emirates scenario)", () => { + withEnv( + { + ...CLEAR_ENV, + ANTHROPIC_BASE_URL: "https://api-eu1.aigateway.emirates.group", + ANTHROPIC_AUTH_TOKEN: "gateway-token", + ANTHROPIC_MODEL: "claude-opus-4-7", + }, + () => { + const r = autoDetectProvider(); + assert.equal(r.type, "litellm"); + assert.equal(r.baseUrl, "https://api-eu1.aigateway.emirates.group"); + assert.equal(r.envKey, "ANTHROPIC_AUTH_TOKEN"); + }, + ); +}); + +// --- ANTHROPIC_MODEL override --- + +test("envModelOverride: returns ANTHROPIC_MODEL when set", () => { + withEnv({ ANTHROPIC_MODEL: "claude-opus-4-7" }, () => { + assert.equal(envModelOverride(), "claude-opus-4-7"); + }); +}); + +test("envModelOverride: returns FORGE_MODEL as fallback", () => { + withEnv({ ANTHROPIC_MODEL: undefined, FORGE_MODEL: "custom-model" }, () => { + assert.equal(envModelOverride(), "custom-model"); + }); +}); + +test("envModelOverride: returns null when neither is set", () => { + withEnv({ ANTHROPIC_MODEL: undefined, FORGE_MODEL: undefined }, () => { + assert.equal(envModelOverride(), null); + }); +}); + +test("resolveModel: ANTHROPIC_MODEL overrides tier-based resolution", () => { + const root = tmpRoot(); + withEnv({ ...CLEAR_ENV, ANTHROPIC_MODEL: "claude-opus-4-7" }, () => { + assert.equal(resolveModel(root, "haiku"), "claude-opus-4-7"); + assert.equal(resolveModel(root, "sonnet"), "claude-opus-4-7"); + assert.equal(resolveModel(root, "opus"), "claude-opus-4-7"); + }); +}); + +test("resolveModel: without ANTHROPIC_MODEL, tier resolution is unchanged", () => { + const root = tmpRoot(); + withEnv({ ANTHROPIC_MODEL: undefined, FORGE_MODEL: undefined }, () => { + const id = resolveModel(root, "haiku"); + assert.ok(id); + assert.notEqual(id, "claude-opus-4-7"); + }); +}); + +// --- providerStatus envScan includes new env vars --- + +test("providerStatus: envScan includes ANTHROPIC_AUTH_TOKEN and ANTHROPIC_MODEL", () => { + const root = tmpRoot(); + withEnv(CLEAR_ENV, () => { + const status = providerStatus(root); + const keys = status.envScan.map((e) => e.key); + assert.ok(keys.includes("ANTHROPIC_AUTH_TOKEN")); + assert.ok(keys.includes("ANTHROPIC_MODEL")); + }); +}); + +// --- listDetectedProviders: ANTHROPIC_AUTH_TOKEN --- + +test("listDetectedProviders: ANTHROPIC_AUTH_TOKEN (no API_KEY) → listed", () => { + withEnv({ ...CLEAR_ENV, ANTHROPIC_AUTH_TOKEN: "token-test" }, () => { + const detected = listDetectedProviders(); + assert.ok(detected.some((d) => d.source === "ANTHROPIC_AUTH_TOKEN")); + }); +}); + +test("listDetectedProviders: ANTHROPIC_API_KEY set → ANTHROPIC_AUTH_TOKEN not double-listed", () => { + withEnv( + { ...CLEAR_ENV, ANTHROPIC_API_KEY: "sk-test", ANTHROPIC_AUTH_TOKEN: "token-test" }, + () => { + const detected = listDetectedProviders(); + const authTokenEntries = detected.filter((d) => d.source === "ANTHROPIC_AUTH_TOKEN"); + assert.equal(authTokenEntries.length, 0); + }, + ); +}); + +test("listDetectedProviders: gateway URL detected as litellm", () => { + withEnv( + { + ...CLEAR_ENV, + ANTHROPIC_BASE_URL: "https://api.aigateway.example.com", + ANTHROPIC_AUTH_TOKEN: "token", + }, + () => { + const detected = listDetectedProviders(); + const gw = detected.find((d) => d.source === "ANTHROPIC_BASE_URL"); + assert.equal(gw.type, "litellm"); + assert.equal(gw.name, "litellm-gateway"); + }, + ); +});