@gajae-code/ai 0.12.7 → 0.12.8

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.12.8] - 2026-08-02
6
+ ### Added
7
+
8
+ - Added read-only OpenCodex provider discovery with runtime-port resolution, identity-checked health probing, cached `/api/models` catalogs, raw wire model ids, and `/login opencodex` status reprobes without credential persistence.
9
+
10
+ ### Changed
11
+
12
+ - OpenAI-compatible discovery and OpenAI Completions/Responses transports now preserve query-bearing endpoint routing, including repeated query parameters. Model resolution records whether a provider discovery result was fetched so consumers can distinguish current discovery evidence from cached data.
13
+
14
+ ### Fixed
15
+
16
+ - Closed the two remaining ingress holes behind bare `Request Blocked` failures on OpenAI codex models. (1) The chatgpt.com/backend-api pre-model gate rejects with an HTTP 400 bare-`detail` body (`{"detail": "Request blocked."}`) carrying no `error.*` envelope and no `code=invalid_prompt`, so `parseCodexError` surfaced an unexplained message, `isInvalidPromptError` and the codex non-retryable classification missed it, and the session-level `invalid_prompt` circuit breaker never attempted a repaired resend. `parseCodexError` now reads top-level `detail` (string or `{message}`) bodies and classifies a leading `Request blocked` message without an explicit provider code as `invalid_prompt`, surfacing `Request blocked (code=invalid_prompt)` so every existing invalid_prompt contract engages. (2) Outgoing tool definitions (descriptions and JSON-schema strings) bypassed every request-boundary sanitizer on both the OpenAI Responses and OpenAI-codex-responses transports, so a `<|channel|>`-quoting MCP/skill tool description poisoned every request on the session in a way no history repair could fix. Both `convertTools` paths now neutralize reserved control tokens across the whole tool payload via the shared idempotent zero-width-space insertion (ref openai/codex#35838).
17
+
5
18
  ## [0.12.7] - 2026-07-31
6
19
 
7
20
  ## [0.12.6] - 2026-07-31
@@ -290,8 +290,9 @@ export type AuthStorageOptions = {
290
290
  * Resolve a config value (API key, header value, etc.) to an actual value.
291
291
  * - coding-agent injects its resolveConfigValue (supports "!command" syntax via pi-natives)
292
292
  * - Default: checks environment variable first, then treats as literal
293
+ * `cacheScope` changes whenever the provider credential configuration changes.
293
294
  */
294
- configValueResolver?: (config: string) => Promise<string | undefined>;
295
+ configValueResolver?: (config: string, cacheScope?: string) => Promise<string | undefined>;
295
296
  /**
296
297
  * Optional callback fired when AuthStorage automatically disables a
297
298
  * credential because something detected it as no longer usable — today
@@ -389,6 +390,9 @@ export declare class AuthStorage {
389
390
  */
390
391
  close(): void;
391
392
  getGeneration(): number;
393
+ getProviderConfigurationGeneration(provider: string): number;
394
+ getProviderOAuthRefreshGeneration(provider: string): number;
395
+ getProviderEvidenceGeneration(provider: string, resolvedApiKey?: string): string;
392
396
  onGenerationChanged(listener: (generation: number) => void): () => void;
393
397
  offGenerationChanged(listener: (generation: number) => void): void;
394
398
  /**
@@ -482,11 +486,11 @@ export declare class AuthStorage {
482
486
  * Check if credentials exist for a provider in storage.
483
487
  */
484
488
  has(provider: string): boolean;
489
+ hasAuth(provider: string): boolean;
485
490
  /**
486
- * Check if any form of auth is configured for a provider.
487
- * Unlike getApiKey(), this doesn't refresh OAuth tokens.
491
+ * Check whether configured auth is currently usable without resolving credentials.
488
492
  */
489
- hasAuth(provider: string): boolean;
493
+ hasUsableAuth(provider: string): boolean;
490
494
  /**
491
495
  * Check if OAuth credentials are configured for a provider.
492
496
  */
@@ -24,6 +24,7 @@ export * from "./providers/mock";
24
24
  export * from "./providers/ollama";
25
25
  export * from "./providers/openai-codex-responses";
26
26
  export * from "./providers/openai-completions";
27
+ export * from "./providers/openai-opencodex-responses";
27
28
  export * from "./providers/openai-responses";
28
29
  export * from "./providers/synthetic";
29
30
  export * from "./rate-limit-utils";
@@ -44,6 +44,8 @@ export interface ModelManagerOptions<TApi extends Api = Api, TModelsDevPayload =
44
44
  export interface ModelResolutionResult<TApi extends Api = Api> {
45
45
  models: Model<TApi>[];
46
46
  stale: boolean;
47
+ /** Whether this resolution successfully fetched dynamic models. */
48
+ fetched: boolean;
47
49
  }
48
50
  /**
49
51
  * Stateful facade over provider model resolution.
@@ -1,4 +1,5 @@
1
1
  import type { ModelManagerOptions } from "../model-manager";
2
+ export declare function openCodexModelManagerOptions(): ModelManagerOptions<"openai-responses">;
2
3
  export interface OpenAICodexModelManagerConfig {
3
4
  accessToken?: string;
4
5
  accountId?: string;
@@ -0,0 +1,10 @@
1
+ import type { Model } from "../types";
2
+ export declare const OPENCODEX_DEFAULT_PORT = 10100;
3
+ export declare const OPENCODEX_PROBE_TIMEOUT_MS = 750;
4
+ export declare const OPENCODEX_MODEL_CACHE_TTL_MS: number;
5
+ export interface OpenCodexEndpoint {
6
+ baseUrl: string;
7
+ }
8
+ export declare function resolveOpenCodexEndpoint(signal?: AbortSignal): Promise<OpenCodexEndpoint | undefined>;
9
+ export declare function fetchOpenCodexModels(): Promise<readonly Model<"openai-responses">[] | null>;
10
+ export declare function checkOpenCodexStatus(onProgress?: (message: string) => void): Promise<void>;
@@ -51,7 +51,7 @@ export interface ThinkingConfig {
51
51
  /** Provider-specific transport used to encode the selected effort. */
52
52
  mode: ThinkingControlMode;
53
53
  }
54
- export type KnownProvider = "alibaba-token-plan" | "amazon-bedrock" | "azure-openai" | "anthropic" | "google" | "google-gemini-cli" | "google-antigravity" | "google-vertex" | "openai" | "openai-codex" | "kimi-code" | "minimax-code" | "minimax-code-cn" | "github-copilot" | "fireworks" | "firepass" | "fugu" | "gitlab-duo" | "cursor" | "deepseek" | "deepinfra" | "xai" | "groq" | "cerebras" | "openrouter" | "kilo" | "vercel-ai-gateway" | "zai" | "glm-zcode" | "mistral" | "minimax" | "opencode-go" | "opencode-zen" | "opengateway" | "bizrouter" | "mara" | "synthetic" | "cloudflare-ai-gateway" | "huggingface" | "litellm" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "qianfan" | "qwen-portal" | "together" | "venice" | "vllm" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "zenmux" | "lm-studio";
54
+ export type KnownProvider = "alibaba-token-plan" | "amazon-bedrock" | "azure-openai" | "anthropic" | "google" | "google-gemini-cli" | "google-antigravity" | "google-vertex" | "openai" | "openai-codex" | "opencodex" | "kimi-code" | "minimax-code" | "minimax-code-cn" | "github-copilot" | "fireworks" | "firepass" | "fugu" | "gitlab-duo" | "cursor" | "deepseek" | "deepinfra" | "xai" | "groq" | "cerebras" | "openrouter" | "kilo" | "vercel-ai-gateway" | "zai" | "glm-zcode" | "mistral" | "minimax" | "opencode-go" | "opencode-zen" | "opengateway" | "bizrouter" | "mara" | "synthetic" | "cloudflare-ai-gateway" | "huggingface" | "litellm" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "qianfan" | "qwen-portal" | "together" | "venice" | "vllm" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "zenmux" | "lm-studio";
55
55
  export type Provider = KnownProvider | string;
56
56
  import type { Effort } from "./model-thinking";
57
57
  /** Token budgets for each thinking level (token-based providers only) */
@@ -7,7 +7,7 @@ export type OAuthCredentials = {
7
7
  email?: string;
8
8
  accountId?: string;
9
9
  };
10
- export type OAuthProvider = "alibaba-token-plan" | "anthropic" | "bizrouter" | "mara" | "cerebras" | "cloudflare-ai-gateway" | "cursor" | "deepseek" | "deepinfra" | "fireworks" | "firepass" | "fugu" | "github-copilot" | "google-gemini-cli" | "google-antigravity" | "gitlab-duo" | "huggingface" | "kimi-code" | "kilo" | "kagi" | "litellm" | "lm-studio" | "minimax-code" | "minimax-code-cn" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "openai-codex" | "openai-codex-device" | "opencode-go" | "opencode-zen" | "opengateway" | "parallel" | "perplexity" | "qianfan" | "qwen-portal" | "synthetic" | "tavily" | "together" | "venice" | "vercel-ai-gateway" | "vllm" | "xai" | "glm-zcode" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "zenmux" | "zai";
10
+ export type OAuthProvider = "alibaba-token-plan" | "anthropic" | "bizrouter" | "mara" | "cerebras" | "cloudflare-ai-gateway" | "cursor" | "deepseek" | "deepinfra" | "fireworks" | "firepass" | "fugu" | "github-copilot" | "google-gemini-cli" | "google-antigravity" | "gitlab-duo" | "huggingface" | "kimi-code" | "kilo" | "kagi" | "litellm" | "lm-studio" | "minimax-code" | "minimax-code-cn" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "openai-codex" | "openai-codex-device" | "opencode-go" | "opencode-zen" | "opengateway" | "parallel" | "perplexity" | "qianfan" | "qwen-portal" | "synthetic" | "tavily" | "together" | "venice" | "vercel-ai-gateway" | "vllm" | "xai" | "glm-zcode" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "zenmux" | "opencodex" | "zai";
11
11
  export type OAuthProviderId = OAuthProvider | (string & {});
12
12
  export type OAuthPrompt = {
13
13
  message: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/ai",
4
- "version": "0.12.7",
4
+ "version": "0.12.8",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -40,7 +40,7 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/sdk": "^0.94.0",
42
42
  "@bufbuild/protobuf": "^2.12.0",
43
- "@gajae-code/utils": "0.12.7",
43
+ "@gajae-code/utils": "0.12.8",
44
44
  "openai": "^6.36.0",
45
45
  "partial-json": "^0.1.7",
46
46
  "zod": "4.4.3"
@@ -8,9 +8,11 @@
8
8
  * - `SqliteAuthCredentialStore`: concrete SQLite-backed implementation
9
9
  */
10
10
  import { Database, type Statement } from "bun:sqlite";
11
+ import * as crypto from "node:crypto";
11
12
  import * as fs from "node:fs/promises";
12
13
  import * as path from "node:path";
13
14
  import { getAgentDbPath, logger } from "@gajae-code/utils";
15
+ import { checkOpenCodexStatus } from "./providers/openai-opencodex-responses";
14
16
  import { getEnvApiKey } from "./stream";
15
17
  import type { Provider } from "./types";
16
18
  import type {
@@ -455,8 +457,9 @@ export type AuthStorageOptions = {
455
457
  * Resolve a config value (API key, header value, etc.) to an actual value.
456
458
  * - coding-agent injects its resolveConfigValue (supports "!command" syntax via pi-natives)
457
459
  * - Default: checks environment variable first, then treats as literal
460
+ * `cacheScope` changes whenever the provider credential configuration changes.
458
461
  */
459
- configValueResolver?: (config: string) => Promise<string | undefined>;
462
+ configValueResolver?: (config: string, cacheScope?: string) => Promise<string | undefined>;
460
463
  /**
461
464
  * Optional callback fired when AuthStorage automatically disables a
462
465
  * credential because something detected it as no longer usable — today
@@ -843,7 +846,9 @@ export class AuthStorage {
843
846
  #usageLogger?: UsageLogger;
844
847
  #fallbackResolver?: (provider: string) => string | undefined;
845
848
  #store: AuthCredentialStore;
846
- #configValueResolver: (config: string) => Promise<string | undefined>;
849
+ #configValueResolver: (config: string, cacheScope?: string) => Promise<string | undefined>;
850
+ #resolvedStoredApiKeyValues: Map<string, Map<string, { fingerprint: string; usable: boolean }>> = new Map();
851
+ #storedApiKeyResolutionInFlight: Map<string, Map<string, Promise<string | undefined>>> = new Map();
847
852
  #refreshOAuthCredentialOverride?: AuthStorageOptions["refreshOAuthCredential"];
848
853
  #fetchUsageReportsOverride?: AuthStorageOptions["fetchUsageReports"];
849
854
  #sourceLabel?: string;
@@ -858,6 +863,9 @@ export class AuthStorage {
858
863
  */
859
864
  #pendingDisabledEvents: CredentialDisabledEvent[] = [];
860
865
  #generation = 1;
866
+ #providerGenerations = new Map<string, number>();
867
+ #providerConfigurationGenerations = new Map<string, number>();
868
+ #providerOAuthRefreshGenerations = new Map<string, number>();
861
869
  #generationListeners: Set<(generation: number) => void> = new Set();
862
870
  #oauthRefreshInFlight: Map<number, Promise<AuthCredentialSnapshotEntry>> = new Map();
863
871
  #oauthCredentialRefreshInFlight: Map<number, Promise<OAuthCredentials>> = new Map();
@@ -914,7 +922,66 @@ export class AuthStorage {
914
922
  getGeneration(): number {
915
923
  return this.#generation;
916
924
  }
917
-
925
+ getProviderConfigurationGeneration(provider: string): number {
926
+ return this.#getProviderConfigurationGeneration(provider);
927
+ }
928
+ getProviderOAuthRefreshGeneration(provider: string): number {
929
+ return this.#providerOAuthRefreshGenerations.get(resolveOAuthStorageProvider(provider)) ?? 0;
930
+ }
931
+ #getProviderGeneration(provider: string): number {
932
+ return this.#providerGenerations.get(resolveOAuthStorageProvider(provider)) ?? 1;
933
+ }
934
+ #getProviderConfigurationGeneration(provider: string): number {
935
+ return this.#providerConfigurationGenerations.get(resolveOAuthStorageProvider(provider)) ?? 1;
936
+ }
937
+ getProviderEvidenceGeneration(provider: string, resolvedApiKey?: string): string {
938
+ const storageProvider = resolveOAuthStorageProvider(provider);
939
+ const evidenceApiKey = resolvedApiKey;
940
+ let selectedCredential: ({ index: number } & StoredCredential) | undefined;
941
+ try {
942
+ selectedCredential = this.#resolveSelectedStoredCredential(provider);
943
+ } catch {
944
+ return crypto
945
+ .createHash("sha256")
946
+ .update(`${this.#getProviderGeneration(storageProvider)}\u0000unavailable-selector`)
947
+ .digest("hex");
948
+ }
949
+ const credentials = selectedCredential
950
+ ? [selectedCredential.credential]
951
+ : this.#getCredentialsForProvider(provider);
952
+ const hasApiKey = credentials.some(credential => credential.type === "api_key");
953
+ const hasUsableOAuth = credentials.some(
954
+ credential =>
955
+ credential.type === "oauth" && Number.isFinite(credential.expires) && credential.expires > Date.now(),
956
+ );
957
+ const effectiveEnvKey =
958
+ this.#runtimeOverrides.get(provider) || this.#configOverrides.get(provider) || hasApiKey || hasUsableOAuth
959
+ ? undefined
960
+ : getEnvApiKey(provider);
961
+ const storedApiKeyFingerprint = credentials
962
+ .filter(
963
+ (credential): credential is Extract<AuthCredential, { type: "api_key" }> => credential.type === "api_key",
964
+ )
965
+ .map(credential => {
966
+ const resolved = this.#resolvedStoredApiKeyValues.get(storageProvider)?.get(credential.key);
967
+ return `${credential.key}\u0000${
968
+ credential.key.startsWith("!")
969
+ ? (resolved?.fingerprint ?? "")
970
+ : (process.env[credential.key] ?? credential.key)
971
+ }`;
972
+ })
973
+ .join("\u0001");
974
+ const storedOAuthFingerprint = credentials
975
+ .filter((credential): credential is Extract<AuthCredential, { type: "oauth" }> => credential.type === "oauth")
976
+ .map(credential => `${credential.expires}\u0000${credential.expires > Date.now() ? "usable" : "expired"}`)
977
+ .join("\u0001");
978
+ return crypto
979
+ .createHash("sha256")
980
+ .update(
981
+ `${this.#getProviderGeneration(storageProvider)}\u0000${effectiveEnvKey ?? ""}\u0000${storedApiKeyFingerprint}\u0000${storedOAuthFingerprint}\u0000${evidenceApiKey ?? ""}`,
982
+ )
983
+ .digest("hex");
984
+ }
918
985
  onGenerationChanged(listener: (generation: number) => void): () => void {
919
986
  this.#generationListeners.add(listener);
920
987
  return () => {
@@ -926,8 +993,18 @@ export class AuthStorage {
926
993
  this.#generationListeners.delete(listener);
927
994
  }
928
995
 
929
- #bumpGeneration(reason: string): void {
996
+ #bumpGeneration(reason: string, provider?: string): void {
930
997
  this.#generation += 1;
998
+ if (provider) {
999
+ const storageProvider = resolveOAuthStorageProvider(provider);
1000
+ this.#providerGenerations.set(storageProvider, this.#getProviderGeneration(storageProvider) + 1);
1001
+ if (reason !== "stored-api-key-usability") {
1002
+ this.#providerConfigurationGenerations.set(
1003
+ storageProvider,
1004
+ this.#getProviderConfigurationGeneration(storageProvider) + 1,
1005
+ );
1006
+ }
1007
+ }
931
1008
  for (const listener of [...this.#generationListeners]) {
932
1009
  try {
933
1010
  listener(this.#generation);
@@ -976,7 +1053,7 @@ export class AuthStorage {
976
1053
  */
977
1054
  setRuntimeApiKey(provider: string, apiKey: string): void {
978
1055
  this.#runtimeOverrides.set(provider, apiKey);
979
- this.#bumpGeneration("set-runtime-api-key");
1056
+ this.#bumpGeneration("set-runtime-api-key", provider);
980
1057
  }
981
1058
 
982
1059
  /**
@@ -987,20 +1064,24 @@ export class AuthStorage {
987
1064
  const storageProvider = resolveOAuthStorageProvider(provider);
988
1065
  this.#assertCredentialSelectorUsable(storageProvider, selector);
989
1066
  this.#runtimeCredentialSelectors.set(storageProvider, selector);
1067
+ this.#bumpGeneration("set-runtime-credential-selector", provider);
990
1068
  }
991
1069
 
992
1070
  /**
993
1071
  * Remove a runtime credential selector.
994
1072
  */
995
1073
  removeRuntimeCredentialSelector(provider: string): void {
996
- this.#runtimeCredentialSelectors.delete(resolveOAuthStorageProvider(provider));
1074
+ const storageProvider = resolveOAuthStorageProvider(provider);
1075
+ if (this.#runtimeCredentialSelectors.delete(storageProvider)) {
1076
+ this.#bumpGeneration("remove-runtime-credential-selector", provider);
1077
+ }
997
1078
  }
998
1079
 
999
1080
  /**
1000
1081
  * Remove a runtime API key override.
1001
1082
  */
1002
1083
  removeRuntimeApiKey(provider: string): void {
1003
- if (this.#runtimeOverrides.delete(provider)) this.#bumpGeneration("remove-runtime-api-key");
1084
+ if (this.#runtimeOverrides.delete(provider)) this.#bumpGeneration("remove-runtime-api-key", provider);
1004
1085
  }
1005
1086
 
1006
1087
  /** Whether a provider is currently authenticated by a runtime API-key override. */
@@ -1020,14 +1101,14 @@ export class AuthStorage {
1020
1101
  */
1021
1102
  setConfigApiKey(provider: string, apiKey: string): void {
1022
1103
  this.#configOverrides.set(provider, apiKey);
1023
- this.#bumpGeneration("set-config-api-key");
1104
+ this.#bumpGeneration("set-config-api-key", provider);
1024
1105
  }
1025
1106
 
1026
1107
  /**
1027
1108
  * Remove a single config-sourced API key override.
1028
1109
  */
1029
1110
  removeConfigApiKey(provider: string): void {
1030
- if (this.#configOverrides.delete(provider)) this.#bumpGeneration("remove-config-api-key");
1111
+ if (this.#configOverrides.delete(provider)) this.#bumpGeneration("remove-config-api-key", provider);
1031
1112
  }
1032
1113
 
1033
1114
  /**
@@ -1035,9 +1116,10 @@ export class AuthStorage {
1035
1116
  * re-parsing `models.yml` so removed entries actually disappear.
1036
1117
  */
1037
1118
  clearConfigApiKeys(): void {
1038
- if (this.#configOverrides.size === 0) return;
1119
+ const providers = [...this.#configOverrides.keys()];
1120
+ if (providers.length === 0) return;
1039
1121
  this.#configOverrides.clear();
1040
- this.#bumpGeneration("clear-config-api-keys");
1122
+ for (const provider of providers) this.#bumpGeneration("clear-config-api-keys", provider);
1041
1123
  }
1042
1124
 
1043
1125
  /**
@@ -1097,12 +1179,14 @@ export class AuthStorage {
1097
1179
  #setStoredCredentials(provider: string, credentials: StoredCredential[]): void {
1098
1180
  const current = this.#data.get(provider) ?? [];
1099
1181
  if (storedCredentialArraysEqual(current, credentials)) return;
1182
+ this.#resolvedStoredApiKeyValues.delete(provider);
1183
+ this.#storedApiKeyResolutionInFlight.delete(provider);
1100
1184
  if (credentials.length === 0) {
1101
1185
  this.#data.delete(provider);
1102
1186
  } else {
1103
1187
  this.#data.set(provider, credentials);
1104
1188
  }
1105
- this.#bumpGeneration("credentials");
1189
+ this.#bumpGeneration("credentials", provider);
1106
1190
  }
1107
1191
 
1108
1192
  #resolveOAuthDedupeIdentityKey(provider: string, credential: OAuthCredential): string | null {
@@ -1355,6 +1439,7 @@ export class AuthStorage {
1355
1439
  provider: string,
1356
1440
  type: T,
1357
1441
  sessionId?: string,
1442
+ isUsable?: (credential: Extract<AuthCredential, { type: T }>, index: number) => boolean,
1358
1443
  ): { credential: Extract<AuthCredential, { type: T }>; index: number } | undefined {
1359
1444
  const credentials = this.#getCredentialsForProvider(provider)
1360
1445
  .map((credential, index) => ({ credential, index }))
@@ -1372,7 +1457,10 @@ export class AuthStorage {
1372
1457
 
1373
1458
  for (const idx of order) {
1374
1459
  const candidate = credentials[idx];
1375
- if (!this.#isCredentialBlocked(providerKey, candidate.index)) {
1460
+ if (
1461
+ !this.#isCredentialBlocked(providerKey, candidate.index) &&
1462
+ (isUsable === undefined || isUsable(candidate.credential, candidate.index))
1463
+ ) {
1376
1464
  return candidate;
1377
1465
  }
1378
1466
  }
@@ -1407,6 +1495,19 @@ export class AuthStorage {
1407
1495
  const updated = [...entries];
1408
1496
  updated[index] = { id: target.id, credential };
1409
1497
  this.#setStoredCredentials(provider, updated);
1498
+ if (
1499
+ credential.type === "oauth" &&
1500
+ target.credential.type === "oauth" &&
1501
+ (credential.access !== target.credential.access ||
1502
+ credential.refresh !== target.credential.refresh ||
1503
+ credential.expires !== target.credential.expires)
1504
+ ) {
1505
+ const storageProvider = resolveOAuthStorageProvider(provider);
1506
+ this.#providerOAuthRefreshGenerations.set(
1507
+ storageProvider,
1508
+ this.getProviderOAuthRefreshGeneration(storageProvider) + 1,
1509
+ );
1510
+ }
1410
1511
  }
1411
1512
 
1412
1513
  /**
@@ -1627,9 +1728,8 @@ export class AuthStorage {
1627
1728
  * Check if any form of auth is configured for a provider.
1628
1729
  * Unlike getApiKey(), this doesn't refresh OAuth tokens.
1629
1730
  */
1630
- hasAuth(provider: string): boolean {
1631
- const storageProvider = resolveOAuthStorageProvider(provider);
1632
- if (this.#runtimeOverrides.has(storageProvider)) return true;
1731
+ #hasConfiguredAuth(storageProvider: string): boolean {
1732
+ if (this.hasRuntimeApiKey(storageProvider)) return true;
1633
1733
  if (this.#configOverrides.has(storageProvider)) return true;
1634
1734
  if (this.#getCredentialsForProvider(storageProvider).length > 0) return true;
1635
1735
  if (getEnvApiKey(storageProvider)) return true;
@@ -1637,6 +1737,67 @@ export class AuthStorage {
1637
1737
  return false;
1638
1738
  }
1639
1739
 
1740
+ hasAuth(provider: string): boolean {
1741
+ const storageProvider = resolveOAuthStorageProvider(provider);
1742
+ try {
1743
+ this.#resolveSelectedStoredCredential(storageProvider);
1744
+ } catch {
1745
+ return false;
1746
+ }
1747
+ return this.#hasConfiguredAuth(storageProvider);
1748
+ }
1749
+
1750
+ /**
1751
+ * Check whether configured auth is currently usable without resolving credentials.
1752
+ */
1753
+ hasUsableAuth(provider: string): boolean {
1754
+ const storageProvider = resolveOAuthStorageProvider(provider);
1755
+ try {
1756
+ const selectedCredential = this.#resolveSelectedStoredCredential(storageProvider);
1757
+ if (this.hasRuntimeApiKey(storageProvider)) return true;
1758
+ if (this.#configOverrides.has(storageProvider)) return true;
1759
+ if (selectedCredential) {
1760
+ if (selectedCredential.credential.type === "api_key") {
1761
+ return (
1762
+ !this.#isCredentialBlocked(
1763
+ this.#getProviderTypeKey(storageProvider, selectedCredential.credential.type),
1764
+ selectedCredential.index,
1765
+ ) && this.#hasUsableResolvedStoredApiKey(storageProvider, selectedCredential.credential.key)
1766
+ );
1767
+ }
1768
+ return !this.#isCredentialBlocked(
1769
+ this.#getProviderTypeKey(storageProvider, selectedCredential.credential.type),
1770
+ selectedCredential.index,
1771
+ );
1772
+ }
1773
+
1774
+ const credentials = this.#getCredentialsForProvider(storageProvider);
1775
+ let hasStoredApiKey = false;
1776
+ let hasSelectableApiKey = false;
1777
+ let hasUsableApiKey = false;
1778
+ for (const [index, credential] of credentials.entries()) {
1779
+ if (credential.type !== "api_key") continue;
1780
+ hasStoredApiKey = true;
1781
+ if (this.#isCredentialBlocked(this.#getProviderTypeKey(storageProvider, credential.type), index)) continue;
1782
+ hasSelectableApiKey = true;
1783
+ hasUsableApiKey ||= this.#hasUsableResolvedStoredApiKey(storageProvider, credential.key);
1784
+ }
1785
+ if (hasStoredApiKey) return hasSelectableApiKey && hasUsableApiKey;
1786
+ if (
1787
+ this.#getCredentialsForProvider(storageProvider).some(
1788
+ (credential, index) =>
1789
+ credential.type === "oauth" &&
1790
+ !this.#isCredentialBlocked(this.#getProviderTypeKey(storageProvider, credential.type), index),
1791
+ )
1792
+ ) {
1793
+ return true;
1794
+ }
1795
+ } catch {
1796
+ return false;
1797
+ }
1798
+ return Boolean(getEnvApiKey(storageProvider) || this.#fallbackResolver?.(storageProvider));
1799
+ }
1800
+
1640
1801
  /**
1641
1802
  * Check if OAuth credentials are configured for a provider.
1642
1803
  */
@@ -1726,7 +1887,12 @@ export class AuthStorage {
1726
1887
  await this.set(provider, newCredential);
1727
1888
  };
1728
1889
  const manualCodeInput = () => ctrl.onPrompt({ message: "Paste the authorization code (or full redirect URL):" });
1890
+
1729
1891
  switch (provider) {
1892
+ case "opencodex": {
1893
+ await checkOpenCodexStatus(ctrl.onProgress);
1894
+ return;
1895
+ }
1730
1896
  case "anthropic": {
1731
1897
  const { loginAnthropic } = await import("./utils/oauth/anthropic");
1732
1898
  credentials = await loginAnthropic({
@@ -3498,6 +3664,64 @@ export class AuthStorage {
3498
3664
  return undefined;
3499
3665
  }
3500
3666
 
3667
+ async #resolveStoredApiKey(provider: string, key: string): Promise<string | undefined> {
3668
+ const storageProvider = resolveOAuthStorageProvider(provider);
3669
+ const configurationGeneration = this.#getProviderConfigurationGeneration(storageProvider);
3670
+ const resolutions =
3671
+ this.#storedApiKeyResolutionInFlight.get(storageProvider) ?? new Map<string, Promise<string | undefined>>();
3672
+ this.#storedApiKeyResolutionInFlight.set(storageProvider, resolutions);
3673
+ const existing = resolutions.get(key);
3674
+ if (existing) return existing;
3675
+
3676
+ const { promise, resolve, reject } = Promise.withResolvers<string | undefined>();
3677
+ resolutions.set(key, promise);
3678
+ const publish = (value: string | undefined) => {
3679
+ if (
3680
+ configurationGeneration !== this.#getProviderConfigurationGeneration(storageProvider) ||
3681
+ this.#storedApiKeyResolutionInFlight.get(storageProvider) !== resolutions ||
3682
+ resolutions.get(key) !== promise
3683
+ ) {
3684
+ return;
3685
+ }
3686
+ const values =
3687
+ this.#resolvedStoredApiKeyValues.get(storageProvider) ??
3688
+ new Map<string, { fingerprint: string; usable: boolean }>();
3689
+ const wasUsable = !values.has(key) || values.get(key)?.usable === true;
3690
+ const isUsable = (value?.length ?? 0) > 0;
3691
+ values.set(key, {
3692
+ fingerprint: value ? crypto.createHash("sha256").update(value).digest("hex") : "",
3693
+ usable: isUsable,
3694
+ });
3695
+ this.#resolvedStoredApiKeyValues.set(storageProvider, values);
3696
+ if (key.startsWith("!") && wasUsable !== isUsable) {
3697
+ this.#bumpGeneration("stored-api-key-usability", storageProvider);
3698
+ }
3699
+ };
3700
+ void (async () => {
3701
+ try {
3702
+ const value = await this.#configValueResolver(key, String(configurationGeneration));
3703
+ publish(value);
3704
+ resolve(value);
3705
+ } catch (error) {
3706
+ publish(undefined);
3707
+ reject(error);
3708
+ } finally {
3709
+ if (
3710
+ this.#storedApiKeyResolutionInFlight.get(storageProvider) === resolutions &&
3711
+ resolutions.get(key) === promise
3712
+ ) {
3713
+ resolutions.delete(key);
3714
+ if (resolutions.size === 0) this.#storedApiKeyResolutionInFlight.delete(storageProvider);
3715
+ }
3716
+ }
3717
+ })();
3718
+ return promise;
3719
+ }
3720
+ #hasUsableResolvedStoredApiKey(provider: string, key: string): boolean {
3721
+ const resolved = this.#resolvedStoredApiKeyValues.get(resolveOAuthStorageProvider(provider))?.get(key);
3722
+ return key.startsWith("!") ? resolved?.usable === true : true;
3723
+ }
3724
+
3501
3725
  /**
3502
3726
  * Peek at API key for a provider without refreshing OAuth tokens.
3503
3727
  * Used for model discovery where we only need to know if credentials exist
@@ -3506,21 +3730,38 @@ export class AuthStorage {
3506
3730
  */
3507
3731
  async peekApiKey(provider: string): Promise<string | undefined> {
3508
3732
  const runtimeKey = this.#runtimeOverrides.get(provider);
3509
- if (runtimeKey) {
3510
- return runtimeKey;
3511
- }
3733
+ if (runtimeKey) return runtimeKey;
3512
3734
 
3513
3735
  const configKey = this.#configOverrides.get(provider);
3514
- if (configKey) {
3515
- return configKey;
3736
+ if (configKey) return configKey;
3737
+
3738
+ const selectedCredential = this.#resolveSelectedStoredCredential(provider);
3739
+ if (selectedCredential?.credential.type === "api_key") {
3740
+ return this.#resolveStoredApiKey(provider, selectedCredential.credential.key);
3516
3741
  }
3517
3742
 
3518
- const apiKeySelection = this.#selectCredentialByType(provider, "api_key");
3743
+ // Return current OAuth access token only if it is not already expired.
3744
+ if (selectedCredential?.credential.type === "oauth") {
3745
+ const expiresAt = selectedCredential.credential.expires;
3746
+ if (Number.isFinite(expiresAt) && expiresAt > Date.now()) {
3747
+ if (provider === "github-copilot") {
3748
+ return JSON.stringify({
3749
+ token: selectedCredential.credential.access,
3750
+ enterpriseUrl: selectedCredential.credential.enterpriseUrl,
3751
+ });
3752
+ }
3753
+ return selectedCredential.credential.access;
3754
+ }
3755
+ return undefined;
3756
+ }
3757
+
3758
+ const apiKeySelection = this.#selectCredentialByType(provider, "api_key", undefined, credential =>
3759
+ this.#hasUsableResolvedStoredApiKey(provider, credential.key),
3760
+ );
3519
3761
  if (apiKeySelection) {
3520
- return this.#configValueResolver(apiKeySelection.credential.key);
3762
+ return this.#resolveStoredApiKey(provider, apiKeySelection.credential.key);
3521
3763
  }
3522
3764
 
3523
- // Return current OAuth access token only if it is not already expired.
3524
3765
  const oauthSelection = this.#selectCredentialByType(provider, "oauth");
3525
3766
  if (oauthSelection) {
3526
3767
  const expiresAt = oauthSelection.credential.expires;
@@ -3535,10 +3776,7 @@ export class AuthStorage {
3535
3776
  }
3536
3777
  }
3537
3778
 
3538
- const envKey = getEnvApiKey(provider);
3539
- if (envKey) return envKey;
3540
-
3541
- return this.#fallbackResolver?.(provider) ?? undefined;
3779
+ return getEnvApiKey(provider) || this.#fallbackResolver?.(provider);
3542
3780
  }
3543
3781
 
3544
3782
  /**
@@ -3572,14 +3810,16 @@ export class AuthStorage {
3572
3810
 
3573
3811
  if (selectedCredential?.credential.type === "api_key") {
3574
3812
  this.#recordSessionCredential(provider, sessionId, "api_key", selectedCredential.index);
3575
- return this.#configValueResolver(selectedCredential.credential.key);
3813
+ return this.#resolveStoredApiKey(provider, selectedCredential.credential.key);
3576
3814
  }
3577
3815
 
3578
3816
  if (!selectedCredential) {
3579
- const apiKeySelection = this.#selectCredentialByType(provider, "api_key", sessionId);
3817
+ const apiKeySelection = this.#selectCredentialByType(provider, "api_key", sessionId, credential =>
3818
+ this.#hasUsableResolvedStoredApiKey(provider, credential.key),
3819
+ );
3580
3820
  if (apiKeySelection) {
3581
3821
  this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
3582
- return this.#configValueResolver(apiKeySelection.credential.key);
3822
+ return this.#resolveStoredApiKey(provider, apiKeySelection.credential.key);
3583
3823
  }
3584
3824
  }
3585
3825
 
@@ -3648,9 +3888,14 @@ export class AuthStorage {
3648
3888
  }
3649
3889
  }
3650
3890
 
3651
- async #credentialMatchesApiKey(credential: AuthCredential, apiKey: string): Promise<boolean> {
3891
+ async #credentialMatchesApiKey(provider: string, credential: AuthCredential, apiKey: string): Promise<boolean> {
3652
3892
  if (credential.type === "api_key") {
3653
- return (await this.#configValueResolver(credential.key)) === apiKey;
3893
+ return (
3894
+ (await this.#configValueResolver(
3895
+ credential.key,
3896
+ String(this.#getProviderConfigurationGeneration(provider)),
3897
+ )) === apiKey
3898
+ );
3654
3899
  }
3655
3900
  if (credential.access === apiKey) return true;
3656
3901
  return this.#extractStructuredApiKeyToken(apiKey) === credential.access;
@@ -3673,7 +3918,7 @@ export class AuthStorage {
3673
3918
  let matched: { id: number; type: AuthCredential["type"]; index: number } | undefined;
3674
3919
  for (let index = 0; index < stored.length; index++) {
3675
3920
  const entry = stored[index];
3676
- if (entry && (await this.#credentialMatchesApiKey(entry.credential, apiKey))) {
3921
+ if (entry && (await this.#credentialMatchesApiKey(provider, entry.credential, apiKey))) {
3677
3922
  matched = { id: entry.id, type: entry.credential.type, index };
3678
3923
  break;
3679
3924
  }
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ export * from "./providers/mock";
24
24
  export * from "./providers/ollama";
25
25
  export * from "./providers/openai-codex-responses";
26
26
  export * from "./providers/openai-completions";
27
+ export * from "./providers/openai-opencodex-responses";
27
28
  export * from "./providers/openai-responses";
28
29
  export * from "./providers/synthetic";
29
30
  export * from "./rate-limit-utils";