Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) |
Expand Down
2 changes: 1 addition & 1 deletion src/doctor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
),
);
}
Expand Down
98 changes: 77 additions & 21 deletions src/llm.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
70 changes: 70 additions & 0 deletions src/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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",
Expand Down
47 changes: 47 additions & 0 deletions test/llm.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
Loading
Loading