@sayknow-cli/ai 0.3.7 → 0.3.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.
@@ -70,6 +70,7 @@ export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
70
70
  getCache(key: string): string | null;
71
71
  setCache(key: string, value: string, expiresAtSec: number): void;
72
72
  cleanExpiredCache(): void;
73
+ deleteCachePrefix(prefix: string): void;
73
74
  /**
74
75
  * Store-level hook consumed by `AuthStorage` — routes refresh through the
75
76
  * broker so the actual refresh token never leaves the broker host. Returns
@@ -420,6 +420,7 @@ export declare const credentialIfAbsentUploadResponseSchema: z.ZodObject<{
420
420
  "skipped-existing-fallback": "skipped-existing-fallback";
421
421
  "skipped-existing-runtime": "skipped-existing-runtime";
422
422
  "skipped-invalid": "skipped-invalid";
423
+ "updated-existing": "updated-existing";
423
424
  }>;
424
425
  entries: z.ZodArray<z.ZodObject<{
425
426
  id: z.ZodNumber;
@@ -102,7 +102,7 @@ export interface AuthCredentialSnapshotEntry {
102
102
  credential: SnapshotCredential;
103
103
  identityKey: string | null;
104
104
  }
105
- export type AuthCredentialIfAbsentReason = "inserted" | "skipped-existing" | "skipped-existing-runtime" | "skipped-existing-config" | "skipped-existing-env" | "skipped-existing-fallback" | "skipped-invalid";
105
+ export type AuthCredentialIfAbsentReason = "inserted" | "updated-existing" | "skipped-existing" | "skipped-existing-runtime" | "skipped-existing-config" | "skipped-existing-env" | "skipped-existing-fallback" | "skipped-invalid";
106
106
  export interface AuthCredentialIfAbsentResult {
107
107
  inserted: boolean;
108
108
  reason: AuthCredentialIfAbsentReason;
@@ -147,6 +147,7 @@ export interface AuthCredentialStore {
147
147
  includeExpired?: boolean;
148
148
  }): string | null;
149
149
  setCache(key: string, value: string, expiresAtSec: number): void;
150
+ deleteCachePrefix?(prefix: string): void;
150
151
  cleanExpiredCache(): void;
151
152
  /**
152
153
  * Optional store-supplied OAuth refresh. When present, `AuthStorage` uses
@@ -646,6 +647,7 @@ export declare class SqliteAuthCredentialStore implements AuthCredentialStore {
646
647
  includeExpired?: boolean;
647
648
  }): string | null;
648
649
  setCache(key: string, value: string, expiresAtSec: number): void;
650
+ deleteCachePrefix(prefix: string): void;
649
651
  cleanExpiredCache(): void;
650
652
  /**
651
653
  * Save OAuth credentials for a provider.
@@ -0,0 +1,3 @@
1
+ import type { FetchImpl } from "../types";
2
+ export declare function isOpenAIUsageExhaustionResponse(bodyText: string, retryAfterMs: number | undefined, retryDelayCapMs: number): boolean;
3
+ export declare function wrapOpenAIFetchForBoundedRateLimits(baseFetch: FetchImpl, maxRetryDelayMs: number | undefined): FetchImpl;
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/ai",
4
- "version": "0.3.7",
4
+ "version": "0.3.8",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
- "homepage": "https://github.com/jaybeyond/Sayknow_CLI",
6
+ "homepage": "https://sayknow-cli.com",
7
7
  "author": "jaybeyond",
8
8
  "contributors": [
9
9
  "Mario Zechner"
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "@anthropic-ai/sdk": "^0.94.0",
45
45
  "@bufbuild/protobuf": "^2.12.0",
46
- "@sayknow-cli/utils": "0.3.7",
46
+ "@sayknow-cli/utils": "0.3.8",
47
47
  "openai": "^6.36.0",
48
48
  "partial-json": "^0.1.7",
49
49
  "zod": "4.4.3"
@@ -86,6 +86,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
86
86
  #cache: Map<string, CacheEntry> = new Map();
87
87
  #usageCache?: UsageCacheEntry;
88
88
  #usageInflight?: Promise<UsageReport[] | null>;
89
+ #usageCacheEpoch = 0;
89
90
  #closed = false;
90
91
  /**
91
92
  * `true` once the SSE consumer received its first frame and hasn't dropped
@@ -465,6 +466,19 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
465
466
  }
466
467
  }
467
468
 
469
+ deleteCachePrefix(prefix: string): void {
470
+ for (const key of this.#cache.keys()) {
471
+ if (key.startsWith(prefix)) this.#cache.delete(key);
472
+ }
473
+ if (prefix.startsWith("usage_cache:")) this.#invalidateUsageCache();
474
+ }
475
+
476
+ #invalidateUsageCache(): void {
477
+ this.#usageCache = undefined;
478
+ this.#usageInflight = undefined;
479
+ this.#usageCacheEpoch += 1;
480
+ }
481
+
468
482
  /**
469
483
  * Store-level hook consumed by `AuthStorage` — routes refresh through the
470
484
  * broker so the actual refresh token never leaves the broker host. Returns
@@ -561,10 +575,11 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
561
575
  return Promise.resolve(cached.reports);
562
576
  }
563
577
  if (this.#usageInflight) return this.#usageInflight;
578
+ const epoch = this.#usageCacheEpoch;
564
579
  const inflight = this.#client
565
580
  .fetchUsage()
566
581
  .then(body => {
567
- this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
582
+ if (this.#usageCacheEpoch === epoch) this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
568
583
  return body.reports;
569
584
  })
570
585
  .catch(error => {
@@ -572,7 +587,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
572
587
  return null;
573
588
  })
574
589
  .finally(() => {
575
- this.#usageInflight = undefined;
590
+ if (this.#usageCacheEpoch === epoch) this.#usageInflight = undefined;
576
591
  });
577
592
  this.#usageInflight = inflight;
578
593
  return inflight;
@@ -187,6 +187,7 @@ export const credentialDisableResponseSchema = z
187
187
  // ─── Upload ────────────────────────────────────────────────────────────────
188
188
  const credentialIfAbsentReasonSchema = z.enum([
189
189
  "inserted",
190
+ "updated-existing",
190
191
  "skipped-existing",
191
192
  "skipped-existing-runtime",
192
193
  "skipped-existing-config",
@@ -32,6 +32,7 @@ import { kimiUsageProvider } from "./usage/kimi";
32
32
  import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex";
33
33
  import { zaiUsageProvider } from "./usage/zai";
34
34
  import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken, resolveOAuthStorageProvider } from "./utils/oauth";
35
+ import { loginDeepInfra } from "./utils/oauth/deepinfra";
35
36
  import { loginDeepSeek } from "./utils/oauth/deepseek";
36
37
  import { loginOpenAICodexDevice } from "./utils/oauth/openai-codex";
37
38
  import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./utils/oauth/types";
@@ -153,6 +154,7 @@ export interface AuthCredentialSnapshotEntry {
153
154
 
154
155
  export type AuthCredentialIfAbsentReason =
155
156
  | "inserted"
157
+ | "updated-existing"
156
158
  | "skipped-existing"
157
159
  | "skipped-existing-runtime"
158
160
  | "skipped-existing-config"
@@ -209,6 +211,7 @@ export interface AuthCredentialStore {
209
211
  deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void;
210
212
  getCache(key: string, options?: { includeExpired?: boolean }): string | null;
211
213
  setCache(key: string, value: string, expiresAtSec: number): void;
214
+ deleteCachePrefix?(prefix: string): void;
212
215
  cleanExpiredCache(): void;
213
216
  /**
214
217
  * Optional store-supplied OAuth refresh. When present, `AuthStorage` uses
@@ -471,6 +474,7 @@ interface UsageCache {
471
474
  get<T>(key: string): UsageCacheEntry<T> | undefined;
472
475
  getStale<T>(key: string): UsageCacheEntry<T> | undefined;
473
476
  set<T>(key: string, entry: UsageCacheEntry<T>): void;
477
+ deletePrefix?(prefix: string): void;
474
478
  cleanup?(): void;
475
479
  }
476
480
 
@@ -661,6 +665,10 @@ class AuthStorageUsageCache implements UsageCache {
661
665
  this.store.setCache(`${USAGE_CACHE_PREFIX}${key}`, payload, Math.floor(durableExpiresAt / 1000));
662
666
  }
663
667
 
668
+ deletePrefix(prefix: string): void {
669
+ this.store.deleteCachePrefix?.(`${USAGE_CACHE_PREFIX}${prefix}`);
670
+ }
671
+
664
672
  cleanup(): void {
665
673
  this.store.cleanExpiredCache();
666
674
  }
@@ -1296,8 +1304,6 @@ export class AuthStorage {
1296
1304
  return this.#snapshotSkipResult(storageProvider, "skipped-existing-runtime");
1297
1305
  if (this.#configOverrides.has(storageProvider))
1298
1306
  return this.#snapshotSkipResult(storageProvider, "skipped-existing-config");
1299
- if (this.#getCredentialsForProvider(storageProvider).length > 0)
1300
- return this.#snapshotSkipResult(storageProvider, "skipped-existing");
1301
1307
  if (getEnvApiKey(storageProvider)) return this.#snapshotSkipResult(storageProvider, "skipped-existing-env");
1302
1308
  if (this.#fallbackResolver?.(storageProvider))
1303
1309
  return this.#snapshotSkipResult(storageProvider, "skipped-existing-fallback");
@@ -1310,6 +1316,7 @@ export class AuthStorage {
1310
1316
  result.entries.map(entry => ({ id: entry.id, credential: entry.credential })),
1311
1317
  );
1312
1318
  this.#resetProviderAssignments(storageProvider);
1319
+ if (result.inserted) this.#invalidateUsageCacheForProvider(storageProvider);
1313
1320
  return {
1314
1321
  inserted: result.inserted,
1315
1322
  reason: result.reason,
@@ -1327,6 +1334,13 @@ export class AuthStorage {
1327
1334
  stored.map(record => ({ id: record.id, credential: record.credential })),
1328
1335
  );
1329
1336
  this.#resetProviderAssignments(provider);
1337
+ this.#invalidateUsageCacheForProvider(provider);
1338
+ }
1339
+
1340
+ #invalidateUsageCacheForProvider(provider: string): void {
1341
+ this.#usageRequestInFlight.clear();
1342
+ this.#usageReportsInFlight.clear();
1343
+ this.#usageCache.deletePrefix?.(`report:${provider}:`);
1330
1344
  }
1331
1345
 
1332
1346
  /**
@@ -1593,6 +1607,11 @@ export class AuthStorage {
1593
1607
  await saveApiKeyCredential(apiKey);
1594
1608
  return;
1595
1609
  }
1610
+ case "deepinfra": {
1611
+ const apiKey = await loginDeepInfra(ctrl);
1612
+ await saveApiKeyCredential(apiKey);
1613
+ return;
1614
+ }
1596
1615
  case "xai": {
1597
1616
  const { loginXai } = await import("./utils/oauth/xai");
1598
1617
  credentials = await loginXai({
@@ -3699,6 +3718,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
3699
3718
  #getCacheStmt: Statement;
3700
3719
  #getCacheIncludingExpiredStmt: Statement;
3701
3720
  #upsertCacheStmt: Statement;
3721
+ #deleteCachePrefixStmt: Statement;
3702
3722
  #deleteExpiredCacheStmt: Statement;
3703
3723
  #closed = false;
3704
3724
 
@@ -3738,6 +3758,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
3738
3758
  this.#upsertCacheStmt = this.#db.prepare(
3739
3759
  "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
3740
3760
  );
3761
+ this.#deleteCachePrefixStmt = this.#db.prepare("DELETE FROM cache WHERE substr(key, 1, ?) = ?");
3741
3762
  this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
3742
3763
  }
3743
3764
 
@@ -4096,16 +4117,58 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4096
4117
  }
4097
4118
 
4098
4119
  const writeIfAbsent = this.#db.transaction(
4099
- (providerName: string, record: SerializedCredentialRecord): AuthCredentialIfAbsentResult => {
4120
+ (
4121
+ providerName: string,
4122
+ item: AuthCredential,
4123
+ record: SerializedCredentialRecord,
4124
+ ): AuthCredentialIfAbsentResult => {
4100
4125
  const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
4101
- const existing: StoredAuthCredential[] = [];
4126
+ const existing: Array<{
4127
+ id: number;
4128
+ credential: AuthCredential;
4129
+ identityKey: string | null;
4130
+ }> = [];
4102
4131
  for (const row of existingRows) {
4103
4132
  const activeCredential = deserializeCredential(row);
4104
4133
  if (!activeCredential) continue;
4105
- existing.push(toStoredAuthCredential(row, activeCredential));
4134
+ existing.push({
4135
+ id: row.id,
4136
+ credential: activeCredential,
4137
+ identityKey: resolveRowCredentialIdentityKey(providerName, row),
4138
+ });
4106
4139
  }
4107
4140
  if (existing.length > 0) {
4108
- return { inserted: false, reason: "skipped-existing", provider: providerName, entries: existing };
4141
+ let targetId: number | null = null;
4142
+ for (const row of existing) {
4143
+ if (!matchesReplacementCredential(providerName, row.credential, row.identityKey, item)) continue;
4144
+ if (targetId === null) {
4145
+ targetId = row.id;
4146
+ this.#updateStmt.run(record.credentialType, record.data, record.identityKey, row.id);
4147
+ } else {
4148
+ this.#deleteStmt.run("replaced by newer credential", row.id);
4149
+ }
4150
+ }
4151
+
4152
+ if (targetId !== null) {
4153
+ return {
4154
+ inserted: true,
4155
+ reason: "updated-existing",
4156
+ provider: providerName,
4157
+ entries: this.listAuthCredentials(providerName),
4158
+ };
4159
+ }
4160
+
4161
+ return {
4162
+ inserted: false,
4163
+ reason: "skipped-existing",
4164
+ provider: providerName,
4165
+ entries: existing.map(row => ({
4166
+ id: row.id,
4167
+ provider: providerName,
4168
+ credential: row.credential,
4169
+ disabledCause: null,
4170
+ })),
4171
+ };
4109
4172
  }
4110
4173
 
4111
4174
  this.#insertStmt.get(providerName, record.credentialType, record.data, record.identityKey);
@@ -4118,7 +4181,9 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4118
4181
  },
4119
4182
  );
4120
4183
 
4121
- return writeIfAbsent.immediate(provider, serialized);
4184
+ const result = writeIfAbsent.immediate(provider, credential, serialized);
4185
+ if (result.inserted) this.#purgeSupersededDisabledRows(provider, result.entries);
4186
+ return result;
4122
4187
  }
4123
4188
 
4124
4189
  /**
@@ -4215,6 +4280,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4215
4280
  }
4216
4281
  }
4217
4282
 
4283
+ deleteCachePrefix(prefix: string): void {
4284
+ if (prefix.length === 0) return;
4285
+ try {
4286
+ this.#deleteCachePrefixStmt.run(prefix.length, prefix);
4287
+ } catch {}
4288
+ }
4289
+
4218
4290
  cleanExpiredCache(): void {
4219
4291
  try {
4220
4292
  this.#deleteExpiredCacheStmt.run();
@@ -4305,6 +4377,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4305
4377
  this.#getCacheStmt.finalize();
4306
4378
  this.#getCacheIncludingExpiredStmt.finalize();
4307
4379
  this.#upsertCacheStmt.finalize();
4380
+ this.#deleteCachePrefixStmt.finalize();
4308
4381
  this.#deleteExpiredCacheStmt.finalize();
4309
4382
  this.#db.close();
4310
4383
  }
@@ -2281,8 +2281,6 @@ function buildParams(
2281
2281
  params.output_config = { effort } as typeof params.output_config;
2282
2282
  }
2283
2283
  }
2284
- } else if (options?.thinkingEnabled === false) {
2285
- params.thinking = { type: "disabled" };
2286
2284
  }
2287
2285
  }
2288
2286
 
@@ -35,6 +35,7 @@ import {
35
35
  markToolChoiceIncapability,
36
36
  resolveToolChoice,
37
37
  } from "../utils/tool-choice-capability";
38
+ import { wrapOpenAIFetchForBoundedRateLimits } from "./openai-bounded-rate-limits";
38
39
  import { normalizeOpenAIResponsesPromptCacheKey, supportsDeveloperRole } from "./openai-responses";
39
40
  import {
40
41
  appendResponsesToolResultMessages,
@@ -272,7 +273,7 @@ function createClient(model: Model<"azure-openai-responses">, apiKey: string, op
272
273
 
273
274
  const { baseUrl, apiVersion } = resolveAzureConfig(model, options);
274
275
 
275
- const baseFetch = options?.fetch ?? fetch;
276
+ const baseFetch = wrapOpenAIFetchForBoundedRateLimits(options?.fetch ?? fetch, options?.maxRetryDelayMs);
276
277
  const onSseEvent = options?.onSseEvent;
277
278
  return new AzureOpenAI({
278
279
  apiKey,
@@ -0,0 +1,57 @@
1
+ import type { FetchImpl } from "../types";
2
+ import { getRetryAfterMsFromHeaders } from "../utils/retry-after";
3
+
4
+ const OPENAI_RETRY_DELAY_CAP_MS = 60_000;
5
+
6
+ // Mirror of `wrapAnthropicFetchForBoundedRateLimits`: OpenAI-compatible providers
7
+ // (e.g. opencode-go) return HTTP 429 for *permanent* usage/quota exhaustion — a
8
+ // monthly-cap reset that can be days away. The OpenAI SDK treats 429 as transient
9
+ // and retries up to `maxRetries`, honoring an out-of-range `Retry-After`; the
10
+ // `create()` call then hangs before the error can surface to the agent loop, so
11
+ // no assistant error is produced and the session-level retry/fallback never runs.
12
+ // Detect exhaustion and set `x-should-retry: false` so the SDK gives up at once
13
+ // and the session retry layer applies its own fail-fast (retry-after > maxDelayMs).
14
+ //
15
+ // Shared by every adapter that drives a raw OpenAI SDK client — openai-completions,
16
+ // openai-responses, and azure-openai-responses. Adapters that route through
17
+ // `fetchWithRetry` (codex, bedrock, ollama, gemini-cli) already bound 429 retries
18
+ // themselves and do not need this wrapper.
19
+ export function isOpenAIUsageExhaustionResponse(
20
+ bodyText: string,
21
+ retryAfterMs: number | undefined,
22
+ retryDelayCapMs: number,
23
+ ): boolean {
24
+ if (retryAfterMs !== undefined && retryAfterMs > retryDelayCapMs) return true;
25
+ return /monthly usage limit|usage limit reached|usage_limit_reached|out_of_credits|insufficient_quota|quota[ _]?exceeded/i.test(
26
+ bodyText,
27
+ );
28
+ }
29
+
30
+ export function wrapOpenAIFetchForBoundedRateLimits(
31
+ baseFetch: FetchImpl,
32
+ maxRetryDelayMs: number | undefined,
33
+ ): FetchImpl {
34
+ const retryDelayCapMs = maxRetryDelayMs ?? OPENAI_RETRY_DELAY_CAP_MS;
35
+ return Object.assign(
36
+ async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
37
+ const response = await baseFetch(input, init);
38
+ if (response.status !== 429 || retryDelayCapMs === 0) return response;
39
+
40
+ const headers = new Headers(response.headers);
41
+ const retryAfterMs = getRetryAfterMsFromHeaders(headers);
42
+ const bodyText = await response
43
+ .clone()
44
+ .text()
45
+ .catch(() => "");
46
+ if (!isOpenAIUsageExhaustionResponse(bodyText, retryAfterMs, retryDelayCapMs)) return response;
47
+
48
+ headers.set("x-should-retry", "false");
49
+ return new Response(bodyText, {
50
+ status: response.status,
51
+ statusText: response.statusText,
52
+ headers,
53
+ });
54
+ },
55
+ baseFetch.preconnect ? { preconnect: baseFetch.preconnect } : {},
56
+ );
57
+ }
@@ -104,6 +104,10 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
104
104
  baseUrl.includes("opencode.ai");
105
105
  const isOpenCodeProvider = provider === "opencode-go" || provider === "opencode-zen";
106
106
  const isOpenCodeGoReasoning = provider === "opencode-go" && Boolean(model.reasoning);
107
+ const isOpenCodeGoKimiReasoning = provider === "opencode-go" && isKimiModel && Boolean(model.reasoning);
108
+ const isOpenCodeGoKimi25Reasoning = isOpenCodeGoKimiReasoning && model.id === "kimi-k2.5";
109
+ const isOpenCodeGoKimi27CodeReasoning = isOpenCodeGoKimiReasoning && model.id === "kimi-k2.7-code";
110
+ const needsOpenCodeGoKimiEffortMap = isOpenCodeGoKimi25Reasoning || isOpenCodeGoKimi27CodeReasoning;
107
111
 
108
112
  const useMaxTokens =
109
113
  provider === "mistral" ||
@@ -170,22 +174,31 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
170
174
  xhigh: "default",
171
175
  max: "default",
172
176
  } satisfies Partial<Record<OpenAIReasoningEffort, string>>)
173
- : isDeepseekFamily && model.reasoning
177
+ : needsOpenCodeGoKimiEffortMap
174
178
  ? ({
175
- minimal: "high",
176
- low: "high",
177
- medium: "high",
178
- high: "high",
179
- xhigh: "max",
180
- max: "max",
179
+ // Live Go probes (2026-07-06) showed model-specific effort gaps:
180
+ // kimi-k2.5 rejects "minimal", while kimi-k2.7-code rejects
181
+ // OpenAI-style "xhigh" and "max"; all other Kimi efforts tested
182
+ // successfully and should pass through unchanged.
183
+ ...(isOpenCodeGoKimi25Reasoning ? { minimal: "low" } : {}),
184
+ ...(isOpenCodeGoKimi27CodeReasoning ? { xhigh: "high", max: "high" } : {}),
181
185
  } satisfies Partial<Record<OpenAIReasoningEffort, string>>)
182
- : isFireworks
186
+ : isDeepseekFamily && model.reasoning
183
187
  ? ({
184
- // Fireworks' OpenAI-compatible endpoint rejects OpenAI's
185
- // `minimal` literal but accepts `none` for the lowest setting.
186
- minimal: "none",
188
+ minimal: "high",
189
+ low: "high",
190
+ medium: "high",
191
+ high: "high",
192
+ xhigh: "max",
193
+ max: "max",
187
194
  } satisfies Partial<Record<OpenAIReasoningEffort, string>>)
188
- : {};
195
+ : isFireworks
196
+ ? ({
197
+ // Fireworks' OpenAI-compatible endpoint rejects OpenAI's
198
+ // `minimal` literal but accepts `none` for the lowest setting.
199
+ minimal: "none",
200
+ } satisfies Partial<Record<OpenAIReasoningEffort, string>>)
201
+ : {};
189
202
 
190
203
  return {
191
204
  supportsStore: !isNonStandard,
@@ -198,7 +211,7 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
198
211
  disableReasoningOnForcedToolChoice: isKimiModel || isAnthropicModel || isOpenCodeGoReasoning,
199
212
  disableReasoningOnToolChoice: isDeepseekFamily && Boolean(model.reasoning) && !isOpenRouter,
200
213
  supportsToolChoice: !isDirectDeepseekReasoning,
201
- supportsForcedToolChoice: true,
214
+ supportsForcedToolChoice: !isOpenCodeGoKimiReasoning,
202
215
  maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens",
203
216
  requiresToolResultName: isMistral,
204
217
  requiresAssistantAfterToolResult: false,
@@ -71,6 +71,7 @@ import {
71
71
  resolveGitHubCopilotBaseUrl,
72
72
  } from "./github-copilot-headers";
73
73
  import { compactGrammarDefinition } from "./grammar";
74
+ import { wrapOpenAIFetchForBoundedRateLimits } from "./openai-bounded-rate-limits";
74
75
  import {
75
76
  applyOpenAIRequestTransformBody,
76
77
  applyOpenAIRequestTransformHeaders,
@@ -274,6 +275,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
274
275
  options?.fetch,
275
276
  options?.authCredentialType,
276
277
  options?.requestMaxRetries,
278
+ options?.maxRetryDelayMs,
277
279
  );
278
280
  const premiumRequestsTotal = copilotPremiumRequests;
279
281
  const providerSessionState = getOpenAIResponsesProviderSessionState(model, options?.providerSessionState);
@@ -400,6 +402,7 @@ function createClient(
400
402
  fetchOverride?: FetchImpl,
401
403
  authCredentialType?: OpenAIResponsesOptions["authCredentialType"],
402
404
  requestMaxRetries?: number,
405
+ maxRetryDelayMs?: number,
403
406
  ): {
404
407
  client: OpenAI;
405
408
  copilotPremiumRequests: number | undefined;
@@ -446,8 +449,9 @@ function createClient(
446
449
  headers["x-client-request-id"] ??= sessionId;
447
450
  }
448
451
  const baseFetch = fetchOverride ?? fetch;
452
+ const boundedFetch = wrapOpenAIFetchForBoundedRateLimits(baseFetch, maxRetryDelayMs);
449
453
  const transformedFetch = wrapFetchForOpenAIRequestTransform(
450
- baseFetch,
454
+ boundedFetch,
451
455
  model.requestTransform,
452
456
  `Sayknow-CLI/${packageJson.version}`,
453
457
  );
package/src/utils.ts CHANGED
@@ -215,7 +215,11 @@ function sanitizeOpenAIResponsesHistoryItemForReplay(
215
215
  // providerPayload stores raw output items; replay strips fields that are output-only.
216
216
  const { id: _id, ...itemWithoutId } = item;
217
217
  const sanitizedItem =
218
- item.type === "computer_call" ? sanitizeComputerCallForResponsesInput(itemWithoutId) : itemWithoutId;
218
+ item.type === "computer_call"
219
+ ? sanitizeComputerCallForResponsesInput(itemWithoutId)
220
+ : item.type === "image_generation_call"
221
+ ? sanitizeImageGenerationCallForResponsesInput(itemWithoutId)
222
+ : itemWithoutId;
219
223
  if (typeof item.call_id === "string") {
220
224
  sanitizedItem.call_id = normalizeReplayedResponsesHistoryCallId(item.call_id, normalizedCallIds);
221
225
  }
@@ -231,6 +235,22 @@ function sanitizeComputerCallForResponsesInput(item: Record<string, unknown>): R
231
235
  return inputSafeItem;
232
236
  }
233
237
 
238
+ function sanitizeImageGenerationCallForResponsesInput(item: Record<string, unknown>): Record<string, unknown> {
239
+ // Image generation output items include request-time knobs that are not part of
240
+ // the Responses input replay schema. Replaying them verbatim makes OpenAI-compatible
241
+ // endpoints reject the next turn, e.g. `Unknown parameter: input[n].action`.
242
+ const {
243
+ action: _action,
244
+ background: _background,
245
+ output_format: _outputFormat,
246
+ quality: _quality,
247
+ revised_prompt: _revisedPrompt,
248
+ size: _size,
249
+ ...inputSafeItem
250
+ } = item;
251
+ return inputSafeItem;
252
+ }
253
+
234
254
  function normalizeReplayedResponsesHistoryCallId(value: string, normalizedValues: Map<string, string>): string {
235
255
  const normalized = normalizedValues.get(value);
236
256
  if (normalized) return normalized;