@sayknow-cli/ai 0.4.7 → 0.5.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 (71) hide show
  1. package/README.md +6 -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 +19 -2
  8. package/dist/types/providers/anthropic.d.ts +11 -1
  9. package/dist/types/providers/azure-openai-responses.d.ts +6 -1
  10. package/dist/types/providers/google-auth.d.ts +2 -0
  11. package/dist/types/providers/google-gemini-headers.d.ts +1 -1
  12. package/dist/types/providers/google-vertex.d.ts +2 -0
  13. package/dist/types/providers/openai-codex-responses.d.ts +4 -0
  14. package/dist/types/providers/openai-completions.d.ts +2 -0
  15. package/dist/types/providers/openai-responses.d.ts +2 -0
  16. package/dist/types/providers/register-builtins.d.ts +8 -0
  17. package/dist/types/providers/transform-messages.d.ts +1 -0
  18. package/dist/types/types.d.ts +3 -1
  19. package/dist/types/usage/grok-cli.d.ts +3 -1
  20. package/dist/types/usage/kimi.d.ts +2 -0
  21. package/dist/types/utils/anthropic-auth.d.ts +8 -0
  22. package/dist/types/utils/foundry.d.ts +10 -0
  23. package/dist/types/utils/http-inspector.d.ts +13 -0
  24. package/dist/types/utils/idle-iterator.d.ts +3 -2
  25. package/dist/types/utils/oauth/alibaba-token-plan.d.ts +19 -0
  26. package/dist/types/utils/oauth/bizrouter.d.ts +1 -0
  27. package/dist/types/utils/oauth/opengateway.d.ts +1 -0
  28. package/dist/types/utils/oauth/types.d.ts +1 -1
  29. package/package.json +2 -2
  30. package/src/auth-broker/client.ts +13 -0
  31. package/src/auth-broker/refresher.ts +1 -0
  32. package/src/auth-broker/remote-store.ts +25 -0
  33. package/src/auth-broker/server.ts +10 -2
  34. package/src/auth-broker/types.ts +4 -0
  35. package/src/auth-broker/wire-schemas.ts +17 -1
  36. package/src/auth-storage.ts +234 -45
  37. package/src/cli.ts +2 -0
  38. package/src/model-thinking.ts +11 -3
  39. package/src/models.json +3289 -486
  40. package/src/provider-models/descriptors.ts +19 -6
  41. package/src/provider-models/openai-compat.ts +99 -18
  42. package/src/providers/amazon-bedrock.ts +4 -0
  43. package/src/providers/anthropic.ts +131 -28
  44. package/src/providers/azure-openai-responses.ts +16 -3
  45. package/src/providers/google-auth.ts +13 -2
  46. package/src/providers/google-gemini-headers.ts +1 -1
  47. package/src/providers/google-vertex.ts +7 -2
  48. package/src/providers/openai-anthropic-shim.ts +4 -0
  49. package/src/providers/openai-codex-responses.ts +52 -10
  50. package/src/providers/openai-completions-compat.ts +2 -2
  51. package/src/providers/openai-completions.ts +20 -3
  52. package/src/providers/openai-responses.ts +17 -10
  53. package/src/providers/register-builtins.ts +21 -2
  54. package/src/providers/transform-messages.ts +25 -6
  55. package/src/stream.ts +3 -1
  56. package/src/types.ts +9 -2
  57. package/src/usage/claude.ts +21 -3
  58. package/src/usage/grok-cli.ts +12 -1
  59. package/src/usage/kimi.ts +16 -2
  60. package/src/utils/anthropic-auth.ts +11 -3
  61. package/src/utils/foundry.ts +12 -2
  62. package/src/utils/http-inspector.ts +77 -0
  63. package/src/utils/idle-iterator.ts +20 -7
  64. package/src/utils/oauth/{alibaba-coding-plan.ts → alibaba-token-plan.ts} +12 -11
  65. package/src/utils/oauth/bizrouter.ts +15 -0
  66. package/src/utils/oauth/index.ts +15 -2
  67. package/src/utils/oauth/opengateway.ts +15 -0
  68. package/src/utils/oauth/types.ts +3 -1
  69. package/src/utils/validation.ts +17 -2
  70. package/src/utils.ts +41 -4
  71. package/dist/types/utils/oauth/alibaba-coding-plan.d.ts +0 -18
@@ -7,7 +7,7 @@ export type OAuthCredentials = {
7
7
  email?: string;
8
8
  accountId?: string;
9
9
  };
10
- export type OAuthProvider = "alibaba-coding-plan" | "anthropic" | "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" | "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" | "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";
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": "@sayknow-cli/ai",
4
- "version": "0.4.7",
4
+ "version": "0.5.0",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://sayknow-cli.com",
7
7
  "author": "jaybeyond",
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "@anthropic-ai/sdk": "^0.94.0",
45
45
  "@bufbuild/protobuf": "^2.12.0",
46
- "@sayknow-cli/utils": "0.4.7",
46
+ "@sayknow-cli/utils": "0.5.0",
47
47
  "openai": "^6.36.0",
48
48
  "partial-json": "^0.1.7",
49
49
  "zod": "4.4.3"
@@ -12,6 +12,7 @@ import type {
12
12
  CredentialDisableRequest,
13
13
  CredentialDisableResponse,
14
14
  CredentialIfAbsentUploadResponse,
15
+ CredentialRefreshRequest,
15
16
  CredentialRefreshResponse,
16
17
  CredentialUploadRequest,
17
18
  CredentialUploadResponse,
@@ -242,6 +243,18 @@ export class AuthBrokerClient {
242
243
  }) as Promise<CredentialRefreshResponse>;
243
244
  }
244
245
 
246
+ async refreshMCPCredential(
247
+ id: number,
248
+ body: CredentialRefreshRequest,
249
+ signal?: AbortSignal,
250
+ ): Promise<CredentialRefreshResponse> {
251
+ return this.#request("POST", `/v1/credential/${id}/refresh`, {
252
+ body,
253
+ schema: credentialRefreshResponseSchema,
254
+ signal,
255
+ }) as Promise<CredentialRefreshResponse>;
256
+ }
257
+
245
258
  async disableCredential(id: number, cause: string, signal?: AbortSignal): Promise<CredentialDisableResponse> {
246
259
  const body: CredentialDisableRequest = { cause };
247
260
  return this.#request("POST", `/v1/credential/${id}/disable`, {
@@ -96,6 +96,7 @@ export class AuthBrokerRefresher {
96
96
  const targets: number[] = [];
97
97
  for (const entry of snapshot.credentials) {
98
98
  if (entry.credential.type !== "oauth") continue;
99
+ if (entry.credential.mcpBinding) continue;
99
100
  const expires = entry.credential.expires;
100
101
  if (typeof expires !== "number" || !Number.isFinite(expires)) continue;
101
102
  if (expires > deadline) continue;
@@ -14,6 +14,8 @@ import {
14
14
  type AuthCredentialIfAbsentResult,
15
15
  type AuthCredentialSnapshotEntry,
16
16
  type AuthCredentialStore,
17
+ assertCanonicalMCPOAuthBinding,
18
+ type MCPOAuthRefreshClient,
17
19
  type OAuthCredential,
18
20
  REMOTE_REFRESH_SENTINEL,
19
21
  type StoredAuthCredential,
@@ -512,6 +514,29 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
512
514
  };
513
515
  }
514
516
 
517
+ async refreshMCPOAuthCredential(
518
+ credentialId: number,
519
+ credential: OAuthCredential,
520
+ client: MCPOAuthRefreshClient,
521
+ signal?: AbortSignal,
522
+ ): Promise<OAuthCredential> {
523
+ const { entry } = await this.#client.refreshMCPCredential(credentialId, client, signal);
524
+ if (entry.credential.type !== "oauth") {
525
+ throw new Error(`Broker returned non-OAuth credential for id=${credentialId}`);
526
+ }
527
+ assertCanonicalMCPOAuthBinding(credential.mcpBinding);
528
+ assertCanonicalMCPOAuthBinding(entry.credential.mcpBinding);
529
+ if (
530
+ entry.credential.mcpBinding.resourceOrigin !== credential.mcpBinding.resourceOrigin ||
531
+ entry.credential.mcpBinding.tokenEndpoint !== credential.mcpBinding.tokenEndpoint
532
+ ) {
533
+ throw new Error("Broker returned mismatched MCP OAuth credential binding");
534
+ }
535
+ this.#applyCredentialEntry(entry);
536
+ this.#maybeRefreshSnapshot("MCP credential refresh");
537
+ return entry.credential;
538
+ }
539
+
515
540
  /**
516
541
  * Store-level hook consumed by `AuthStorage.fetchUsageReports()` — proxies
517
542
  * to the broker's `/v1/usage` endpoint. The broker's egress IP isn't
@@ -16,6 +16,7 @@ import { AuthBrokerRefresher, type AuthBrokerRefresherSchedule } from "./refresh
16
16
  import type {
17
17
  CredentialDisableResponse,
18
18
  CredentialIfAbsentUploadResponse,
19
+ CredentialRefreshRequest,
19
20
  CredentialRefreshResponse,
20
21
  CredentialUploadResponse,
21
22
  HealthzResponse,
@@ -33,7 +34,11 @@ import {
33
34
  DEFAULT_SERVER_IDLE_TIMEOUT_S,
34
35
  DEFAULT_STREAM_KEEPALIVE_MS,
35
36
  } from "./types";
36
- import { credentialDisableRequestSchema, credentialUploadRequestSchema } from "./wire-schemas";
37
+ import {
38
+ credentialDisableRequestSchema,
39
+ credentialRefreshRequestSchema,
40
+ credentialUploadRequestSchema,
41
+ } from "./wire-schemas";
37
42
 
38
43
  export interface AuthBrokerServerOptions {
39
44
  /** Underlying credential storage (wraps the local SQLite store on the broker). */
@@ -561,7 +566,10 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
561
566
  if (refreshMatch) {
562
567
  const id = Number.parseInt(refreshMatch[1], 10);
563
568
  try {
564
- const entry = await opts.storage.refreshCredentialById(id, req.signal);
569
+ const parsed = await parseBody(req, credentialRefreshRequestSchema, { allowEmpty: true });
570
+ if (!parsed.ok) return parsed.response;
571
+ const refreshRequest: CredentialRefreshRequest = parsed.data;
572
+ const entry = await opts.storage.refreshCredentialById(id, req.signal, refreshRequest);
565
573
  const body: CredentialRefreshResponse = { entry };
566
574
  logger.info("auth-broker credential refreshed", {
567
575
  id,
@@ -11,6 +11,7 @@ import type {
11
11
  AuthCredentialIfAbsentReason,
12
12
  AuthCredentialSnapshot,
13
13
  AuthCredentialSnapshotEntry,
14
+ MCPOAuthRefreshClient,
14
15
  } from "../auth-storage";
15
16
  import type { UsageReport } from "../usage";
16
17
 
@@ -49,6 +50,9 @@ export interface CredentialRefreshResponse {
49
50
  entry: AuthCredentialSnapshotEntry;
50
51
  }
51
52
 
53
+ /** Optional MCP client metadata; the broker still selects the stored token endpoint. */
54
+ export type CredentialRefreshRequest = MCPOAuthRefreshClient;
55
+
52
56
  /** POST /v1/credential/:id/disable request body. */
53
57
  export interface CredentialDisableRequest {
54
58
  cause: string;
@@ -11,11 +11,19 @@
11
11
  * `hasOnlyFields` allowlist for the same effect.
12
12
  */
13
13
  import * as z from "zod/v4";
14
- import { REMOTE_REFRESH_SENTINEL } from "../auth-storage";
14
+ import { isCanonicalMCPOAuthBinding, REMOTE_REFRESH_SENTINEL } from "../auth-storage";
15
15
  import { usageReportSchema } from "../usage";
16
16
 
17
17
  // ─── Credential payloads ───────────────────────────────────────────────────
18
18
 
19
+ export const mcpOAuthBindingSchema = z
20
+ .object({
21
+ resourceOrigin: z.string().min(1),
22
+ tokenEndpoint: z.string().min(1),
23
+ })
24
+ .strict()
25
+ .refine(isCanonicalMCPOAuthBinding, { message: "MCP OAuth binding must use canonical HTTP(S) URLs" });
26
+
19
27
  /** Real OAuth credential (broker-side) — refresh token is the actual upstream value. */
20
28
  export const oauthCredentialSchema = z
21
29
  .object({
@@ -37,6 +45,7 @@ export const oauthCredentialSchema = z
37
45
  projectId: z.string().optional(),
38
46
  email: z.string().optional(),
39
47
  accountId: z.string().optional(),
48
+ mcpBinding: mcpOAuthBindingSchema.optional(),
40
49
  })
41
50
  .strict();
42
51
 
@@ -164,6 +173,13 @@ export const usageResponseSchema = z
164
173
 
165
174
  // ─── Refresh ───────────────────────────────────────────────────────────────
166
175
 
176
+ export const credentialRefreshRequestSchema = z
177
+ .object({
178
+ clientId: z.string().optional(),
179
+ clientSecret: z.string().optional(),
180
+ })
181
+ .strict();
182
+
167
183
  export const credentialRefreshResponseSchema = z
168
184
  .object({
169
185
  entry: credentialSnapshotEntrySchema,
@@ -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
  }
@@ -1888,6 +1998,18 @@ export class AuthStorage {
1888
1998
  await saveApiKeyCredential(apiKey);
1889
1999
  return;
1890
2000
  }
2001
+ case "bizrouter": {
2002
+ const { loginBizRouter } = await import("./utils/oauth/bizrouter");
2003
+ const apiKey = await loginBizRouter(ctrl);
2004
+ await saveApiKeyCredential(apiKey);
2005
+ return;
2006
+ }
2007
+ case "opengateway": {
2008
+ const { loginOpenGateway } = await import("./utils/oauth/opengateway");
2009
+ const apiKey = await loginOpenGateway(ctrl);
2010
+ await saveApiKeyCredential(apiKey);
2011
+ return;
2012
+ }
1891
2013
  default: {
1892
2014
  const customProvider = getOAuthProvider(provider);
1893
2015
  if (!customProvider) {
@@ -2398,9 +2520,12 @@ export class AuthStorage {
2398
2520
  if (storeHook) {
2399
2521
  return storeHook(provider, credential, options?.signal);
2400
2522
  }
2401
- return this.#fetchUsageCached(
2402
- this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
2403
- options?.timeoutMs ?? this.#usageRequestTimeoutMs,
2523
+ return raceUsageWithSignal(
2524
+ this.#fetchUsageCached(
2525
+ this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
2526
+ options?.timeoutMs ?? this.#usageRequestTimeoutMs,
2527
+ ),
2528
+ options?.signal,
2404
2529
  );
2405
2530
  }
2406
2531
 
@@ -2452,7 +2577,7 @@ export class AuthStorage {
2452
2577
  const cacheKey = this.#buildUsageReportsCacheKey(requests);
2453
2578
 
2454
2579
  const inFlight = this.#usageReportsInFlight.get(cacheKey);
2455
- if (inFlight) return inFlight;
2580
+ if (inFlight) return raceUsageWithSignal(inFlight, options?.signal);
2456
2581
 
2457
2582
  const promise = (async () => {
2458
2583
  if (options?.logDetails !== false) {
@@ -2500,7 +2625,7 @@ export class AuthStorage {
2500
2625
  });
2501
2626
 
2502
2627
  this.#usageReportsInFlight.set(cacheKey, promise);
2503
- return promise;
2628
+ return raceUsageWithSignal(promise, options?.signal);
2504
2629
  }
2505
2630
 
2506
2631
  /**
@@ -2751,7 +2876,7 @@ export class AuthStorage {
2751
2876
  const blockedUntil = this.#getCredentialBlockedUntil(args.providerKey, selection.index);
2752
2877
  if (blockedUntil !== undefined) return { selection, usage: null, usageChecked: false, blockedUntil };
2753
2878
  const usage = await this.#getUsageReport(args.provider, selection.credential, {
2754
- ...args.options,
2879
+ baseUrl: args.options?.baseUrl,
2755
2880
  timeoutMs: this.#usageRequestTimeoutMs,
2756
2881
  });
2757
2882
  return { selection, usage, usageChecked: true, blockedUntil: undefined as number | undefined };
@@ -2764,16 +2889,21 @@ export class AuthStorage {
2764
2889
  // path so memory drops immediately.
2765
2890
  const timer = setTimeout(() => timeoutSignal.resolve(null), usageTimeout);
2766
2891
  timer.unref?.();
2767
- const usageResults = await Promise.race([usagePromise, timeoutSignal.promise]).then(result => {
2768
- clearTimeout(timer);
2769
- return (
2770
- result ??
2771
- args.order.map(idx => {
2772
- const selection = args.credentials[idx];
2773
- return selection ? { selection, usage: null, usageChecked: false, blockedUntil: undefined } : null;
2774
- })
2892
+ let resolvedUsageResults: Awaited<typeof usagePromise> | null;
2893
+ try {
2894
+ resolvedUsageResults = await raceUsageWithSignal(
2895
+ Promise.race([usagePromise, timeoutSignal.promise]),
2896
+ args.options?.signal,
2775
2897
  );
2776
- });
2898
+ } finally {
2899
+ clearTimeout(timer);
2900
+ }
2901
+ const usageResults =
2902
+ resolvedUsageResults ??
2903
+ args.order.map(idx => {
2904
+ const selection = args.credentials[idx];
2905
+ return selection ? { selection, usage: null, usageChecked: true, blockedUntil: undefined } : null;
2906
+ });
2777
2907
 
2778
2908
  for (let orderPos = 0; orderPos < usageResults.length; orderPos += 1) {
2779
2909
  const result = usageResults[orderPos];
@@ -3009,6 +3139,8 @@ export class AuthStorage {
3009
3139
  const overrideRefresh = this.#refreshOAuthCredentialOverride ?? storeRefresh;
3010
3140
  if (overrideRefresh && credentialId !== undefined) {
3011
3141
  refreshPromise = overrideRefresh(provider, credentialId, credential, signal);
3142
+ } else if (credential.mcpBinding) {
3143
+ refreshPromise = refreshBoundMCPOAuthCredential(credential, {}, signal);
3012
3144
  } else {
3013
3145
  const customProvider = getOAuthProvider(provider);
3014
3146
  if (customProvider) {
@@ -3202,10 +3334,38 @@ export class AuthStorage {
3202
3334
  return { apiKey: result.apiKey, credential: updated };
3203
3335
  } catch (error) {
3204
3336
  const errorMsg = String(error);
3337
+ // Peer-rotation recovery runs before ANY failure classification: a
3338
+ // concurrent process may have rotated the refresh token, which
3339
+ // invalidates the snapshot token we just attempted. Re-read the row —
3340
+ // if the persisted refresh token changed, the peer's rotation succeeded
3341
+ // and we pick up the fresh credential instead of disabling (definitive
3342
+ // path) or temp-blocking (transient path) a row that is actually
3343
+ // healthy. This matters for providers whose invalid-grant response does
3344
+ // not match the definitive regex below (e.g. Kimi's 400 "The provided
3345
+ // authorization grant is invalid"): with short-lived access tokens and
3346
+ // multiple skc processes sharing the store, the stale-snapshot failure
3347
+ // would otherwise be misclassified as transient and the credential
3348
+ // temp-blocked on every rotation race.
3349
+ const attemptedCredentialId = this.#getStoredCredentials(provider)[selection.index]?.id;
3350
+ if (attemptedCredentialId !== undefined) {
3351
+ const latestRow = this.#store.listAuthCredentials(provider).find(row => row.id === attemptedCredentialId);
3352
+ const latestCredential = latestRow?.credential;
3353
+ if (latestCredential?.type === "oauth" && latestCredential.refresh !== selection.credential.refresh) {
3354
+ logger.debug("OAuth refresh race detected; another process rotated token first", {
3355
+ provider,
3356
+ index: selection.index,
3357
+ credentialId: attemptedCredentialId,
3358
+ });
3359
+ await this.reload();
3360
+ return this.#resolveOAuthSelection(provider, sessionId, options);
3361
+ }
3362
+ }
3205
3363
  // Only remove credentials for definitive auth failures
3206
3364
  // Keep credentials for transient errors (network, 5xx) and block temporarily
3207
3365
  const isDefinitiveFailure =
3208
- /invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i.test(errorMsg) ||
3366
+ /invalid_grant|grant is invalid|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i.test(
3367
+ errorMsg,
3368
+ ) ||
3209
3369
  (/\b(401|403)\b/.test(errorMsg) && !/timeout|network|fetch failed|ECONNREFUSED/i.test(errorMsg));
3210
3370
 
3211
3371
  logger.warn("OAuth token refresh failed", {
@@ -3216,27 +3376,6 @@ export class AuthStorage {
3216
3376
  });
3217
3377
 
3218
3378
  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
3379
  // Permanently disable invalid credentials with an explicit cause for inspection/debugging.
3241
3380
  // Use a CAS-style disable conditioned on the row still containing the stale credential
3242
3381
  // we tried to refresh, so a peer rotation that lands between the pre-check above and
@@ -3518,14 +3657,18 @@ export class AuthStorage {
3518
3657
  * refresh attempt, which is required for providers that rotate refresh tokens
3519
3658
  * on every successful refresh.
3520
3659
  */
3521
- async refreshCredentialById(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry> {
3660
+ async refreshCredentialById(
3661
+ id: number,
3662
+ signal?: AbortSignal,
3663
+ mcpClient: MCPOAuthRefreshClient = {},
3664
+ ): Promise<AuthCredentialSnapshotEntry> {
3522
3665
  const existing = this.#oauthRefreshInFlight.get(id);
3523
3666
  if (existing) return raceCredentialRefreshWithSignal(existing, signal);
3524
3667
 
3525
3668
  const promise = (async () => {
3526
3669
  this.#bumpGeneration("credential-refresh-start");
3527
3670
  try {
3528
- return await this.#forceRefreshCredentialByIdUnshared(id, signal);
3671
+ return await this.#forceRefreshCredentialByIdUnshared(id, signal, mcpClient);
3529
3672
  } catch (error) {
3530
3673
  this.#bumpGeneration("credential-refresh-failure");
3531
3674
  throw error;
@@ -3549,7 +3692,32 @@ export class AuthStorage {
3549
3692
  return this.refreshCredentialById(id, signal);
3550
3693
  }
3551
3694
 
3552
- async #forceRefreshCredentialByIdUnshared(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry> {
3695
+ /** Force-refresh the first OAuth credential stored for a provider. */
3696
+ async forceRefreshOAuthCredential(
3697
+ provider: string,
3698
+ expected: OAuthCredential,
3699
+ client: MCPOAuthRefreshClient = {},
3700
+ signal?: AbortSignal,
3701
+ ): Promise<OAuthCredential> {
3702
+ const storageProvider = resolveOAuthStorageProvider(provider);
3703
+ const target = this.#getStoredCredentials(storageProvider).find(
3704
+ entry => entry.credential === expected || authCredentialEquals(entry.credential, expected),
3705
+ );
3706
+ if (target?.credential.type !== "oauth") {
3707
+ throw new Error(`No OAuth credential found for provider=${storageProvider}`);
3708
+ }
3709
+ const entry = await this.refreshCredentialById(target.id, signal, client);
3710
+ if (entry.credential.type !== "oauth") {
3711
+ throw new Error(`Credential ${target.id} is not OAuth`);
3712
+ }
3713
+ return entry.credential;
3714
+ }
3715
+
3716
+ async #forceRefreshCredentialByIdUnshared(
3717
+ id: number,
3718
+ signal?: AbortSignal,
3719
+ mcpClient: MCPOAuthRefreshClient = {},
3720
+ ): Promise<AuthCredentialSnapshotEntry> {
3553
3721
  for (const [provider, entries] of this.#data) {
3554
3722
  const index = entries.findIndex(entry => entry.id === id);
3555
3723
  if (index === -1) continue;
@@ -3560,7 +3728,27 @@ export class AuthStorage {
3560
3728
  // Pass a clone with expires=0 so the cached not-yet-expired short-circuit
3561
3729
  // in #refreshOAuthCredential doesn't suppress the requested refresh.
3562
3730
  const stale: OAuthCredential = { ...target.credential, expires: 0 };
3563
- const refreshed = await this.#refreshOAuthCredential(provider as Provider, stale, id, signal);
3731
+ let refreshed: OAuthCredentials;
3732
+ if (target.credential.mcpBinding) {
3733
+ assertCanonicalMCPOAuthBinding(target.credential.mcpBinding);
3734
+ const remoteRefresh = this.#store.refreshMCPOAuthCredential?.bind(this.#store);
3735
+ const refreshedCredential = remoteRefresh
3736
+ ? await remoteRefresh(id, stale, mcpClient, signal)
3737
+ : {
3738
+ type: "oauth" as const,
3739
+ ...(await refreshBoundMCPOAuthCredential(stale, mcpClient, signal)),
3740
+ mcpBinding: target.credential.mcpBinding,
3741
+ };
3742
+ if (
3743
+ refreshedCredential.mcpBinding?.resourceOrigin !== target.credential.mcpBinding.resourceOrigin ||
3744
+ refreshedCredential.mcpBinding.tokenEndpoint !== target.credential.mcpBinding.tokenEndpoint
3745
+ ) {
3746
+ throw new Error("Refreshed MCP OAuth credential binding mismatch");
3747
+ }
3748
+ refreshed = refreshedCredential;
3749
+ } else {
3750
+ refreshed = await this.#refreshOAuthCredential(provider as Provider, stale, id, signal);
3751
+ }
3564
3752
  const updated: OAuthCredential = {
3565
3753
  type: "oauth",
3566
3754
  access: refreshed.access,
@@ -3570,6 +3758,7 @@ export class AuthStorage {
3570
3758
  email: refreshed.email ?? target.credential.email,
3571
3759
  projectId: refreshed.projectId ?? target.credential.projectId,
3572
3760
  enterpriseUrl: refreshed.enterpriseUrl ?? target.credential.enterpriseUrl,
3761
+ mcpBinding: target.credential.mcpBinding,
3573
3762
  };
3574
3763
  this.#replaceCredentialAt(provider, index, updated);
3575
3764
  return {
package/src/cli.ts CHANGED
@@ -117,6 +117,8 @@ Providers:
117
117
  minimax-code-cn MiniMax Coding Plan (China)
118
118
  cursor Cursor (Anthropic, GPT, etc.)
119
119
  zenmux ZenMux
120
+ opengateway OpenGateway by Sionic AI
121
+ bizrouter BizRouter
120
122
  ollama-cloud Ollama Cloud
121
123
 
122
124
  Examples:
@@ -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
  }