@gajae-code/ai 0.11.6 → 0.11.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/types/auth-broker/client.d.ts +2 -1
  3. package/dist/types/auth-broker/remote-store.d.ts +2 -1
  4. package/dist/types/auth-broker/types.d.ts +3 -1
  5. package/dist/types/auth-broker/wire-schemas.d.ts +68 -0
  6. package/dist/types/auth-storage.d.ts +21 -1
  7. package/dist/types/provider-models/openai-compat.d.ts +2 -2
  8. package/dist/types/providers/anthropic.d.ts +9 -0
  9. package/dist/types/providers/transform-messages.d.ts +1 -0
  10. package/dist/types/types.d.ts +3 -1
  11. package/dist/types/utils/oauth/alibaba-token-plan.d.ts +19 -0
  12. package/dist/types/utils/oauth/types.d.ts +1 -1
  13. package/package.json +2 -2
  14. package/src/auth-broker/client.ts +13 -0
  15. package/src/auth-broker/refresher.ts +1 -0
  16. package/src/auth-broker/remote-store.ts +25 -0
  17. package/src/auth-broker/server.ts +10 -2
  18. package/src/auth-broker/types.ts +4 -0
  19. package/src/auth-broker/wire-schemas.ts +17 -1
  20. package/src/auth-storage.ts +199 -30
  21. package/src/model-thinking.ts +11 -3
  22. package/src/models.json +5362 -1219
  23. package/src/provider-models/descriptors.ts +5 -6
  24. package/src/provider-models/openai-compat.ts +12 -12
  25. package/src/providers/amazon-bedrock.ts +4 -0
  26. package/src/providers/anthropic.ts +78 -22
  27. package/src/providers/openai-completions-compat.ts +2 -2
  28. package/src/providers/openai-completions.ts +4 -1
  29. package/src/providers/openai-responses.ts +4 -1
  30. package/src/providers/transform-messages.ts +25 -6
  31. package/src/stream.ts +1 -1
  32. package/src/types.ts +7 -2
  33. package/src/utils/oauth/{alibaba-coding-plan.ts → alibaba-token-plan.ts} +12 -11
  34. package/src/utils/oauth/index.ts +3 -2
  35. package/src/utils/oauth/types.ts +1 -1
  36. package/src/utils/validation.ts +17 -2
  37. package/src/utils.ts +41 -4
  38. package/dist/types/utils/oauth/alibaba-coding-plan.d.ts +0 -18
@@ -46,12 +46,113 @@ export type ApiKeyCredential = {
46
46
  key: string;
47
47
  };
48
48
 
49
+ export interface MCPOAuthBinding {
50
+ /** Exact HTTP(S) origin of the MCP resource endpoint. */
51
+ resourceOrigin: string;
52
+ /** Exact canonical HTTP(S) token endpoint used to create and refresh the credential. */
53
+ tokenEndpoint: string;
54
+ }
55
+
56
+ function resolveCanonicalHttpUrl(value: string): URL | undefined {
57
+ try {
58
+ const parsed = new URL(value);
59
+ if (
60
+ (parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
61
+ parsed.username !== "" ||
62
+ parsed.password !== "" ||
63
+ parsed.hash !== ""
64
+ ) {
65
+ return undefined;
66
+ }
67
+ return parsed;
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ }
72
+
73
+ export function resolveMCPOAuthResourceOrigin(value: string): string | undefined {
74
+ return resolveCanonicalHttpUrl(value)?.origin;
75
+ }
76
+
77
+ export function resolveMCPOAuthTokenEndpoint(value: string): string | undefined {
78
+ return resolveCanonicalHttpUrl(value)?.href;
79
+ }
80
+
81
+ export function isCanonicalMCPOAuthBinding(binding: MCPOAuthBinding): boolean {
82
+ return (
83
+ resolveMCPOAuthResourceOrigin(binding.resourceOrigin) === binding.resourceOrigin &&
84
+ resolveMCPOAuthTokenEndpoint(binding.tokenEndpoint) === binding.tokenEndpoint
85
+ );
86
+ }
87
+
88
+ export function assertCanonicalMCPOAuthBinding(
89
+ binding: MCPOAuthBinding | undefined,
90
+ ): asserts binding is MCPOAuthBinding {
91
+ if (!binding || !isCanonicalMCPOAuthBinding(binding)) {
92
+ throw new Error("Invalid MCP OAuth credential binding");
93
+ }
94
+ }
95
+
49
96
  export type OAuthCredential = {
50
97
  type: "oauth";
98
+ /** Present only for credentials created by runtime MCP OAuth. */
99
+ mcpBinding?: MCPOAuthBinding;
51
100
  } & OAuthCredentials;
52
101
 
53
102
  export type AuthCredential = ApiKeyCredential | OAuthCredential;
54
103
 
104
+ export interface MCPOAuthRefreshClient {
105
+ clientId?: string;
106
+ clientSecret?: string;
107
+ }
108
+
109
+ async function refreshBoundMCPOAuthCredential(
110
+ credential: OAuthCredential,
111
+ client: MCPOAuthRefreshClient = {},
112
+ signal?: AbortSignal,
113
+ ): Promise<OAuthCredentials> {
114
+ const binding = credential.mcpBinding;
115
+ assertCanonicalMCPOAuthBinding(binding);
116
+ const params = new URLSearchParams({
117
+ grant_type: "refresh_token",
118
+ refresh_token: credential.refresh,
119
+ });
120
+ if (client.clientId) params.set("client_id", client.clientId);
121
+ if (client.clientSecret) params.set("client_secret", client.clientSecret);
122
+
123
+ const response = await fetch(binding.tokenEndpoint, {
124
+ method: "POST",
125
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
126
+ body: params.toString(),
127
+ redirect: "manual",
128
+ signal,
129
+ });
130
+ if (response.status >= 300 && response.status < 400) {
131
+ throw new Error(`MCP OAuth refresh rejected redirect response (${response.status})`);
132
+ }
133
+ if (!response.ok) throw new Error(`MCP OAuth refresh failed (${response.status})`);
134
+ const payload: unknown = await response.json();
135
+ if (!payload || typeof payload !== "object") throw new Error("MCP OAuth refresh returned an invalid payload");
136
+ const data = payload as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
137
+ if (typeof data.access_token !== "string" || data.access_token.length === 0) {
138
+ throw new Error("MCP OAuth refresh returned an invalid access token");
139
+ }
140
+ if (data.refresh_token !== undefined && typeof data.refresh_token !== "string") {
141
+ throw new Error("MCP OAuth refresh returned an invalid refresh token");
142
+ }
143
+ if (
144
+ data.expires_in !== undefined &&
145
+ (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in < 0)
146
+ ) {
147
+ throw new Error("MCP OAuth refresh returned an invalid expiry");
148
+ }
149
+ return {
150
+ access: data.access_token,
151
+ refresh: data.refresh_token || credential.refresh,
152
+ expires: Date.now() + (data.expires_in ?? 3600) * 1000,
153
+ };
154
+ }
155
+
55
156
  export type AuthCredentialEntry = AuthCredential | AuthCredential[];
56
157
 
57
158
  export type AuthStorageData = Record<string, AuthCredentialEntry>;
@@ -230,6 +331,13 @@ export interface AuthCredentialStore {
230
331
  credential: OAuthCredential,
231
332
  signal?: AbortSignal,
232
333
  ): Promise<OAuthCredentials>;
334
+ /** Broker-backed MCP refresh using the broker's stored token endpoint and refresh secret. */
335
+ refreshMCPOAuthCredential?(
336
+ credentialId: number,
337
+ credential: OAuthCredential,
338
+ client: MCPOAuthRefreshClient,
339
+ signal?: AbortSignal,
340
+ ): Promise<OAuthCredential>;
233
341
  /**
234
342
  * Optional async pre-read hook invoked after AuthStorage selects a stored
235
343
  * credential but before it returns that credential for an outbound request.
@@ -632,7 +740,9 @@ function authCredentialEquals(left: AuthCredential, right: AuthCredential): bool
632
740
  left.accountId === right.accountId &&
633
741
  left.email === right.email &&
634
742
  left.projectId === right.projectId &&
635
- left.enterpriseUrl === right.enterpriseUrl
743
+ left.enterpriseUrl === right.enterpriseUrl &&
744
+ left.mcpBinding?.resourceOrigin === right.mcpBinding?.resourceOrigin &&
745
+ left.mcpBinding?.tokenEndpoint === right.mcpBinding?.tokenEndpoint
636
746
  );
637
747
  }
638
748
 
@@ -1587,9 +1697,9 @@ export class AuthStorage {
1587
1697
  });
1588
1698
  break;
1589
1699
  }
1590
- case "alibaba-coding-plan": {
1591
- const { loginAlibabaCodingPlan } = await import("./utils/oauth/alibaba-coding-plan");
1592
- const apiKey = await loginAlibabaCodingPlan(ctrl);
1700
+ case "alibaba-token-plan": {
1701
+ const { loginAlibabaTokenPlan } = await import("./utils/oauth/alibaba-token-plan");
1702
+ const apiKey = await loginAlibabaTokenPlan(ctrl);
1593
1703
  await saveApiKeyCredential(apiKey);
1594
1704
  return;
1595
1705
  }
@@ -3009,6 +3119,8 @@ export class AuthStorage {
3009
3119
  const overrideRefresh = this.#refreshOAuthCredentialOverride ?? storeRefresh;
3010
3120
  if (overrideRefresh && credentialId !== undefined) {
3011
3121
  refreshPromise = overrideRefresh(provider, credentialId, credential, signal);
3122
+ } else if (credential.mcpBinding) {
3123
+ refreshPromise = refreshBoundMCPOAuthCredential(credential, {}, signal);
3012
3124
  } else {
3013
3125
  const customProvider = getOAuthProvider(provider);
3014
3126
  if (customProvider) {
@@ -3202,10 +3314,38 @@ export class AuthStorage {
3202
3314
  return { apiKey: result.apiKey, credential: updated };
3203
3315
  } catch (error) {
3204
3316
  const errorMsg = String(error);
3317
+ // Peer-rotation recovery runs before ANY failure classification: a
3318
+ // concurrent process may have rotated the refresh token, which
3319
+ // invalidates the snapshot token we just attempted. Re-read the row —
3320
+ // if the persisted refresh token changed, the peer's rotation succeeded
3321
+ // and we pick up the fresh credential instead of disabling (definitive
3322
+ // path) or temp-blocking (transient path) a row that is actually
3323
+ // healthy. This matters for providers whose invalid-grant response does
3324
+ // not match the definitive regex below (e.g. Kimi's 400 "The provided
3325
+ // authorization grant is invalid"): with short-lived access tokens and
3326
+ // multiple gjc processes sharing the store, the stale-snapshot failure
3327
+ // would otherwise be misclassified as transient and the credential
3328
+ // temp-blocked on every rotation race.
3329
+ const attemptedCredentialId = this.#getStoredCredentials(provider)[selection.index]?.id;
3330
+ if (attemptedCredentialId !== undefined) {
3331
+ const latestRow = this.#store.listAuthCredentials(provider).find(row => row.id === attemptedCredentialId);
3332
+ const latestCredential = latestRow?.credential;
3333
+ if (latestCredential?.type === "oauth" && latestCredential.refresh !== selection.credential.refresh) {
3334
+ logger.debug("OAuth refresh race detected; another process rotated token first", {
3335
+ provider,
3336
+ index: selection.index,
3337
+ credentialId: attemptedCredentialId,
3338
+ });
3339
+ await this.reload();
3340
+ return this.#resolveOAuthSelection(provider, sessionId, options);
3341
+ }
3342
+ }
3205
3343
  // Only remove credentials for definitive auth failures
3206
3344
  // Keep credentials for transient errors (network, 5xx) and block temporarily
3207
3345
  const isDefinitiveFailure =
3208
- /invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i.test(errorMsg) ||
3346
+ /invalid_grant|grant is invalid|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i.test(
3347
+ errorMsg,
3348
+ ) ||
3209
3349
  (/\b(401|403)\b/.test(errorMsg) && !/timeout|network|fetch failed|ECONNREFUSED/i.test(errorMsg));
3210
3350
 
3211
3351
  logger.warn("OAuth token refresh failed", {
@@ -3216,27 +3356,6 @@ export class AuthStorage {
3216
3356
  });
3217
3357
 
3218
3358
  if (isDefinitiveFailure) {
3219
- // The credential at this index may have been rotated by another process between
3220
- // our in-memory snapshot and the refresh attempt: Anthropic rotates refresh
3221
- // tokens on every use, so the peer's success leaves our stored token invalid.
3222
- // Re-read the row from disk before marking it disabled — if the persisted
3223
- // refresh token has changed, the peer rotation succeeded and we should pick
3224
- // up the new credential instead of soft-deleting the row that the peer just
3225
- // updated.
3226
- const credentialId = this.#getStoredCredentials(provider)[selection.index]?.id;
3227
- if (credentialId !== undefined) {
3228
- const latestRow = this.#store.listAuthCredentials(provider).find(row => row.id === credentialId);
3229
- const latestCredential = latestRow?.credential;
3230
- if (latestCredential?.type === "oauth" && latestCredential.refresh !== selection.credential.refresh) {
3231
- logger.debug("OAuth refresh race detected; another process rotated token first", {
3232
- provider,
3233
- index: selection.index,
3234
- credentialId,
3235
- });
3236
- await this.reload();
3237
- return this.#resolveOAuthSelection(provider, sessionId, options);
3238
- }
3239
- }
3240
3359
  // Permanently disable invalid credentials with an explicit cause for inspection/debugging.
3241
3360
  // Use a CAS-style disable conditioned on the row still containing the stale credential
3242
3361
  // we tried to refresh, so a peer rotation that lands between the pre-check above and
@@ -3518,14 +3637,18 @@ export class AuthStorage {
3518
3637
  * refresh attempt, which is required for providers that rotate refresh tokens
3519
3638
  * on every successful refresh.
3520
3639
  */
3521
- async refreshCredentialById(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry> {
3640
+ async refreshCredentialById(
3641
+ id: number,
3642
+ signal?: AbortSignal,
3643
+ mcpClient: MCPOAuthRefreshClient = {},
3644
+ ): Promise<AuthCredentialSnapshotEntry> {
3522
3645
  const existing = this.#oauthRefreshInFlight.get(id);
3523
3646
  if (existing) return raceCredentialRefreshWithSignal(existing, signal);
3524
3647
 
3525
3648
  const promise = (async () => {
3526
3649
  this.#bumpGeneration("credential-refresh-start");
3527
3650
  try {
3528
- return await this.#forceRefreshCredentialByIdUnshared(id, signal);
3651
+ return await this.#forceRefreshCredentialByIdUnshared(id, signal, mcpClient);
3529
3652
  } catch (error) {
3530
3653
  this.#bumpGeneration("credential-refresh-failure");
3531
3654
  throw error;
@@ -3549,7 +3672,32 @@ export class AuthStorage {
3549
3672
  return this.refreshCredentialById(id, signal);
3550
3673
  }
3551
3674
 
3552
- async #forceRefreshCredentialByIdUnshared(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry> {
3675
+ /** Force-refresh the first OAuth credential stored for a provider. */
3676
+ async forceRefreshOAuthCredential(
3677
+ provider: string,
3678
+ expected: OAuthCredential,
3679
+ client: MCPOAuthRefreshClient = {},
3680
+ signal?: AbortSignal,
3681
+ ): Promise<OAuthCredential> {
3682
+ const storageProvider = resolveOAuthStorageProvider(provider);
3683
+ const target = this.#getStoredCredentials(storageProvider).find(
3684
+ entry => entry.credential === expected || authCredentialEquals(entry.credential, expected),
3685
+ );
3686
+ if (target?.credential.type !== "oauth") {
3687
+ throw new Error(`No OAuth credential found for provider=${storageProvider}`);
3688
+ }
3689
+ const entry = await this.refreshCredentialById(target.id, signal, client);
3690
+ if (entry.credential.type !== "oauth") {
3691
+ throw new Error(`Credential ${target.id} is not OAuth`);
3692
+ }
3693
+ return entry.credential;
3694
+ }
3695
+
3696
+ async #forceRefreshCredentialByIdUnshared(
3697
+ id: number,
3698
+ signal?: AbortSignal,
3699
+ mcpClient: MCPOAuthRefreshClient = {},
3700
+ ): Promise<AuthCredentialSnapshotEntry> {
3553
3701
  for (const [provider, entries] of this.#data) {
3554
3702
  const index = entries.findIndex(entry => entry.id === id);
3555
3703
  if (index === -1) continue;
@@ -3560,7 +3708,27 @@ export class AuthStorage {
3560
3708
  // Pass a clone with expires=0 so the cached not-yet-expired short-circuit
3561
3709
  // in #refreshOAuthCredential doesn't suppress the requested refresh.
3562
3710
  const stale: OAuthCredential = { ...target.credential, expires: 0 };
3563
- const refreshed = await this.#refreshOAuthCredential(provider as Provider, stale, id, signal);
3711
+ let refreshed: OAuthCredentials;
3712
+ if (target.credential.mcpBinding) {
3713
+ assertCanonicalMCPOAuthBinding(target.credential.mcpBinding);
3714
+ const remoteRefresh = this.#store.refreshMCPOAuthCredential?.bind(this.#store);
3715
+ const refreshedCredential = remoteRefresh
3716
+ ? await remoteRefresh(id, stale, mcpClient, signal)
3717
+ : {
3718
+ type: "oauth" as const,
3719
+ ...(await refreshBoundMCPOAuthCredential(stale, mcpClient, signal)),
3720
+ mcpBinding: target.credential.mcpBinding,
3721
+ };
3722
+ if (
3723
+ refreshedCredential.mcpBinding?.resourceOrigin !== target.credential.mcpBinding.resourceOrigin ||
3724
+ refreshedCredential.mcpBinding.tokenEndpoint !== target.credential.mcpBinding.tokenEndpoint
3725
+ ) {
3726
+ throw new Error("Refreshed MCP OAuth credential binding mismatch");
3727
+ }
3728
+ refreshed = refreshedCredential;
3729
+ } else {
3730
+ refreshed = await this.#refreshOAuthCredential(provider as Provider, stale, id, signal);
3731
+ }
3564
3732
  const updated: OAuthCredential = {
3565
3733
  type: "oauth",
3566
3734
  access: refreshed.access,
@@ -3570,6 +3738,7 @@ export class AuthStorage {
3570
3738
  email: refreshed.email ?? target.credential.email,
3571
3739
  projectId: refreshed.projectId ?? target.credential.projectId,
3572
3740
  enterpriseUrl: refreshed.enterpriseUrl ?? target.credential.enterpriseUrl,
3741
+ mcpBinding: target.credential.mcpBinding,
3573
3742
  };
3574
3743
  this.#replaceCredentialAt(provider, index, updated);
3575
3744
  return {
@@ -62,7 +62,7 @@ type SemVer = {
62
62
  };
63
63
 
64
64
  type GeminiKind = "pro" | "flash";
65
- type AnthropicKind = "opus" | "sonnet";
65
+ type AnthropicKind = "opus" | "sonnet" | "fable";
66
66
  type OpenAIVariant =
67
67
  | "base"
68
68
  | "codex"
@@ -638,6 +638,11 @@ function inferAnthropicSupportedEfforts<TApi extends Api>(
638
638
  (model.api === "anthropic-messages" || model.api === "bedrock-converse-stream") &&
639
639
  semverGte(parsedModel.version, "4.6")
640
640
  ) {
641
+ if (parsedModel.kind === "fable") {
642
+ // Fable exposes Anthropic's Messages-only xhigh preset; Bedrock
643
+ // Converse lacks it (same split as Opus 4.7+ below).
644
+ return model.api === "anthropic-messages" ? DEFAULT_REASONING_EFFORTS_WITH_XHIGH : DEFAULT_REASONING_EFFORTS;
645
+ }
641
646
  if (parsedModel.kind !== "opus") return DEFAULT_REASONING_EFFORTS;
642
647
  return anthropicModelHasRealXHighEffort(model)
643
648
  ? DEFAULT_REASONING_EFFORTS_WITH_XHIGH_AND_MAX
@@ -697,7 +702,10 @@ function inferThinkingControlMode<TApi extends Api>(
697
702
 
698
703
  case "bedrock-converse-stream":
699
704
  if (parsedModel.family === "anthropic") {
700
- if (semverGte(parsedModel.version, "4.6") && parsedModel.kind === "opus") {
705
+ if (
706
+ semverGte(parsedModel.version, "4.6") &&
707
+ (parsedModel.kind === "opus" || parsedModel.kind === "fable")
708
+ ) {
701
709
  return "anthropic-adaptive";
702
710
  }
703
711
  if (semverGte(parsedModel.version, "4.5")) {
@@ -737,7 +745,7 @@ function parseGeminiModel(modelId: string): GeminiModel | null {
737
745
  }
738
746
 
739
747
  function parseAnthropicModel(modelId: string): AnthropicModel | null {
740
- const match = /claude-(opus|sonnet)-(\d{1,2}(?:[.-]\d{1,2}){0,2})\b/.exec(modelId);
748
+ const match = /claude-(opus|sonnet|fable)-(\d{1,2}(?:[.-]\d{1,2}){0,2})\b/.exec(modelId);
741
749
  if (!match) {
742
750
  return null;
743
751
  }