diff --git a/CHANGELOG.md b/CHANGELOG.md index 7462f49..a31d6a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **OpenAI + Gemini provider detection** — `autoDetectProvider()` now recognizes + `OPENAI_API_KEY` and `GEMINI_API_KEY` (or `GOOGLE_API_KEY`) as zero-config + fallbacks after Anthropic, exposing `openai` and `gemini` as built-in providers + with tier→model maps. Both are reached over their OpenAI-compatible + chat/completions surface: `src/llm.js` gained an OpenAI-compatible wire format + (`resolveHttpProvider()` now returns a `format` field) alongside the Anthropic + Messages path, so a native key from either vendor works with no manual config. + Anthropic credentials still win when present. `forge config` status and + `listDetectedProviders()` surface the new keys. ## [0.16.0] - 2026-07-15 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 2571b21..a3b57fa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,7 +33,7 @@ confidence only from independent oracles, and merges across teammates conflict-f - **Zero-config provider auto-detection** — `autoDetectProvider()` probes env vars for LiteLLM (local + hosted gateway), OpenRouter, and Anthropic (key, auth token, or custom base URL); `forge init` reports what it found, no manual config needed. - (OpenAI and Gemini detection: see Next.) + (OpenAI and Gemini detection shipped later as a zero-config fallback — see CHANGELOG.) - **Hosted LiteLLM gateway** — `emitGatewayConfig()` writes a `litellm.config.yaml` exposing the complexity tiers as model aliases; point `ANTHROPIC_BASE_URL` at the proxy and every model call routes through it. @@ -60,6 +60,12 @@ confidence only from independent oracles, and merges across teammates conflict-f ## Next +- **Legacy store retirement** — the read-path flip has shipped: every read surface + (cortex injection/status, the substrate advisory, routing, `recall list`, brain's + AGENTS.md index) is now a merged view (legacy ∪ ledger) via `src/ledger_read.js`, + so teammate knowledge from `forge ledger merge` reaches injection. The legacy + formats (`lessons/*.md`, recall/brain fact files) are still written as the canonical + local state; the remaining step is retiring them so the ledger is the only store. - **OpenAI + Gemini provider detection** — extend `autoDetectProvider()` beyond Anthropic/OpenRouter/LiteLLM (`OPENAI_API_KEY`, `GEMINI_API_KEY`) with the same zero-config contract. diff --git a/docs/GUIDE.md b/docs/GUIDE.md index c15ffdc..d407659 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -234,7 +234,11 @@ Shows, switches, and registers model providers, and sets the default model. Forg auto-detects the provider from the environment with zero config — the priority order is `LITELLM_BASE_URL` → `ANTHROPIC_BASE_URL` (a URL that answers `/health` or names a gateway is classified as one) → `OPENROUTER_API_KEY` → `ANTHROPIC_API_KEY` → -`ANTHROPIC_AUTH_TOKEN`. An explicit `.forge/providers.json` always wins over detection. +`ANTHROPIC_AUTH_TOKEN` → `OPENAI_API_KEY` → `GEMINI_API_KEY` (or `GOOGLE_API_KEY`). +Anthropic credentials win when present (forge is Claude-native); OpenAI and Gemini are +picked up as the zero-config fallback when they are the only key set, and are reached +over their OpenAI-compatible chat/completions surface. An explicit +`.forge/providers.json` always wins over detection. ```console $ forge config show # active provider + how it was resolved @@ -1050,9 +1054,11 @@ code reads but this table misses fails CI on the forge repo): | `ANTHROPIC_MODEL` / `FORGE_MODEL` | pin one model — bypasses tier routing entirely | | `LITELLM_BASE_URL` / `LITELLM_API_KEY` | hosted LiteLLM gateway endpoint + key (highest detection priority) | | `OPENROUTER_API_KEY` | OpenRouter provider | +| `OPENAI_API_KEY` | OpenAI provider (OpenAI-compatible chat/completions); zero-config fallback after Anthropic | +| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Google Gemini provider via its OpenAI-compatible endpoint; zero-config fallback after Anthropic | | `FORGE_LLM` | `1` enables the LLM proposer layer (off = fully deterministic) | | `FORGE_LLM_AMBIENT` | `1` lets the ambient hook use the proposer too | -| `FORGE_LLM_HTTP` | `1` forces direct HTTP (Anthropic Messages API) instead of the `claude` CLI; automatic when the CLI is absent | +| `FORGE_LLM_HTTP` | `1` forces direct HTTP (Anthropic Messages or OpenAI-compatible, per the resolved provider) instead of the `claude` CLI; automatic when the CLI is absent | | `FORGE_ENFORCE` | `1` turns the substrate advisory into a hard block on the strongest signals | | `FORGE_AUTOSYNC` | `0` disables the Stop-hook AGENTS.md auto-repair | | `FORGE_LEDGER_ONLY` | `1` retires the legacy stores — stop writing `lessons/*.md` + recall/brain fact files; the ledger is the only store (reads materialize from it) | diff --git a/src/doctor.js b/src/doctor.js index 393059d..663ff38 100644 --- a/src/doctor.js +++ b/src/doctor.js @@ -39,7 +39,7 @@ function checkTooling(out) { ? 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)", + "not found — LLM proposer falls back to direct HTTP (needs ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, OPENAI_API_KEY, or GEMINI_API_KEY)", ), ); } diff --git a/src/llm.js b/src/llm.js index 5a3d67b..1f3731d 100644 --- a/src/llm.js +++ b/src/llm.js @@ -1,45 +1,101 @@ -// 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. +// forge llm — direct HTTP LLM call. 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. +// +// Two wire formats are supported so a native key from any major vendor works with zero +// config: the Anthropic Messages API (default), and the OpenAI-compatible chat/completions +// API that OpenAI, Google Gemini, OpenRouter, and LiteLLM all expose. 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);}});`; +// Anthropic Messages API — POST {baseUrl}/v1/messages, x-api-key / bearer auth. +const HTTP_CHILD_ANTHROPIC = `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. */ +// OpenAI-compatible chat/completions — POST {baseUrl}/chat/completions, bearer auth. +const HTTP_CHILD_OPENAI = `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"};if(key)headers.authorization=key.startsWith("Bearer ")?key:"Bearer "+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 c=data.choices&&data.choices[0]&&data.choices[0].message&&data.choices[0].message.content;const text=Array.isArray(c)?c.map(p=>typeof p==="string"?p:p.text||"").join(""):(c||"");process.stdout.write(text);}catch(e){process.stderr.write("llm: "+(e.message||e));process.exit(1);}});`; + +const GEMINI_OPENAI_URL = "https://generativelanguage.googleapis.com/v1beta/openai"; + +/** + * Resolve base URL, auth key, wire format, and model override from environment. + * Anthropic credentials win when present (forge is Claude-native); OpenAI and Gemini + * are the zero-config fallback when they are the only key set. Returns null if no auth + * is available. + * @returns {{baseUrl:string, key:string, model:string|null, format:"anthropic"|"openai", path:string, defaultModel:string|null}|null} + */ export function resolveHttpProvider() { - const key = + const modelOverride = + process.env.ANTHROPIC_MODEL?.trim() || process.env.FORGE_MODEL?.trim() || null; + + const anthropicKey = 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 }; + if (anthropicKey) { + const baseUrl = ( + process.env.LITELLM_BASE_URL || + process.env.ANTHROPIC_BASE_URL || + "https://api.anthropic.com" + ).replace(/\/+$/, ""); + return { + baseUrl, + key: anthropicKey, + model: modelOverride, + format: "anthropic", + path: "/v1/messages", + defaultModel: null, + }; + } + + if (process.env.OPENAI_API_KEY) { + return { + baseUrl: "https://api.openai.com/v1", + key: process.env.OPENAI_API_KEY, + model: modelOverride, + format: "openai", + path: "/chat/completions", + defaultModel: "gpt-5-mini", + }; + } + + const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || ""; + if (geminiKey) { + return { + baseUrl: GEMINI_OPENAI_URL, + key: geminiKey, + model: modelOverride, + format: "openai", + path: "/chat/completions", + defaultModel: "gemini-2.5-flash", + }; + } + + return null; } /** - * Build an HTTP-based LLM runner (Anthropic Messages API). - * Same contract as adjudicate.buildRunner: returns (prompt) => string. + * Build an HTTP-based LLM runner. Same contract as adjudicate.buildRunner: + * returns (prompt) => string. Selects the Anthropic or OpenAI-compatible wire format + * from the resolved provider. * @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"); + throw new Error( + "no LLM provider configured — set ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, OPENAI_API_KEY, or GEMINI_API_KEY", + ); + const child = provider.format === "openai" ? HTTP_CHILD_OPENAI : HTTP_CHILD_ANTHROPIC; + const chosenModel = provider.model || provider.defaultModel || model; const input = JSON.stringify({ - url: `${provider.baseUrl}/v1/messages`, - model: provider.model || model, + url: `${provider.baseUrl}${provider.path}`, + model: chosenModel, prompt, maxTokens: 1024, }); - const r = spawnSync(process.execPath, ["-e", HTTP_CHILD], { + const r = spawnSync(process.execPath, ["-e", child], { input, encoding: "utf8", timeout: timeoutMs, diff --git a/src/providers.js b/src/providers.js index bf19239..328103b 100644 --- a/src/providers.js +++ b/src/providers.js @@ -76,8 +76,45 @@ const BUILTIN_PROVIDERS = { fable: "forge-complex", }, }, + // OpenAI and Gemini are reached over their OpenAI-compatible chat/completions + // surface (format: "openai" — see src/llm.js), so a native key works with zero + // config. Tier→model maps size the model to the task, same as the Anthropic table; + // rotate them via `forge config provider add` when the vendor rolls the lineup. + openai: { + type: "openai", + format: "openai", + label: "OpenAI", + baseUrl: "https://api.openai.com/v1", + envKey: "OPENAI_API_KEY", + models: { + haiku: "gpt-5-nano", + sonnet: "gpt-5-mini", + opus: "gpt-5", + fable: "gpt-5", + }, + }, + gemini: { + type: "gemini", + format: "openai", + label: "Google Gemini", + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + envKey: "GEMINI_API_KEY", + models: { + haiku: "gemini-2.5-flash-lite", + sonnet: "gemini-2.5-flash", + opus: "gemini-2.5-pro", + fable: "gemini-2.5-pro", + }, + }, }; +/** Resolve the Gemini key env var name (GEMINI_API_KEY preferred, GOOGLE_API_KEY alias). */ +function geminiKeyEnv() { + if (process.env.GEMINI_API_KEY) return "GEMINI_API_KEY"; + if (process.env.GOOGLE_API_KEY) return "GOOGLE_API_KEY"; + return null; +} + function defaults() { return { active: "anthropic", @@ -259,6 +296,17 @@ export function autoDetectProvider() { }; } + // Anthropic stays the default when its credentials are present; OpenAI and Gemini + // are picked up only as the sole configured provider (zero-config fallback). + if (process.env.OPENAI_API_KEY) { + return { name: "openai", ...BUILTIN_PROVIDERS.openai, source: "OPENAI_API_KEY" }; + } + + const geminiKey = geminiKeyEnv(); + if (geminiKey) { + return { name: "gemini", ...BUILTIN_PROVIDERS.gemini, envKey: geminiKey, source: geminiKey }; + } + return null; } @@ -313,6 +361,25 @@ export function listDetectedProviders() { available: true, }); } + if (process.env.OPENAI_API_KEY) { + detected.push({ + name: "openai", + type: "openai", + label: "OpenAI", + source: "OPENAI_API_KEY", + available: true, + }); + } + const geminiKey = geminiKeyEnv(); + if (geminiKey) { + detected.push({ + name: "gemini", + type: "gemini", + label: "Google Gemini", + source: geminiKey, + available: true, + }); + } return detected; } @@ -367,6 +434,9 @@ export function providerStatus(root = process.cwd()) { { key: "LITELLM_API_KEY", set: Boolean(process.env.LITELLM_API_KEY) }, { key: "ANTHROPIC_BASE_URL", set: Boolean(process.env.ANTHROPIC_BASE_URL) }, { key: "OPENROUTER_API_KEY", set: Boolean(process.env.OPENROUTER_API_KEY) }, + { key: "OPENAI_API_KEY", set: Boolean(process.env.OPENAI_API_KEY) }, + { key: "GEMINI_API_KEY", set: Boolean(process.env.GEMINI_API_KEY) }, + { key: "GOOGLE_API_KEY", set: Boolean(process.env.GOOGLE_API_KEY) }, { key: "ANTHROPIC_API_KEY", set: Boolean(process.env.ANTHROPIC_API_KEY) }, { key: "ANTHROPIC_AUTH_TOKEN", diff --git a/test/llm.test.js b/test/llm.test.js index bb71807..c6f045b 100644 --- a/test/llm.test.js +++ b/test/llm.test.js @@ -26,6 +26,10 @@ const CLEAR = { ANTHROPIC_BASE_URL: undefined, LITELLM_BASE_URL: undefined, ANTHROPIC_MODEL: undefined, + FORGE_MODEL: undefined, + OPENAI_API_KEY: undefined, + GEMINI_API_KEY: undefined, + GOOGLE_API_KEY: undefined, }; test("resolveHttpProvider: ANTHROPIC_API_KEY + default URL", () => { @@ -102,3 +106,46 @@ test("resolveHttpProvider: strips trailing slash from URL", () => { }, ); }); + +test("resolveHttpProvider: OPENAI_API_KEY → OpenAI-compatible chat/completions", () => { + withEnv({ ...CLEAR, OPENAI_API_KEY: "sk-oai" }, () => { + const p = resolveHttpProvider(); + assert.equal(p.format, "openai"); + assert.equal(p.baseUrl, "https://api.openai.com/v1"); + assert.equal(p.path, "/chat/completions"); + assert.equal(p.key, "sk-oai"); + assert.equal(p.defaultModel, "gpt-5-mini"); + }); +}); + +test("resolveHttpProvider: GEMINI_API_KEY → Gemini OpenAI-compatible endpoint", () => { + withEnv({ ...CLEAR, GEMINI_API_KEY: "gm" }, () => { + const p = resolveHttpProvider(); + assert.equal(p.format, "openai"); + assert.equal(p.baseUrl, "https://generativelanguage.googleapis.com/v1beta/openai"); + assert.equal(p.defaultModel, "gemini-2.5-flash"); + }); +}); + +test("resolveHttpProvider: GOOGLE_API_KEY is a Gemini alias", () => { + withEnv({ ...CLEAR, GOOGLE_API_KEY: "goog" }, () => { + const p = resolveHttpProvider(); + assert.equal(p.format, "openai"); + assert.equal(p.key, "goog"); + }); +}); + +test("resolveHttpProvider: Anthropic key wins over OpenAI (format stays anthropic)", () => { + withEnv({ ...CLEAR, ANTHROPIC_API_KEY: "sk-ant", OPENAI_API_KEY: "sk-oai" }, () => { + const p = resolveHttpProvider(); + assert.equal(p.format, "anthropic"); + assert.equal(p.key, "sk-ant"); + }); +}); + +test("resolveHttpProvider: FORGE_MODEL override surfaces for OpenAI", () => { + withEnv({ ...CLEAR, OPENAI_API_KEY: "sk-oai", FORGE_MODEL: "gpt-5" }, () => { + const p = resolveHttpProvider(); + assert.equal(p.model, "gpt-5"); + }); +}); diff --git a/test/providers.test.js b/test/providers.test.js index d22bfd3..d2e452d 100644 --- a/test/providers.test.js +++ b/test/providers.test.js @@ -43,6 +43,9 @@ const CLEAR_ENV = { LITELLM_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, OPENROUTER_API_KEY: undefined, + OPENAI_API_KEY: undefined, + GEMINI_API_KEY: undefined, + GOOGLE_API_KEY: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_AUTH_TOKEN: undefined, ANTHROPIC_MODEL: undefined, @@ -555,3 +558,77 @@ test("listDetectedProviders: gateway URL detected as litellm", () => { }, ); }); + +// --- OpenAI + Gemini provider detection --- + +test("autoDetectProvider: OPENAI_API_KEY → openai provider (OpenAI-compatible)", () => { + withEnv({ ...CLEAR_ENV, OPENAI_API_KEY: "sk-openai-test" }, () => { + const r = autoDetectProvider(); + assert.equal(r.name, "openai"); + assert.equal(r.type, "openai"); + assert.equal(r.format, "openai"); + assert.equal(r.envKey, "OPENAI_API_KEY"); + assert.equal(r.source, "OPENAI_API_KEY"); + assert.equal(r.baseUrl, "https://api.openai.com/v1"); + assert.equal(r.models.sonnet, "gpt-5-mini"); + }); +}); + +test("autoDetectProvider: GEMINI_API_KEY → gemini provider", () => { + withEnv({ ...CLEAR_ENV, GEMINI_API_KEY: "gm-test" }, () => { + const r = autoDetectProvider(); + assert.equal(r.name, "gemini"); + assert.equal(r.type, "gemini"); + assert.equal(r.format, "openai"); + assert.equal(r.envKey, "GEMINI_API_KEY"); + assert.equal(r.models.opus, "gemini-2.5-pro"); + }); +}); + +test("autoDetectProvider: GOOGLE_API_KEY is a Gemini alias", () => { + withEnv({ ...CLEAR_ENV, GOOGLE_API_KEY: "goog-test" }, () => { + const r = autoDetectProvider(); + assert.equal(r.name, "gemini"); + assert.equal(r.envKey, "GOOGLE_API_KEY"); + assert.equal(r.source, "GOOGLE_API_KEY"); + }); +}); + +test("autoDetectProvider: Anthropic wins over OpenAI and Gemini when both present", () => { + withEnv( + { ...CLEAR_ENV, ANTHROPIC_API_KEY: "sk-ant", OPENAI_API_KEY: "sk-oai", GEMINI_API_KEY: "gm" }, + () => { + const r = autoDetectProvider(); + assert.equal(r.name, "anthropic"); + assert.equal(r.source, "ANTHROPIC_API_KEY"); + }, + ); +}); + +test("resolveModel: OpenAI auto-detected → tiers map to gpt-* ids", () => { + const root = tmpRoot(); + withEnv({ ...CLEAR_ENV, OPENAI_API_KEY: "sk-oai" }, () => { + assert.equal(resolveModel(root, "haiku"), "gpt-5-nano"); + assert.equal(resolveModel(root, "sonnet"), "gpt-5-mini"); + assert.equal(resolveModel(root, "opus"), "gpt-5"); + }); +}); + +test("listDetectedProviders: OpenAI and Gemini keys are listed", () => { + withEnv({ ...CLEAR_ENV, OPENAI_API_KEY: "sk-oai", GEMINI_API_KEY: "gm" }, () => { + const detected = listDetectedProviders(); + assert.ok(detected.find((d) => d.name === "openai" && d.source === "OPENAI_API_KEY")); + assert.ok(detected.find((d) => d.name === "gemini" && d.source === "GEMINI_API_KEY")); + }); +}); + +test("providerStatus: envScan includes OpenAI and Gemini keys", () => { + const root = tmpRoot(); + withEnv(CLEAR_ENV, () => { + const status = providerStatus(root); + const keys = status.envScan.map((e) => e.key); + assert.ok(keys.includes("OPENAI_API_KEY")); + assert.ok(keys.includes("GEMINI_API_KEY")); + assert.ok(keys.includes("GOOGLE_API_KEY")); + }); +});