@almightygpt/core 0.10.0 → 0.11.0

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/defaults.d.ts.map +1 -1
  2. package/dist/adapters/defaults.js +5 -0
  3. package/dist/adapters/defaults.js.map +1 -1
  4. package/dist/adapters/factory.d.ts.map +1 -1
  5. package/dist/adapters/factory.js +11 -1
  6. package/dist/adapters/factory.js.map +1 -1
  7. package/dist/adapters/index.d.ts +9 -5
  8. package/dist/adapters/index.d.ts.map +1 -1
  9. package/dist/adapters/index.js +9 -5
  10. package/dist/adapters/index.js.map +1 -1
  11. package/dist/adapters/ollama.d.ts +47 -0
  12. package/dist/adapters/ollama.d.ts.map +1 -0
  13. package/dist/adapters/ollama.js +124 -0
  14. package/dist/adapters/ollama.js.map +1 -0
  15. package/dist/adapters/openrouter.d.ts +50 -0
  16. package/dist/adapters/openrouter.d.ts.map +1 -0
  17. package/dist/adapters/openrouter.js +148 -0
  18. package/dist/adapters/openrouter.js.map +1 -0
  19. package/dist/auth/__tests__/keychain.test.d.ts +18 -0
  20. package/dist/auth/__tests__/keychain.test.d.ts.map +1 -0
  21. package/dist/auth/__tests__/keychain.test.js +155 -0
  22. package/dist/auth/__tests__/keychain.test.js.map +1 -0
  23. package/dist/auth/__tests__/resolver.test.d.ts +13 -0
  24. package/dist/auth/__tests__/resolver.test.d.ts.map +1 -0
  25. package/dist/auth/__tests__/resolver.test.js +200 -0
  26. package/dist/auth/__tests__/resolver.test.js.map +1 -0
  27. package/dist/auth/__tests__/validator.test.d.ts +15 -0
  28. package/dist/auth/__tests__/validator.test.d.ts.map +1 -0
  29. package/dist/auth/__tests__/validator.test.js +197 -0
  30. package/dist/auth/__tests__/validator.test.js.map +1 -0
  31. package/dist/auth/types.d.ts +1 -1
  32. package/dist/auth/types.d.ts.map +1 -1
  33. package/dist/auth/types.js +8 -0
  34. package/dist/auth/types.js.map +1 -1
  35. package/dist/auth/validator.d.ts.map +1 -1
  36. package/dist/auth/validator.js +117 -11
  37. package/dist/auth/validator.js.map +1 -1
  38. package/dist/config/schema.d.ts +12 -12
  39. package/dist/config/schema.d.ts.map +1 -1
  40. package/dist/config/schema.js +8 -1
  41. package/dist/config/schema.js.map +1 -1
  42. package/dist/index.d.ts +1 -1
  43. package/dist/index.js +1 -1
  44. package/package.json +4 -2
  45. package/src/adapters/defaults.ts +5 -0
  46. package/src/adapters/factory.ts +11 -1
  47. package/src/adapters/index.ts +9 -5
  48. package/src/adapters/ollama.ts +157 -0
  49. package/src/adapters/openrouter.ts +194 -0
  50. package/src/auth/__tests__/keychain.test.ts +171 -0
  51. package/src/auth/__tests__/resolver.test.ts +255 -0
  52. package/src/auth/__tests__/validator.test.ts +241 -0
  53. package/src/auth/types.ts +15 -1
  54. package/src/auth/validator.ts +130 -11
  55. package/src/config/schema.ts +8 -1
  56. package/src/index.ts +1 -1
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Resolver tests — proves the priority chain.
3
+ *
4
+ * Codex's v0.8 security review (P1 #3) called out that without these
5
+ * tests, the resolver could silently regress and ship — because
6
+ * `npm run test` would pass vacuously.
7
+ *
8
+ * Each test isolates the keychain via vi.mock so we control its
9
+ * behavior per case. Env vars are reset in beforeEach so test order
10
+ * doesn't matter.
11
+ */
12
+
13
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
14
+ import { resolveApiKey, requireApiKey } from "../resolver.js";
15
+ import { AuthMissingError } from "../types.js";
16
+
17
+ // Mock the keychain module before any test runs. Each test then
18
+ // overrides the mock's return for getKeychain().
19
+ vi.mock("../keychain.js", () => ({
20
+ getKeychain: vi.fn(),
21
+ }));
22
+ import { getKeychain } from "../keychain.js";
23
+
24
+ const PROVIDER_ENV_VAR = {
25
+ openai: "OPENAI_API_KEY",
26
+ anthropic: "ANTHROPIC_API_KEY",
27
+ google: "GOOGLE_API_KEY",
28
+ openrouter: "OPENROUTER_API_KEY", // added v0.12.0
29
+ // ollama has no env var (local, no auth)
30
+ } as const;
31
+
32
+ function makeKeychainStub(behavior: {
33
+ available: boolean;
34
+ get?: () => Promise<
35
+ | { kind: "found"; key: string }
36
+ | { kind: "absent" }
37
+ | { kind: "error"; message: string }
38
+ >;
39
+ }) {
40
+ return {
41
+ available: behavior.available,
42
+ describeBackend: () => "test-backend",
43
+ get: behavior.get ?? (async () => ({ kind: "absent" as const })),
44
+ set: async () => {},
45
+ remove: async () => true,
46
+ };
47
+ }
48
+
49
+ describe("resolveApiKey — priority order", () => {
50
+ const savedEnv: Record<string, string | undefined> = {};
51
+
52
+ beforeEach(() => {
53
+ // Snapshot + clear all provider env vars so test inputs are
54
+ // deterministic.
55
+ for (const v of Object.values(PROVIDER_ENV_VAR)) {
56
+ savedEnv[v] = process.env[v];
57
+ delete process.env[v];
58
+ }
59
+ delete process.env.GEMINI_API_KEY;
60
+ vi.clearAllMocks();
61
+ });
62
+
63
+ afterEach(() => {
64
+ // Restore original env so we don't pollute the runner / sibling tests.
65
+ for (const [k, v] of Object.entries(savedEnv)) {
66
+ if (v === undefined) delete process.env[k];
67
+ else process.env[k] = v;
68
+ }
69
+ });
70
+
71
+ it("explicit param wins over env (which would otherwise win over keychain)", async () => {
72
+ process.env.OPENAI_API_KEY = "from-env";
73
+ vi.mocked(getKeychain).mockResolvedValue(
74
+ makeKeychainStub({
75
+ available: true,
76
+ get: async () => ({ kind: "found", key: "from-keychain" }),
77
+ }),
78
+ );
79
+ const r = await resolveApiKey("openai", { explicit: "from-explicit" });
80
+ expect(r.source).toBe("explicit");
81
+ expect(r.key).toBe("from-explicit");
82
+ });
83
+
84
+ it("env wins over keychain — prevents stale-keychain-key bug Codex flagged", async () => {
85
+ process.env.OPENAI_API_KEY = "from-env";
86
+ vi.mocked(getKeychain).mockResolvedValue(
87
+ makeKeychainStub({
88
+ available: true,
89
+ get: async () => ({ kind: "found", key: "from-keychain" }),
90
+ }),
91
+ );
92
+ const r = await resolveApiKey("openai");
93
+ expect(r.source).toBe("env");
94
+ expect(r.key).toBe("from-env");
95
+ expect(r.envVar).toBe("OPENAI_API_KEY");
96
+ });
97
+
98
+ it("keychain wins over missing", async () => {
99
+ vi.mocked(getKeychain).mockResolvedValue(
100
+ makeKeychainStub({
101
+ available: true,
102
+ get: async () => ({ kind: "found", key: "from-keychain" }),
103
+ }),
104
+ );
105
+ const r = await resolveApiKey("openai");
106
+ expect(r.source).toBe("keychain");
107
+ expect(r.key).toBe("from-keychain");
108
+ });
109
+
110
+ it("returns missing when no source has the key", async () => {
111
+ vi.mocked(getKeychain).mockResolvedValue(
112
+ makeKeychainStub({ available: true }),
113
+ );
114
+ const r = await resolveApiKey("openai");
115
+ expect(r.source).toBe("missing");
116
+ expect(r.key).toBeUndefined();
117
+ });
118
+
119
+ it("returns keychain_error (not missing) when keychain read fails", async () => {
120
+ vi.mocked(getKeychain).mockResolvedValue(
121
+ makeKeychainStub({
122
+ available: true,
123
+ get: async () => ({ kind: "error", message: "denied by OS" }),
124
+ }),
125
+ );
126
+ const r = await resolveApiKey("openai");
127
+ expect(r.source).toBe("keychain_error");
128
+ expect(r.keychainError).toBe("denied by OS");
129
+ });
130
+
131
+ it("keychain unavailability degrades to missing (not error) — backward compat", async () => {
132
+ vi.mocked(getKeychain).mockResolvedValue(
133
+ makeKeychainStub({ available: false }),
134
+ );
135
+ const r = await resolveApiKey("openai");
136
+ expect(r.source).toBe("missing");
137
+ });
138
+
139
+ it("skipKeychain option bypasses keychain entirely", async () => {
140
+ process.env.OPENAI_API_KEY = "from-env";
141
+ // Keychain mock would throw if called — proves skipKeychain takes effect.
142
+ vi.mocked(getKeychain).mockImplementation(() => {
143
+ throw new Error("getKeychain should not be called when skipKeychain is true");
144
+ });
145
+ const r = await resolveApiKey("openai", { skipKeychain: true });
146
+ expect(r.source).toBe("env");
147
+ });
148
+
149
+ it("Google provider checks BOTH GOOGLE_API_KEY and GEMINI_API_KEY", async () => {
150
+ delete process.env.GOOGLE_API_KEY;
151
+ process.env.GEMINI_API_KEY = "from-gemini-env";
152
+ vi.mocked(getKeychain).mockResolvedValue(
153
+ makeKeychainStub({ available: false }),
154
+ );
155
+ const r = await resolveApiKey("google");
156
+ expect(r.source).toBe("env");
157
+ expect(r.key).toBe("from-gemini-env");
158
+ expect(r.envVar).toBe("GEMINI_API_KEY");
159
+ });
160
+
161
+ it("Google prefers GOOGLE_API_KEY over GEMINI_API_KEY when both set", async () => {
162
+ process.env.GOOGLE_API_KEY = "primary";
163
+ process.env.GEMINI_API_KEY = "secondary";
164
+ vi.mocked(getKeychain).mockResolvedValue(
165
+ makeKeychainStub({ available: false }),
166
+ );
167
+ const r = await resolveApiKey("google");
168
+ expect(r.source).toBe("env");
169
+ expect(r.envVar).toBe("GOOGLE_API_KEY");
170
+ expect(r.key).toBe("primary");
171
+ });
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
+
195
+ it("empty env var is treated as not set (falls through to keychain)", async () => {
196
+ process.env.OPENAI_API_KEY = "";
197
+ vi.mocked(getKeychain).mockResolvedValue(
198
+ makeKeychainStub({
199
+ available: true,
200
+ get: async () => ({ kind: "found", key: "from-keychain" }),
201
+ }),
202
+ );
203
+ const r = await resolveApiKey("openai");
204
+ expect(r.source).toBe("keychain");
205
+ });
206
+ });
207
+
208
+ describe("requireApiKey — throws AuthMissingError on missing / keychain_error", () => {
209
+ const savedEnv: Record<string, string | undefined> = {};
210
+
211
+ beforeEach(() => {
212
+ for (const v of Object.values(PROVIDER_ENV_VAR)) {
213
+ savedEnv[v] = process.env[v];
214
+ delete process.env[v];
215
+ }
216
+ delete process.env.GEMINI_API_KEY;
217
+ vi.clearAllMocks();
218
+ });
219
+
220
+ afterEach(() => {
221
+ for (const [k, v] of Object.entries(savedEnv)) {
222
+ if (v === undefined) delete process.env[k];
223
+ else process.env[k] = v;
224
+ }
225
+ });
226
+
227
+ it("returns the key when found", async () => {
228
+ process.env.ANTHROPIC_API_KEY = "the-key";
229
+ vi.mocked(getKeychain).mockResolvedValue(
230
+ makeKeychainStub({ available: false }),
231
+ );
232
+ const key = await requireApiKey("anthropic");
233
+ expect(key).toBe("the-key");
234
+ });
235
+
236
+ it("throws AuthMissingError with provider + env var when missing", async () => {
237
+ vi.mocked(getKeychain).mockResolvedValue(
238
+ makeKeychainStub({ available: true }),
239
+ );
240
+ await expect(requireApiKey("anthropic")).rejects.toThrow(AuthMissingError);
241
+ await expect(requireApiKey("anthropic")).rejects.toThrow(/anthropic/);
242
+ await expect(requireApiKey("anthropic")).rejects.toThrow(/ANTHROPIC_API_KEY/);
243
+ });
244
+
245
+ it("throws AuthMissingError with a DIFFERENT message for keychain_error", async () => {
246
+ vi.mocked(getKeychain).mockResolvedValue(
247
+ makeKeychainStub({
248
+ available: true,
249
+ get: async () => ({ kind: "error", message: "keyring locked" }),
250
+ }),
251
+ );
252
+ await expect(requireApiKey("anthropic")).rejects.toThrow(/keyring locked/);
253
+ await expect(requireApiKey("anthropic")).rejects.toThrow(/Keychain read failed/);
254
+ });
255
+ });
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Validator tests with mocked fetch.
3
+ *
4
+ * Covers Codex's v0.8 security review demand:
5
+ * "Add a regression test that failed validation never logs or returns
6
+ * a URL containing the key."
7
+ *
8
+ * Plus the broader contract:
9
+ * - happy path → ok: true with model
10
+ * - timeout → ok: false with friendly message
11
+ * - non-OK response → normalized error with statusCode, never raw body
12
+ * - submitted key NEVER appears in error / model field for ANY provider
13
+ */
14
+
15
+ import { describe, it, expect, beforeEach, vi } from "vitest";
16
+ import { validateKey } from "../validator.js";
17
+
18
+ const fetchMock = vi.fn();
19
+ vi.stubGlobal("fetch", fetchMock);
20
+
21
+ // Keys with recognizable patterns so we can grep for them in any output.
22
+ const KEYS = {
23
+ openai: "sk-test-openai-CANARY-VALUE-12345",
24
+ anthropic: "sk-ant-test-anthropic-CANARY-VALUE-67890",
25
+ google: "AIza-test-google-CANARY-VALUE-abcde",
26
+ } as const;
27
+
28
+ function jsonResponse(status: number, body: object | string): Response {
29
+ return new Response(typeof body === "string" ? body : JSON.stringify(body), {
30
+ status,
31
+ headers: { "content-type": "application/json" },
32
+ });
33
+ }
34
+
35
+ beforeEach(() => {
36
+ fetchMock.mockReset();
37
+ });
38
+
39
+ describe("validator — OpenAI", () => {
40
+ it("happy path returns ok with model name from response", async () => {
41
+ fetchMock.mockResolvedValueOnce(
42
+ jsonResponse(200, { model: "gpt-4o-2024-08-06", choices: [] }),
43
+ );
44
+ const r = await validateKey("openai", KEYS.openai);
45
+ expect(r.ok).toBe(true);
46
+ expect(r.model).toBe("gpt-4o-2024-08-06");
47
+ expect(r.latencyMs).toBeGreaterThanOrEqual(0);
48
+ });
49
+
50
+ it("posts to /v1/chat/completions with the key as Bearer header (NOT in URL)", async () => {
51
+ fetchMock.mockResolvedValueOnce(
52
+ jsonResponse(200, { model: "gpt-4o" }),
53
+ );
54
+ await validateKey("openai", KEYS.openai);
55
+ const [url, init] = fetchMock.mock.calls[0]!;
56
+ expect(String(url)).toBe("https://api.openai.com/v1/chat/completions");
57
+ expect(String(url)).not.toContain(KEYS.openai);
58
+ expect((init as RequestInit).headers).toMatchObject({
59
+ authorization: `Bearer ${KEYS.openai}`,
60
+ });
61
+ });
62
+
63
+ it("non-OK response returns normalized error with statusCode and no raw body in error", async () => {
64
+ fetchMock.mockResolvedValueOnce(
65
+ jsonResponse(401, {
66
+ error: {
67
+ message: "Incorrect API key provided",
68
+ type: "invalid_request_error",
69
+ code: "invalid_api_key",
70
+ },
71
+ }),
72
+ );
73
+ const r = await validateKey("openai", KEYS.openai);
74
+ expect(r.ok).toBe(false);
75
+ expect(r.statusCode).toBe(401);
76
+ expect(r.error).toContain("Incorrect API key provided");
77
+ // The error string should be short — not the raw JSON body.
78
+ expect(r.error!.length).toBeLessThan(200);
79
+ // rawBody is preserved separately for explicit debug use.
80
+ expect(r.rawBody).toBeDefined();
81
+ });
82
+ });
83
+
84
+ describe("validator — Anthropic", () => {
85
+ it("happy path returns ok with model from response", async () => {
86
+ fetchMock.mockResolvedValueOnce(
87
+ jsonResponse(200, { model: "claude-sonnet-4-6", content: [] }),
88
+ );
89
+ const r = await validateKey("anthropic", KEYS.anthropic);
90
+ expect(r.ok).toBe(true);
91
+ expect(r.model).toBe("claude-sonnet-4-6");
92
+ });
93
+
94
+ it("posts to /v1/messages with x-api-key header (NOT in URL)", async () => {
95
+ fetchMock.mockResolvedValueOnce(
96
+ jsonResponse(200, { model: "claude-sonnet-4-6" }),
97
+ );
98
+ await validateKey("anthropic", KEYS.anthropic);
99
+ const [url, init] = fetchMock.mock.calls[0]!;
100
+ expect(String(url)).toBe("https://api.anthropic.com/v1/messages");
101
+ expect(String(url)).not.toContain(KEYS.anthropic);
102
+ expect((init as RequestInit).headers).toMatchObject({
103
+ "x-api-key": KEYS.anthropic,
104
+ });
105
+ });
106
+
107
+ it("non-OK response returns normalized error with parsed message", async () => {
108
+ fetchMock.mockResolvedValueOnce(
109
+ jsonResponse(400, {
110
+ type: "error",
111
+ error: {
112
+ type: "authentication_error",
113
+ message: "invalid x-api-key",
114
+ },
115
+ }),
116
+ );
117
+ const r = await validateKey("anthropic", KEYS.anthropic);
118
+ expect(r.ok).toBe(false);
119
+ expect(r.statusCode).toBe(400);
120
+ expect(r.error).toContain("invalid x-api-key");
121
+ });
122
+ });
123
+
124
+ describe("validator — Google", () => {
125
+ it("happy path returns ok with the configured model name", async () => {
126
+ fetchMock.mockResolvedValueOnce(
127
+ jsonResponse(200, { candidates: [{ content: { parts: [] } }] }),
128
+ );
129
+ const r = await validateKey("google", KEYS.google);
130
+ expect(r.ok).toBe(true);
131
+ expect(r.model).toBe("gemini-2.5-flash");
132
+ });
133
+
134
+ it("CODEX V0.8 P1: uses x-goog-api-key header — NEVER puts the key in the URL", async () => {
135
+ fetchMock.mockResolvedValueOnce(
136
+ jsonResponse(200, { candidates: [] }),
137
+ );
138
+ await validateKey("google", KEYS.google);
139
+ const [url, init] = fetchMock.mock.calls[0]!;
140
+ // This is the regression-test Codex specifically requested.
141
+ expect(String(url)).not.toContain(KEYS.google);
142
+ expect(String(url)).not.toContain("?key=");
143
+ // And the key MUST be in the header instead.
144
+ expect((init as RequestInit).headers).toMatchObject({
145
+ "x-goog-api-key": KEYS.google,
146
+ });
147
+ });
148
+
149
+ it("non-OK response returns normalized error with statusCode", async () => {
150
+ fetchMock.mockResolvedValueOnce(
151
+ jsonResponse(403, {
152
+ error: {
153
+ code: 403,
154
+ message: "API key not valid",
155
+ status: "PERMISSION_DENIED",
156
+ },
157
+ }),
158
+ );
159
+ const r = await validateKey("google", KEYS.google);
160
+ expect(r.ok).toBe(false);
161
+ expect(r.statusCode).toBe(403);
162
+ expect(r.error).toContain("API key not valid");
163
+ });
164
+ });
165
+
166
+ describe("validator — KEY-LEAK REGRESSION (Codex v0.8 P2 #6)", () => {
167
+ /**
168
+ * The submitted key must never appear in `error` or `model` for
169
+ * ANY provider, in ANY failure path. Tests every provider × every
170
+ * failure mode we care about.
171
+ */
172
+ const failureModes: Array<{ status: number; body: object | string }> = [
173
+ { status: 400, body: { error: { message: "bad request" } } },
174
+ { status: 401, body: { error: { message: "unauthorized" } } },
175
+ { status: 403, body: { error: { message: "forbidden" } } },
176
+ { status: 429, body: { error: { message: "rate limited" } } },
177
+ { status: 500, body: "Internal Server Error (plain text body)" },
178
+ {
179
+ status: 200,
180
+ body: "malformed-not-json{{{",
181
+ },
182
+ ];
183
+
184
+ for (const provider of ["openai", "anthropic", "google"] as const) {
185
+ for (const mode of failureModes) {
186
+ it(`${provider} ${mode.status}: error message never contains the submitted key verbatim`, async () => {
187
+ fetchMock.mockResolvedValueOnce(
188
+ jsonResponse(mode.status, mode.body),
189
+ );
190
+ const key = KEYS[provider];
191
+ const r = await validateKey(provider, key);
192
+ // Check every string-shaped field we expose to users.
193
+ for (const field of [r.error, r.model] as Array<string | undefined>) {
194
+ if (field) {
195
+ expect(field).not.toContain(key);
196
+ }
197
+ }
198
+ });
199
+ }
200
+ }
201
+
202
+ it("if a provider echoes the key in its error body, the normalized error MUST NOT include the key", async () => {
203
+ // Worst-case: provider literally echoes the key in the response.
204
+ // Could happen with poorly-written upstream tooling.
205
+ fetchMock.mockResolvedValueOnce(
206
+ jsonResponse(400, {
207
+ error: {
208
+ message: `key ${KEYS.openai} is malformed`,
209
+ },
210
+ }),
211
+ );
212
+ const r = await validateKey("openai", KEYS.openai);
213
+ expect(r.ok).toBe(false);
214
+ // The normalized error MUST redact the key.
215
+ expect(r.error).not.toContain(KEYS.openai);
216
+ expect(r.error).toContain("<redacted-key>");
217
+ });
218
+ });
219
+
220
+ describe("validator — network failure handling", () => {
221
+ it("network throw returns ok:false with friendly message (not stack trace)", async () => {
222
+ fetchMock.mockRejectedValueOnce(
223
+ new TypeError("fetch failed: ECONNREFUSED"),
224
+ );
225
+ const r = await validateKey("openai", KEYS.openai);
226
+ expect(r.ok).toBe(false);
227
+ expect(r.error).toBeDefined();
228
+ expect(r.error).not.toContain(KEYS.openai);
229
+ // friendlyNetworkError should produce a short message
230
+ expect(r.error!.length).toBeLessThan(150);
231
+ });
232
+
233
+ it("abort (timeout) returns ok:false with friendly message", async () => {
234
+ fetchMock.mockRejectedValueOnce(
235
+ Object.assign(new Error("aborted"), { name: "AbortError" }),
236
+ );
237
+ const r = await validateKey("openai", KEYS.openai);
238
+ expect(r.ok).toBe(false);
239
+ expect(r.error).toMatch(/timed out|abort/i);
240
+ });
241
+ });
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
  };