@almightygpt/core 0.10.1 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/adapters/claude.d.ts.map +1 -1
  2. package/dist/adapters/claude.js +5 -0
  3. package/dist/adapters/claude.js.map +1 -1
  4. package/dist/adapters/defaults.d.ts.map +1 -1
  5. package/dist/adapters/defaults.js +5 -0
  6. package/dist/adapters/defaults.js.map +1 -1
  7. package/dist/adapters/factory.d.ts.map +1 -1
  8. package/dist/adapters/factory.js +11 -1
  9. package/dist/adapters/factory.js.map +1 -1
  10. package/dist/adapters/gemini.d.ts.map +1 -1
  11. package/dist/adapters/gemini.js +5 -0
  12. package/dist/adapters/gemini.js.map +1 -1
  13. package/dist/adapters/index.d.ts +9 -5
  14. package/dist/adapters/index.d.ts.map +1 -1
  15. package/dist/adapters/index.js +9 -5
  16. package/dist/adapters/index.js.map +1 -1
  17. package/dist/adapters/ollama.d.ts +47 -0
  18. package/dist/adapters/ollama.d.ts.map +1 -0
  19. package/dist/adapters/ollama.js +124 -0
  20. package/dist/adapters/ollama.js.map +1 -0
  21. package/dist/adapters/openai.d.ts.map +1 -1
  22. package/dist/adapters/openai.js +8 -0
  23. package/dist/adapters/openai.js.map +1 -1
  24. package/dist/adapters/openrouter.d.ts +50 -0
  25. package/dist/adapters/openrouter.d.ts.map +1 -0
  26. package/dist/adapters/openrouter.js +153 -0
  27. package/dist/adapters/openrouter.js.map +1 -0
  28. package/dist/auth/__tests__/resolver.test.js +18 -0
  29. package/dist/auth/__tests__/resolver.test.js.map +1 -1
  30. package/dist/auth/types.d.ts +1 -1
  31. package/dist/auth/types.d.ts.map +1 -1
  32. package/dist/auth/types.js +8 -0
  33. package/dist/auth/types.js.map +1 -1
  34. package/dist/auth/validator.d.ts.map +1 -1
  35. package/dist/auth/validator.js +127 -0
  36. package/dist/auth/validator.js.map +1 -1
  37. package/dist/config/schema.d.ts +12 -12
  38. package/dist/config/schema.d.ts.map +1 -1
  39. package/dist/config/schema.js +8 -1
  40. package/dist/config/schema.js.map +1 -1
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.js +1 -1
  43. package/package.json +1 -1
  44. package/src/adapters/claude.ts +8 -0
  45. package/src/adapters/defaults.ts +5 -0
  46. package/src/adapters/factory.ts +11 -1
  47. package/src/adapters/gemini.ts +8 -0
  48. package/src/adapters/index.ts +9 -5
  49. package/src/adapters/ollama.ts +157 -0
  50. package/src/adapters/openai.ts +11 -0
  51. package/src/adapters/openrouter.ts +202 -0
  52. package/src/auth/__tests__/resolver.test.ts +24 -0
  53. package/src/auth/types.ts +15 -1
  54. package/src/auth/validator.ts +135 -0
  55. package/src/config/schema.ts +8 -1
  56. package/src/index.ts +1 -1
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * - review/ ✅ task #11 diff review pipeline (with #12/#13/#14 wiring)
13
13
  * - budget/ ✅ task #14 BudgetTracker + BudgetExceededError
14
14
  */
15
- export const VERSION = "0.10.0";
15
+ export const VERSION = "0.11.0";
16
16
  // MCP server (v0.9.0+) — exposes AlmightyGPT's review surface as MCP tools.
17
17
  export { startMcpServer } from "./mcp/server.js";
18
18
  // Git safety primitives
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@almightygpt/core",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "description": "Core orchestrator, adapters, config, runs, and review logic for AlmightyGPT",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -75,6 +75,14 @@ export class ClaudeAdapter implements Adapter {
75
75
  const opts = this.explicitApiKey ? { explicit: this.explicitApiKey } : {};
76
76
  const result = await resolveApiKey("anthropic", opts);
77
77
  if (result.source === "missing") return null;
78
+ if (result.source === "keychain_error") {
79
+ throw new AdapterError(
80
+ `Keychain read failed for Anthropic: ${result.keychainError ?? "unknown error"}. ` +
81
+ "Set ANTHROPIC_API_KEY in your environment to override, or fix the " +
82
+ "keychain state.",
83
+ this.name,
84
+ );
85
+ }
78
86
  this.client = new Anthropic({ apiKey: result.key! });
79
87
  return this.client;
80
88
  }
@@ -31,6 +31,9 @@ export const DEFAULT_MODELS: Record<ProviderId, string> = {
31
31
  openai: "gpt-4o",
32
32
  anthropic: "claude-sonnet-4-6",
33
33
  google: "gemini-2.5-flash",
34
+ // v0.12.0+ — meta-providers
35
+ openrouter: "deepseek/deepseek-chat",
36
+ ollama: "llama3.3:70b",
34
37
  };
35
38
 
36
39
  /**
@@ -41,4 +44,6 @@ export const PROVIDER_PRETTY_NAME: Record<ProviderId, string> = {
41
44
  openai: "OpenAI",
42
45
  anthropic: "Anthropic",
43
46
  google: "Google",
47
+ openrouter: "OpenRouter",
48
+ ollama: "Ollama (local)",
44
49
  };
@@ -12,6 +12,8 @@ import { MockAdapter } from "./mock.js";
12
12
  import { OpenAIAdapter } from "./openai.js";
13
13
  import { ClaudeAdapter } from "./claude.js";
14
14
  import { GeminiAdapter } from "./gemini.js";
15
+ import { OpenRouterAdapter } from "./openrouter.js";
16
+ import { OllamaAdapter } from "./ollama.js";
15
17
 
16
18
  export function makeAdapter(name: string, provider: string): Adapter {
17
19
  switch (provider) {
@@ -21,12 +23,16 @@ export function makeAdapter(name: string, provider: string): Adapter {
21
23
  return new ClaudeAdapter(name);
22
24
  case "google":
23
25
  return new GeminiAdapter(name);
26
+ case "openrouter":
27
+ return new OpenRouterAdapter(name);
28
+ case "ollama":
29
+ return new OllamaAdapter(name);
24
30
  case "mock":
25
31
  return new MockAdapter();
26
32
  default:
27
33
  throw new Error(
28
34
  `Provider "${provider}" not supported. ` +
29
- `Use "openai", "anthropic", "google", or "mock".`,
35
+ `Use "openai", "anthropic", "google", "openrouter", "ollama", or "mock".`,
30
36
  );
31
37
  }
32
38
  }
@@ -39,6 +45,10 @@ export function envHintForProvider(provider: string): string {
39
45
  return "Export ANTHROPIC_API_KEY in your environment.";
40
46
  case "google":
41
47
  return "Export GOOGLE_API_KEY (or GEMINI_API_KEY) in your environment.";
48
+ case "openrouter":
49
+ return "Export OPENROUTER_API_KEY in your environment.";
50
+ case "ollama":
51
+ return "Install Ollama (https://ollama.ai) and run `ollama serve` locally.";
42
52
  default:
43
53
  return "";
44
54
  }
@@ -88,6 +88,14 @@ export class GeminiAdapter implements Adapter {
88
88
  const opts = this.explicitApiKey ? { explicit: this.explicitApiKey } : {};
89
89
  const result = await resolveApiKey("google", opts);
90
90
  if (result.source === "missing") return null;
91
+ if (result.source === "keychain_error") {
92
+ throw new AdapterError(
93
+ `Keychain read failed for Google: ${result.keychainError ?? "unknown error"}. ` +
94
+ "Set GOOGLE_API_KEY (or GEMINI_API_KEY) in your environment to " +
95
+ "override, or fix the keychain state.",
96
+ this.name,
97
+ );
98
+ }
91
99
  this.client = new GoogleGenerativeAI(result.key!);
92
100
  return this.client;
93
101
  }
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Adapter exports.
3
3
  *
4
- * Add new adapters here as they're implemented:
5
- * - Claude (Anthropic) — task #15
6
- * - Gemini post-MVP 1
7
- * - OpenRouter — post-MVP 1 (for cost-conscious users; see
8
- * docs/claude/competitor-ai-council.md)
4
+ * Currently shipped adapters:
5
+ * - OpenAI (gpt-4o family)
6
+ * - Anthropic (Claude Sonnet 4.x family)
7
+ * - Google (Gemini 2.5 family)
8
+ * - OpenRouter — v0.12.0+, meta-provider unlocking 100+ models
9
+ * - Ollama — v0.12.0+, local models, no cloud calls
10
+ * - Mock — for testing without API keys
9
11
  */
10
12
 
11
13
  export type {
@@ -19,3 +21,5 @@ export { MockAdapter } from "./mock.js";
19
21
  export { OpenAIAdapter, type OpenAIAdapterOptions } from "./openai.js";
20
22
  export { ClaudeAdapter, type ClaudeAdapterOptions } from "./claude.js";
21
23
  export { GeminiAdapter, type GeminiAdapterOptions } from "./gemini.js";
24
+ export { OpenRouterAdapter, type OpenRouterAdapterOptions } from "./openrouter.js";
25
+ export { OllamaAdapter, type OllamaAdapterOptions } from "./ollama.js";
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Ollama adapter (v0.12.0+).
3
+ *
4
+ * Ollama runs LLMs locally — no cloud calls, no API key, code never
5
+ * leaves the machine. Opens AlmightyGPT to regulated / government /
6
+ * air-gapped enterprise users who can't use cloud AI but want the
7
+ * Worker/Reviewer convention.
8
+ *
9
+ * Ollama exposes an OpenAI-compatible API at http://localhost:11434/v1,
10
+ * so this adapter is a thin wrapper around the OpenAI SDK with a
11
+ * custom baseURL.
12
+ *
13
+ * Requires the user to have Ollama installed + running locally. See
14
+ * https://ollama.ai. The adapter does NOT install Ollama or pull
15
+ * models — that's the user's setup step.
16
+ *
17
+ * Default model: llama3.3:70b (large, very capable for code review).
18
+ * Users with smaller machines should override via config:
19
+ * - llama3.2:3b (3B parameter, runs on most laptops)
20
+ * - qwen2.5-coder:7b (7B, code-tuned, decent for review)
21
+ * - qwen2.5-coder:32b (32B, much stronger for code, needs ~24GB RAM)
22
+ * - deepseek-r1:14b (14B, reasoning-tuned)
23
+ *
24
+ * No auth, no API key, no pricing — local model.
25
+ */
26
+
27
+ import OpenAI from "openai";
28
+ import { AdapterError, type Adapter, type AdapterInput, type AdapterOutput } from "./types.js";
29
+ import { DEFAULT_MODELS } from "./defaults.js";
30
+
31
+ const DEFAULT_BASE_URL = "http://localhost:11434/v1";
32
+ const DEFAULT_MODEL = DEFAULT_MODELS.ollama;
33
+
34
+ export interface OllamaAdapterOptions {
35
+ /** Override the local Ollama base URL. Defaults to http://localhost:11434/v1. */
36
+ baseUrl?: string;
37
+ defaultModel?: string;
38
+ defaultMaxOutputTokens?: number;
39
+ defaultTimeoutMs?: number;
40
+ }
41
+
42
+ export class OllamaAdapter implements Adapter {
43
+ readonly name: string;
44
+ readonly provider = "ollama";
45
+
46
+ private readonly client: OpenAI;
47
+ readonly defaultModel: string;
48
+ private readonly defaultMaxOutputTokens: number;
49
+ private readonly defaultTimeoutMs: number;
50
+ private readonly baseUrl: string;
51
+
52
+ constructor(name = "ollama", options: OllamaAdapterOptions = {}) {
53
+ this.name = name;
54
+ this.defaultModel = options.defaultModel ?? DEFAULT_MODEL;
55
+ // Larger default — local models often run slower than cloud, and
56
+ // shorter responses are rarely the bottleneck.
57
+ this.defaultMaxOutputTokens = options.defaultMaxOutputTokens ?? 8192;
58
+ // Longer default — large local models can take 30-90s on CPU/GPU.
59
+ this.defaultTimeoutMs = options.defaultTimeoutMs ?? 300_000;
60
+ this.baseUrl =
61
+ options.baseUrl ?? process.env["OLLAMA_BASE_URL"] ?? DEFAULT_BASE_URL;
62
+
63
+ // OpenAI SDK requires SOMETHING for apiKey even when the server
64
+ // doesn't validate it. "ollama" is the documented sentinel.
65
+ this.client = new OpenAI({
66
+ apiKey: "ollama",
67
+ baseURL: this.baseUrl,
68
+ });
69
+ }
70
+
71
+ /** Ollama is "available" if the local daemon responds. */
72
+ async isAvailable(): Promise<boolean> {
73
+ try {
74
+ // Cheap probe — Ollama's /api/tags lists local models. If the
75
+ // daemon isn't running, this throws / times out fast.
76
+ const probeUrl = this.baseUrl.replace(/\/v1\/?$/, "") + "/api/tags";
77
+ const controller = new AbortController();
78
+ const timer = setTimeout(() => controller.abort(), 2000);
79
+ try {
80
+ const res = await fetch(probeUrl, { signal: controller.signal });
81
+ return res.ok;
82
+ } finally {
83
+ clearTimeout(timer);
84
+ }
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ async execute(input: AdapterInput): Promise<AdapterOutput> {
91
+ const model = input.model ?? this.defaultModel;
92
+ const maxOutputTokens = input.maxOutputTokens ?? this.defaultMaxOutputTokens;
93
+ const timeoutMs = input.timeoutMs ?? this.defaultTimeoutMs;
94
+
95
+ const start = Date.now();
96
+ let response: OpenAI.Chat.Completions.ChatCompletion;
97
+ try {
98
+ response = await this.client.chat.completions.create(
99
+ {
100
+ model,
101
+ max_tokens: maxOutputTokens,
102
+ messages: [
103
+ { role: "system", content: input.systemPrompt },
104
+ { role: "user", content: input.userMessage },
105
+ ],
106
+ },
107
+ { timeout: timeoutMs },
108
+ );
109
+ } catch (err) {
110
+ const message = err instanceof Error ? err.message : String(err);
111
+ // Common failure: Ollama daemon isn't running.
112
+ if (message.includes("ECONNREFUSED") || message.includes("fetch failed")) {
113
+ throw new AdapterError(
114
+ `Ollama daemon unreachable at ${this.baseUrl}. ` +
115
+ "Is Ollama installed and running? Try `ollama serve` or " +
116
+ "open the Ollama app.",
117
+ this.name,
118
+ err,
119
+ );
120
+ }
121
+ // Common failure: model not pulled locally.
122
+ if (message.includes("model") && message.includes("not found")) {
123
+ throw new AdapterError(
124
+ `Ollama model "${model}" not found locally. Pull it first: ` +
125
+ `\`ollama pull ${model}\``,
126
+ this.name,
127
+ err,
128
+ );
129
+ }
130
+ throw new AdapterError(
131
+ `Ollama call failed: ${message}`,
132
+ this.name,
133
+ err,
134
+ );
135
+ }
136
+
137
+ const choice = response.choices[0];
138
+ if (!choice || !choice.message.content) {
139
+ throw new AdapterError(
140
+ "Ollama returned an empty response.",
141
+ this.name,
142
+ );
143
+ }
144
+
145
+ return {
146
+ content: choice.message.content,
147
+ tokensIn: response.usage?.prompt_tokens ?? 0,
148
+ cachedTokensIn: 0,
149
+ tokensOut: response.usage?.completion_tokens ?? 0,
150
+ // Local models are $0. Always.
151
+ costUsd: 0,
152
+ latencyMs: Date.now() - start,
153
+ modelUsed: response.model ?? model,
154
+ provider: this.provider,
155
+ };
156
+ }
157
+ }
@@ -72,6 +72,17 @@ export class OpenAIAdapter implements Adapter {
72
72
  const opts = this.explicitApiKey ? { explicit: this.explicitApiKey } : {};
73
73
  const result = await resolveApiKey("openai", opts);
74
74
  if (result.source === "missing") return null;
75
+ // v0.12.1 (per Codex): if the keychain entry exists but the OS
76
+ // denied / failed the read, surface that diagnostic — don't
77
+ // construct a client with an undefined key.
78
+ if (result.source === "keychain_error") {
79
+ throw new AdapterError(
80
+ `Keychain read failed for OpenAI: ${result.keychainError ?? "unknown error"}. ` +
81
+ "Set OPENAI_API_KEY in your environment to override, or fix the " +
82
+ "keychain state.",
83
+ this.name,
84
+ );
85
+ }
75
86
  this.client = new OpenAI({
76
87
  apiKey: result.key!,
77
88
  ...(this.organization ? { organization: this.organization } : {}),
@@ -0,0 +1,202 @@
1
+ /**
2
+ * OpenRouter adapter (v0.12.0+).
3
+ *
4
+ * OpenRouter is a meta-provider that fronts 100+ models behind a single
5
+ * OpenAI-compatible API. Adding it unlocks DeepSeek, Mistral, Codestral,
6
+ * Llama (hosted), Grok, Qwen, and many others without us shipping one
7
+ * adapter per provider.
8
+ *
9
+ * Reads OPENROUTER_API_KEY from the environment (resolver: explicit →
10
+ * env → keychain → missing).
11
+ *
12
+ * Default model: deepseek/deepseek-chat — cheap, capable, popular OR
13
+ * pick. Users override via the `model` field in their config's agents
14
+ * map, e.g. `model: "anthropic/claude-haiku-4-5"`.
15
+ *
16
+ * Model naming: `<provider>/<model>` as required by OpenRouter
17
+ * (e.g. `deepseek/deepseek-chat`, `meta-llama/llama-3.3-70b-instruct`).
18
+ *
19
+ * Pricing: passed through from OpenRouter's response. Their `usage`
20
+ * object includes a `cost` field (USD) when available; otherwise we
21
+ * estimate from a small pricing table for popular models.
22
+ */
23
+
24
+ import OpenAI from "openai";
25
+ import { AdapterError, type Adapter, type AdapterInput, type AdapterOutput } from "./types.js";
26
+ import { resolveApiKey } from "../auth/resolver.js";
27
+ import { DEFAULT_MODELS } from "./defaults.js";
28
+
29
+ const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
30
+
31
+ /**
32
+ * Fallback pricing for popular OpenRouter models in case the API doesn't
33
+ * return cost. USD per 1M tokens. Match by prefix (startsWith).
34
+ *
35
+ * Source: https://openrouter.ai/models (verify periodically).
36
+ */
37
+ const PRICING_USD_PER_1M: Record<string, { input: number; output: number }> = {
38
+ "deepseek/deepseek-chat": { input: 0.27, output: 1.1 },
39
+ "deepseek/deepseek-coder": { input: 0.27, output: 1.1 },
40
+ "anthropic/claude-haiku-4-5": { input: 1.0, output: 5.0 },
41
+ "anthropic/claude-sonnet-4-6": { input: 3.0, output: 15.0 },
42
+ "openai/gpt-4o-mini": { input: 0.15, output: 0.6 },
43
+ "openai/gpt-4o": { input: 2.5, output: 10.0 },
44
+ "meta-llama/llama-3.3-70b-instruct": { input: 0.59, output: 0.79 },
45
+ "qwen/qwen-2.5-coder-32b-instruct": { input: 0.07, output: 0.16 },
46
+ "mistralai/codestral-2508": { input: 0.3, output: 0.9 },
47
+ "x-ai/grok-2": { input: 2.0, output: 10.0 },
48
+ };
49
+
50
+ const DEFAULT_MODEL = DEFAULT_MODELS.openrouter;
51
+
52
+ export interface OpenRouterAdapterOptions {
53
+ apiKey?: string;
54
+ defaultModel?: string;
55
+ defaultMaxOutputTokens?: number;
56
+ defaultTimeoutMs?: number;
57
+ /** Optional HTTP-Referer for OR's stats / leaderboards. */
58
+ appUrl?: string;
59
+ /** Optional X-Title for OR's stats / leaderboards. */
60
+ appTitle?: string;
61
+ }
62
+
63
+ export class OpenRouterAdapter implements Adapter {
64
+ readonly name: string;
65
+ readonly provider = "openrouter";
66
+
67
+ private client: OpenAI | null = null;
68
+ private resolved = false;
69
+ private readonly explicitApiKey: string | undefined;
70
+ private readonly appUrl: string | undefined;
71
+ private readonly appTitle: string | undefined;
72
+ readonly defaultModel: string;
73
+ private readonly defaultMaxOutputTokens: number;
74
+ private readonly defaultTimeoutMs: number;
75
+
76
+ constructor(name = "openrouter", options: OpenRouterAdapterOptions = {}) {
77
+ this.name = name;
78
+ this.defaultModel = options.defaultModel ?? DEFAULT_MODEL;
79
+ this.defaultMaxOutputTokens = options.defaultMaxOutputTokens ?? 4096;
80
+ this.defaultTimeoutMs = options.defaultTimeoutMs ?? 120_000;
81
+ this.explicitApiKey = options.apiKey;
82
+ this.appUrl = options.appUrl ?? "https://github.com/roxjayanath/almightygpt";
83
+ this.appTitle = options.appTitle ?? "AlmightyGPT";
84
+ }
85
+
86
+ private async ensureClient(): Promise<OpenAI | null> {
87
+ if (this.resolved) return this.client;
88
+ this.resolved = true;
89
+ const opts = this.explicitApiKey ? { explicit: this.explicitApiKey } : {};
90
+ const result = await resolveApiKey("openrouter", opts);
91
+ if (result.source === "missing") return null;
92
+ if (result.source === "keychain_error") {
93
+ throw new AdapterError(
94
+ `Keychain read failed for OpenRouter: ${result.keychainError ?? "unknown error"}. ` +
95
+ "Set OPENROUTER_API_KEY in your environment to override, or fix the " +
96
+ "keychain state.",
97
+ this.name,
98
+ );
99
+ }
100
+ this.client = new OpenAI({
101
+ apiKey: result.key!,
102
+ baseURL: OPENROUTER_BASE_URL,
103
+ defaultHeaders: {
104
+ ...(this.appUrl ? { "HTTP-Referer": this.appUrl } : {}),
105
+ ...(this.appTitle ? { "X-Title": this.appTitle } : {}),
106
+ },
107
+ });
108
+ return this.client;
109
+ }
110
+
111
+ async isAvailable(): Promise<boolean> {
112
+ return (await this.ensureClient()) !== null;
113
+ }
114
+
115
+ async execute(input: AdapterInput): Promise<AdapterOutput> {
116
+ const client = await this.ensureClient();
117
+ if (!client) {
118
+ throw new AdapterError(
119
+ 'No OpenRouter API key found. Run "almightygpt auth openrouter" ' +
120
+ "or export OPENROUTER_API_KEY in your environment.",
121
+ this.name,
122
+ );
123
+ }
124
+
125
+ const model = input.model ?? this.defaultModel;
126
+ const maxOutputTokens = input.maxOutputTokens ?? this.defaultMaxOutputTokens;
127
+ const timeoutMs = input.timeoutMs ?? this.defaultTimeoutMs;
128
+
129
+ const start = Date.now();
130
+ let response: OpenAI.Chat.Completions.ChatCompletion;
131
+ try {
132
+ response = await client.chat.completions.create(
133
+ {
134
+ model,
135
+ max_tokens: maxOutputTokens,
136
+ messages: [
137
+ { role: "system", content: input.systemPrompt },
138
+ { role: "user", content: input.userMessage },
139
+ ],
140
+ ...(input.responseFormat === "json_object"
141
+ ? { response_format: { type: "json_object" } }
142
+ : {}),
143
+ },
144
+ { timeout: timeoutMs },
145
+ );
146
+ } catch (err) {
147
+ throw new AdapterError(
148
+ `OpenRouter call failed: ${err instanceof Error ? err.message : String(err)}`,
149
+ this.name,
150
+ err,
151
+ );
152
+ }
153
+
154
+ const choice = response.choices[0];
155
+ if (!choice || !choice.message.content) {
156
+ throw new AdapterError(
157
+ "OpenRouter returned an empty response.",
158
+ this.name,
159
+ );
160
+ }
161
+
162
+ const content = choice.message.content;
163
+ const usage = response.usage as
164
+ | (typeof response.usage & {
165
+ /** OpenRouter returns the actual cost in USD when available. */
166
+ cost?: number;
167
+ })
168
+ | undefined;
169
+ const tokensIn = usage?.prompt_tokens ?? 0;
170
+ const tokensOut = usage?.completion_tokens ?? 0;
171
+ // Prefer OR's reported cost (real, billed); fall back to estimate.
172
+ const costUsd =
173
+ typeof usage?.cost === "number"
174
+ ? usage.cost
175
+ : estimateCostUsd(model, tokensIn, tokensOut);
176
+
177
+ return {
178
+ content,
179
+ tokensIn,
180
+ cachedTokensIn: 0,
181
+ tokensOut,
182
+ costUsd,
183
+ latencyMs: Date.now() - start,
184
+ modelUsed: response.model ?? model,
185
+ provider: this.provider,
186
+ };
187
+ }
188
+ }
189
+
190
+ function estimateCostUsd(
191
+ model: string,
192
+ tokensIn: number,
193
+ tokensOut: number,
194
+ ): number {
195
+ const key = Object.keys(PRICING_USD_PER_1M).find((k) =>
196
+ model.toLowerCase().startsWith(k),
197
+ );
198
+ if (!key) return 0;
199
+ const rates = PRICING_USD_PER_1M[key];
200
+ if (!rates) return 0;
201
+ return (tokensIn / 1_000_000) * rates.input + (tokensOut / 1_000_000) * rates.output;
202
+ }
@@ -25,6 +25,8 @@ const PROVIDER_ENV_VAR = {
25
25
  openai: "OPENAI_API_KEY",
26
26
  anthropic: "ANTHROPIC_API_KEY",
27
27
  google: "GOOGLE_API_KEY",
28
+ openrouter: "OPENROUTER_API_KEY", // added v0.12.0
29
+ // ollama has no env var (local, no auth)
28
30
  } as const;
29
31
 
30
32
  function makeKeychainStub(behavior: {
@@ -168,6 +170,28 @@ describe("resolveApiKey — priority order", () => {
168
170
  expect(r.key).toBe("primary");
169
171
  });
170
172
 
173
+ it("OpenRouter provider reads OPENROUTER_API_KEY (v0.12.0+)", async () => {
174
+ process.env.OPENROUTER_API_KEY = "from-or";
175
+ vi.mocked(getKeychain).mockResolvedValue(
176
+ makeKeychainStub({ available: false }),
177
+ );
178
+ const r = await resolveApiKey("openrouter");
179
+ expect(r.source).toBe("env");
180
+ expect(r.key).toBe("from-or");
181
+ expect(r.envVar).toBe("OPENROUTER_API_KEY");
182
+ });
183
+
184
+ it("Ollama has no env vars and falls straight to keychain → missing", async () => {
185
+ vi.mocked(getKeychain).mockResolvedValue(
186
+ makeKeychainStub({ available: true }),
187
+ );
188
+ // Ollama has no API key — PROVIDER_ENV_VARS["ollama"] is empty.
189
+ // The resolver should skip the env loop entirely and check
190
+ // keychain → return "missing" since the keychain stub is empty.
191
+ const r = await resolveApiKey("ollama");
192
+ expect(r.source).toBe("missing");
193
+ });
194
+
171
195
  it("empty env var is treated as not set (falls through to keychain)", async () => {
172
196
  process.env.OPENAI_API_KEY = "";
173
197
  vi.mocked(getKeychain).mockResolvedValue(
package/src/auth/types.ts CHANGED
@@ -17,7 +17,13 @@
17
17
  * a stale keychain copy. See docs/claude/v0.8-auth-plan.md.
18
18
  */
19
19
 
20
- export type ProviderId = "openai" | "anthropic" | "google";
20
+ export type ProviderId =
21
+ | "openai"
22
+ | "anthropic"
23
+ | "google"
24
+ // v0.12.0+ meta-providers
25
+ | "openrouter"
26
+ | "ollama";
21
27
 
22
28
  /**
23
29
  * Where a resolved API key came from.
@@ -74,6 +80,11 @@ export const PROVIDER_ENV_VARS: Record<ProviderId, readonly string[]> = {
74
80
  anthropic: ["ANTHROPIC_API_KEY"],
75
81
  // Google's SDK historically accepted both names; honor both.
76
82
  google: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
83
+ openrouter: ["OPENROUTER_API_KEY"],
84
+ // Ollama is local — no key. The empty list signals "no auth"
85
+ // to the resolver (it just falls through to keychain → missing,
86
+ // and the Ollama adapter bypasses the resolver entirely).
87
+ ollama: [],
77
88
  };
78
89
 
79
90
  /**
@@ -84,4 +95,7 @@ export const PROVIDER_KEY_URLS: Record<ProviderId, string> = {
84
95
  openai: "https://platform.openai.com/api-keys",
85
96
  anthropic: "https://console.anthropic.com/settings/keys",
86
97
  google: "https://aistudio.google.com/apikey",
98
+ openrouter: "https://openrouter.ai/keys",
99
+ // Ollama is local — no keys page. Linked instead to install docs.
100
+ ollama: "https://ollama.ai/download",
87
101
  };