@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.
- package/README.md +6 -0
- package/dist/types/auth-broker/client.d.ts +2 -1
- package/dist/types/auth-broker/remote-store.d.ts +2 -1
- package/dist/types/auth-broker/types.d.ts +3 -1
- package/dist/types/auth-broker/wire-schemas.d.ts +68 -0
- package/dist/types/auth-storage.d.ts +21 -1
- package/dist/types/provider-models/openai-compat.d.ts +19 -2
- package/dist/types/providers/anthropic.d.ts +11 -1
- package/dist/types/providers/azure-openai-responses.d.ts +6 -1
- package/dist/types/providers/google-auth.d.ts +2 -0
- package/dist/types/providers/google-gemini-headers.d.ts +1 -1
- package/dist/types/providers/google-vertex.d.ts +2 -0
- package/dist/types/providers/openai-codex-responses.d.ts +4 -0
- package/dist/types/providers/openai-completions.d.ts +2 -0
- package/dist/types/providers/openai-responses.d.ts +2 -0
- package/dist/types/providers/register-builtins.d.ts +8 -0
- package/dist/types/providers/transform-messages.d.ts +1 -0
- package/dist/types/types.d.ts +3 -1
- package/dist/types/usage/grok-cli.d.ts +3 -1
- package/dist/types/usage/kimi.d.ts +2 -0
- package/dist/types/utils/anthropic-auth.d.ts +8 -0
- package/dist/types/utils/foundry.d.ts +10 -0
- package/dist/types/utils/http-inspector.d.ts +13 -0
- package/dist/types/utils/idle-iterator.d.ts +3 -2
- package/dist/types/utils/oauth/alibaba-token-plan.d.ts +19 -0
- package/dist/types/utils/oauth/bizrouter.d.ts +1 -0
- package/dist/types/utils/oauth/opengateway.d.ts +1 -0
- package/dist/types/utils/oauth/types.d.ts +1 -1
- package/package.json +2 -2
- package/src/auth-broker/client.ts +13 -0
- package/src/auth-broker/refresher.ts +1 -0
- package/src/auth-broker/remote-store.ts +25 -0
- package/src/auth-broker/server.ts +10 -2
- package/src/auth-broker/types.ts +4 -0
- package/src/auth-broker/wire-schemas.ts +17 -1
- package/src/auth-storage.ts +234 -45
- package/src/cli.ts +2 -0
- package/src/model-thinking.ts +11 -3
- package/src/models.json +3289 -486
- package/src/provider-models/descriptors.ts +19 -6
- package/src/provider-models/openai-compat.ts +99 -18
- package/src/providers/amazon-bedrock.ts +4 -0
- package/src/providers/anthropic.ts +131 -28
- package/src/providers/azure-openai-responses.ts +16 -3
- package/src/providers/google-auth.ts +13 -2
- package/src/providers/google-gemini-headers.ts +1 -1
- package/src/providers/google-vertex.ts +7 -2
- package/src/providers/openai-anthropic-shim.ts +4 -0
- package/src/providers/openai-codex-responses.ts +52 -10
- package/src/providers/openai-completions-compat.ts +2 -2
- package/src/providers/openai-completions.ts +20 -3
- package/src/providers/openai-responses.ts +17 -10
- package/src/providers/register-builtins.ts +21 -2
- package/src/providers/transform-messages.ts +25 -6
- package/src/stream.ts +3 -1
- package/src/types.ts +9 -2
- package/src/usage/claude.ts +21 -3
- package/src/usage/grok-cli.ts +12 -1
- package/src/usage/kimi.ts +16 -2
- package/src/utils/anthropic-auth.ts +11 -3
- package/src/utils/foundry.ts +12 -2
- package/src/utils/http-inspector.ts +77 -0
- package/src/utils/idle-iterator.ts +20 -7
- package/src/utils/oauth/{alibaba-coding-plan.ts → alibaba-token-plan.ts} +12 -11
- package/src/utils/oauth/bizrouter.ts +15 -0
- package/src/utils/oauth/index.ts +15 -2
- package/src/utils/oauth/opengateway.ts +15 -0
- package/src/utils/oauth/types.ts +3 -1
- package/src/utils/validation.ts +17 -2
- package/src/utils.ts +41 -4
- 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-
|
|
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
|
+
"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.
|
|
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 {
|
|
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
|
|
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,
|
package/src/auth-broker/types.ts
CHANGED
|
@@ -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,
|
package/src/auth-storage.ts
CHANGED
|
@@ -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-
|
|
1591
|
-
const {
|
|
1592
|
-
const apiKey = await
|
|
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
|
|
2402
|
-
this.#
|
|
2403
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
args.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
package/src/model-thinking.ts
CHANGED
|
@@ -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 (
|
|
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
|
}
|