@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 +9 -30
- package/dist/types/auth/affinity.d.ts +5 -0
- package/dist/types/auth/pool.d.ts +3 -1
- package/dist/types/auth/sqlite-credential-store.d.ts +5 -0
- package/dist/types/auth/types.d.ts +2 -0
- package/dist/types/utils/http-inspector.d.ts +14 -0
- package/package.json +6 -6
- package/src/auth/affinity.ts +60 -8
- package/src/auth/oauth.ts +16 -11
- package/src/auth/pool.ts +6 -2
- package/src/auth/sqlite-credential-store.ts +47 -16
- package/src/auth/types.ts +2 -0
- package/src/error/flags.ts +2 -0
- package/src/utils/http-inspector.ts +62 -2
- package/src/utils/proxy.ts +8 -4
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
|
-
|
|
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.
|
|
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.
|
|
159
|
-
"@oh-my-pi/pi-catalog": "18.4.
|
|
160
|
-
"@oh-my-pi/pi-natives": "18.4.
|
|
161
|
-
"@oh-my-pi/pi-utils": "18.4.
|
|
162
|
-
"@oh-my-pi/pi-wire": "18.4.
|
|
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"
|
package/src/auth/affinity.ts
CHANGED
|
@@ -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?.(
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
|
1420
|
-
|
|
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
|
|
1445
|
-
|
|
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 {
|
package/src/error/flags.ts
CHANGED
|
@@ -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
|
|
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,
|
package/src/utils/proxy.ts
CHANGED
|
@@ -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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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;
|