@almightygpt/core 0.7.2 → 0.8.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 (51) hide show
  1. package/dist/adapters/claude.d.ts +8 -1
  2. package/dist/adapters/claude.d.ts.map +1 -1
  3. package/dist/adapters/claude.js +26 -7
  4. package/dist/adapters/claude.js.map +1 -1
  5. package/dist/adapters/gemini.d.ts +5 -1
  6. package/dist/adapters/gemini.d.ts.map +1 -1
  7. package/dist/adapters/gemini.js +23 -10
  8. package/dist/adapters/gemini.js.map +1 -1
  9. package/dist/adapters/openai.d.ts +6 -1
  10. package/dist/adapters/openai.d.ts.map +1 -1
  11. package/dist/adapters/openai.js +28 -16
  12. package/dist/adapters/openai.js.map +1 -1
  13. package/dist/auth/keychain.d.ts +30 -0
  14. package/dist/auth/keychain.d.ts.map +1 -0
  15. package/dist/auth/keychain.js +106 -0
  16. package/dist/auth/keychain.js.map +1 -0
  17. package/dist/auth/resolver.d.ts +32 -0
  18. package/dist/auth/resolver.d.ts.map +1 -0
  19. package/dist/auth/resolver.js +58 -0
  20. package/dist/auth/resolver.js.map +1 -0
  21. package/dist/auth/types.d.ts +45 -0
  22. package/dist/auth/types.d.ts.map +1 -0
  23. package/dist/auth/types.js +51 -0
  24. package/dist/auth/types.js.map +1 -0
  25. package/dist/auth/validator.d.ts +33 -0
  26. package/dist/auth/validator.d.ts.map +1 -0
  27. package/dist/auth/validator.js +130 -0
  28. package/dist/auth/validator.js.map +1 -0
  29. package/dist/config/schema.d.ts +24 -0
  30. package/dist/config/schema.d.ts.map +1 -1
  31. package/dist/config/schema.js +8 -0
  32. package/dist/config/schema.js.map +1 -1
  33. package/dist/index.d.ts +5 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +6 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/review/run-diff-review.d.ts +6 -0
  38. package/dist/review/run-diff-review.d.ts.map +1 -1
  39. package/dist/review/run-diff-review.js +3 -2
  40. package/dist/review/run-diff-review.js.map +1 -1
  41. package/package.json +4 -1
  42. package/src/adapters/claude.ts +24 -7
  43. package/src/adapters/gemini.ts +21 -11
  44. package/src/adapters/openai.ts +26 -15
  45. package/src/auth/keychain.ts +132 -0
  46. package/src/auth/resolver.ts +81 -0
  47. package/src/auth/types.ts +68 -0
  48. package/src/auth/validator.ts +145 -0
  49. package/src/config/schema.ts +8 -0
  50. package/src/index.ts +22 -1
  51. package/src/review/run-diff-review.ts +10 -2
@@ -29,6 +29,7 @@ import {
29
29
  type AdapterInput,
30
30
  type AdapterOutput,
31
31
  } from "./types.js";
32
+ import { resolveApiKey } from "../auth/resolver.js";
32
33
 
33
34
  const PRICING_USD_PER_1M: Record<string, { input: number; output: number }> = {
34
35
  "gemini-2.5-pro": { input: 1.25, output: 10.0 },
@@ -58,7 +59,9 @@ export class GeminiAdapter implements Adapter {
58
59
  readonly name: string;
59
60
  readonly provider = "google";
60
61
 
61
- private readonly client: GoogleGenerativeAI | null;
62
+ private client: GoogleGenerativeAI | null = null;
63
+ private resolved = false;
64
+ private readonly explicitApiKey: string | undefined;
62
65
  readonly defaultModel: string;
63
66
  private readonly defaultMaxOutputTokens: number;
64
67
  private readonly defaultTimeoutMs: number;
@@ -73,23 +76,30 @@ export class GeminiAdapter implements Adapter {
73
76
  // below to make sure the visible response gets the full budget.
74
77
  this.defaultMaxOutputTokens = options.defaultMaxOutputTokens ?? 8192;
75
78
  this.defaultTimeoutMs = options.defaultTimeoutMs ?? 120_000;
79
+ this.explicitApiKey = options.apiKey;
80
+ }
76
81
 
77
- const apiKey =
78
- options.apiKey ??
79
- process.env["GOOGLE_API_KEY"] ??
80
- process.env["GEMINI_API_KEY"];
81
- this.client = apiKey ? new GoogleGenerativeAI(apiKey) : null;
82
+ /** Lazily resolve via the unified resolver: explicit → env → keychain. */
83
+ private async ensureClient(): Promise<GoogleGenerativeAI | null> {
84
+ if (this.resolved) return this.client;
85
+ this.resolved = true;
86
+ const opts = this.explicitApiKey ? { explicit: this.explicitApiKey } : {};
87
+ const result = await resolveApiKey("google", opts);
88
+ if (result.source === "missing") return null;
89
+ this.client = new GoogleGenerativeAI(result.key!);
90
+ return this.client;
82
91
  }
83
92
 
84
93
  async isAvailable(): Promise<boolean> {
85
- return this.client !== null;
94
+ return (await this.ensureClient()) !== null;
86
95
  }
87
96
 
88
97
  async execute(input: AdapterInput): Promise<AdapterOutput> {
89
- if (!this.client) {
98
+ const client = await this.ensureClient();
99
+ if (!client) {
90
100
  throw new AdapterError(
91
- "GOOGLE_API_KEY (or GEMINI_API_KEY) is not set. " +
92
- "Export one in your environment.",
101
+ 'No Google API key found. Run "almightygpt auth google" ' +
102
+ "or export GOOGLE_API_KEY (or GEMINI_API_KEY) in your environment.",
93
103
  this.name,
94
104
  );
95
105
  }
@@ -112,7 +122,7 @@ export class GeminiAdapter implements Adapter {
112
122
  generationConfig["responseMimeType"] = "application/json";
113
123
  }
114
124
 
115
- const generative = this.client.getGenerativeModel({
125
+ const generative = client.getGenerativeModel({
116
126
  model,
117
127
  systemInstruction: input.systemPrompt,
118
128
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -14,6 +14,7 @@
14
14
 
15
15
  import OpenAI from "openai";
16
16
  import { AdapterError, type Adapter, type AdapterInput, type AdapterOutput } from "./types.js";
17
+ import { resolveApiKey } from "../auth/resolver.js";
17
18
 
18
19
  /** USD per 1M tokens, by model. Lowercased keys. */
19
20
  const PRICING_USD_PER_1M: Record<string, { input: number; output: number }> = {
@@ -42,7 +43,10 @@ export class OpenAIAdapter implements Adapter {
42
43
  readonly name: string;
43
44
  readonly provider = "openai";
44
45
 
45
- private readonly client: OpenAI | null;
46
+ private client: OpenAI | null = null;
47
+ private resolved = false;
48
+ private readonly explicitApiKey: string | undefined;
49
+ private readonly organization: string | undefined;
46
50
  readonly defaultModel: string;
47
51
  private readonly defaultMaxOutputTokens: number;
48
52
  private readonly defaultTimeoutMs: number;
@@ -55,27 +59,34 @@ export class OpenAIAdapter implements Adapter {
55
59
  this.defaultModel = options.defaultModel ?? DEFAULT_MODEL;
56
60
  this.defaultMaxOutputTokens = options.defaultMaxOutputTokens ?? 4096;
57
61
  this.defaultTimeoutMs = options.defaultTimeoutMs ?? 120_000;
62
+ this.explicitApiKey = options.apiKey;
63
+ this.organization = options.organization;
64
+ }
58
65
 
59
- const apiKey = options.apiKey ?? process.env["OPENAI_API_KEY"];
60
- if (apiKey && apiKey.length > 0) {
61
- this.client = new OpenAI({
62
- apiKey,
63
- ...(options.organization ? { organization: options.organization } : {}),
64
- });
65
- } else {
66
- this.client = null;
67
- }
66
+ /** Lazily resolve via the unified resolver: explicit → env → keychain. */
67
+ private async ensureClient(): Promise<OpenAI | null> {
68
+ if (this.resolved) return this.client;
69
+ this.resolved = true;
70
+ const opts = this.explicitApiKey ? { explicit: this.explicitApiKey } : {};
71
+ const result = await resolveApiKey("openai", opts);
72
+ if (result.source === "missing") return null;
73
+ this.client = new OpenAI({
74
+ apiKey: result.key!,
75
+ ...(this.organization ? { organization: this.organization } : {}),
76
+ });
77
+ return this.client;
68
78
  }
69
79
 
70
80
  async isAvailable(): Promise<boolean> {
71
- return this.client !== null;
81
+ return (await this.ensureClient()) !== null;
72
82
  }
73
83
 
74
84
  async execute(input: AdapterInput): Promise<AdapterOutput> {
75
- if (!this.client) {
85
+ const client = await this.ensureClient();
86
+ if (!client) {
76
87
  throw new AdapterError(
77
- "OPENAI_API_KEY is not set. Export it in your environment or pass it " +
78
- "via the adapter constructor.",
88
+ 'No OpenAI API key found. Run "almightygpt auth openai" ' +
89
+ "or export OPENAI_API_KEY in your environment.",
79
90
  this.name,
80
91
  );
81
92
  }
@@ -87,7 +98,7 @@ export class OpenAIAdapter implements Adapter {
87
98
  const start = Date.now();
88
99
  let response: OpenAI.Chat.Completions.ChatCompletion;
89
100
  try {
90
- response = await this.client.chat.completions.create(
101
+ response = await client.chat.completions.create(
91
102
  {
92
103
  model,
93
104
  max_tokens: maxOutputTokens,
@@ -0,0 +1,132 @@
1
+ /**
2
+ * OS keychain wrapper — @napi-rs/keyring loaded via DYNAMIC IMPORT.
3
+ *
4
+ * The native binary is an OPTIONAL runtime capability:
5
+ * - If @napi-rs/keyring is installed and loads → real keychain access
6
+ * (macOS Keychain, Windows Credential Manager, Linux Secret Service).
7
+ * - If it's NOT installed (optionalDependencies skipped on exotic OS,
8
+ * or just plain missing) → returns an unavailable shim. Resolver
9
+ * falls through to env vars without crashing.
10
+ *
11
+ * This pattern means `npm install almightygpt` NEVER blocks on keychain
12
+ * binary failure. Codex's review explicitly flagged this as the
13
+ * correct strategy.
14
+ *
15
+ * Service name: `almightygpt` (consistent across all platforms; what
16
+ * users see in macOS Keychain Access.app, Windows credential UI, etc.)
17
+ */
18
+
19
+ import type { ProviderId } from "./types.js";
20
+
21
+ const KEYCHAIN_SERVICE = "almightygpt";
22
+
23
+ export interface KeychainAdapter {
24
+ available: boolean;
25
+ get(provider: ProviderId): Promise<string | undefined>;
26
+ set(provider: ProviderId, key: string): Promise<void>;
27
+ remove(provider: ProviderId): Promise<boolean>;
28
+ /** Optional diagnostic — backend name, or "unavailable". */
29
+ describeBackend(): string;
30
+ }
31
+
32
+ /**
33
+ * Lazy-loaded singleton. We never throw on import failure; instead the
34
+ * resulting adapter reports `available: false` and the resolver knows
35
+ * to skip it.
36
+ */
37
+ let cached: KeychainAdapter | null = null;
38
+
39
+ export async function getKeychain(): Promise<KeychainAdapter> {
40
+ if (cached) return cached;
41
+ cached = await loadKeychain();
42
+ return cached;
43
+ }
44
+
45
+ async function loadKeychain(): Promise<KeychainAdapter> {
46
+ try {
47
+ // Dynamic import — never crashes the module if the package is
48
+ // missing or its native binary fails to load on the host.
49
+ const mod = (await import("@napi-rs/keyring")) as {
50
+ Entry: new (service: string, account: string) => {
51
+ getPassword(): string | null;
52
+ setPassword(password: string): void;
53
+ deletePassword(): boolean;
54
+ };
55
+ };
56
+
57
+ const entryFor = (provider: ProviderId) =>
58
+ new mod.Entry(KEYCHAIN_SERVICE, provider);
59
+
60
+ // Probe: try to construct an Entry. If the native binding throws
61
+ // (no Secret Service daemon on Linux, etc.), we degrade.
62
+ try {
63
+ entryFor("openai");
64
+ } catch {
65
+ return makeUnavailable("native binding failed to initialize");
66
+ }
67
+
68
+ return {
69
+ available: true,
70
+ describeBackend: () => detectBackend(),
71
+ async get(provider) {
72
+ try {
73
+ const v = entryFor(provider).getPassword();
74
+ return v === null ? undefined : v;
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ },
79
+ async set(provider, key) {
80
+ entryFor(provider).setPassword(key);
81
+ },
82
+ async remove(provider) {
83
+ try {
84
+ return entryFor(provider).deletePassword();
85
+ } catch {
86
+ return false;
87
+ }
88
+ },
89
+ };
90
+ } catch (err) {
91
+ return makeUnavailable(
92
+ err instanceof Error ? err.message : "import failed",
93
+ );
94
+ }
95
+ }
96
+
97
+ function makeUnavailable(reason: string): KeychainAdapter {
98
+ return {
99
+ available: false,
100
+ describeBackend: () => `unavailable (${reason})`,
101
+ async get() {
102
+ return undefined;
103
+ },
104
+ async set() {
105
+ throw new Error(
106
+ `Keychain unavailable: ${reason}. Set the API key via an environment ` +
107
+ `variable instead (e.g. ANTHROPIC_API_KEY).`,
108
+ );
109
+ },
110
+ async remove() {
111
+ return false;
112
+ },
113
+ };
114
+ }
115
+
116
+ function detectBackend(): string {
117
+ switch (process.platform) {
118
+ case "darwin":
119
+ return "macos-keychain";
120
+ case "win32":
121
+ return "windows-credential-manager";
122
+ case "linux":
123
+ return "linux-secret-service";
124
+ default:
125
+ return process.platform;
126
+ }
127
+ }
128
+
129
+ /** Exposed for tests that want to reset the singleton between cases. */
130
+ export function _resetKeychainCache(): void {
131
+ cached = null;
132
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * API key resolver — the single entry point every adapter must call
3
+ * to find its key. Replaces direct `process.env[...]` reads.
4
+ *
5
+ * Priority order (top to bottom):
6
+ * 1. Explicit parameter (caller passed a key — tests, extension handoff)
7
+ * 2. Environment variable (per-process override; wins over keychain)
8
+ * 3. OS keychain (persistent convenience; optional capability)
9
+ * 4. Missing → throw AuthMissingError
10
+ *
11
+ * Env wins over keychain by design — see types.ts header for the
12
+ * bug-class reasoning that drove the decision.
13
+ */
14
+
15
+ import { getKeychain } from "./keychain.js";
16
+ import {
17
+ AuthMissingError,
18
+ PROVIDER_ENV_VARS,
19
+ type KeyResolution,
20
+ type ProviderId,
21
+ } from "./types.js";
22
+
23
+ export interface ResolveOptions {
24
+ /** Optional caller-supplied key — wins over all other sources. */
25
+ explicit?: string;
26
+ /** Skip keychain lookup entirely (used by tests). */
27
+ skipKeychain?: boolean;
28
+ }
29
+
30
+ /**
31
+ * Resolve where the API key for `provider` lives.
32
+ * Returns a KeyResolution describing the source even when not found
33
+ * (caller can decide whether to throw).
34
+ */
35
+ export async function resolveApiKey(
36
+ provider: ProviderId,
37
+ options: ResolveOptions = {},
38
+ ): Promise<KeyResolution> {
39
+ // 1. Explicit override
40
+ if (options.explicit) {
41
+ return { provider, source: "explicit", key: options.explicit };
42
+ }
43
+
44
+ // 2. Environment variable
45
+ for (const envVar of PROVIDER_ENV_VARS[provider]) {
46
+ const v = process.env[envVar];
47
+ if (v && v.length > 0) {
48
+ return { provider, source: "env", key: v, envVar };
49
+ }
50
+ }
51
+
52
+ // 3. OS keychain (optional)
53
+ if (!options.skipKeychain) {
54
+ const keychain = await getKeychain();
55
+ if (keychain.available) {
56
+ const v = await keychain.get(provider);
57
+ if (v && v.length > 0) {
58
+ return { provider, source: "keychain", key: v };
59
+ }
60
+ }
61
+ }
62
+
63
+ // 4. Missing
64
+ return { provider, source: "missing" };
65
+ }
66
+
67
+ /**
68
+ * Convenience wrapper that throws AuthMissingError on absence — for
69
+ * callers (adapters) that need the key or can't proceed.
70
+ */
71
+ export async function requireApiKey(
72
+ provider: ProviderId,
73
+ options: ResolveOptions = {},
74
+ ): Promise<string> {
75
+ const resolved = await resolveApiKey(provider, options);
76
+ if (resolved.source === "missing") {
77
+ const primaryEnv = PROVIDER_ENV_VARS[provider][0]!;
78
+ throw new AuthMissingError(provider, primaryEnv);
79
+ }
80
+ return resolved.key!;
81
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Auth subsystem types — provider identifiers, resolution sources,
3
+ * error classes.
4
+ *
5
+ * The auth module's job is to answer one question per call:
6
+ * "where is the API key for <provider>, and what is it?"
7
+ *
8
+ * Resolution priority (top to bottom):
9
+ * 1. Explicit parameter (passed by caller — tests, extension handoff)
10
+ * 2. Environment variable (per-process override; wins over keychain)
11
+ * 3. OS keychain (persistent convenience; @napi-rs/keyring optional)
12
+ * 4. Missing -> throw AuthMissingError
13
+ *
14
+ * Env-wins-over-keychain is intentional. The original draft had the
15
+ * order flipped; Codex's independent review caught that it would
16
+ * silently override the VS Code extension's freshly-stored key with
17
+ * a stale keychain copy. See docs/claude/v0.8-auth-plan.md.
18
+ */
19
+
20
+ export type ProviderId = "openai" | "anthropic" | "google";
21
+
22
+ export type KeySource = "explicit" | "env" | "keychain" | "missing";
23
+
24
+ export interface KeyResolution {
25
+ provider: ProviderId;
26
+ source: KeySource;
27
+ /** Present iff source !== "missing". */
28
+ key?: string;
29
+ /** Which env var was used (only set when source === "env"). */
30
+ envVar?: string;
31
+ }
32
+
33
+ /**
34
+ * Thrown when no key is found in any source. Caller should surface
35
+ * the message verbatim — it tells the user exactly how to fix it.
36
+ */
37
+ export class AuthMissingError extends Error {
38
+ readonly provider: ProviderId;
39
+ readonly envVar: string;
40
+ constructor(provider: ProviderId, envVar: string) {
41
+ super(
42
+ `No API key found for ${provider}. ` +
43
+ `Run "almightygpt auth ${provider}" to set one up interactively, ` +
44
+ `or export ${envVar} in your shell.`,
45
+ );
46
+ this.name = "AuthMissingError";
47
+ this.provider = provider;
48
+ this.envVar = envVar;
49
+ }
50
+ }
51
+
52
+ /** Maps each provider to the canonical env var name(s) we read. */
53
+ export const PROVIDER_ENV_VARS: Record<ProviderId, readonly string[]> = {
54
+ openai: ["OPENAI_API_KEY"],
55
+ anthropic: ["ANTHROPIC_API_KEY"],
56
+ // Google's SDK historically accepted both names; honor both.
57
+ google: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
58
+ };
59
+
60
+ /**
61
+ * Browser-launchable URL where a user creates / manages keys for each
62
+ * provider. Used by the `almightygpt auth <provider>` flow.
63
+ */
64
+ export const PROVIDER_KEY_URLS: Record<ProviderId, string> = {
65
+ openai: "https://platform.openai.com/api-keys",
66
+ anthropic: "https://console.anthropic.com/settings/keys",
67
+ google: "https://aistudio.google.com/apikey",
68
+ };
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Model-level key validation.
3
+ *
4
+ * Codex's review caught that listing models (e.g. `GET /v1/models`) can
5
+ * succeed when the key lacks model permission, has quota issues, or
6
+ * has billing-state problems. The user doesn't care that list-models
7
+ * works — they care that AlmightyGPT can run the configured review
8
+ * model.
9
+ *
10
+ * So we validate with the same operation class real reviews use:
11
+ * - OpenAI: tiny chat completion against gpt-4o
12
+ * - Anthropic: tiny messages call against claude-sonnet-4-6
13
+ * - Google: tiny generateContent call against gemini-2.5-flash
14
+ *
15
+ * Cost is fractions of a cent per call. Latency is ~1-3 seconds.
16
+ * Failure surfaces the provider's exact error message so the user
17
+ * can fix it.
18
+ */
19
+
20
+ import type { ProviderId } from "./types.js";
21
+
22
+ export interface ValidationResult {
23
+ ok: boolean;
24
+ /** Provider-reported model used (e.g. "gpt-4o-2024-08-06"). */
25
+ model?: string;
26
+ /** Provider's error message verbatim if ok === false. */
27
+ error?: string;
28
+ }
29
+
30
+ const DEFAULT_VALIDATION_MODELS: Record<ProviderId, string> = {
31
+ openai: "gpt-4o",
32
+ anthropic: "claude-sonnet-4-6",
33
+ google: "gemini-2.5-flash",
34
+ };
35
+
36
+ const VALIDATION_TIMEOUT_MS = 15_000;
37
+
38
+ /**
39
+ * Validate that a key can actually invoke the model real reviews use.
40
+ * Returns a result object — never throws on validation failure (only
41
+ * on programmer errors like an unsupported provider).
42
+ */
43
+ export async function validateKey(
44
+ provider: ProviderId,
45
+ key: string,
46
+ ): Promise<ValidationResult> {
47
+ try {
48
+ switch (provider) {
49
+ case "openai":
50
+ return await validateOpenAI(key);
51
+ case "anthropic":
52
+ return await validateAnthropic(key);
53
+ case "google":
54
+ return await validateGoogle(key);
55
+ }
56
+ } catch (err) {
57
+ return {
58
+ ok: false,
59
+ error: err instanceof Error ? err.message : String(err),
60
+ };
61
+ }
62
+ }
63
+
64
+ async function validateOpenAI(key: string): Promise<ValidationResult> {
65
+ const model = DEFAULT_VALIDATION_MODELS.openai;
66
+ const controller = new AbortController();
67
+ const timer = setTimeout(() => controller.abort(), VALIDATION_TIMEOUT_MS);
68
+ try {
69
+ const res = await fetch("https://api.openai.com/v1/chat/completions", {
70
+ method: "POST",
71
+ headers: {
72
+ "content-type": "application/json",
73
+ authorization: `Bearer ${key}`,
74
+ },
75
+ body: JSON.stringify({
76
+ model,
77
+ messages: [{ role: "user", content: "hi" }],
78
+ max_tokens: 1,
79
+ }),
80
+ signal: controller.signal,
81
+ });
82
+ if (!res.ok) {
83
+ return { ok: false, error: await res.text().catch(() => res.statusText) };
84
+ }
85
+ const data = (await res.json()) as { model?: string };
86
+ return { ok: true, model: data.model ?? model };
87
+ } finally {
88
+ clearTimeout(timer);
89
+ }
90
+ }
91
+
92
+ async function validateAnthropic(key: string): Promise<ValidationResult> {
93
+ const model = DEFAULT_VALIDATION_MODELS.anthropic;
94
+ const controller = new AbortController();
95
+ const timer = setTimeout(() => controller.abort(), VALIDATION_TIMEOUT_MS);
96
+ try {
97
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
98
+ method: "POST",
99
+ headers: {
100
+ "content-type": "application/json",
101
+ "x-api-key": key,
102
+ "anthropic-version": "2023-06-01",
103
+ },
104
+ body: JSON.stringify({
105
+ model,
106
+ max_tokens: 5,
107
+ messages: [{ role: "user", content: "hi" }],
108
+ }),
109
+ signal: controller.signal,
110
+ });
111
+ if (!res.ok) {
112
+ return { ok: false, error: await res.text().catch(() => res.statusText) };
113
+ }
114
+ const data = (await res.json()) as { model?: string };
115
+ return { ok: true, model: data.model ?? model };
116
+ } finally {
117
+ clearTimeout(timer);
118
+ }
119
+ }
120
+
121
+ async function validateGoogle(key: string): Promise<ValidationResult> {
122
+ const model = DEFAULT_VALIDATION_MODELS.google;
123
+ const controller = new AbortController();
124
+ const timer = setTimeout(() => controller.abort(), VALIDATION_TIMEOUT_MS);
125
+ try {
126
+ const url =
127
+ `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=` +
128
+ encodeURIComponent(key);
129
+ const res = await fetch(url, {
130
+ method: "POST",
131
+ headers: { "content-type": "application/json" },
132
+ body: JSON.stringify({
133
+ contents: [{ parts: [{ text: "hi" }] }],
134
+ generationConfig: { maxOutputTokens: 5 },
135
+ }),
136
+ signal: controller.signal,
137
+ });
138
+ if (!res.ok) {
139
+ return { ok: false, error: await res.text().catch(() => res.statusText) };
140
+ }
141
+ return { ok: true, model };
142
+ } finally {
143
+ clearTimeout(timer);
144
+ }
145
+ }
@@ -24,6 +24,14 @@ export const ConfigSchema = z
24
24
  .object({
25
25
  version: z.literal(1),
26
26
  reviewsDir: z.string().min(1).default("docs/codex-reviews"),
27
+ /**
28
+ * Where `almightygpt precommit` writes its output. Separated from
29
+ * `reviewsDir` so quick-reviewer (typically Gemini Flash) output
30
+ * doesn't accumulate alongside deep cross-AI reviews. Falls back to
31
+ * `reviewsDir` if unset (preserves pre-v0.8.1 behavior for existing
32
+ * configs that don't have this field).
33
+ */
34
+ precommitDir: z.string().min(1).default("docs/precommit-reviews"),
27
35
  runsDir: z.string().min(1).default(".almightygpt/runs"),
28
36
  agents: z.record(z.string(), AgentConfigSchema).default({}),
29
37
  defaults: z
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * - budget/ ✅ task #14 BudgetTracker + BudgetExceededError
14
14
  */
15
15
 
16
- export const VERSION = "0.7.2";
16
+ export const VERSION = "0.8.1";
17
17
 
18
18
  // Git safety primitives
19
19
  export {
@@ -127,3 +127,24 @@ export type {
127
127
  ReviewEventHandler,
128
128
  AgentRoleInRun,
129
129
  } from "./review/events.js";
130
+
131
+ // Auth subsystem (v0.8.0+) — resolver, keychain, validator
132
+ export {
133
+ resolveApiKey,
134
+ requireApiKey,
135
+ type ResolveOptions,
136
+ } from "./auth/resolver.js";
137
+ export {
138
+ getKeychain,
139
+ _resetKeychainCache,
140
+ type KeychainAdapter,
141
+ } from "./auth/keychain.js";
142
+ export { validateKey, type ValidationResult } from "./auth/validator.js";
143
+ export {
144
+ AuthMissingError,
145
+ PROVIDER_ENV_VARS,
146
+ PROVIDER_KEY_URLS,
147
+ type ProviderId,
148
+ type KeySource,
149
+ type KeyResolution,
150
+ } from "./auth/types.js";
@@ -58,6 +58,12 @@ export interface DiffReviewOptions {
58
58
  range?: string;
59
59
  /** Bypass the git status safety check. */
60
60
  force?: boolean;
61
+ /**
62
+ * Override the output directory. Defaults to `config.reviewsDir`.
63
+ * Used by `almightygpt precommit` to write into `config.precommitDir`
64
+ * so quick reviews don't pile into the main `docs/codex-reviews/`.
65
+ */
66
+ reviewsDirOverride?: string;
61
67
  }
62
68
 
63
69
  export interface DiffReviewResult {
@@ -115,12 +121,14 @@ export async function runDiffReview(
115
121
  );
116
122
  }
117
123
 
124
+ const outputDir = opts.reviewsDirOverride ?? config.reviewsDir;
125
+
118
126
  // Preflight the review-file collision BEFORE any paid adapter call.
119
127
  // (Codex review v0.5: previously the check ran post-adapter, burning API
120
128
  // money on duplicate-topic runs that were going to fail anyway.)
121
129
  await preflightReviewFileCollision(
122
130
  opts.repoRoot,
123
- config.reviewsDir,
131
+ outputDir,
124
132
  opts.topic,
125
133
  opts.force ?? false,
126
134
  );
@@ -250,7 +258,7 @@ export async function runDiffReview(
250
258
 
251
259
  const writeOpts: Parameters<typeof writeHumanReviewFile>[0] = {
252
260
  repoRoot: opts.repoRoot,
253
- reviewsDir: config.reviewsDir,
261
+ reviewsDir: outputDir,
254
262
  topic: opts.topic,
255
263
  reviewerName,
256
264
  reviewerProvider: adapter.provider,