@oh-my-pi/pi-ai 18.4.8 → 18.4.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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.4.9] - 2026-10-01
6
+
7
+ ### Fixed
8
+
9
+ - Fixed rejected HTTP 400 requests from consuming unbounded disk space by automatically cleaning up old request logs and enforcing a size limit.
10
+ - Fixed unnecessary credential and session updates that could trigger needless authentication reloads in other running processes.
11
+ - Fixed context-overflow recovery for Strata requests that exceed the model context limit but return no usage information.
12
+
5
13
  ## [18.4.6] - 2026-10-01
6
14
 
7
15
  ### Fixed
@@ -2287,33 +2295,4 @@
2287
2295
  - Fixed tool schema conversion for non-Cloud Code Assist Google Gemini models by normalizing parameters with `normalizeSchemaForGoogle` to prevent un-normalized schema properties (such as `additionalProperties: false` or type arrays) from causing Gemini API errors.
2288
2296
  - Fixed OpenAI-family request builders dropping forced named `tool_choice` directives when the named tool is absent from the serialized `tools` array, preventing spec-strict providers from rejecting self-inconsistent requests. ([#1701](https://github.com/can1357/oh-my-pi/issues/1701))
2289
2297
 
2290
- ## [15.12.4] - 2026-06-13
2291
-
2292
- ### Added
2293
-
2294
- - Added `GITLAB_CLIENT_ID` and `GITLAB_REDIRECT_URI` env-var overrides for the GitLab Duo OAuth login flow so users running with their own GitLab OAuth application can replace the bundled credentials when GitLab rejects the bundled `client_id`'s redirect URI. Setting `GITLAB_REDIRECT_URI` also disables the random-port fallback (strict OAuth providers reject mismatched URIs anyway). ([#2424](https://github.com/can1357/oh-my-pi/issues/2424))
2295
- - Added `AuthStorage.listStoredCredentials()` and `AuthStorage.removeCredential()` for per-account credential management.
2296
-
2297
- ### Changed
2298
-
2299
- - Replaced the OpenAI SDK client usage in `openai-completions`, `openai-responses`, `azure-openai-responses`, and `openai-codex-responses` with the new internal `postOpenAIStream` OpenAI-wire JSON/SSE transport
2300
-
2301
- ### Fixed
2302
-
2303
- - Fixed streaming providers to cancel upstream model requests when the client closes the response body, so interrupted SSE sessions stop instead of continuing in the background
2304
- - Fixed: provider request builders treat unknown `model.maxTokens` (`null`) as "no model cap" instead of coercing to `0` via `Math.min`; Anthropic falls back to the 64k Claude-Code cap for its required `max_tokens`.
2305
- - Fixed transient stream failures on OpenAI-compatible providers by retrying HTTP 408/429/5xx responses and transient network errors with Retry-After/quota-hint aware backoff
2306
- - Fixed SSE stream handling for OpenAI-compatible responses by parsing wire-level JSON frames directly and honoring `[DONE]` termination
2307
- - Fixed stream error handling for OpenAI-compatible providers by preserving structured HTTP status/headers and response body details from failed requests for retry and strict-tool fallback logic
2308
- - Fixed OpenAI-compat streams ending with a bare `finish_reason: "error"` (gateways like OpenRouter reporting upstream failures, e.g. Gemini `MALFORMED_FUNCTION_CALL`) surfacing as a non-retryable `Provider finish_reason: error`. The reason is now mapped to `Provider returned error finish_reason`, which the session retry classifier recognizes as transient, so the turn auto-retries instead of stopping with a pinned error banner.
2309
- - Fixed `SqliteAuthCredentialStore.open()` crashing with `SQLITE_BUSY_RECOVERY` (errno 261) when several `omp --session` panes restore concurrently after an unclean shutdown: `PRAGMA busy_timeout = 5000` now runs as a standalone statement BEFORE `PRAGMA journal_mode=WAL` (the first lock-taking statement during WAL recovery), and `open()` retries the BUSY family — `SQLITE_BUSY`, `SQLITE_BUSY_RECOVERY`, `SQLITE_BUSY_SNAPSHOT`, `SQLITE_BUSY_TIMEOUT` — with bounded exponential backoff. The exhausted-retry error message includes the DB path. Exported `isSqliteBusyError(err)` for callers that need the same classifier ([#2421](https://github.com/can1357/oh-my-pi/issues/2421)).
2310
- - Fixed MiniMax-M3 OpenAI-compatible streams rendering reasoning twice when the same chunk carried both `<think>…</think>` content and structured `reasoning_content`; structured reasoning now wins and cumulative MiniMax reasoning snapshots are collapsed to deltas. ([#2433](https://github.com/can1357/oh-my-pi/issues/2433))
2311
- - Fixed Gemini turns silently halting the agent when the model returned `finishReason: STOP` with only an empty (or whitespace-only) text part and no tool call — the well-known "empty response" failure. All Google surfaces (public Generative Language `streamGoogle`, Vertex `streamGoogleVertex`, and Cloud Code Assist `google-gemini-cli`/`google-antigravity`) now classify such a turn as empty via the shared `hasMeaningfulGoogleContent` check and retry it up to `MAX_EMPTY_STREAM_RETRIES` times before surfacing an error. The Cloud Code Assist path previously had an empty-stream retry that never fired for this case (its `hasContent` flag counted an empty-string text part as content), and the public/Vertex path had no retry at all; the retry now emits a single `start` event so no duplicate partial message leaks downstream.
2312
-
2313
- ## [15.12.1] - 2026-06-12
2314
-
2315
- ### Added
2316
-
2317
- - Added the optional `ToolResultMessage.useless` flag: tools can declare a finished result contextually useless (zero matches, elapsed wait) so compaction passes may elide it once consumed. Never serialized to provider wire formats and never set together with `isError`.
2318
-
2319
- Older entries are archived in [packages\ai\CHANGELOG.md@cd762117522c](https://github.com/can1357/oh-my-pi/blob/cd762117522cc4122cd45b8d2d14de5f7be5133b/packages\ai\CHANGELOG.md).
2298
+ Older entries are archived in [packages/ai/CHANGELOG.md@edb740cbad49](https://github.com/can1357/oh-my-pi/blob/edb740cbad499dbc96f8b5b46ebf78f70d6af4d0/packages/ai/CHANGELOG.md).
@@ -26,6 +26,11 @@ export declare class SessionAffinity implements SessionsApi {
26
26
  * `lastUsedAtMs` backdates the sticky (session-file pin restores on resume);
27
27
  * it defaults to now for live selections. Automatic re-recording of the same
28
28
  * durable row preserves an explicit user pin.
29
+ *
30
+ * The in-memory sticky is always exact. The persisted row is rewritten only
31
+ * when the credential, its type, or explicitness changes, or when the stored
32
+ * last-use drifts by {@link SESSION_STICKY_PERSIST_INTERVAL_MS} — per-request
33
+ * rewrites were pure database churn.
29
34
  */
30
35
  record(provider: string, sessionId: string | undefined, type: AuthCredential["type"], index: number, lastUsedAtMs?: number, explicit?: boolean): void;
31
36
  /** Retrieves the last credential used by a session. */
@@ -116,7 +116,9 @@ export declare class CredentialPool implements CredentialsApi {
116
116
  /**
117
117
  * Persist a refreshed credential by id only while the row still matches this
118
118
  * process's snapshot. A peer rotation wins the CAS and is reloaded instead of
119
- * being overwritten after this process releases its refresh lease.
119
+ * being overwritten after this process releases its refresh lease. An
120
+ * unchanged credential still runs the CAS check (stores skip rewriting
121
+ * identical bytes), so peer rotations are detected without churning the row.
120
122
  *
121
123
  * Returns the row's current index, or -1 when it was disabled or removed.
122
124
  */
@@ -36,6 +36,11 @@ export declare class SqliteAuthCredentialStore implements AuthCredentialStore {
36
36
  listDisabledCredentials(provider?: string): Promise<DisabledCredentialSummary[]>;
37
37
  replaceAuthCredentials(provider: string, credentials: AuthCredential[]): Promise<StoredAuthCredential[]>;
38
38
  upsertAuthCredential(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]>;
39
+ /**
40
+ * Rewrites a row only when its persisted bytes change: an identical write would
41
+ * still bump `updated_at` and `auth_change_revision`, making every peer process
42
+ * reload credentials for nothing.
43
+ */
39
44
  updateAuthCredential(id: number, credential: AuthCredential): void;
40
45
  tryUpdateAuthCredentialIfMatches(id: number, expectedData: string, credential: AuthCredential, lease?: CredentialRefreshLeaseFence): boolean;
41
46
  deleteAuthCredential(id: number, disabledCause: string): Promise<boolean>;
@@ -515,6 +515,8 @@ export interface OAuthAccountSummary {
515
515
  orgName?: string;
516
516
  /** True when this account is the session-sticky OAuth credential requested by `listOAuthAccounts`. */
517
517
  active: boolean;
518
+ /** Last use recorded on the session sticky; set only on the `active` account. */
519
+ lastUsedAtMs?: number;
518
520
  }
519
521
  /** Scope a matching-key invalidation to a session or signal. */
520
522
  export interface InvalidateCredentialMatchingOptions {
@@ -39,6 +39,20 @@ export declare function shouldDumpRejectedRequest(error: unknown): boolean;
39
39
  */
40
40
  export declare function stripRawHttpRequestDiagnostics(message: string): string;
41
41
  export declare function appendRawHttpRequestDumpFor400(message: string, error: unknown, dump: RawHttpRequestDump | undefined): Promise<string>;
42
+ export interface HttpRequestDumpRetention {
43
+ /** Dump file name never deleted (the one just written and referenced by the error). */
44
+ keep?: string;
45
+ /** Dumps last modified longer ago than this are deleted (default 7 days). */
46
+ maxAgeMs?: number;
47
+ /** Oldest remaining dumps are deleted until the directory totals at most this (default 64 MiB). */
48
+ maxTotalBytes?: number;
49
+ now?: number;
50
+ }
51
+ /**
52
+ * Bound the rejected-request dump directory: delete `*.json` dumps older than
53
+ * the age limit, then the oldest survivors until the total fits the size cap.
54
+ */
55
+ export declare function pruneHttpRequestDumps(dir: string, retention?: HttpRequestDumpRetention): Promise<void>;
42
56
  export declare function finalizeErrorMessage(error: unknown, rawRequestDump: RawHttpRequestDump | undefined, capturedErrorResponse?: CapturedHttpErrorResponse): Promise<string>;
43
57
  /**
44
58
  * Rewrite error message for GitHub Copilot request failures.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.4.8",
3
+ "version": "18.4.9",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.4.8",
159
- "@oh-my-pi/pi-catalog": "18.4.8",
160
- "@oh-my-pi/pi-natives": "18.4.8",
161
- "@oh-my-pi/pi-utils": "18.4.8",
162
- "@oh-my-pi/pi-wire": "18.4.8"
158
+ "@oh-my-pi/omptype": "18.4.9",
159
+ "@oh-my-pi/pi-catalog": "18.4.9",
160
+ "@oh-my-pi/pi-natives": "18.4.9",
161
+ "@oh-my-pi/pi-utils": "18.4.9",
162
+ "@oh-my-pi/pi-wire": "18.4.9"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -7,6 +7,21 @@ import type { KeyOverrides } from "./cascade";
7
7
 
8
8
  /** Prefix for persisted session-to-credential affinity. */
9
9
  export const SESSION_STICKY_CACHE_PREFIX = "session:sticky:";
10
+ /** Persisted sticky rows live this long past their last use. */
11
+ const SESSION_STICKY_TTL_SEC = 30 * 24 * 60 * 60;
12
+ /**
13
+ * Same-credential re-records rewrite the persisted row at most this often. The
14
+ * in-memory sticky stays exact; only the copy other processes resume from lags.
15
+ */
16
+ const SESSION_STICKY_PERSIST_INTERVAL_MS = 60_000;
17
+
18
+ /** What this process last wrote (or read) for one persisted sticky row. */
19
+ type PersistedSticky = {
20
+ type: AuthCredential["type"];
21
+ credentialId: number;
22
+ explicit: boolean;
23
+ lastUsedAtMs: number;
24
+ };
10
25
 
11
26
  /** A session's pinned credential (resolved index + durable row id). */
12
27
  export type SessionCredential = {
@@ -22,6 +37,8 @@ export type SessionCredential = {
22
37
  export class SessionAffinity implements SessionsApi {
23
38
  /** Tracks the last used credential per provider for a session (used for rate-limit switching). */
24
39
  #sessionLastCredential: Map<string, Map<string, SessionCredential>> = new Map();
40
+ /** Persisted sticky rows keyed by cache key, so unchanged re-records skip the write. */
41
+ #persistedSticky: Map<string, PersistedSticky> = new Map();
25
42
  #store: AuthCredentialStore;
26
43
  #pool: CredentialPool;
27
44
  #overrides: KeyOverrides;
@@ -35,8 +52,12 @@ export class SessionAffinity implements SessionsApi {
35
52
  /** Drop every pin for a provider, both in memory and in the persisted cache. */
36
53
  clearProvider(provider: string): void {
37
54
  this.#sessionLastCredential.delete(provider);
55
+ const prefix = `${SESSION_STICKY_CACHE_PREFIX}${provider}:`;
56
+ for (const cacheKey of this.#persistedSticky.keys()) {
57
+ if (cacheKey.startsWith(prefix)) this.#persistedSticky.delete(cacheKey);
58
+ }
38
59
  try {
39
- this.#store.deleteCachePrefix?.(`${SESSION_STICKY_CACHE_PREFIX}${provider}:`);
60
+ this.#store.deleteCachePrefix?.(prefix);
40
61
  } catch (err) {
41
62
  logger.debug("Failed to clear provider session sticky credentials from persistent store cache", { err });
42
63
  }
@@ -52,6 +73,11 @@ export class SessionAffinity implements SessionsApi {
52
73
  * `lastUsedAtMs` backdates the sticky (session-file pin restores on resume);
53
74
  * it defaults to now for live selections. Automatic re-recording of the same
54
75
  * durable row preserves an explicit user pin.
76
+ *
77
+ * The in-memory sticky is always exact. The persisted row is rewritten only
78
+ * when the credential, its type, or explicitness changes, or when the stored
79
+ * last-use drifts by {@link SESSION_STICKY_PERSIST_INTERVAL_MS} — per-request
80
+ * rewrites were pure database churn.
55
81
  */
56
82
  record(
57
83
  provider: string,
@@ -80,14 +106,29 @@ export class SessionAffinity implements SessionsApi {
80
106
  sessionMap.set(sessionId, sessionCredential);
81
107
  this.#sessionLastCredential.set(provider, sessionMap);
82
108
 
109
+ if (credentialId === undefined) return;
110
+ const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
111
+ const expiresAtSec = Math.floor(nowMs / 1000) + SESSION_STICKY_TTL_SEC;
112
+ const persisted = this.#persistedSticky.get(cacheKey);
113
+ if (
114
+ persisted &&
115
+ persisted.type === type &&
116
+ persisted.credentialId === credentialId &&
117
+ persisted.explicit === isExplicit &&
118
+ Math.abs(nowMs - persisted.lastUsedAtMs) < SESSION_STICKY_PERSIST_INTERVAL_MS
119
+ ) {
120
+ return;
121
+ }
83
122
  try {
84
- if (credentialId !== undefined) {
85
- const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
86
- // Expires in 30 days
87
- const expiresAtSec = Math.floor(nowMs / 1000) + 30 * 24 * 60 * 60;
88
- this.#store.setCache(cacheKey, JSON.stringify(sessionCredential), expiresAtSec);
89
- }
123
+ this.#store.setCache(cacheKey, JSON.stringify(sessionCredential), expiresAtSec);
124
+ this.#persistedSticky.set(cacheKey, {
125
+ type,
126
+ credentialId,
127
+ explicit: isExplicit,
128
+ lastUsedAtMs: nowMs,
129
+ });
90
130
  } catch (err) {
131
+ this.#persistedSticky.delete(cacheKey);
91
132
  logger.debug("Failed to write session sticky credential to persistent store cache", { err });
92
133
  }
93
134
  }
@@ -122,12 +163,14 @@ export class SessionAffinity implements SessionsApi {
122
163
  const stored = this.#pool.entries(provider);
123
164
  const actualIndex = stored.findIndex(entry => entry.id === val.credentialId);
124
165
  if (actualIndex === -1 || stored[actualIndex]?.credential.type !== val.type) {
166
+ this.#persistedSticky.delete(cacheKey);
125
167
  this.#store.setCache(cacheKey, "", 0);
126
168
  return undefined;
127
169
  }
128
170
  val.index = actualIndex;
129
171
  } else {
130
172
  // Fallback: drop unsafe index-only cache rows to prevent wrong-account routing
173
+ this.#persistedSticky.delete(cacheKey);
131
174
  this.#store.setCache(cacheKey, "", 0);
132
175
  return undefined;
133
176
  }
@@ -143,6 +186,14 @@ export class SessionAffinity implements SessionsApi {
143
186
  lastUsedAtMs: val.lastUsedAtMs,
144
187
  ...(val.explicit === true ? { explicit: true } : {}),
145
188
  };
189
+ if (typeof val.lastUsedAtMs === "number") {
190
+ this.#persistedSticky.set(cacheKey, {
191
+ type: val.type,
192
+ credentialId: val.credentialId,
193
+ explicit: val.explicit === true,
194
+ lastUsedAtMs: val.lastUsedAtMs,
195
+ });
196
+ }
146
197
  sessionMap.set(sessionId, sessionVal);
147
198
  return sessionVal;
148
199
  }
@@ -162,8 +213,9 @@ export class SessionAffinity implements SessionsApi {
162
213
  this.#sessionLastCredential.delete(provider);
163
214
  }
164
215
  }
216
+ const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
217
+ this.#persistedSticky.delete(cacheKey);
165
218
  try {
166
- const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
167
219
  this.#store.setCache(cacheKey, "", 0);
168
220
  } catch (err) {
169
221
  logger.debug("Failed to clear session sticky credential from persistent store cache", { err });
package/src/auth/oauth.ts CHANGED
@@ -244,17 +244,22 @@ export class OAuthAccounts implements OAuthApi {
244
244
  sessionCredential?.type === "oauth"
245
245
  ? this.#deps.pool.entries(provider)[sessionCredential.index]?.id
246
246
  : undefined;
247
- return this.#getStoredOAuthSelections(provider).map((selection, position) => ({
248
- position,
249
- credentialId: selection.credentialId,
250
- accountId: selection.credential.accountId,
251
- email: selection.credential.email,
252
- projectId: selection.credential.projectId,
253
- enterpriseUrl: selection.credential.enterpriseUrl,
254
- orgId: selection.credential.orgId,
255
- orgName: selection.credential.orgName,
256
- active: selection.credentialId === activeCredentialId,
257
- }));
247
+ const activeLastUsedAtMs = activeCredentialId !== undefined ? sessionCredential?.lastUsedAtMs : undefined;
248
+ return this.#getStoredOAuthSelections(provider).map((selection, position) => {
249
+ const active = selection.credentialId === activeCredentialId;
250
+ return {
251
+ position,
252
+ credentialId: selection.credentialId,
253
+ accountId: selection.credential.accountId,
254
+ email: selection.credential.email,
255
+ projectId: selection.credential.projectId,
256
+ enterpriseUrl: selection.credential.enterpriseUrl,
257
+ orgId: selection.credential.orgId,
258
+ orgName: selection.credential.orgName,
259
+ active,
260
+ ...(active && activeLastUsedAtMs !== undefined ? { lastUsedAtMs: activeLastUsedAtMs } : {}),
261
+ };
262
+ });
258
263
  }
259
264
 
260
265
  /**
package/src/auth/pool.ts CHANGED
@@ -467,7 +467,9 @@ export class CredentialPool implements CredentialsApi {
467
467
  /**
468
468
  * Persist a refreshed credential by id only while the row still matches this
469
469
  * process's snapshot. A peer rotation wins the CAS and is reloaded instead of
470
- * being overwritten after this process releases its refresh lease.
470
+ * being overwritten after this process releases its refresh lease. An
471
+ * unchanged credential still runs the CAS check (stores skip rewriting
472
+ * identical bytes), so peer rotations are detected without churning the row.
471
473
  *
472
474
  * Returns the row's current index, or -1 when it was disabled or removed.
473
475
  */
@@ -489,7 +491,9 @@ export class CredentialPool implements CredentialsApi {
489
491
  return latest.findIndex(row => row.id === id);
490
492
  }
491
493
  if (!expected || !this.#store.tryUpdateAuthCredentialIfMatches) {
492
- this.#store.updateAuthCredential(id, credential);
494
+ if (serializeCredential(provider, credential)?.data !== expected?.data) {
495
+ this.#store.updateAuthCredential(id, credential);
496
+ }
493
497
  }
494
498
  const updated = [...entries];
495
499
  updated[index] = { id, credential };
@@ -88,6 +88,23 @@ type SerializedCredentialRecord = {
88
88
  identityKey: string | null;
89
89
  };
90
90
 
91
+ /** Persisted columns compared to skip rewriting a credential row with identical bytes. */
92
+ type CredentialStateRow = {
93
+ provider: string;
94
+ credential_type: string;
95
+ data: string;
96
+ identity_key: string | null;
97
+ disabled_cause: string | null;
98
+ };
99
+
100
+ function storesSerializedCredential(row: CredentialStateRow, serialized: SerializedCredentialRecord): boolean {
101
+ return (
102
+ row.data === serialized.data &&
103
+ row.credential_type === serialized.credentialType &&
104
+ (row.identity_key ?? null) === serialized.identityKey
105
+ );
106
+ }
107
+
91
108
  const AUTH_SCHEMA_VERSION = 8;
92
109
  const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
93
110
  const LEGACY_CODEX_BLOCK_PROVIDER_KEY = "openai-codex:oauth";
@@ -378,6 +395,8 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
378
395
  #getCredentialRefreshLeaseStmt: Statement;
379
396
  #renewCredentialRefreshLeaseStmt: Statement;
380
397
  #releaseCredentialRefreshLeaseStmt: Statement;
398
+ #getCredentialStateStmt: Statement;
399
+ #holdsCredentialRefreshLeaseStmt: Statement;
381
400
  #credentialBlockReconcileAfter: Map<string, number> = new Map();
382
401
  #insertUsageHistoryStmt: Statement;
383
402
  #lastUsageHistoryStmt: Statement;
@@ -495,6 +514,12 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
495
514
  this.#releaseCredentialRefreshLeaseStmt = this.#db.prepare(
496
515
  "DELETE FROM auth_credential_refresh_leases WHERE credential_id = ? AND owner = ?",
497
516
  );
517
+ this.#getCredentialStateStmt = this.#db.prepare(
518
+ "SELECT provider, credential_type, data, identity_key, disabled_cause FROM auth_credentials WHERE id = ?",
519
+ );
520
+ this.#holdsCredentialRefreshLeaseStmt = this.#db.prepare(
521
+ "SELECT 1 FROM auth_credential_refresh_leases WHERE credential_id = ? AND owner = ? AND expires_at_ms > ?",
522
+ );
498
523
  this.#insertUsageHistoryStmt = this.#db.prepare(
499
524
  "INSERT INTO usage_history (recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
500
525
  );
@@ -1414,18 +1439,18 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
1414
1439
  }
1415
1440
  }
1416
1441
 
1442
+ /**
1443
+ * Rewrites a row only when its persisted bytes change: an identical write would
1444
+ * still bump `updated_at` and `auth_change_revision`, making every peer process
1445
+ * reload credentials for nothing.
1446
+ */
1417
1447
  updateAuthCredential(id: number, credential: AuthCredential): void {
1418
1448
  try {
1419
- const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?");
1420
- let providerRow: { provider?: string } | undefined;
1421
- try {
1422
- providerRow = providerStmt.get(id) as { provider?: string } | undefined;
1423
- } finally {
1424
- providerStmt.finalize();
1425
- }
1426
- const provider = providerRow?.provider ?? "";
1449
+ const row = this.#getCredentialStateStmt.get(id) as CredentialStateRow | null;
1450
+ const provider = row?.provider ?? "";
1427
1451
  const serialized = serializeCredential(provider, credential);
1428
1452
  if (!serialized) return;
1453
+ if (row && storesSerializedCredential(row, serialized)) return;
1429
1454
  this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, id);
1430
1455
  if (provider) {
1431
1456
  this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider));
@@ -1441,16 +1466,20 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
1441
1466
  credential: AuthCredential,
1442
1467
  lease?: CredentialRefreshLeaseFence,
1443
1468
  ): boolean {
1444
- const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?");
1445
- let providerRow: { provider?: string } | undefined;
1446
- try {
1447
- providerRow = providerStmt.get(id) as { provider?: string } | undefined;
1448
- } finally {
1449
- providerStmt.finalize();
1450
- }
1451
- const provider = providerRow?.provider ?? "";
1469
+ const row = this.#getCredentialStateStmt.get(id) as CredentialStateRow | null;
1470
+ const provider = row?.provider ?? "";
1452
1471
  const serialized = serializeCredential(provider, credential);
1453
1472
  if (!serialized) return false;
1473
+ if (
1474
+ row &&
1475
+ row.disabled_cause === null &&
1476
+ serialized.data === expectedData &&
1477
+ storesSerializedCredential(row, serialized)
1478
+ ) {
1479
+ // The row already holds exactly this credential: the CAS (and lease fence)
1480
+ // matches, but rewriting identical bytes would only churn the revision.
1481
+ return !lease || Boolean(this.#holdsCredentialRefreshLeaseStmt.get(id, lease.owner, lease.nowMs));
1482
+ }
1454
1483
  const result = lease
1455
1484
  ? (this.#updateIfMatchesWithLeaseStmt.run(
1456
1485
  serialized.credentialType,
@@ -2038,6 +2067,8 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
2038
2067
  this.#getCredentialRefreshLeaseStmt.finalize();
2039
2068
  this.#renewCredentialRefreshLeaseStmt.finalize();
2040
2069
  this.#releaseCredentialRefreshLeaseStmt.finalize();
2070
+ this.#getCredentialStateStmt.finalize();
2071
+ this.#holdsCredentialRefreshLeaseStmt.finalize();
2041
2072
  this.#db.close();
2042
2073
  }
2043
2074
  }
package/src/auth/types.ts CHANGED
@@ -581,6 +581,8 @@ export interface OAuthAccountSummary {
581
581
  orgName?: string;
582
582
  /** True when this account is the session-sticky OAuth credential requested by `listOAuthAccounts`. */
583
583
  active: boolean;
584
+ /** Last use recorded on the session sticky; set only on the `active` account. */
585
+ lastUsedAtMs?: number;
584
586
  }
585
587
  /** Scope a matching-key invalidation to a session or signal. */
586
588
  export interface InvalidateCredentialMatchingOptions {
@@ -85,6 +85,8 @@ const CONTEXT_OVERFLOW_EVIDENCE_PATTERNS = [
85
85
  /reduce the length of the messages/i, // Groq
86
86
  /maximum context length is \d+ tokens/i, // OpenRouter (all backends)
87
87
  /exceeds the available context size/i, // llama.cpp server
88
+ /\bprompt\s*\(\s*\d+\s+tokens\s*\)\s*\+\s*max\s+tokens\s*\(\s*\d+\s*\)\s+exceeds\s+the\s+context\s*\(\s*\d+\s*\)/i, // Strata server
89
+ /\bprompt\s*\(\s*\d+\s+tokens\s*\)\s+leaves\s+no\s+room\s+to\s+answer\s+in\s+the\s+context\s*\(\s*\d+\s*\)/i, // Strata server
88
90
  /requested tokens?.*exceed.*context (window|length|size)/i, // llama.cpp / OpenAI-compatible local servers
89
91
  /context (window|length|size).*(exceeded|overflow|too small)/i, // Generic local server variants
90
92
  /(prompt|input).*(too long|too large).*(context|n_ctx)/i, // llama.cpp phrasing variants
@@ -1,5 +1,6 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import * as path from "node:path";
2
- import { getLogsDir, isBunTestRuntime, isRecord } from "@oh-my-pi/pi-utils";
3
+ import { getLogsDir, isBunTestRuntime, isEnoent, isRecord, logger } from "@oh-my-pi/pi-utils";
3
4
  import * as AIError from "../error/flags";
4
5
  import { formatErrorMessageWithRetryAfter } from "./retry-after.js";
5
6
 
@@ -90,10 +91,15 @@ export async function appendRawHttpRequestDumpFor400(
90
91
 
91
92
  const payload = buildHttp400DumpPayload(dump, error, message);
92
93
  const fileName = `${Date.now()}-${Bun.hash(JSON.stringify(payload)).toString(36)}.json`;
93
- const filePath = path.join(getLogsDir(), "http-400-requests", fileName);
94
+ const dumpDir = path.join(getLogsDir(), HTTP_DUMP_DIR_NAME);
95
+ const filePath = path.join(dumpDir, fileName);
94
96
 
95
97
  try {
96
98
  await Bun.write(filePath, `${JSON.stringify(payload, null, 2)}\n`);
99
+ // Dumps are rare; bound the directory right after adding to it.
100
+ pruneHttpRequestDumps(dumpDir, { keep: fileName }).catch(err => {
101
+ logger.warn("Failed to prune HTTP request dumps", { dir: dumpDir, err });
102
+ });
97
103
  return `${message}\n${RAW_HTTP_REQUEST_LINE}${filePath}`;
98
104
  } catch (writeError) {
99
105
  const writeMessage = writeError instanceof Error ? writeError.message : String(writeError);
@@ -101,6 +107,60 @@ export async function appendRawHttpRequestDumpFor400(
101
107
  }
102
108
  }
103
109
 
110
+ const HTTP_DUMP_DIR_NAME = "http-400-requests";
111
+ const HTTP_DUMP_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
112
+ const HTTP_DUMP_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
113
+
114
+ export interface HttpRequestDumpRetention {
115
+ /** Dump file name never deleted (the one just written and referenced by the error). */
116
+ keep?: string;
117
+ /** Dumps last modified longer ago than this are deleted (default 7 days). */
118
+ maxAgeMs?: number;
119
+ /** Oldest remaining dumps are deleted until the directory totals at most this (default 64 MiB). */
120
+ maxTotalBytes?: number;
121
+ now?: number;
122
+ }
123
+
124
+ /**
125
+ * Bound the rejected-request dump directory: delete `*.json` dumps older than
126
+ * the age limit, then the oldest survivors until the total fits the size cap.
127
+ */
128
+ export async function pruneHttpRequestDumps(dir: string, retention: HttpRequestDumpRetention = {}): Promise<void> {
129
+ const maxAgeMs = retention.maxAgeMs ?? HTTP_DUMP_MAX_AGE_MS;
130
+ const maxTotalBytes = retention.maxTotalBytes ?? HTTP_DUMP_MAX_TOTAL_BYTES;
131
+ const now = retention.now ?? Date.now();
132
+ let names: string[];
133
+ try {
134
+ names = await fs.readdir(dir);
135
+ } catch (err) {
136
+ if (isEnoent(err)) return;
137
+ throw err;
138
+ }
139
+ const dumps: Array<{ name: string; mtimeMs: number; size: number }> = [];
140
+ for (const name of names) {
141
+ if (!name.endsWith(".json")) continue;
142
+ try {
143
+ const stat = await fs.stat(path.join(dir, name));
144
+ if (stat.isFile()) dumps.push({ name, mtimeMs: stat.mtimeMs, size: stat.size });
145
+ } catch (err) {
146
+ if (!isEnoent(err)) throw err;
147
+ }
148
+ }
149
+ dumps.sort((a, b) => b.mtimeMs - a.mtimeMs || (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
150
+ let retainedBytes = 0;
151
+ for (const dump of dumps) {
152
+ if (dump.name === retention.keep) {
153
+ retainedBytes += dump.size;
154
+ continue;
155
+ }
156
+ if (now - dump.mtimeMs <= maxAgeMs && retainedBytes + dump.size <= maxTotalBytes) {
157
+ retainedBytes += dump.size;
158
+ continue;
159
+ }
160
+ await fs.rm(path.join(dir, dump.name), { force: true });
161
+ }
162
+ }
163
+
104
164
  export async function finalizeErrorMessage(
105
165
  error: unknown,
106
166
  rawRequestDump: RawHttpRequestDump | undefined,
@@ -256,10 +256,14 @@ export function installGlobalProxyFetch(): void {
256
256
  NO_PROXY: Bun.env.NO_PROXY || Bun.env.no_proxy,
257
257
  };
258
258
  if (!proxyUrl) {
259
- logger.debug("global proxy fetch not installed", {
260
- reason: "PI_PROXY unset",
261
- env,
262
- });
259
+ // Without any proxy variable there is nothing to diagnose; skip the
260
+ // per-process line. When some are set, record why none was installed.
261
+ if (Object.values(env).some(value => value !== undefined && value !== "")) {
262
+ logger.debug("global proxy fetch not installed", {
263
+ reason: "PI_PROXY unset",
264
+ env,
265
+ });
266
+ }
263
267
  return;
264
268
  }
265
269
  globalProxyFetchInstalled = true;