@oh-my-pi/pi-ai 18.4.8 → 18.4.10

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 (45) hide show
  1. package/CHANGELOG.md +20 -30
  2. package/dist/types/auth/affinity.d.ts +5 -0
  3. package/dist/types/auth/pool.d.ts +3 -1
  4. package/dist/types/auth/sqlite-credential-store.d.ts +5 -0
  5. package/dist/types/auth/types.d.ts +2 -0
  6. package/dist/types/providers/transform-messages.d.ts +7 -0
  7. package/dist/types/utils/event-stream.d.ts +8 -0
  8. package/dist/types/utils/http-inspector.d.ts +14 -0
  9. package/dist/types/utils/idle-iterator.d.ts +11 -8
  10. package/dist/types/utils/tool-call-arguments.d.ts +9 -0
  11. package/dist/types/utils.d.ts +16 -0
  12. package/package.json +6 -6
  13. package/src/auth/affinity.ts +60 -8
  14. package/src/auth/oauth.ts +16 -11
  15. package/src/auth/pool.ts +6 -2
  16. package/src/auth/sqlite-credential-store.ts +47 -16
  17. package/src/auth/types.ts +2 -0
  18. package/src/dialect/deepseek.ts +2 -5
  19. package/src/dialect/harmony.ts +2 -6
  20. package/src/dialect/hermes.ts +2 -5
  21. package/src/dialect/kimi.ts +2 -6
  22. package/src/dialect/owned-stream.ts +28 -3
  23. package/src/dialect/qwen3.ts +2 -5
  24. package/src/error/flags.ts +2 -0
  25. package/src/error/rate-limit.ts +11 -0
  26. package/src/providers/amazon-bedrock.ts +101 -70
  27. package/src/providers/anthropic.ts +5 -18
  28. package/src/providers/apple-foundation-models.ts +2 -1
  29. package/src/providers/cursor.ts +17 -7
  30. package/src/providers/devin.ts +3 -2
  31. package/src/providers/gitlab-duo-workflow.ts +3 -8
  32. package/src/providers/ollama.ts +3 -1
  33. package/src/providers/openai-codex-responses.ts +6 -4
  34. package/src/providers/openai-completions.ts +2 -1
  35. package/src/providers/openai-shared.ts +5 -7
  36. package/src/providers/register-builtins.ts +1 -1
  37. package/src/providers/transform-messages.ts +32 -14
  38. package/src/registry/oauth/factory-droid.ts +21 -1
  39. package/src/utils/event-stream.ts +13 -0
  40. package/src/utils/http-inspector.ts +62 -2
  41. package/src/utils/idle-iterator.ts +29 -24
  42. package/src/utils/proxy.ts +8 -4
  43. package/src/utils/tool-call-arguments.ts +32 -0
  44. package/src/utils/validation.ts +2 -6
  45. package/src/utils.ts +113 -3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.4.10] - 2026-10-02
6
+
7
+ ### Fixed
8
+
9
+ - Factory Droid login now reports the account's organization error instead of accepting a credential that every request rejects ([#14032](https://github.com/can1357/oh-my-pi/issues/14032)).
10
+ - Fixed Cursor turns being aborted with "Provider stream stalled while waiting for the next event" right after a long local tool finished; the provider now gets a full idle window once local tool work completes ([#13682](https://github.com/can1357/oh-my-pi/pull/13682) by [@eggpeat](https://github.com/eggpeat))
11
+ - Tool calls whose final argument JSON is cut off or followed by trailing text are no longer executed from an auto-closed preview. OpenAI Completions, Anthropic, Bedrock, Responses, Codex, Devin, Ollama, Apple, Cursor, GitLab Duo, and the in-band JSON dialects now give such a call the existing parse-error arguments, so the tool is not run and the model receives the parse error and can resend the call ([#13868](https://github.com/can1357/oh-my-pi/pull/13868) by [@alphastorm](https://github.com/alphastorm))
12
+ - Fixed Cursor "prepaid balance is used up" (`USAGE_PRICING_REQUIRED`) failures repeating on the same account instead of rotating to a sibling Cursor credential ([#14053](https://github.com/can1357/oh-my-pi/issues/14053))
13
+ - Sessions no longer get stuck on `400 string_above_max_length` after a model writes its whole tool invocation into the tool name. Tool calls with blank names, names longer than 128 characters, or names containing whitespace or control characters are dropped from replayed history, together with their tool results. This also applies when OpenAI Responses replays its stored native history ([#13985](https://github.com/can1357/oh-my-pi/pull/13985) by [@Xytronix](https://github.com/Xytronix)).
14
+ - Fixed Bedrock Converse requests failing with a "bound to a different conversation" 400 after the system prompt changed under signed thinking: the request is retried once without replayed reasoning ([#14019](https://github.com/can1357/oh-my-pi/pull/14019) by [@nick-maderight](https://github.com/nick-maderight))
15
+
16
+ ## [18.4.9] - 2026-10-01
17
+
18
+ ### Fixed
19
+
20
+ - Fixed rejected HTTP 400 requests from consuming unbounded disk space by automatically cleaning up old request logs and enforcing a size limit.
21
+ - Fixed unnecessary credential and session updates that could trigger needless authentication reloads in other running processes.
22
+ - Fixed context-overflow recovery for Strata requests that exceed the model context limit but return no usage information.
23
+
5
24
  ## [18.4.6] - 2026-10-01
6
25
 
7
26
  ### Fixed
@@ -2287,33 +2306,4 @@
2287
2306
  - 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
2307
  - 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
2308
 
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).
2309
+ 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 {
@@ -1,4 +1,11 @@
1
1
  import type { Api, AssistantMessage, Message, Model } from "../types.js";
2
+ /**
3
+ * Whether a tool-call name cannot belong to any declared tool: missing, empty,
4
+ * longer than OpenAI's 128-character replay limit, or containing whitespace or
5
+ * control characters (tool schemas never allow them; their presence means the
6
+ * name slot carries invocation text).
7
+ */
8
+ export declare function isMalformedToolCallName(name: unknown): boolean;
2
9
  /**
3
10
  * Normalize tool call ID for cross-provider compatibility.
4
11
  * OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
@@ -2,6 +2,12 @@ import type { AssistantMessage, AssistantMessageEvent } from "../types.js";
2
2
  /** Anything a stream watchdog can consult for in-flight consumer-side local work. */
3
3
  export interface LocalWorkSource {
4
4
  readonly hasPendingLocalWork: boolean;
5
+ /**
6
+ * Epoch ms at which tracked local work last drained to zero, or 0 if none
7
+ * has completed. The provider cannot answer until it receives the local
8
+ * result, so idle watchdogs measure provider silence from this instant.
9
+ */
10
+ readonly localWorkSettledAt: number;
5
11
  }
6
12
  export declare class EventStream<T, R = T> implements AsyncIterable<T> {
7
13
  #private;
@@ -35,6 +41,8 @@ export declare class EventStream<T, R = T> implements AsyncIterable<T> {
35
41
  result(): Promise<R>;
36
42
  /** True while local work tracked via {@link trackLocalWork} — on this stream or a forwarded delegate — is pending. */
37
43
  get hasPendingLocalWork(): boolean;
44
+ /** Latest {@link LocalWorkSource.localWorkSettledAt} across this stream and a forwarded delegate. */
45
+ get localWorkSettledAt(): number;
38
46
  /**
39
47
  * Count `source`'s pending local work as this stream's own. Used when this
40
48
  * stream forwards another's events (Cursor discovered-id retry) so the
@@ -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.
@@ -1,3 +1,4 @@
1
+ import type { LocalWorkSource } from "./event-stream.js";
1
2
  /**
2
3
  * Returns the idle timeout used for provider streaming transports.
3
4
  *
@@ -86,15 +87,17 @@ export interface IdleTimeoutIteratorOptions {
86
87
  */
87
88
  isProgressItem?: (item: unknown) => boolean;
88
89
  /**
89
- * Reports consumer-side local work in flight for the stream: the provider
90
- * transport is waiting on a server-requested local tool bridge (e.g. the
91
- * Cursor exec channel) before anything can flow upstream again. While it
92
- * returns true, an expired idle / first-item deadline slides forward
93
- * instead of aborting — the silence is ours, not a provider stall. The
94
- * watchdog re-arms with a full budget once the local work completes, so a
95
- * provider that stalls afterwards is still caught.
90
+ * Consumer-side local work in flight for the stream: the provider transport
91
+ * is waiting on a server-requested local tool bridge (e.g. the Cursor exec
92
+ * channel) before anything can flow upstream again. While work is pending,
93
+ * an expired idle / first-item deadline slides forward instead of aborting —
94
+ * the silence is ours, not a provider stall. The provider cannot respond
95
+ * before it receives the local result, so both deadlines are measured from
96
+ * no earlier than `localWorkSettledAt`: a tool that finishes just before the
97
+ * deadline gets a full budget, and a provider that stalls afterwards is
98
+ * still caught.
96
99
  */
97
- hasPendingLocalWork?: () => boolean;
100
+ localWork?: LocalWorkSource;
98
101
  /**
99
102
  * Cancel iteration as soon as this signal aborts. Required for caller-driven
100
103
  * cancellation (ESC) when the underlying transport does not surface signal
@@ -0,0 +1,9 @@
1
+ import type { ToolCall } from "../types.js";
2
+ /** Final arguments must not reuse the streaming parser's auto-closed preview. */
3
+ export declare function parseToolCallArguments(json: string | undefined): ToolCall["arguments"];
4
+ /** Longest raw argument text kept in the parse-error diagnostic. */
5
+ export declare const INVALID_ARGUMENTS_RAW_LIMIT = 512;
6
+ /** Bound raw argument text for a diagnostic. Text this function already bounded is returned unchanged. */
7
+ export declare function boundRawToolArguments(raw: string): string;
8
+ /** Preserve a bounded diagnostic, never any partially recovered executable keys. */
9
+ export declare function invalidToolCallArguments(raw: string, error: unknown): ToolCall["arguments"];
@@ -63,6 +63,22 @@ export declare function sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(it
63
63
  * Drop hidden-only fallback assistant replay after a native Responses snapshot is rejected.
64
64
  */
65
65
  export declare function sanitizeOpenAIResponsesAssistantFallbackItemsForReplay(items: ResponseInput): ResponseInput;
66
+ /**
67
+ * Drop each native tool call with a malformed name and only the matching
68
+ * `function_call_output` / `custom_tool_call_output` in the same window.
69
+ *
70
+ * `call_id` is not unique across a snapshot. Pair the same way
71
+ * `sanitizeMalformedToolCalls` does for transcript messages: a per-id FIFO of
72
+ * malformed-ness, keyed by function vs custom, cleared at user/developer and
73
+ * other non-result client boundaries so a missing result cannot eat the next
74
+ * window's output. Returns `items` itself when nothing is malformed.
75
+ */
76
+ export declare function dropMalformedOpenAIResponsesToolCalls<T extends {
77
+ type?: unknown;
78
+ call_id?: unknown;
79
+ name?: unknown;
80
+ role?: unknown;
81
+ }>(items: readonly T[]): T[];
66
82
  export declare function createOpenAIResponsesHistoryPayload(provider: string, items: Array<Record<string, unknown>>, incremental?: boolean): OpenAIResponsesHistoryPayload;
67
83
  export declare function getOpenAIResponsesHistoryPayload(providerPayload: ProviderPayload | undefined, currentProvider: string, fallbackProvider?: string): OpenAIResponsesHistoryPayload | undefined;
68
84
  export declare function getOpenAIResponsesHistoryItems(providerPayload: ProviderPayload | undefined, currentProvider: string, fallbackProvider?: string): Array<Record<string, unknown>> | undefined;
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.10",
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.10",
159
+ "@oh-my-pi/pi-catalog": "18.4.10",
160
+ "@oh-my-pi/pi-natives": "18.4.10",
161
+ "@oh-my-pi/pi-utils": "18.4.10",
162
+ "@oh-my-pi/pi-wire": "18.4.10"
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 {
@@ -1,5 +1,6 @@
1
1
  import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
2
2
  import type { Message, ToolCall } from "../types";
3
+ import { parseToolCallArguments } from "../utils/tool-call-arguments";
3
4
  import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
4
5
  import dialectPrompt from "./deepseek.md" with { type: "text" };
5
6
  import { assistantTranscriptParts, collectToolResultRun, messageContentText, stringifyJson } from "./rendering";
@@ -477,11 +478,7 @@ export class DeepSeekInbandScanner implements InbandScanner {
477
478
  #parseArgs(rawArgs: string): Record<string, unknown> {
478
479
  const trimmed = rawArgs.trim();
479
480
  if (trimmed.length === 0) return {};
480
- try {
481
- return asRecord(parseJsonWithRepair<unknown>(trimmed));
482
- } catch {
483
- return {};
484
- }
481
+ return asRecord(parseToolCallArguments(trimmed));
485
482
  }
486
483
 
487
484
  #skipWhitespace(): string {
@@ -1,5 +1,5 @@
1
- import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
2
1
  import type { Message, ToolCall } from "../types";
2
+ import { parseToolCallArguments } from "../utils/tool-call-arguments";
3
3
  import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
4
4
  import dialectPrompt from "./harmony.md" with { type: "text" };
5
5
  import {
@@ -225,11 +225,7 @@ export class HarmonyInbandScanner implements InbandScanner {
225
225
  #parseArgs(): Record<string, unknown> {
226
226
  const raw = this.#toolArgs.trim();
227
227
  if (raw.length === 0) return {};
228
- try {
229
- return asRecord(parseJsonWithRepair<unknown>(raw));
230
- } catch {
231
- return {};
232
- }
228
+ return asRecord(parseToolCallArguments(raw));
233
229
  }
234
230
 
235
231
  #clearBody(resetRawBlock = true): void {
@@ -1,5 +1,6 @@
1
1
  import { parseJsonWithRepair, parseStreamingJson } from "@oh-my-pi/pi-utils";
2
2
  import type { Message, ToolCall } from "../types";
3
+ import { parseToolCallArguments } from "../utils/tool-call-arguments";
3
4
  import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
4
5
  import dialectPrompt from "./hermes.md" with { type: "text" };
5
6
  import { renderChatMlTranscript, renderDelimitedThinking, renderToolResponseResults, stringifyJson } from "./rendering";
@@ -147,11 +148,7 @@ export class HermesInbandScanner implements InbandScanner {
147
148
  if (typeof parsed.name !== "string" || parsed.name.length === 0) return undefined;
148
149
  let args = parsed.arguments;
149
150
  if (typeof args === "string") {
150
- try {
151
- args = parseJsonWithRepair<unknown>(args);
152
- } catch {
153
- args = {};
154
- }
151
+ args = parseToolCallArguments(args);
155
152
  }
156
153
  return { name: parsed.name, arguments: asRecord(args) };
157
154
  } catch {