@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +3 -0
  3. package/dist/types/auth-broker/client.d.ts +2 -1
  4. package/dist/types/auth-broker/remote-store.d.ts +2 -1
  5. package/dist/types/auth-broker/types.d.ts +3 -1
  6. package/dist/types/auth-broker/wire-schemas.d.ts +68 -0
  7. package/dist/types/auth-storage.d.ts +21 -1
  8. package/dist/types/provider-models/openai-compat.d.ts +10 -0
  9. package/dist/types/providers/anthropic.d.ts +9 -0
  10. package/dist/types/providers/register-builtins.d.ts +8 -0
  11. package/dist/types/providers/transform-messages.d.ts +1 -0
  12. package/dist/types/types.d.ts +3 -1
  13. package/dist/types/utils/idle-iterator.d.ts +1 -0
  14. package/dist/types/utils/oauth/opengateway.d.ts +1 -0
  15. package/dist/types/utils/oauth/types.d.ts +1 -1
  16. package/package.json +2 -2
  17. package/src/auth-broker/client.ts +13 -0
  18. package/src/auth-broker/refresher.ts +1 -0
  19. package/src/auth-broker/remote-store.ts +25 -0
  20. package/src/auth-broker/server.ts +10 -2
  21. package/src/auth-broker/types.ts +4 -0
  22. package/src/auth-broker/wire-schemas.ts +17 -1
  23. package/src/auth-storage.ts +202 -27
  24. package/src/cli.ts +1 -0
  25. package/src/models.json +72 -0
  26. package/src/provider-models/descriptors.ts +7 -0
  27. package/src/provider-models/openai-compat.ts +20 -0
  28. package/src/providers/anthropic.ts +53 -23
  29. package/src/providers/openai-anthropic-shim.ts +4 -0
  30. package/src/providers/openai-completions.ts +8 -1
  31. package/src/providers/openai-responses.ts +4 -1
  32. package/src/providers/register-builtins.ts +21 -2
  33. package/src/providers/transform-messages.ts +25 -6
  34. package/src/stream.ts +1 -0
  35. package/src/types.ts +7 -1
  36. package/src/utils/idle-iterator.ts +5 -0
  37. package/src/utils/oauth/index.ts +6 -0
  38. package/src/utils/oauth/opengateway.ts +15 -0
  39. package/src/utils/oauth/types.ts +1 -0
  40. package/src/utils/validation.ts +17 -2
  41. package/src/utils.ts +41 -4
@@ -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(errorMsg) ||
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(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry> {
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
- async #forceRefreshCredentialByIdUnshared(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry> {
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
- const refreshed = await this.#refreshOAuthCredential(provider as Provider, stale, id, signal);
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
@@ -118,6 +118,7 @@ Providers:
118
118
  minimax-code-cn MiniMax Coding Plan (China)
119
119
  cursor Cursor (Anthropic, GPT, etc.)
120
120
  zenmux ZenMux
121
+ opengateway OpenGateway by Sionic AI
121
122
  ollama-cloud Ollama Cloud
122
123
 
123
124
  Examples:
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 { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
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
- const prepareParams = async (paramsOptions?: {
1320
- repairLatestAssistantThinking?: boolean;
1321
- dropForcedToolChoice?: boolean;
1322
- }): Promise<MessageCreateParamsStreaming> => {
1323
- let nextParams = buildParams(
1324
- model,
1325
- baseUrl,
1326
- context,
1327
- isOAuthToken,
1328
- options,
1329
- disableStrictTools,
1330
- paramsOptions?.repairLatestAssistantThinking === true,
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 firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
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({ dropForcedToolChoice: true });
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 latest assistant thinking replay after provider rejection", {
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
- params = await prepareParams({ repairLatestAssistantThinking: true });
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 = false,
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, { repairLatestAssistantThinking }),
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;