@gajae-code/ai 0.11.7 → 0.11.9
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 +22 -0
- package/README.md +3 -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 +10 -0
- package/dist/types/providers/anthropic.d.ts +9 -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/utils/idle-iterator.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 +202 -27
- package/src/cli.ts +1 -0
- package/src/models.json +72 -0
- package/src/provider-models/descriptors.ts +7 -0
- package/src/provider-models/openai-compat.ts +20 -0
- package/src/providers/anthropic.ts +53 -23
- package/src/providers/openai-anthropic-shim.ts +4 -0
- package/src/providers/openai-completions.ts +8 -1
- package/src/providers/openai-responses.ts +4 -1
- package/src/providers/register-builtins.ts +21 -2
- package/src/providers/transform-messages.ts +25 -6
- package/src/stream.ts +1 -0
- package/src/types.ts +7 -1
- package/src/utils/idle-iterator.ts +5 -0
- package/src/utils/oauth/index.ts +6 -0
- package/src/utils/oauth/opengateway.ts +15 -0
- package/src/utils/oauth/types.ts +1 -0
- package/src/utils/validation.ts +17 -2
- package/src/utils.ts +41 -4
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
|
|
|
@@ -1888,6 +1998,12 @@ export class AuthStorage {
|
|
|
1888
1998
|
await saveApiKeyCredential(apiKey);
|
|
1889
1999
|
return;
|
|
1890
2000
|
}
|
|
2001
|
+
case "opengateway": {
|
|
2002
|
+
const { loginOpenGateway } = await import("./utils/oauth/opengateway");
|
|
2003
|
+
const apiKey = await loginOpenGateway(ctrl);
|
|
2004
|
+
await saveApiKeyCredential(apiKey);
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
1891
2007
|
default: {
|
|
1892
2008
|
const customProvider = getOAuthProvider(provider);
|
|
1893
2009
|
if (!customProvider) {
|
|
@@ -3009,6 +3125,8 @@ export class AuthStorage {
|
|
|
3009
3125
|
const overrideRefresh = this.#refreshOAuthCredentialOverride ?? storeRefresh;
|
|
3010
3126
|
if (overrideRefresh && credentialId !== undefined) {
|
|
3011
3127
|
refreshPromise = overrideRefresh(provider, credentialId, credential, signal);
|
|
3128
|
+
} else if (credential.mcpBinding) {
|
|
3129
|
+
refreshPromise = refreshBoundMCPOAuthCredential(credential, {}, signal);
|
|
3012
3130
|
} else {
|
|
3013
3131
|
const customProvider = getOAuthProvider(provider);
|
|
3014
3132
|
if (customProvider) {
|
|
@@ -3202,10 +3320,38 @@ export class AuthStorage {
|
|
|
3202
3320
|
return { apiKey: result.apiKey, credential: updated };
|
|
3203
3321
|
} catch (error) {
|
|
3204
3322
|
const errorMsg = String(error);
|
|
3323
|
+
// Peer-rotation recovery runs before ANY failure classification: a
|
|
3324
|
+
// concurrent process may have rotated the refresh token, which
|
|
3325
|
+
// invalidates the snapshot token we just attempted. Re-read the row —
|
|
3326
|
+
// if the persisted refresh token changed, the peer's rotation succeeded
|
|
3327
|
+
// and we pick up the fresh credential instead of disabling (definitive
|
|
3328
|
+
// path) or temp-blocking (transient path) a row that is actually
|
|
3329
|
+
// healthy. This matters for providers whose invalid-grant response does
|
|
3330
|
+
// not match the definitive regex below (e.g. Kimi's 400 "The provided
|
|
3331
|
+
// authorization grant is invalid"): with short-lived access tokens and
|
|
3332
|
+
// multiple gjc processes sharing the store, the stale-snapshot failure
|
|
3333
|
+
// would otherwise be misclassified as transient and the credential
|
|
3334
|
+
// temp-blocked on every rotation race.
|
|
3335
|
+
const attemptedCredentialId = this.#getStoredCredentials(provider)[selection.index]?.id;
|
|
3336
|
+
if (attemptedCredentialId !== undefined) {
|
|
3337
|
+
const latestRow = this.#store.listAuthCredentials(provider).find(row => row.id === attemptedCredentialId);
|
|
3338
|
+
const latestCredential = latestRow?.credential;
|
|
3339
|
+
if (latestCredential?.type === "oauth" && latestCredential.refresh !== selection.credential.refresh) {
|
|
3340
|
+
logger.debug("OAuth refresh race detected; another process rotated token first", {
|
|
3341
|
+
provider,
|
|
3342
|
+
index: selection.index,
|
|
3343
|
+
credentialId: attemptedCredentialId,
|
|
3344
|
+
});
|
|
3345
|
+
await this.reload();
|
|
3346
|
+
return this.#resolveOAuthSelection(provider, sessionId, options);
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3205
3349
|
// Only remove credentials for definitive auth failures
|
|
3206
3350
|
// Keep credentials for transient errors (network, 5xx) and block temporarily
|
|
3207
3351
|
const isDefinitiveFailure =
|
|
3208
|
-
/invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i.test(
|
|
3352
|
+
/invalid_grant|grant is invalid|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i.test(
|
|
3353
|
+
errorMsg,
|
|
3354
|
+
) ||
|
|
3209
3355
|
(/\b(401|403)\b/.test(errorMsg) && !/timeout|network|fetch failed|ECONNREFUSED/i.test(errorMsg));
|
|
3210
3356
|
|
|
3211
3357
|
logger.warn("OAuth token refresh failed", {
|
|
@@ -3216,27 +3362,6 @@ export class AuthStorage {
|
|
|
3216
3362
|
});
|
|
3217
3363
|
|
|
3218
3364
|
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
3365
|
// Permanently disable invalid credentials with an explicit cause for inspection/debugging.
|
|
3241
3366
|
// Use a CAS-style disable conditioned on the row still containing the stale credential
|
|
3242
3367
|
// we tried to refresh, so a peer rotation that lands between the pre-check above and
|
|
@@ -3518,14 +3643,18 @@ export class AuthStorage {
|
|
|
3518
3643
|
* refresh attempt, which is required for providers that rotate refresh tokens
|
|
3519
3644
|
* on every successful refresh.
|
|
3520
3645
|
*/
|
|
3521
|
-
async refreshCredentialById(
|
|
3646
|
+
async refreshCredentialById(
|
|
3647
|
+
id: number,
|
|
3648
|
+
signal?: AbortSignal,
|
|
3649
|
+
mcpClient: MCPOAuthRefreshClient = {},
|
|
3650
|
+
): Promise<AuthCredentialSnapshotEntry> {
|
|
3522
3651
|
const existing = this.#oauthRefreshInFlight.get(id);
|
|
3523
3652
|
if (existing) return raceCredentialRefreshWithSignal(existing, signal);
|
|
3524
3653
|
|
|
3525
3654
|
const promise = (async () => {
|
|
3526
3655
|
this.#bumpGeneration("credential-refresh-start");
|
|
3527
3656
|
try {
|
|
3528
|
-
return await this.#forceRefreshCredentialByIdUnshared(id, signal);
|
|
3657
|
+
return await this.#forceRefreshCredentialByIdUnshared(id, signal, mcpClient);
|
|
3529
3658
|
} catch (error) {
|
|
3530
3659
|
this.#bumpGeneration("credential-refresh-failure");
|
|
3531
3660
|
throw error;
|
|
@@ -3549,7 +3678,32 @@ export class AuthStorage {
|
|
|
3549
3678
|
return this.refreshCredentialById(id, signal);
|
|
3550
3679
|
}
|
|
3551
3680
|
|
|
3552
|
-
|
|
3681
|
+
/** Force-refresh the first OAuth credential stored for a provider. */
|
|
3682
|
+
async forceRefreshOAuthCredential(
|
|
3683
|
+
provider: string,
|
|
3684
|
+
expected: OAuthCredential,
|
|
3685
|
+
client: MCPOAuthRefreshClient = {},
|
|
3686
|
+
signal?: AbortSignal,
|
|
3687
|
+
): Promise<OAuthCredential> {
|
|
3688
|
+
const storageProvider = resolveOAuthStorageProvider(provider);
|
|
3689
|
+
const target = this.#getStoredCredentials(storageProvider).find(
|
|
3690
|
+
entry => entry.credential === expected || authCredentialEquals(entry.credential, expected),
|
|
3691
|
+
);
|
|
3692
|
+
if (target?.credential.type !== "oauth") {
|
|
3693
|
+
throw new Error(`No OAuth credential found for provider=${storageProvider}`);
|
|
3694
|
+
}
|
|
3695
|
+
const entry = await this.refreshCredentialById(target.id, signal, client);
|
|
3696
|
+
if (entry.credential.type !== "oauth") {
|
|
3697
|
+
throw new Error(`Credential ${target.id} is not OAuth`);
|
|
3698
|
+
}
|
|
3699
|
+
return entry.credential;
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3702
|
+
async #forceRefreshCredentialByIdUnshared(
|
|
3703
|
+
id: number,
|
|
3704
|
+
signal?: AbortSignal,
|
|
3705
|
+
mcpClient: MCPOAuthRefreshClient = {},
|
|
3706
|
+
): Promise<AuthCredentialSnapshotEntry> {
|
|
3553
3707
|
for (const [provider, entries] of this.#data) {
|
|
3554
3708
|
const index = entries.findIndex(entry => entry.id === id);
|
|
3555
3709
|
if (index === -1) continue;
|
|
@@ -3560,7 +3714,27 @@ export class AuthStorage {
|
|
|
3560
3714
|
// Pass a clone with expires=0 so the cached not-yet-expired short-circuit
|
|
3561
3715
|
// in #refreshOAuthCredential doesn't suppress the requested refresh.
|
|
3562
3716
|
const stale: OAuthCredential = { ...target.credential, expires: 0 };
|
|
3563
|
-
|
|
3717
|
+
let refreshed: OAuthCredentials;
|
|
3718
|
+
if (target.credential.mcpBinding) {
|
|
3719
|
+
assertCanonicalMCPOAuthBinding(target.credential.mcpBinding);
|
|
3720
|
+
const remoteRefresh = this.#store.refreshMCPOAuthCredential?.bind(this.#store);
|
|
3721
|
+
const refreshedCredential = remoteRefresh
|
|
3722
|
+
? await remoteRefresh(id, stale, mcpClient, signal)
|
|
3723
|
+
: {
|
|
3724
|
+
type: "oauth" as const,
|
|
3725
|
+
...(await refreshBoundMCPOAuthCredential(stale, mcpClient, signal)),
|
|
3726
|
+
mcpBinding: target.credential.mcpBinding,
|
|
3727
|
+
};
|
|
3728
|
+
if (
|
|
3729
|
+
refreshedCredential.mcpBinding?.resourceOrigin !== target.credential.mcpBinding.resourceOrigin ||
|
|
3730
|
+
refreshedCredential.mcpBinding.tokenEndpoint !== target.credential.mcpBinding.tokenEndpoint
|
|
3731
|
+
) {
|
|
3732
|
+
throw new Error("Refreshed MCP OAuth credential binding mismatch");
|
|
3733
|
+
}
|
|
3734
|
+
refreshed = refreshedCredential;
|
|
3735
|
+
} else {
|
|
3736
|
+
refreshed = await this.#refreshOAuthCredential(provider as Provider, stale, id, signal);
|
|
3737
|
+
}
|
|
3564
3738
|
const updated: OAuthCredential = {
|
|
3565
3739
|
type: "oauth",
|
|
3566
3740
|
access: refreshed.access,
|
|
@@ -3570,6 +3744,7 @@ export class AuthStorage {
|
|
|
3570
3744
|
email: refreshed.email ?? target.credential.email,
|
|
3571
3745
|
projectId: refreshed.projectId ?? target.credential.projectId,
|
|
3572
3746
|
enterpriseUrl: refreshed.enterpriseUrl ?? target.credential.enterpriseUrl,
|
|
3747
|
+
mcpBinding: target.credential.mcpBinding,
|
|
3573
3748
|
};
|
|
3574
3749
|
this.#replaceCredentialAt(provider, index, updated);
|
|
3575
3750
|
return {
|
package/src/cli.ts
CHANGED
package/src/models.json
CHANGED
|
@@ -61333,6 +61333,78 @@
|
|
|
61333
61333
|
"maxTokens": 131072
|
|
61334
61334
|
}
|
|
61335
61335
|
},
|
|
61336
|
+
"opengateway": {
|
|
61337
|
+
"openai/gpt-4o": {
|
|
61338
|
+
"id": "openai/gpt-4o",
|
|
61339
|
+
"name": "GPT-4o (OpenGateway)",
|
|
61340
|
+
"api": "openai-completions",
|
|
61341
|
+
"provider": "opengateway",
|
|
61342
|
+
"baseUrl": "https://apis.opengateway.ai/v1",
|
|
61343
|
+
"reasoning": false,
|
|
61344
|
+
"input": [
|
|
61345
|
+
"text",
|
|
61346
|
+
"image"
|
|
61347
|
+
],
|
|
61348
|
+
"cost": {
|
|
61349
|
+
"input": 2.5,
|
|
61350
|
+
"output": 10,
|
|
61351
|
+
"cacheRead": 1.25,
|
|
61352
|
+
"cacheWrite": 0
|
|
61353
|
+
},
|
|
61354
|
+
"contextWindow": 128000,
|
|
61355
|
+
"maxTokens": 16384
|
|
61356
|
+
},
|
|
61357
|
+
"anthropic/claude-sonnet-4-5": {
|
|
61358
|
+
"id": "anthropic/claude-sonnet-4-5",
|
|
61359
|
+
"name": "Anthropic Sonnet 4.5 (OpenGateway)",
|
|
61360
|
+
"api": "openai-completions",
|
|
61361
|
+
"provider": "opengateway",
|
|
61362
|
+
"baseUrl": "https://apis.opengateway.ai/v1",
|
|
61363
|
+
"reasoning": true,
|
|
61364
|
+
"input": [
|
|
61365
|
+
"text",
|
|
61366
|
+
"image"
|
|
61367
|
+
],
|
|
61368
|
+
"cost": {
|
|
61369
|
+
"input": 3,
|
|
61370
|
+
"output": 15,
|
|
61371
|
+
"cacheRead": 0.3,
|
|
61372
|
+
"cacheWrite": 3.75
|
|
61373
|
+
},
|
|
61374
|
+
"contextWindow": 200000,
|
|
61375
|
+
"maxTokens": 64000,
|
|
61376
|
+
"thinking": {
|
|
61377
|
+
"mode": "effort",
|
|
61378
|
+
"minLevel": "minimal",
|
|
61379
|
+
"maxLevel": "high"
|
|
61380
|
+
}
|
|
61381
|
+
},
|
|
61382
|
+
"google/gemini-2.5-pro": {
|
|
61383
|
+
"id": "google/gemini-2.5-pro",
|
|
61384
|
+
"name": "Gemini 2.5 Pro (OpenGateway)",
|
|
61385
|
+
"api": "openai-completions",
|
|
61386
|
+
"provider": "opengateway",
|
|
61387
|
+
"baseUrl": "https://apis.opengateway.ai/v1",
|
|
61388
|
+
"reasoning": true,
|
|
61389
|
+
"input": [
|
|
61390
|
+
"text",
|
|
61391
|
+
"image"
|
|
61392
|
+
],
|
|
61393
|
+
"cost": {
|
|
61394
|
+
"input": 1.25,
|
|
61395
|
+
"output": 10,
|
|
61396
|
+
"cacheRead": 0.31,
|
|
61397
|
+
"cacheWrite": 0
|
|
61398
|
+
},
|
|
61399
|
+
"contextWindow": 1048576,
|
|
61400
|
+
"maxTokens": 65536,
|
|
61401
|
+
"thinking": {
|
|
61402
|
+
"mode": "effort",
|
|
61403
|
+
"minLevel": "minimal",
|
|
61404
|
+
"maxLevel": "high"
|
|
61405
|
+
}
|
|
61406
|
+
}
|
|
61407
|
+
},
|
|
61336
61408
|
"openrouter": {
|
|
61337
61409
|
"~anthropic/claude-fable-latest": {
|
|
61338
61410
|
"id": "~anthropic/claude-fable-latest",
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
openaiModelManagerOptions,
|
|
34
34
|
opencodeGoModelManagerOptions,
|
|
35
35
|
opencodeZenModelManagerOptions,
|
|
36
|
+
opengatewayModelManagerOptions,
|
|
36
37
|
openrouterModelManagerOptions,
|
|
37
38
|
qianfanModelManagerOptions,
|
|
38
39
|
qwenPortalModelManagerOptions,
|
|
@@ -312,6 +313,12 @@ export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = [
|
|
|
312
313
|
config => zenmuxModelManagerOptions(config),
|
|
313
314
|
catalog("ZenMux", ["ZENMUX_API_KEY"]),
|
|
314
315
|
),
|
|
316
|
+
catalogDescriptor(
|
|
317
|
+
"opengateway",
|
|
318
|
+
"openai/gpt-4o",
|
|
319
|
+
config => opengatewayModelManagerOptions(config),
|
|
320
|
+
catalog("OpenGateway by Sionic AI", ["OPENGATEWAY_API_KEY"]),
|
|
321
|
+
),
|
|
315
322
|
catalogDescriptor("zai", "glm-5.2", config => zaiModelManagerOptions(config), catalog("zAI", ["ZAI_API_KEY"])),
|
|
316
323
|
catalogDescriptor(
|
|
317
324
|
"glm-zcode",
|
|
@@ -1099,6 +1099,26 @@ export function zenmuxModelManagerOptions(config?: ZenMuxModelManagerConfig): Mo
|
|
|
1099
1099
|
};
|
|
1100
1100
|
}
|
|
1101
1101
|
|
|
1102
|
+
// ---------------------------------------------------------------------------
|
|
1103
|
+
// 10.5.1 OpenGateway by Sionic AI
|
|
1104
|
+
// ---------------------------------------------------------------------------
|
|
1105
|
+
|
|
1106
|
+
export interface OpenGatewayModelManagerConfig {
|
|
1107
|
+
apiKey?: string;
|
|
1108
|
+
baseUrl?: string;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* OpenGateway by Sionic AI — an OpenAI-compatible gateway that fronts OpenAI,
|
|
1113
|
+
* Anthropic, and Google models behind one API key. Models are discovered from
|
|
1114
|
+
* the OpenAI-compatible `/v1/models` endpoint.
|
|
1115
|
+
*/
|
|
1116
|
+
export function opengatewayModelManagerOptions(
|
|
1117
|
+
config?: OpenGatewayModelManagerConfig,
|
|
1118
|
+
): ModelManagerOptions<"openai-completions"> {
|
|
1119
|
+
return createSimpleOpenAICompletionsOptions("opengateway", "https://apis.opengateway.ai/v1", config);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1102
1122
|
// ---------------------------------------------------------------------------
|
|
1103
1123
|
// 10.6 Kilo Gateway
|
|
1104
1124
|
// ---------------------------------------------------------------------------
|
|
@@ -60,7 +60,12 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
|
60
60
|
import { transportFailureFacts } from "../utils/fallback-transport";
|
|
61
61
|
import { isFoundryEnabled } from "../utils/foundry";
|
|
62
62
|
import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
|
|
63
|
-
import {
|
|
63
|
+
import {
|
|
64
|
+
getProviderFirstEventTimeoutFallbackMs,
|
|
65
|
+
getStreamFirstEventTimeoutMs,
|
|
66
|
+
getStreamIdleTimeoutMs,
|
|
67
|
+
iterateWithIdleTimeout,
|
|
68
|
+
} from "../utils/idle-iterator";
|
|
64
69
|
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse";
|
|
65
70
|
import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
|
|
66
71
|
import { notifyProviderResponse } from "../utils/provider-response";
|
|
@@ -411,6 +416,23 @@ export function isAnthropicThinkingBlockMutationError(error: unknown): boolean {
|
|
|
411
416
|
);
|
|
412
417
|
}
|
|
413
418
|
|
|
419
|
+
/**
|
|
420
|
+
* 400 shape where a replayed `thinking`/`redacted_thinking` block fails signature
|
|
421
|
+
* validation, e.g. `messages.5.content.24: Invalid \`signature\` in \`thinking\` block`.
|
|
422
|
+
* Unlike the latest-assistant mutation error above, the cited block can sit anywhere
|
|
423
|
+
* in the replayed history, so recovery must repair every assistant message rather
|
|
424
|
+
* than only the latest one.
|
|
425
|
+
*/
|
|
426
|
+
export function isAnthropicThinkingSignatureInvalidError(error: unknown): boolean {
|
|
427
|
+
if (extractHttpStatusFromError(error) !== 400) return false;
|
|
428
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
429
|
+
return (
|
|
430
|
+
/invalid_request_error/i.test(message) &&
|
|
431
|
+
/thinking|redacted_thinking/i.test(message) &&
|
|
432
|
+
/invalid\s+`?signature`?/i.test(message)
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
414
436
|
function hasStrictAnthropicTools(params: MessageCreateParamsStreaming): boolean {
|
|
415
437
|
const tools = params.tools as Array<{ strict?: unknown }> | undefined;
|
|
416
438
|
return tools?.some(tool => tool.strict === true) ?? false;
|
|
@@ -1316,20 +1338,19 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
|
|
1316
1338
|
let strictFallbackErrorMessage: string | undefined;
|
|
1317
1339
|
let dropFastMode = providerSessionState?.fastModeDisabled ?? false;
|
|
1318
1340
|
let droppedForcedToolChoice = false;
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
)
|
|
1332
|
-
if (paramsOptions?.dropForcedToolChoice === true) {
|
|
1341
|
+
let repairLatestAssistantThinking = false;
|
|
1342
|
+
let repairAllAssistantThinking = false;
|
|
1343
|
+
const prepareParams = async (): Promise<MessageCreateParamsStreaming> => {
|
|
1344
|
+
// Degradation state is cumulative: every fallback rebuild must merge all
|
|
1345
|
+
// repairs activated so far. Rebuilding from only the immediate call lets
|
|
1346
|
+
// a later strict/forced-tool/fast-mode fallback reintroduce the rejected
|
|
1347
|
+
// shape (e.g. invalid thinking signatures or forced tool_choice), and
|
|
1348
|
+
// the one-shot thinking-repair guard then blocks recovery.
|
|
1349
|
+
let nextParams = buildParams(model, baseUrl, context, isOAuthToken, options, disableStrictTools, {
|
|
1350
|
+
repairLatestAssistantThinking,
|
|
1351
|
+
repairAllAssistantThinking,
|
|
1352
|
+
});
|
|
1353
|
+
if (droppedForcedToolChoice) {
|
|
1333
1354
|
delete nextParams.tool_choice;
|
|
1334
1355
|
}
|
|
1335
1356
|
if (disableStrictTools) {
|
|
@@ -1400,7 +1421,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
|
|
1400
1421
|
firstTokenTime = undefined;
|
|
1401
1422
|
};
|
|
1402
1423
|
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs();
|
|
1403
|
-
const
|
|
1424
|
+
const firstEventFallbackMs = getProviderFirstEventTimeoutFallbackMs(model.provider);
|
|
1425
|
+
const firstEventTimeoutMs =
|
|
1426
|
+
options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs);
|
|
1404
1427
|
stream.push({ type: "start", partial: output });
|
|
1405
1428
|
// Retry loop for transient errors from the stream.
|
|
1406
1429
|
// Provider-level transport/rate-limit failures: only before any streamed content starts.
|
|
@@ -1763,23 +1786,30 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
|
|
1763
1786
|
registryKey: resolveToolChoice(model, options?.toolChoice).registryKey,
|
|
1764
1787
|
});
|
|
1765
1788
|
droppedForcedToolChoice = true;
|
|
1766
|
-
params = await prepareParams(
|
|
1789
|
+
params = await prepareParams();
|
|
1767
1790
|
providerRetryAttempt = 0;
|
|
1768
1791
|
resetOutputForRetry();
|
|
1769
1792
|
continue;
|
|
1770
1793
|
}
|
|
1794
|
+
const thinkingSignatureInvalid = isAnthropicThinkingSignatureInvalidError(streamFailure);
|
|
1771
1795
|
if (
|
|
1772
1796
|
!options?.fallbackManaged &&
|
|
1773
1797
|
!thinkingRepairAttempted &&
|
|
1774
1798
|
firstTokenTime === undefined &&
|
|
1775
|
-
isAnthropicThinkingBlockMutationError(streamFailure)
|
|
1799
|
+
(thinkingSignatureInvalid || isAnthropicThinkingBlockMutationError(streamFailure))
|
|
1776
1800
|
) {
|
|
1777
|
-
logger.debug("anthropic: repairing
|
|
1801
|
+
logger.debug("anthropic: repairing assistant thinking replay after provider rejection", {
|
|
1778
1802
|
model: model.id,
|
|
1803
|
+
scope: thinkingSignatureInvalid ? "all" : "latest",
|
|
1779
1804
|
error: streamFailure instanceof Error ? streamFailure.message : String(streamFailure),
|
|
1780
1805
|
});
|
|
1781
1806
|
thinkingRepairAttempted = true;
|
|
1782
|
-
|
|
1807
|
+
if (thinkingSignatureInvalid) {
|
|
1808
|
+
repairAllAssistantThinking = true;
|
|
1809
|
+
} else {
|
|
1810
|
+
repairLatestAssistantThinking = true;
|
|
1811
|
+
}
|
|
1812
|
+
params = await prepareParams();
|
|
1783
1813
|
providerRetryAttempt = 0;
|
|
1784
1814
|
resetOutputForRetry();
|
|
1785
1815
|
continue;
|
|
@@ -2243,13 +2273,13 @@ function buildParams(
|
|
|
2243
2273
|
isOAuthToken: boolean,
|
|
2244
2274
|
options?: AnthropicOptions,
|
|
2245
2275
|
disableStrictTools = false,
|
|
2246
|
-
repairLatestAssistantThinking
|
|
2276
|
+
thinkingRepair?: { repairLatestAssistantThinking?: boolean; repairAllAssistantThinking?: boolean },
|
|
2247
2277
|
): MessageCreateParamsStreaming {
|
|
2248
2278
|
const { mode: cacheMode, cacheControl } = getCacheControl(model, baseUrl, options?.cacheRetention);
|
|
2249
2279
|
|
|
2250
2280
|
const params: AnthropicSamplingParams = {
|
|
2251
2281
|
model: model.id,
|
|
2252
|
-
messages: convertAnthropicMessages(context.messages, model, isOAuthToken,
|
|
2282
|
+
messages: convertAnthropicMessages(context.messages, model, isOAuthToken, thinkingRepair),
|
|
2253
2283
|
max_tokens: options?.maxTokens || (model.maxTokens / 3) | 0,
|
|
2254
2284
|
stream: true,
|
|
2255
2285
|
};
|
|
@@ -2440,7 +2470,7 @@ export function convertAnthropicMessages(
|
|
|
2440
2470
|
messages: Message[],
|
|
2441
2471
|
model: Model<"anthropic-messages">,
|
|
2442
2472
|
isOAuthToken: boolean,
|
|
2443
|
-
options?: { repairLatestAssistantThinking?: boolean },
|
|
2473
|
+
options?: { repairLatestAssistantThinking?: boolean; repairAllAssistantThinking?: boolean },
|
|
2444
2474
|
): MessageParam[] {
|
|
2445
2475
|
const params: MessageParam[] = [];
|
|
2446
2476
|
|
|
@@ -89,6 +89,8 @@ export function streamOpenAIAnthropicShim(
|
|
|
89
89
|
onResponse: options?.onResponse,
|
|
90
90
|
onSseEvent: options?.onSseEvent,
|
|
91
91
|
fetch: options?.fetch,
|
|
92
|
+
streamIdleTimeoutMs: options?.streamIdleTimeoutMs,
|
|
93
|
+
streamFirstEventTimeoutMs: options?.streamFirstEventTimeoutMs,
|
|
92
94
|
thinkingEnabled,
|
|
93
95
|
thinkingBudgetTokens: thinkingBudget,
|
|
94
96
|
});
|
|
@@ -118,6 +120,8 @@ export function streamOpenAIAnthropicShim(
|
|
|
118
120
|
onResponse: options?.onResponse,
|
|
119
121
|
onSseEvent: options?.onSseEvent,
|
|
120
122
|
fetch: options?.fetch,
|
|
123
|
+
streamIdleTimeoutMs: options?.streamIdleTimeoutMs,
|
|
124
|
+
streamFirstEventTimeoutMs: options?.streamFirstEventTimeoutMs,
|
|
121
125
|
reasoning: reasoningEffort,
|
|
122
126
|
});
|
|
123
127
|
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
import {
|
|
50
50
|
createWatchdog,
|
|
51
51
|
getOpenAIStreamIdleTimeoutMs,
|
|
52
|
+
getProviderFirstEventTimeoutFallbackMs,
|
|
52
53
|
getStreamFirstEventTimeoutMs,
|
|
53
54
|
iterateWithIdleTimeout,
|
|
54
55
|
} from "../utils/idle-iterator";
|
|
@@ -426,6 +427,8 @@ function getTrailingPartialDeepseekToken(text: string): string {
|
|
|
426
427
|
return tail;
|
|
427
428
|
}
|
|
428
429
|
|
|
430
|
+
const ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS = 300_000;
|
|
431
|
+
|
|
429
432
|
const OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE =
|
|
430
433
|
"OpenAI completions stream timed out while waiting for the first event";
|
|
431
434
|
|
|
@@ -562,8 +565,12 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
562
565
|
openaiStream = await createCompletionsStream("none");
|
|
563
566
|
}
|
|
564
567
|
}
|
|
568
|
+
const firstEventFallbackMs =
|
|
569
|
+
model.provider === "alibaba-token-plan"
|
|
570
|
+
? ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS
|
|
571
|
+
: getProviderFirstEventTimeoutFallbackMs(model.provider);
|
|
565
572
|
const firstEventWatchdog = createWatchdog(
|
|
566
|
-
options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs),
|
|
573
|
+
options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs),
|
|
567
574
|
() => abortTracker.abortLocally(firstEventTimeoutAbortError),
|
|
568
575
|
);
|
|
569
576
|
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;
|