@gajae-code/ai 0.11.7 → 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.
- package/CHANGELOG.md +13 -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/providers/anthropic.d.ts +9 -0
- package/dist/types/providers/transform-messages.d.ts +1 -0
- package/dist/types/types.d.ts +2 -0
- 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 +196 -27
- package/src/providers/anthropic.ts +44 -21
- package/src/providers/openai-completions.ts +4 -1
- package/src/providers/openai-responses.ts +4 -1
- package/src/providers/transform-messages.ts +25 -6
- package/src/types.ts +6 -1
- package/src/utils/validation.ts +17 -2
- package/src/utils.ts +41 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.11.8] - 2026-07-23
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- OpenAI Responses / Codex native history replay no longer submits missing resident-image placeholders as `input_image.image_url`. Invalid values (including `[Session resident imageUrl blob missing: …]`) are dropped, or retained as `file_id`-only parts when a non-empty `file_id` is present, so a single unavailable historical image cannot brick `/retry` (#2924).
|
|
10
|
+
- Raised the first-event stream timeout floor to five minutes for `alibaba-token-plan` models using the OpenAI Completions and Responses APIs, while preserving caller and environment overrides and the existing inter-event idle timeout.
|
|
11
|
+
- OAuth refresh peer-rotation recovery now runs before failure classification instead of only on the definitive-failure path, and the definitive matcher recognizes the "grant is invalid" phrasing. Providers whose invalid-grant response does not contain the literal `invalid_grant` (e.g. Kimi's 400 "The provided authorization grant is invalid") previously had rotation races misclassified as transient, temp-blocking a healthy credential for five minutes on every race; with Kimi's ~12-minute access tokens and multiple processes sharing the credential store this surfaced as repeated logouts. Genuine revocations are now disabled with a cause instead of looping temp-blocks.
|
|
12
|
+
- Anthropic 400 `Invalid \`signature\` in \`thinking\` block` responses now trigger the one-shot thinking replay repair instead of failing the turn. The existing repair matcher only recognized the "latest assistant message ... cannot be modified" wording, so the signature-validation variant — which can cite a `thinking`/`redacted_thinking` block anywhere in the replayed history (e.g. after compaction/pruning rewrote an earlier turn) — was treated as a fatal request error. The retry now rebuilds the request with thinking blocks dropped from every replayed assistant message (`repairAllAssistantThinking`), while the latest-message mutation variant keeps the targeted latest-only repair.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Raw tool-argument rejection hooks can now select from bounded, authority-controlled correction codes. Unknown or extension-supplied values retain the byte-for-byte generic rejection instead of reaching model-visible validation errors.
|
|
17
|
+
|
|
5
18
|
## [0.11.7] - 2026-07-22
|
|
6
19
|
|
|
7
20
|
### Changed
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AuthCredential } from "../auth-storage";
|
|
2
|
-
import type { CredentialDisableResponse, CredentialIfAbsentUploadResponse, CredentialRefreshResponse, CredentialUploadResponse, HealthzResponse, SnapshotResponse, SnapshotStreamEvent, UsageResponse } from "./types";
|
|
2
|
+
import type { CredentialDisableResponse, CredentialIfAbsentUploadResponse, CredentialRefreshRequest, CredentialRefreshResponse, CredentialUploadResponse, HealthzResponse, SnapshotResponse, SnapshotStreamEvent, UsageResponse } from "./types";
|
|
3
3
|
export interface AuthBrokerClientOptions {
|
|
4
4
|
/** Base URL (e.g. `https://broker.tailnet:8765`). Trailing slashes are trimmed. */
|
|
5
5
|
url: string;
|
|
@@ -61,6 +61,7 @@ export declare class AuthBrokerClient {
|
|
|
61
61
|
}): AsyncGenerator<SnapshotStreamEvent>;
|
|
62
62
|
fetchUsage(signal?: AbortSignal): Promise<UsageResponse>;
|
|
63
63
|
refreshCredential(id: number, signal?: AbortSignal): Promise<CredentialRefreshResponse>;
|
|
64
|
+
refreshMCPCredential(id: number, body: CredentialRefreshRequest, signal?: AbortSignal): Promise<CredentialRefreshResponse>;
|
|
64
65
|
disableCredential(id: number, cause: string, signal?: AbortSignal): Promise<CredentialDisableResponse>;
|
|
65
66
|
uploadCredential(provider: string, credential: AuthCredential, signal?: AbortSignal): Promise<CredentialUploadResponse>;
|
|
66
67
|
uploadCredentialIfAbsent(provider: string, credential: AuthCredential, signal?: AbortSignal): Promise<CredentialIfAbsentUploadResponse>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AuthCredential, type AuthCredentialIfAbsentResult, type AuthCredentialStore, type OAuthCredential, type StoredAuthCredential } from "../auth-storage";
|
|
1
|
+
import { type AuthCredential, type AuthCredentialIfAbsentResult, type AuthCredentialStore, type MCPOAuthRefreshClient, type OAuthCredential, type StoredAuthCredential } from "../auth-storage";
|
|
2
2
|
import type { Provider } from "../types";
|
|
3
3
|
import type { UsageReport } from "../usage";
|
|
4
4
|
import type { OAuthCredentials } from "../utils/oauth/types";
|
|
@@ -78,6 +78,7 @@ export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
78
78
|
* the `refresh` slot.
|
|
79
79
|
*/
|
|
80
80
|
refreshOAuthCredential(_provider: Provider, credentialId: number, _credential: OAuthCredential, signal?: AbortSignal): Promise<OAuthCredentials>;
|
|
81
|
+
refreshMCPOAuthCredential(credentialId: number, credential: OAuthCredential, client: MCPOAuthRefreshClient, signal?: AbortSignal): Promise<OAuthCredential>;
|
|
81
82
|
/**
|
|
82
83
|
* Store-level hook consumed by `AuthStorage.fetchUsageReports()` — proxies
|
|
83
84
|
* to the broker's `/v1/usage` endpoint. The broker's egress IP isn't
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* clients use `access` tokens directly and call back to the broker when a
|
|
6
6
|
* credential expires or a 401 surfaces on a supposedly-fresh credential.
|
|
7
7
|
*/
|
|
8
|
-
import type { AuthCredential, AuthCredentialIfAbsentReason, AuthCredentialSnapshot, AuthCredentialSnapshotEntry } from "../auth-storage";
|
|
8
|
+
import type { AuthCredential, AuthCredentialIfAbsentReason, AuthCredentialSnapshot, AuthCredentialSnapshotEntry, MCPOAuthRefreshClient } from "../auth-storage";
|
|
9
9
|
import type { UsageReport } from "../usage";
|
|
10
10
|
/** GET /v1/healthz response body. */
|
|
11
11
|
export interface HealthzResponse {
|
|
@@ -36,6 +36,8 @@ export interface UsageResponse {
|
|
|
36
36
|
export interface CredentialRefreshResponse {
|
|
37
37
|
entry: AuthCredentialSnapshotEntry;
|
|
38
38
|
}
|
|
39
|
+
/** Optional MCP client metadata; the broker still selects the stored token endpoint. */
|
|
40
|
+
export type CredentialRefreshRequest = MCPOAuthRefreshClient;
|
|
39
41
|
/** POST /v1/credential/:id/disable request body. */
|
|
40
42
|
export interface CredentialDisableRequest {
|
|
41
43
|
cause: string;
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* `hasOnlyFields` allowlist for the same effect.
|
|
12
12
|
*/
|
|
13
13
|
import * as z from "zod/v4";
|
|
14
|
+
export declare const mcpOAuthBindingSchema: z.ZodObject<{
|
|
15
|
+
resourceOrigin: z.ZodString;
|
|
16
|
+
tokenEndpoint: z.ZodString;
|
|
17
|
+
}, z.core.$strict>;
|
|
14
18
|
/** Real OAuth credential (broker-side) — refresh token is the actual upstream value. */
|
|
15
19
|
export declare const oauthCredentialSchema: z.ZodObject<{
|
|
16
20
|
type: z.ZodLiteral<"oauth">;
|
|
@@ -21,6 +25,10 @@ export declare const oauthCredentialSchema: z.ZodObject<{
|
|
|
21
25
|
projectId: z.ZodOptional<z.ZodString>;
|
|
22
26
|
email: z.ZodOptional<z.ZodString>;
|
|
23
27
|
accountId: z.ZodOptional<z.ZodString>;
|
|
28
|
+
mcpBinding: z.ZodOptional<z.ZodObject<{
|
|
29
|
+
resourceOrigin: z.ZodString;
|
|
30
|
+
tokenEndpoint: z.ZodString;
|
|
31
|
+
}, z.core.$strict>>;
|
|
24
32
|
}, z.core.$strict>;
|
|
25
33
|
/** OAuth credential as it appears in broker snapshots — refresh replaced with sentinel. */
|
|
26
34
|
export declare const remoteOauthCredentialSchema: z.ZodObject<{
|
|
@@ -31,6 +39,10 @@ export declare const remoteOauthCredentialSchema: z.ZodObject<{
|
|
|
31
39
|
projectId: z.ZodOptional<z.ZodString>;
|
|
32
40
|
email: z.ZodOptional<z.ZodString>;
|
|
33
41
|
accountId: z.ZodOptional<z.ZodString>;
|
|
42
|
+
mcpBinding: z.ZodOptional<z.ZodObject<{
|
|
43
|
+
resourceOrigin: z.ZodString;
|
|
44
|
+
tokenEndpoint: z.ZodString;
|
|
45
|
+
}, z.core.$strict>>;
|
|
34
46
|
refresh: z.ZodLiteral<"__remote__">;
|
|
35
47
|
}, z.core.$strict>;
|
|
36
48
|
export declare const apiKeyCredentialSchema: z.ZodObject<{
|
|
@@ -47,6 +59,10 @@ export declare const writableAuthCredentialSchema: z.ZodDiscriminatedUnion<[z.Zo
|
|
|
47
59
|
projectId: z.ZodOptional<z.ZodString>;
|
|
48
60
|
email: z.ZodOptional<z.ZodString>;
|
|
49
61
|
accountId: z.ZodOptional<z.ZodString>;
|
|
62
|
+
mcpBinding: z.ZodOptional<z.ZodObject<{
|
|
63
|
+
resourceOrigin: z.ZodString;
|
|
64
|
+
tokenEndpoint: z.ZodString;
|
|
65
|
+
}, z.core.$strict>>;
|
|
50
66
|
}, z.core.$strict>, z.ZodObject<{
|
|
51
67
|
type: z.ZodLiteral<"api_key">;
|
|
52
68
|
key: z.ZodString;
|
|
@@ -60,6 +76,10 @@ export declare const snapshotCredentialSchema: z.ZodDiscriminatedUnion<[z.ZodObj
|
|
|
60
76
|
projectId: z.ZodOptional<z.ZodString>;
|
|
61
77
|
email: z.ZodOptional<z.ZodString>;
|
|
62
78
|
accountId: z.ZodOptional<z.ZodString>;
|
|
79
|
+
mcpBinding: z.ZodOptional<z.ZodObject<{
|
|
80
|
+
resourceOrigin: z.ZodString;
|
|
81
|
+
tokenEndpoint: z.ZodString;
|
|
82
|
+
}, z.core.$strict>>;
|
|
63
83
|
refresh: z.ZodLiteral<"__remote__">;
|
|
64
84
|
}, z.core.$strict>, z.ZodObject<{
|
|
65
85
|
type: z.ZodLiteral<"api_key">;
|
|
@@ -76,6 +96,10 @@ export declare const credentialSnapshotEntrySchema: z.ZodObject<{
|
|
|
76
96
|
projectId: z.ZodOptional<z.ZodString>;
|
|
77
97
|
email: z.ZodOptional<z.ZodString>;
|
|
78
98
|
accountId: z.ZodOptional<z.ZodString>;
|
|
99
|
+
mcpBinding: z.ZodOptional<z.ZodObject<{
|
|
100
|
+
resourceOrigin: z.ZodString;
|
|
101
|
+
tokenEndpoint: z.ZodString;
|
|
102
|
+
}, z.core.$strict>>;
|
|
79
103
|
refresh: z.ZodLiteral<"__remote__">;
|
|
80
104
|
}, z.core.$strict>, z.ZodObject<{
|
|
81
105
|
type: z.ZodLiteral<"api_key">;
|
|
@@ -94,6 +118,10 @@ export declare const snapshotEntrySchema: z.ZodObject<{
|
|
|
94
118
|
projectId: z.ZodOptional<z.ZodString>;
|
|
95
119
|
email: z.ZodOptional<z.ZodString>;
|
|
96
120
|
accountId: z.ZodOptional<z.ZodString>;
|
|
121
|
+
mcpBinding: z.ZodOptional<z.ZodObject<{
|
|
122
|
+
resourceOrigin: z.ZodString;
|
|
123
|
+
tokenEndpoint: z.ZodString;
|
|
124
|
+
}, z.core.$strict>>;
|
|
97
125
|
refresh: z.ZodLiteral<"__remote__">;
|
|
98
126
|
}, z.core.$strict>, z.ZodObject<{
|
|
99
127
|
type: z.ZodLiteral<"api_key">;
|
|
@@ -129,6 +157,10 @@ export declare const snapshotResponseSchema: z.ZodObject<{
|
|
|
129
157
|
projectId: z.ZodOptional<z.ZodString>;
|
|
130
158
|
email: z.ZodOptional<z.ZodString>;
|
|
131
159
|
accountId: z.ZodOptional<z.ZodString>;
|
|
160
|
+
mcpBinding: z.ZodOptional<z.ZodObject<{
|
|
161
|
+
resourceOrigin: z.ZodString;
|
|
162
|
+
tokenEndpoint: z.ZodString;
|
|
163
|
+
}, z.core.$strict>>;
|
|
132
164
|
refresh: z.ZodLiteral<"__remote__">;
|
|
133
165
|
}, z.core.$strict>, z.ZodObject<{
|
|
134
166
|
type: z.ZodLiteral<"api_key">;
|
|
@@ -160,6 +192,10 @@ export declare const snapshotStreamSnapshotEventSchema: z.ZodObject<{
|
|
|
160
192
|
projectId: z.ZodOptional<z.ZodString>;
|
|
161
193
|
email: z.ZodOptional<z.ZodString>;
|
|
162
194
|
accountId: z.ZodOptional<z.ZodString>;
|
|
195
|
+
mcpBinding: z.ZodOptional<z.ZodObject<{
|
|
196
|
+
resourceOrigin: z.ZodString;
|
|
197
|
+
tokenEndpoint: z.ZodString;
|
|
198
|
+
}, z.core.$strict>>;
|
|
163
199
|
refresh: z.ZodLiteral<"__remote__">;
|
|
164
200
|
}, z.core.$strict>, z.ZodObject<{
|
|
165
201
|
type: z.ZodLiteral<"api_key">;
|
|
@@ -192,6 +228,10 @@ export declare const snapshotStreamEntryEventSchema: z.ZodObject<{
|
|
|
192
228
|
projectId: z.ZodOptional<z.ZodString>;
|
|
193
229
|
email: z.ZodOptional<z.ZodString>;
|
|
194
230
|
accountId: z.ZodOptional<z.ZodString>;
|
|
231
|
+
mcpBinding: z.ZodOptional<z.ZodObject<{
|
|
232
|
+
resourceOrigin: z.ZodString;
|
|
233
|
+
tokenEndpoint: z.ZodString;
|
|
234
|
+
}, z.core.$strict>>;
|
|
195
235
|
refresh: z.ZodLiteral<"__remote__">;
|
|
196
236
|
}, z.core.$strict>, z.ZodObject<{
|
|
197
237
|
type: z.ZodLiteral<"api_key">;
|
|
@@ -236,6 +276,10 @@ export declare const snapshotStreamEventSchema: z.ZodDiscriminatedUnion<[z.ZodOb
|
|
|
236
276
|
projectId: z.ZodOptional<z.ZodString>;
|
|
237
277
|
email: z.ZodOptional<z.ZodString>;
|
|
238
278
|
accountId: z.ZodOptional<z.ZodString>;
|
|
279
|
+
mcpBinding: z.ZodOptional<z.ZodObject<{
|
|
280
|
+
resourceOrigin: z.ZodString;
|
|
281
|
+
tokenEndpoint: z.ZodString;
|
|
282
|
+
}, z.core.$strict>>;
|
|
239
283
|
refresh: z.ZodLiteral<"__remote__">;
|
|
240
284
|
}, z.core.$strict>, z.ZodObject<{
|
|
241
285
|
type: z.ZodLiteral<"api_key">;
|
|
@@ -266,6 +310,10 @@ export declare const snapshotStreamEventSchema: z.ZodDiscriminatedUnion<[z.ZodOb
|
|
|
266
310
|
projectId: z.ZodOptional<z.ZodString>;
|
|
267
311
|
email: z.ZodOptional<z.ZodString>;
|
|
268
312
|
accountId: z.ZodOptional<z.ZodString>;
|
|
313
|
+
mcpBinding: z.ZodOptional<z.ZodObject<{
|
|
314
|
+
resourceOrigin: z.ZodString;
|
|
315
|
+
tokenEndpoint: z.ZodString;
|
|
316
|
+
}, z.core.$strict>>;
|
|
269
317
|
refresh: z.ZodLiteral<"__remote__">;
|
|
270
318
|
}, z.core.$strict>, z.ZodObject<{
|
|
271
319
|
type: z.ZodLiteral<"api_key">;
|
|
@@ -348,6 +396,10 @@ export declare const usageResponseSchema: z.ZodObject<{
|
|
|
348
396
|
raw: z.ZodOptional<z.ZodUnknown>;
|
|
349
397
|
}, z.core.$strip>>;
|
|
350
398
|
}, z.core.$strict>;
|
|
399
|
+
export declare const credentialRefreshRequestSchema: z.ZodObject<{
|
|
400
|
+
clientId: z.ZodOptional<z.ZodString>;
|
|
401
|
+
clientSecret: z.ZodOptional<z.ZodString>;
|
|
402
|
+
}, z.core.$strict>;
|
|
351
403
|
export declare const credentialRefreshResponseSchema: z.ZodObject<{
|
|
352
404
|
entry: z.ZodObject<{
|
|
353
405
|
id: z.ZodNumber;
|
|
@@ -360,6 +412,10 @@ export declare const credentialRefreshResponseSchema: z.ZodObject<{
|
|
|
360
412
|
projectId: z.ZodOptional<z.ZodString>;
|
|
361
413
|
email: z.ZodOptional<z.ZodString>;
|
|
362
414
|
accountId: z.ZodOptional<z.ZodString>;
|
|
415
|
+
mcpBinding: z.ZodOptional<z.ZodObject<{
|
|
416
|
+
resourceOrigin: z.ZodString;
|
|
417
|
+
tokenEndpoint: z.ZodString;
|
|
418
|
+
}, z.core.$strict>>;
|
|
363
419
|
refresh: z.ZodLiteral<"__remote__">;
|
|
364
420
|
}, z.core.$strict>, z.ZodObject<{
|
|
365
421
|
type: z.ZodLiteral<"api_key">;
|
|
@@ -385,6 +441,10 @@ export declare const credentialUploadRequestSchema: z.ZodObject<{
|
|
|
385
441
|
projectId: z.ZodOptional<z.ZodString>;
|
|
386
442
|
email: z.ZodOptional<z.ZodString>;
|
|
387
443
|
accountId: z.ZodOptional<z.ZodString>;
|
|
444
|
+
mcpBinding: z.ZodOptional<z.ZodObject<{
|
|
445
|
+
resourceOrigin: z.ZodString;
|
|
446
|
+
tokenEndpoint: z.ZodString;
|
|
447
|
+
}, z.core.$strict>>;
|
|
388
448
|
}, z.core.$strict>, z.ZodObject<{
|
|
389
449
|
type: z.ZodLiteral<"api_key">;
|
|
390
450
|
key: z.ZodString;
|
|
@@ -402,6 +462,10 @@ export declare const credentialUploadResponseSchema: z.ZodObject<{
|
|
|
402
462
|
projectId: z.ZodOptional<z.ZodString>;
|
|
403
463
|
email: z.ZodOptional<z.ZodString>;
|
|
404
464
|
accountId: z.ZodOptional<z.ZodString>;
|
|
465
|
+
mcpBinding: z.ZodOptional<z.ZodObject<{
|
|
466
|
+
resourceOrigin: z.ZodString;
|
|
467
|
+
tokenEndpoint: z.ZodString;
|
|
468
|
+
}, z.core.$strict>>;
|
|
405
469
|
refresh: z.ZodLiteral<"__remote__">;
|
|
406
470
|
}, z.core.$strict>, z.ZodObject<{
|
|
407
471
|
type: z.ZodLiteral<"api_key">;
|
|
@@ -433,6 +497,10 @@ export declare const credentialIfAbsentUploadResponseSchema: z.ZodObject<{
|
|
|
433
497
|
projectId: z.ZodOptional<z.ZodString>;
|
|
434
498
|
email: z.ZodOptional<z.ZodString>;
|
|
435
499
|
accountId: z.ZodOptional<z.ZodString>;
|
|
500
|
+
mcpBinding: z.ZodOptional<z.ZodObject<{
|
|
501
|
+
resourceOrigin: z.ZodString;
|
|
502
|
+
tokenEndpoint: z.ZodString;
|
|
503
|
+
}, z.core.$strict>>;
|
|
436
504
|
refresh: z.ZodLiteral<"__remote__">;
|
|
437
505
|
}, z.core.$strict>, z.ZodObject<{
|
|
438
506
|
type: z.ZodLiteral<"api_key">;
|
|
@@ -15,10 +15,26 @@ export type ApiKeyCredential = {
|
|
|
15
15
|
type: "api_key";
|
|
16
16
|
key: string;
|
|
17
17
|
};
|
|
18
|
+
export interface MCPOAuthBinding {
|
|
19
|
+
/** Exact HTTP(S) origin of the MCP resource endpoint. */
|
|
20
|
+
resourceOrigin: string;
|
|
21
|
+
/** Exact canonical HTTP(S) token endpoint used to create and refresh the credential. */
|
|
22
|
+
tokenEndpoint: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function resolveMCPOAuthResourceOrigin(value: string): string | undefined;
|
|
25
|
+
export declare function resolveMCPOAuthTokenEndpoint(value: string): string | undefined;
|
|
26
|
+
export declare function isCanonicalMCPOAuthBinding(binding: MCPOAuthBinding): boolean;
|
|
27
|
+
export declare function assertCanonicalMCPOAuthBinding(binding: MCPOAuthBinding | undefined): asserts binding is MCPOAuthBinding;
|
|
18
28
|
export type OAuthCredential = {
|
|
19
29
|
type: "oauth";
|
|
30
|
+
/** Present only for credentials created by runtime MCP OAuth. */
|
|
31
|
+
mcpBinding?: MCPOAuthBinding;
|
|
20
32
|
} & OAuthCredentials;
|
|
21
33
|
export type AuthCredential = ApiKeyCredential | OAuthCredential;
|
|
34
|
+
export interface MCPOAuthRefreshClient {
|
|
35
|
+
clientId?: string;
|
|
36
|
+
clientSecret?: string;
|
|
37
|
+
}
|
|
22
38
|
export type AuthCredentialEntry = AuthCredential | AuthCredential[];
|
|
23
39
|
export type AuthStorageData = Record<string, AuthCredentialEntry>;
|
|
24
40
|
/**
|
|
@@ -161,6 +177,8 @@ export interface AuthCredentialStore {
|
|
|
161
177
|
* for `timeoutMs * (maxRetries + 1)`.
|
|
162
178
|
*/
|
|
163
179
|
refreshOAuthCredential?(provider: Provider, credentialId: number, credential: OAuthCredential, signal?: AbortSignal): Promise<OAuthCredentials>;
|
|
180
|
+
/** Broker-backed MCP refresh using the broker's stored token endpoint and refresh secret. */
|
|
181
|
+
refreshMCPOAuthCredential?(credentialId: number, credential: OAuthCredential, client: MCPOAuthRefreshClient, signal?: AbortSignal): Promise<OAuthCredential>;
|
|
164
182
|
/**
|
|
165
183
|
* Optional async pre-read hook invoked after AuthStorage selects a stored
|
|
166
184
|
* credential but before it returns that credential for an outbound request.
|
|
@@ -597,7 +615,7 @@ export declare class AuthStorage {
|
|
|
597
615
|
* refresh attempt, which is required for providers that rotate refresh tokens
|
|
598
616
|
* on every successful refresh.
|
|
599
617
|
*/
|
|
600
|
-
refreshCredentialById(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry>;
|
|
618
|
+
refreshCredentialById(id: number, signal?: AbortSignal, mcpClient?: MCPOAuthRefreshClient): Promise<AuthCredentialSnapshotEntry>;
|
|
601
619
|
/**
|
|
602
620
|
* Force-refresh the OAuth credential with the given id, bypassing the
|
|
603
621
|
* not-yet-expired guard. Used by the auth-broker server to honour
|
|
@@ -607,6 +625,8 @@ export declare class AuthStorage {
|
|
|
607
625
|
* Throws when no OAuth credential with that id is loaded.
|
|
608
626
|
*/
|
|
609
627
|
forceRefreshCredentialById(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry>;
|
|
628
|
+
/** Force-refresh the first OAuth credential stored for a provider. */
|
|
629
|
+
forceRefreshOAuthCredential(provider: string, expected: OAuthCredential, client?: MCPOAuthRefreshClient, signal?: AbortSignal): Promise<OAuthCredential>;
|
|
610
630
|
/**
|
|
611
631
|
* Disable the credential with the given id and emit a
|
|
612
632
|
* {@link CredentialDisabledEvent}. Used by the auth-broker server to honour
|
|
@@ -42,6 +42,14 @@ type AnthropicCacheControl = {
|
|
|
42
42
|
export declare function clearAnthropicFastModeFallback(providerSessionState: Map<string, ProviderSessionState> | undefined): void;
|
|
43
43
|
export declare function isAnthropicFastModeUnsupportedError(error: unknown): boolean;
|
|
44
44
|
export declare function isAnthropicThinkingBlockMutationError(error: unknown): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* 400 shape where a replayed `thinking`/`redacted_thinking` block fails signature
|
|
47
|
+
* validation, e.g. `messages.5.content.24: Invalid \`signature\` in \`thinking\` block`.
|
|
48
|
+
* Unlike the latest-assistant mutation error above, the cited block can sit anywhere
|
|
49
|
+
* in the replayed history, so recovery must repair every assistant message rather
|
|
50
|
+
* than only the latest one.
|
|
51
|
+
*/
|
|
52
|
+
export declare function isAnthropicThinkingSignatureInvalidError(error: unknown): boolean;
|
|
45
53
|
export declare const claudeCodeVersion = "2.1.63";
|
|
46
54
|
export declare const claudeToolPrefix: string;
|
|
47
55
|
export declare const claudeCodeSystemInstruction = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
|
|
@@ -184,6 +192,7 @@ export declare function buildAnthropicClientOptions(args: AnthropicClientOptions
|
|
|
184
192
|
export declare function normalizeCacheControlTtlOrdering(params: MessageCreateParamsStreaming): void;
|
|
185
193
|
export declare function convertAnthropicMessages(messages: Message[], model: Model<"anthropic-messages">, isOAuthToken: boolean, options?: {
|
|
186
194
|
repairLatestAssistantThinking?: boolean;
|
|
195
|
+
repairAllAssistantThinking?: boolean;
|
|
187
196
|
}): MessageParam[];
|
|
188
197
|
/**
|
|
189
198
|
* Normalize a JSON Schema node for Anthropic tool `input_schema`.
|
|
@@ -11,4 +11,5 @@ import type { Api, AssistantMessage, Message, Model } from "../types";
|
|
|
11
11
|
*/
|
|
12
12
|
export declare function transformMessages<TApi extends Api>(messages: Message[], model: Model<TApi>, normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string, options?: {
|
|
13
13
|
repairLatestAssistantThinking?: boolean;
|
|
14
|
+
repairAllAssistantThinking?: boolean;
|
|
14
15
|
}): Message[];
|
package/dist/types/types.d.ts
CHANGED
|
@@ -519,6 +519,7 @@ export type TSchema = ZodType | TJsonSchema;
|
|
|
519
519
|
export type Static<S> = S extends ZodType ? z.infer<S> : S extends {
|
|
520
520
|
static: infer T;
|
|
521
521
|
} ? T : unknown;
|
|
522
|
+
export type RawArgumentRejectionCode = "ask-intent-review-requires-positive-round" | "ask-intent-contract-requires-non-empty-authority" | "ask-deep-interview-metadata-requires-deep-interview-gate";
|
|
522
523
|
export type RawArgumentValidationResult = {
|
|
523
524
|
outcome: "passthrough";
|
|
524
525
|
} | {
|
|
@@ -526,6 +527,7 @@ export type RawArgumentValidationResult = {
|
|
|
526
527
|
arguments: ToolCall["arguments"];
|
|
527
528
|
} | {
|
|
528
529
|
outcome: "reject";
|
|
530
|
+
code?: RawArgumentRejectionCode;
|
|
529
531
|
};
|
|
530
532
|
export interface Tool<TParameters extends TSchema = TSchema> {
|
|
531
533
|
name: string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/ai",
|
|
4
|
-
"version": "0.11.
|
|
4
|
+
"version": "0.11.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.11.
|
|
43
|
+
"@gajae-code/utils": "0.11.8",
|
|
44
44
|
"openai": "^6.36.0",
|
|
45
45
|
"partial-json": "^0.1.7",
|
|
46
46
|
"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
|
|
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -411,6 +411,23 @@ export function isAnthropicThinkingBlockMutationError(error: unknown): boolean {
|
|
|
411
411
|
);
|
|
412
412
|
}
|
|
413
413
|
|
|
414
|
+
/**
|
|
415
|
+
* 400 shape where a replayed `thinking`/`redacted_thinking` block fails signature
|
|
416
|
+
* validation, e.g. `messages.5.content.24: Invalid \`signature\` in \`thinking\` block`.
|
|
417
|
+
* Unlike the latest-assistant mutation error above, the cited block can sit anywhere
|
|
418
|
+
* in the replayed history, so recovery must repair every assistant message rather
|
|
419
|
+
* than only the latest one.
|
|
420
|
+
*/
|
|
421
|
+
export function isAnthropicThinkingSignatureInvalidError(error: unknown): boolean {
|
|
422
|
+
if (extractHttpStatusFromError(error) !== 400) return false;
|
|
423
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
424
|
+
return (
|
|
425
|
+
/invalid_request_error/i.test(message) &&
|
|
426
|
+
/thinking|redacted_thinking/i.test(message) &&
|
|
427
|
+
/invalid\s+`?signature`?/i.test(message)
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
414
431
|
function hasStrictAnthropicTools(params: MessageCreateParamsStreaming): boolean {
|
|
415
432
|
const tools = params.tools as Array<{ strict?: unknown }> | undefined;
|
|
416
433
|
return tools?.some(tool => tool.strict === true) ?? false;
|
|
@@ -1316,20 +1333,19 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
|
|
1316
1333
|
let strictFallbackErrorMessage: string | undefined;
|
|
1317
1334
|
let dropFastMode = providerSessionState?.fastModeDisabled ?? false;
|
|
1318
1335
|
let droppedForcedToolChoice = false;
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
)
|
|
1332
|
-
if (paramsOptions?.dropForcedToolChoice === true) {
|
|
1336
|
+
let repairLatestAssistantThinking = false;
|
|
1337
|
+
let repairAllAssistantThinking = false;
|
|
1338
|
+
const prepareParams = async (): Promise<MessageCreateParamsStreaming> => {
|
|
1339
|
+
// Degradation state is cumulative: every fallback rebuild must merge all
|
|
1340
|
+
// repairs activated so far. Rebuilding from only the immediate call lets
|
|
1341
|
+
// a later strict/forced-tool/fast-mode fallback reintroduce the rejected
|
|
1342
|
+
// shape (e.g. invalid thinking signatures or forced tool_choice), and
|
|
1343
|
+
// the one-shot thinking-repair guard then blocks recovery.
|
|
1344
|
+
let nextParams = buildParams(model, baseUrl, context, isOAuthToken, options, disableStrictTools, {
|
|
1345
|
+
repairLatestAssistantThinking,
|
|
1346
|
+
repairAllAssistantThinking,
|
|
1347
|
+
});
|
|
1348
|
+
if (droppedForcedToolChoice) {
|
|
1333
1349
|
delete nextParams.tool_choice;
|
|
1334
1350
|
}
|
|
1335
1351
|
if (disableStrictTools) {
|
|
@@ -1763,23 +1779,30 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
|
|
1763
1779
|
registryKey: resolveToolChoice(model, options?.toolChoice).registryKey,
|
|
1764
1780
|
});
|
|
1765
1781
|
droppedForcedToolChoice = true;
|
|
1766
|
-
params = await prepareParams(
|
|
1782
|
+
params = await prepareParams();
|
|
1767
1783
|
providerRetryAttempt = 0;
|
|
1768
1784
|
resetOutputForRetry();
|
|
1769
1785
|
continue;
|
|
1770
1786
|
}
|
|
1787
|
+
const thinkingSignatureInvalid = isAnthropicThinkingSignatureInvalidError(streamFailure);
|
|
1771
1788
|
if (
|
|
1772
1789
|
!options?.fallbackManaged &&
|
|
1773
1790
|
!thinkingRepairAttempted &&
|
|
1774
1791
|
firstTokenTime === undefined &&
|
|
1775
|
-
isAnthropicThinkingBlockMutationError(streamFailure)
|
|
1792
|
+
(thinkingSignatureInvalid || isAnthropicThinkingBlockMutationError(streamFailure))
|
|
1776
1793
|
) {
|
|
1777
|
-
logger.debug("anthropic: repairing
|
|
1794
|
+
logger.debug("anthropic: repairing assistant thinking replay after provider rejection", {
|
|
1778
1795
|
model: model.id,
|
|
1796
|
+
scope: thinkingSignatureInvalid ? "all" : "latest",
|
|
1779
1797
|
error: streamFailure instanceof Error ? streamFailure.message : String(streamFailure),
|
|
1780
1798
|
});
|
|
1781
1799
|
thinkingRepairAttempted = true;
|
|
1782
|
-
|
|
1800
|
+
if (thinkingSignatureInvalid) {
|
|
1801
|
+
repairAllAssistantThinking = true;
|
|
1802
|
+
} else {
|
|
1803
|
+
repairLatestAssistantThinking = true;
|
|
1804
|
+
}
|
|
1805
|
+
params = await prepareParams();
|
|
1783
1806
|
providerRetryAttempt = 0;
|
|
1784
1807
|
resetOutputForRetry();
|
|
1785
1808
|
continue;
|
|
@@ -2243,13 +2266,13 @@ function buildParams(
|
|
|
2243
2266
|
isOAuthToken: boolean,
|
|
2244
2267
|
options?: AnthropicOptions,
|
|
2245
2268
|
disableStrictTools = false,
|
|
2246
|
-
repairLatestAssistantThinking
|
|
2269
|
+
thinkingRepair?: { repairLatestAssistantThinking?: boolean; repairAllAssistantThinking?: boolean },
|
|
2247
2270
|
): MessageCreateParamsStreaming {
|
|
2248
2271
|
const { mode: cacheMode, cacheControl } = getCacheControl(model, baseUrl, options?.cacheRetention);
|
|
2249
2272
|
|
|
2250
2273
|
const params: AnthropicSamplingParams = {
|
|
2251
2274
|
model: model.id,
|
|
2252
|
-
messages: convertAnthropicMessages(context.messages, model, isOAuthToken,
|
|
2275
|
+
messages: convertAnthropicMessages(context.messages, model, isOAuthToken, thinkingRepair),
|
|
2253
2276
|
max_tokens: options?.maxTokens || (model.maxTokens / 3) | 0,
|
|
2254
2277
|
stream: true,
|
|
2255
2278
|
};
|
|
@@ -2440,7 +2463,7 @@ export function convertAnthropicMessages(
|
|
|
2440
2463
|
messages: Message[],
|
|
2441
2464
|
model: Model<"anthropic-messages">,
|
|
2442
2465
|
isOAuthToken: boolean,
|
|
2443
|
-
options?: { repairLatestAssistantThinking?: boolean },
|
|
2466
|
+
options?: { repairLatestAssistantThinking?: boolean; repairAllAssistantThinking?: boolean },
|
|
2444
2467
|
): MessageParam[] {
|
|
2445
2468
|
const params: MessageParam[] = [];
|
|
2446
2469
|
|
|
@@ -426,6 +426,7 @@ function getTrailingPartialDeepseekToken(text: string): string {
|
|
|
426
426
|
return tail;
|
|
427
427
|
}
|
|
428
428
|
|
|
429
|
+
const ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS = 300_000;
|
|
429
430
|
const OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE =
|
|
430
431
|
"OpenAI completions stream timed out while waiting for the first event";
|
|
431
432
|
|
|
@@ -562,8 +563,10 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
562
563
|
openaiStream = await createCompletionsStream("none");
|
|
563
564
|
}
|
|
564
565
|
}
|
|
566
|
+
const firstEventFallbackMs =
|
|
567
|
+
model.provider === "alibaba-token-plan" ? ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS : undefined;
|
|
565
568
|
const firstEventWatchdog = createWatchdog(
|
|
566
|
-
options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs),
|
|
569
|
+
options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs),
|
|
567
570
|
() => abortTracker.abortLocally(firstEventTimeoutAbortError),
|
|
568
571
|
);
|
|
569
572
|
if (premiumRequestsTotal !== undefined) {
|
|
@@ -130,6 +130,7 @@ export interface OpenAIResponsesOptions extends StreamOptions {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
const OPENAI_RESPONSES_PROVIDER_SESSION_STATE_PREFIX = "openai-responses:";
|
|
133
|
+
const ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS = 300_000;
|
|
133
134
|
const OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE =
|
|
134
135
|
"OpenAI responses stream timed out while waiting for the first event";
|
|
135
136
|
const OPENAI_DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
|
@@ -330,8 +331,10 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
|
|
330
331
|
await notifyProviderResponse(options, response, model, request_id);
|
|
331
332
|
return data;
|
|
332
333
|
});
|
|
334
|
+
const firstEventFallbackMs =
|
|
335
|
+
model.provider === "alibaba-token-plan" ? ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS : undefined;
|
|
333
336
|
const firstEventWatchdog = createWatchdog(
|
|
334
|
-
options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs),
|
|
337
|
+
options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs),
|
|
335
338
|
() => abortTracker.abortLocally(firstEventTimeoutAbortError),
|
|
336
339
|
);
|
|
337
340
|
if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
|
|
@@ -31,7 +31,7 @@ export function transformMessages<TApi extends Api>(
|
|
|
31
31
|
messages: Message[],
|
|
32
32
|
model: Model<TApi>,
|
|
33
33
|
normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string,
|
|
34
|
-
options?: { repairLatestAssistantThinking?: boolean },
|
|
34
|
+
options?: { repairLatestAssistantThinking?: boolean; repairAllAssistantThinking?: boolean },
|
|
35
35
|
): Message[] {
|
|
36
36
|
// Build a map of original tool call IDs to normalized IDs
|
|
37
37
|
const toolCallIdMap = new Map<string, string>();
|
|
@@ -73,16 +73,29 @@ export function transformMessages<TApi extends Api>(
|
|
|
73
73
|
// are kept so the second pass can either preserve real results or synthesize
|
|
74
74
|
// an explicit aborted result without leaving dangling tool_use blocks.
|
|
75
75
|
const hasPartialThinking = assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error";
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
// One-shot Anthropic replay repair. `repairLatestAssistantThinking` targets the
|
|
77
|
+
// "latest assistant message ... cannot be modified" 400; `repairAllAssistantThinking`
|
|
78
|
+
// targets the "Invalid `signature` in `thinking` block" 400, which can cite a block
|
|
79
|
+
// anywhere in the replayed history (e.g. after compaction/pruning rewrote an earlier
|
|
80
|
+
// turn), so the drop must apply to every assistant message. Within each
|
|
81
|
+
// message only blocks that would replay as native thinking/redacted_thinking
|
|
82
|
+
// are dropped; cross-model reasoning degrades to text and is preserved.
|
|
83
|
+
const dropAssistantThinkingForRepair =
|
|
84
|
+
(options?.repairAllAssistantThinking === true ||
|
|
85
|
+
(options?.repairLatestAssistantThinking === true && index === latestAssistantIndex)) &&
|
|
79
86
|
model.api === "anthropic-messages" &&
|
|
80
87
|
assistantMsg.api === "anthropic-messages";
|
|
81
88
|
|
|
82
89
|
const transformedContent = assistantMsg.content.flatMap(block => {
|
|
83
90
|
if (block.type === "thinking") {
|
|
84
|
-
if (hasPartialThinking
|
|
91
|
+
if (hasPartialThinking) return [];
|
|
85
92
|
const sanitized = block;
|
|
93
|
+
// Repair must only drop blocks that would otherwise replay as native
|
|
94
|
+
// thinking. Cross-model/provider reasoning degrades to unsigned text
|
|
95
|
+
// below and was never replayed as a signed block, so it cannot be the
|
|
96
|
+
// signature failure — dropping it would silently lose valid context.
|
|
97
|
+
const replaysAsNativeThinking = mustPreserveLatestAnthropicThinking || isSameModel;
|
|
98
|
+
if (dropAssistantThinkingForRepair && replaysAsNativeThinking) return [];
|
|
86
99
|
if (mustPreserveLatestAnthropicThinking) return sanitized;
|
|
87
100
|
// For same model: keep thinking blocks with signatures (needed for replay)
|
|
88
101
|
// even if the thinking text is empty (OpenAI encrypted reasoning)
|
|
@@ -97,7 +110,13 @@ export function transformMessages<TApi extends Api>(
|
|
|
97
110
|
}
|
|
98
111
|
|
|
99
112
|
if (block.type === "redactedThinking") {
|
|
100
|
-
if (hasPartialThinking
|
|
113
|
+
if (hasPartialThinking) return [];
|
|
114
|
+
// Same restriction as thinking blocks: cross-model/provider redacted
|
|
115
|
+
// blocks already drop below, so repair only needs to cover blocks that
|
|
116
|
+
// would replay as native redacted_thinking.
|
|
117
|
+
if (dropAssistantThinkingForRepair && (mustPreserveLatestAnthropicThinking || isSameModel)) {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
101
120
|
if (mustPreserveLatestAnthropicThinking) return block;
|
|
102
121
|
if (isSameModel) return block;
|
|
103
122
|
return [];
|
package/src/types.ts
CHANGED
|
@@ -709,10 +709,15 @@ export type TSchema = ZodType | TJsonSchema;
|
|
|
709
709
|
/** Resolve parameter types for tool execution / handlers. */
|
|
710
710
|
export type Static<S> = S extends ZodType ? z.infer<S> : S extends { static: infer T } ? T : unknown;
|
|
711
711
|
|
|
712
|
+
export type RawArgumentRejectionCode =
|
|
713
|
+
| "ask-intent-review-requires-positive-round"
|
|
714
|
+
| "ask-intent-contract-requires-non-empty-authority"
|
|
715
|
+
| "ask-deep-interview-metadata-requires-deep-interview-gate";
|
|
716
|
+
|
|
712
717
|
export type RawArgumentValidationResult =
|
|
713
718
|
| { outcome: "passthrough" }
|
|
714
719
|
| { outcome: "accept"; arguments: ToolCall["arguments"] }
|
|
715
|
-
| { outcome: "reject" };
|
|
720
|
+
| { outcome: "reject"; code?: RawArgumentRejectionCode };
|
|
716
721
|
|
|
717
722
|
export interface Tool<TParameters extends TSchema = TSchema> {
|
|
718
723
|
name: string;
|
package/src/utils/validation.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
import { structuredCloneJSON } from "@gajae-code/utils";
|
|
26
26
|
import type { ZodType } from "zod/v4";
|
|
27
27
|
import type { $ZodIssue as ZodIssue } from "zod/v4/core";
|
|
28
|
-
import type { Tool, ToolCall } from "../types";
|
|
28
|
+
import type { RawArgumentRejectionCode, Tool, ToolCall } from "../types";
|
|
29
29
|
import { upgradeJsonSchemaTo202012 } from "./schema/draft";
|
|
30
30
|
import {
|
|
31
31
|
isJsonSchemaValueValid,
|
|
@@ -958,6 +958,15 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): ToolCall["a
|
|
|
958
958
|
return validateToolArguments(tool, toolCall);
|
|
959
959
|
}
|
|
960
960
|
|
|
961
|
+
const RAW_ARGUMENT_REJECTION_MESSAGES: Record<RawArgumentRejectionCode, string> = {
|
|
962
|
+
"ask-intent-review-requires-positive-round":
|
|
963
|
+
"deepInterview.intent_review is post-Round-0 only and requires a positive round",
|
|
964
|
+
"ask-intent-contract-requires-non-empty-authority":
|
|
965
|
+
"deepInterview.intent_contract requires non-empty items and confirmation_options",
|
|
966
|
+
"ask-deep-interview-metadata-requires-deep-interview-gate":
|
|
967
|
+
"deepInterview metadata cannot be combined with a non-deep-interview workflowGate",
|
|
968
|
+
};
|
|
969
|
+
|
|
961
970
|
/**
|
|
962
971
|
* Validates tool call arguments against the tool's schema (Zod or plain JSON
|
|
963
972
|
* Schema). Applies LLM-quirk coercions (numeric strings, JSON-string
|
|
@@ -969,7 +978,13 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
|
|
|
969
978
|
const originalArgs = toolCall.arguments;
|
|
970
979
|
const rawValidation = tool.rawArgumentValidation?.(originalArgs);
|
|
971
980
|
if (rawValidation?.outcome === "reject") {
|
|
972
|
-
|
|
981
|
+
const base = `Validation failed for tool "${toolCall.name}": raw arguments rejected before coercion`;
|
|
982
|
+
const code = rawValidation.code;
|
|
983
|
+
const correction =
|
|
984
|
+
typeof code === "string" && Object.hasOwn(RAW_ARGUMENT_REJECTION_MESSAGES, code)
|
|
985
|
+
? RAW_ARGUMENT_REJECTION_MESSAGES[code as RawArgumentRejectionCode]
|
|
986
|
+
: undefined;
|
|
987
|
+
throw new Error(correction ? `${base}; ${correction}` : base);
|
|
973
988
|
}
|
|
974
989
|
const rawArgs = rawValidation?.outcome === "accept" ? rawValidation.arguments : originalArgs;
|
|
975
990
|
const ctx = getValidationContext(tool);
|
package/src/utils.ts
CHANGED
|
@@ -271,17 +271,53 @@ function normalizeResponsesImageUrlForReplay(value: unknown): NormalizedResponse
|
|
|
271
271
|
return { imageUrl: stringifyResponsesStringParamForReplay(value) };
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/**
|
|
275
|
+
* OpenAI Responses `input_image.image_url` must be a fetchable HTTP(S) URL or an
|
|
276
|
+
* image data URI. Session resident-blob materialization may leave a human-readable
|
|
277
|
+
* placeholder like `[Session resident imageUrl blob missing: sha256:…; …]` in this
|
|
278
|
+
* field; replaying that string as `image_url` makes Codex reject the entire turn
|
|
279
|
+
* with `invalid_value` (#2924).
|
|
280
|
+
*/
|
|
281
|
+
function isProviderSafeResponsesImageUrl(value: string): boolean {
|
|
282
|
+
const url = value.trim();
|
|
283
|
+
if (url.length === 0) return false;
|
|
284
|
+
if (url.startsWith("https://") || url.startsWith("http://")) return true;
|
|
285
|
+
// Accept only image data URIs — other data: schemes are not valid image inputs.
|
|
286
|
+
if (url.startsWith("data:image/")) return true;
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function hasNonEmptyResponsesFileId(part: Record<string, unknown>): boolean {
|
|
291
|
+
return typeof part.file_id === "string" && part.file_id.trim().length > 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
274
294
|
function sanitizeResponsesMessageContentForReplay(content: unknown): unknown {
|
|
275
295
|
if (typeof content === "string") return neutralizeReservedControlTokens(content.toWellFormed());
|
|
276
296
|
if (!Array.isArray(content)) return content;
|
|
277
|
-
|
|
278
|
-
|
|
297
|
+
const sanitizedContent: unknown[] = [];
|
|
298
|
+
for (const part of content) {
|
|
299
|
+
if (!part || typeof part !== "object") {
|
|
300
|
+
sanitizedContent.push(part);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
279
303
|
const sanitizedPart = { ...(part as Record<string, unknown>) };
|
|
280
304
|
if ("text" in sanitizedPart) {
|
|
281
305
|
sanitizedPart.text = normalizeResponsesMessageTextForReplay(sanitizedPart.text);
|
|
282
306
|
}
|
|
283
307
|
if ("image_url" in sanitizedPart) {
|
|
284
308
|
const normalizedImageUrl = normalizeResponsesImageUrlForReplay(sanitizedPart.image_url);
|
|
309
|
+
if (!isProviderSafeResponsesImageUrl(normalizedImageUrl.imageUrl)) {
|
|
310
|
+
// Keep the part when a provider file_id can stand alone; otherwise drop
|
|
311
|
+
// only this image part so neighboring text/history still replays.
|
|
312
|
+
if (!hasNonEmptyResponsesFileId(sanitizedPart)) continue;
|
|
313
|
+
delete sanitizedPart.image_url;
|
|
314
|
+
if (sanitizedPart.type === "image_url") sanitizedPart.type = "input_image";
|
|
315
|
+
if ("detail" in sanitizedPart && !isResponsesImageDetail(sanitizedPart.detail)) {
|
|
316
|
+
delete sanitizedPart.detail;
|
|
317
|
+
}
|
|
318
|
+
sanitizedContent.push(sanitizedPart);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
285
321
|
sanitizedPart.image_url = normalizedImageUrl.imageUrl;
|
|
286
322
|
if (sanitizedPart.type === "image_url") {
|
|
287
323
|
sanitizedPart.type = "input_image";
|
|
@@ -292,8 +328,9 @@ function sanitizeResponsesMessageContentForReplay(content: unknown): unknown {
|
|
|
292
328
|
delete sanitizedPart.detail;
|
|
293
329
|
}
|
|
294
330
|
}
|
|
295
|
-
|
|
296
|
-
}
|
|
331
|
+
sanitizedContent.push(sanitizedPart);
|
|
332
|
+
}
|
|
333
|
+
return sanitizedContent;
|
|
297
334
|
}
|
|
298
335
|
|
|
299
336
|
function sanitizeResponsesStringFieldsForReplay(item: Record<string, unknown>): void {
|