@oh-my-pi/pi-ai 17.1.6 → 17.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/dist/types/auth-retry.d.ts +4 -4
- package/dist/types/error/auth-classify.d.ts +5 -3
- package/dist/types/utils.d.ts +8 -0
- package/package.json +4 -4
- package/src/auth-broker/remote-store.ts +32 -7
- package/src/auth-retry.ts +9 -4
- package/src/auth-storage.ts +8 -0
- package/src/error/auth-classify.ts +7 -5
- package/src/providers/cursor.ts +4 -2
- package/src/providers/devin.ts +6 -1
- package/src/providers/google-vertex.ts +3 -5
- package/src/providers/openai-codex-responses.ts +3 -1
- package/src/providers/openai-shared.ts +8 -2
- package/src/stream.ts +6 -4
- package/src/utils.ts +173 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.1.8] - 2026-07-28
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed an HTTP 400 error when resuming or replaying OpenAI history after an interrupted native Computer Use turn.
|
|
10
|
+
- Fixed connection 404 errors when using Google Vertex AI in multi-region locations (eu and us) by correctly resolving regional endpoint (REP) hosts.
|
|
11
|
+
- Fixed a resource leak in SqliteAuthCredentialStore.close() where unclosed prepared statements kept the SQLite connection alive, preventing database file cleanup (especially on Windows where files remained locked).
|
|
12
|
+
|
|
13
|
+
## [17.1.7] - 2026-07-27
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Upstream `403 Forbidden` responses (e.g. Anthropic `permission_error` plan/model denials, Copilot model-policy rejections) now rotate through sibling credentials like usage limits do, instead of failing the session on the first denied account. The denied credential is soft-blocked for 60s and re-validated — never removed — and the original 403 surfaces only once every sibling has been tried.
|
|
18
|
+
- Usage report filtering in the auth-broker remote store is memoized per (reports, snapshot) with a precomputed per-provider OAuth credential map, replacing an O(reports × credentials) scan on every credential-selection and status refresh
|
|
19
|
+
- Cursor and Devin Connect-frame readers no longer copy every stream chunk through `Buffer.concat` when the pending buffer is empty
|
|
20
|
+
|
|
5
21
|
## [17.1.6] - 2026-07-27
|
|
6
22
|
|
|
7
23
|
### Added
|
|
@@ -14,7 +14,7 @@ import { isAuthRetryableError } from "./error/auth-classify.js";
|
|
|
14
14
|
* (invalidate/usage-limit the current credential and rotate to a sibling).
|
|
15
15
|
*
|
|
16
16
|
* Current drivers preserve that bounded a/b/c sequence for ordinary 401/auth
|
|
17
|
-
* failures.
|
|
17
|
+
* failures. 403/usage-limit failures skip refresh and may repeat step (c)
|
|
18
18
|
* until the resolver returns `undefined`, cycles, or hits
|
|
19
19
|
* {@link AUTH_RETRY_MAX_ATTEMPTS}.
|
|
20
20
|
*/
|
|
@@ -84,8 +84,8 @@ export declare function resolveNextAuthRetryKey(state: AuthRetryKeyState, resolv
|
|
|
84
84
|
* - A resolver → initial `attempt`, then resolver-driven retries until the
|
|
85
85
|
* applicable policy is exhausted, the resolver declines or cycles, or the
|
|
86
86
|
* operation reaches {@link AUTH_RETRY_MAX_ATTEMPTS}. Ordinary 401/auth
|
|
87
|
-
* failures retain one refresh-same plus one sibling switch; usage
|
|
88
|
-
*
|
|
87
|
+
* failures retain one refresh-same plus one sibling switch; 403/usage-limit
|
|
88
|
+
* failures rotate directly through distinct siblings.
|
|
89
89
|
*
|
|
90
90
|
* Used by non-streaming consumers (image generation, web search, completion
|
|
91
91
|
* helpers). The streaming driver in `stream.ts` implements the same policy with
|
|
@@ -136,7 +136,7 @@ export interface WithOAuthAccessOptions {
|
|
|
136
136
|
* - initial → `getOAuthAccess` (or `opts.seed`).
|
|
137
137
|
* - 401/auth failure → one `getOAuthAccess` with `forceRefresh: true` for the
|
|
138
138
|
* current account, then sibling rotation.
|
|
139
|
-
* - usage-limit failure → `rotateSessionCredential` directly, without a
|
|
139
|
+
* - 403/usage-limit failure → `rotateSessionCredential` directly, without a
|
|
140
140
|
* force-refresh detour.
|
|
141
141
|
*
|
|
142
142
|
* A refresh-same step may retry a new bearer for the same credential identity;
|
|
@@ -9,9 +9,11 @@ export declare function isDefinitiveOAuthFailure(errorMsg: string): boolean;
|
|
|
9
9
|
export declare function isInvalidatedOAuthTokenError(error: unknown): boolean;
|
|
10
10
|
/**
|
|
11
11
|
* Whether an upstream failure should rotate to a sibling credential: a hard
|
|
12
|
-
* `401`, a
|
|
13
|
-
* account
|
|
14
|
-
*
|
|
12
|
+
* `401`, a `403` (token valid but access denied — plan, model policy, or org
|
|
13
|
+
* restriction a sibling account may not share), a body-classified usage limit
|
|
14
|
+
* (Codex `usage_limit_reached`, Anthropic account rate-limit, Google
|
|
15
|
+
* `resource_exhausted`, OpenAI `insufficient_quota`, …), or a bare `429`
|
|
16
|
+
* whose payload did not preserve a richer quota code.
|
|
15
17
|
* Transient 429s (`Too many requests`, per-minute caps) stay in the
|
|
16
18
|
* upstream-backoff lane.
|
|
17
19
|
*/
|
package/dist/types/utils.d.ts
CHANGED
|
@@ -15,8 +15,16 @@ export declare function normalizeResponsesToolCallId(id: string, itemPrefix?: Re
|
|
|
15
15
|
export declare function truncateResponseItemId(id: string, prefix: string): string;
|
|
16
16
|
interface OpenAIResponsesReplaySanitizeOptions {
|
|
17
17
|
supportsImageDetailOriginal?: boolean;
|
|
18
|
+
supportsComputerUse?: boolean;
|
|
18
19
|
}
|
|
19
20
|
export declare function sanitizeOpenAIResponsesHistoryItemsForReplay(items: Array<Record<string, unknown>>, options?: OpenAIResponsesReplaySanitizeOptions): ResponseInput;
|
|
21
|
+
/** Strip reasoning IDs whose only linked native output is a computer call that will be demoted. */
|
|
22
|
+
export declare function stripOpenAIResponsesComputerLinkedReasoningIdsForReplay(items: ResponseInput): ResponseInput;
|
|
23
|
+
/**
|
|
24
|
+
* Finalize provisional native-computer reasoning IDs after the complete
|
|
25
|
+
* Responses input has been rebuilt, model-adapted, and orphan-repaired.
|
|
26
|
+
*/
|
|
27
|
+
export declare function stripUnpairedOpenAIResponsesComputerReasoningIdsForReplay(items: ResponseInput): ResponseInput;
|
|
20
28
|
/**
|
|
21
29
|
* Sanitize assistant-native Responses history for replay.
|
|
22
30
|
*
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "17.1.
|
|
4
|
+
"version": "17.1.8",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.1",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "17.1.
|
|
42
|
-
"@oh-my-pi/pi-utils": "17.1.
|
|
43
|
-
"@oh-my-pi/pi-wire": "17.1.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "17.1.8",
|
|
42
|
+
"@oh-my-pi/pi-utils": "17.1.8",
|
|
43
|
+
"@oh-my-pi/pi-wire": "17.1.8",
|
|
44
44
|
"arktype": "2.2.3",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
|
@@ -253,6 +253,10 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
253
253
|
#usageInflight?: Promise<UsageReport[] | null>;
|
|
254
254
|
#credentialBlockReconcileAfter: Map<string, number> = new Map();
|
|
255
255
|
#usageCacheEpoch = 0;
|
|
256
|
+
/** Per-snapshot lookup of oauth credentials by provider; rebuilt when `#snapshot` is replaced. */
|
|
257
|
+
#usageFilterLookup?: { snapshot: SnapshotResponse; byProvider: Map<Provider, OAuthCredential[]> };
|
|
258
|
+
/** Memoized `#filterUsageReports` output, keyed on (input identity, lookup identity). */
|
|
259
|
+
#usageFilterResult?: { input: UsageReport[]; byProvider: Map<Provider, OAuthCredential[]>; output: UsageReport[] };
|
|
256
260
|
#closed = false;
|
|
257
261
|
/**
|
|
258
262
|
* `true` once the SSE consumer received its first frame and hasn't dropped
|
|
@@ -962,18 +966,39 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
962
966
|
return overlay ?? matched;
|
|
963
967
|
}
|
|
964
968
|
|
|
969
|
+
/**
|
|
970
|
+
* Hot path — called per `getUsageReport()`/`fetchUsageReports()` (status-line
|
|
971
|
+
* refresh cadence). The oauth-credential lookup is memoized on `#snapshot`
|
|
972
|
+
* identity (every update site replaces the reference), and the filtered
|
|
973
|
+
* output on (reports identity, lookup identity) — `#loadUsageReports`
|
|
974
|
+
* serves the same array for 15s, so steady-state calls are O(1).
|
|
975
|
+
*/
|
|
965
976
|
#filterUsageReports(reports: UsageReport[]): UsageReport[] {
|
|
966
977
|
const accountPool = this.#accountPool;
|
|
967
978
|
if (!accountPool) return reports;
|
|
968
|
-
|
|
979
|
+
let lookup = this.#usageFilterLookup;
|
|
980
|
+
if (!lookup || lookup.snapshot !== this.#snapshot) {
|
|
981
|
+
const byProvider = new Map<Provider, OAuthCredential[]>();
|
|
982
|
+
for (const entry of this.#snapshot.credentials) {
|
|
983
|
+
if (entry.credential.type !== "oauth") continue;
|
|
984
|
+
const list = byProvider.get(entry.provider);
|
|
985
|
+
if (list) list.push(entry.credential);
|
|
986
|
+
else byProvider.set(entry.provider, [entry.credential]);
|
|
987
|
+
}
|
|
988
|
+
lookup = { snapshot: this.#snapshot, byProvider };
|
|
989
|
+
this.#usageFilterLookup = lookup;
|
|
990
|
+
}
|
|
991
|
+
const memo = this.#usageFilterResult;
|
|
992
|
+
if (memo && memo.input === reports && memo.byProvider === lookup.byProvider) return memo.output;
|
|
993
|
+
const byProvider = lookup.byProvider;
|
|
994
|
+
const output = reports.filter(report => {
|
|
969
995
|
if (!accountPool.has(report.provider)) return true;
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
entry.credential.type === "oauth" &&
|
|
974
|
-
usageReportMatchesCredential(report, entry.credential),
|
|
975
|
-
);
|
|
996
|
+
const credentials = byProvider.get(report.provider);
|
|
997
|
+
if (!credentials) return false;
|
|
998
|
+
return credentials.some(credential => usageReportMatchesCredential(report, credential));
|
|
976
999
|
});
|
|
1000
|
+
this.#usageFilterResult = { input: reports, byProvider, output };
|
|
1001
|
+
return output;
|
|
977
1002
|
}
|
|
978
1003
|
|
|
979
1004
|
ingestUsageReport(provider: Provider, credential: OAuthCredential, report: UsageReport): boolean {
|
package/src/auth-retry.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
|
|
1
2
|
import type { OAuthAccess } from "./auth-storage";
|
|
2
3
|
import * as AIError from "./error";
|
|
3
4
|
import { isAuthRetryableError, isInvalidatedOAuthTokenError } from "./error/auth-classify";
|
|
@@ -18,7 +19,7 @@ import { isUsageLimitOutcome } from "./error/rate-limit";
|
|
|
18
19
|
* (invalidate/usage-limit the current credential and rotate to a sibling).
|
|
19
20
|
*
|
|
20
21
|
* Current drivers preserve that bounded a/b/c sequence for ordinary 401/auth
|
|
21
|
-
* failures.
|
|
22
|
+
* failures. 403/usage-limit failures skip refresh and may repeat step (c)
|
|
22
23
|
* until the resolver returns `undefined`, cycles, or hits
|
|
23
24
|
* {@link AUTH_RETRY_MAX_ATTEMPTS}.
|
|
24
25
|
*/
|
|
@@ -92,7 +93,11 @@ export const AUTH_RETRY_MAX_ATTEMPTS = 64;
|
|
|
92
93
|
function isDirectCredentialRotationError(error: unknown): boolean {
|
|
93
94
|
if (isUsageLimit(error) || isInvalidatedOAuthTokenError(error)) return true;
|
|
94
95
|
const status = AIError.status(error);
|
|
96
|
+
// 403: the token is valid but access was denied, so refreshing the same
|
|
97
|
+
// credential can't help — rotate straight through the sibling pool.
|
|
98
|
+
if (status === 403) return true;
|
|
95
99
|
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
|
100
|
+
if (status === undefined && message !== undefined && extractHttpStatusFromError({ message }) === 403) return true;
|
|
96
101
|
return isUsageLimitOutcome(status, message);
|
|
97
102
|
}
|
|
98
103
|
|
|
@@ -199,8 +204,8 @@ async function runOAuthAttempt<T>(
|
|
|
199
204
|
* - A resolver → initial `attempt`, then resolver-driven retries until the
|
|
200
205
|
* applicable policy is exhausted, the resolver declines or cycles, or the
|
|
201
206
|
* operation reaches {@link AUTH_RETRY_MAX_ATTEMPTS}. Ordinary 401/auth
|
|
202
|
-
* failures retain one refresh-same plus one sibling switch; usage
|
|
203
|
-
*
|
|
207
|
+
* failures retain one refresh-same plus one sibling switch; 403/usage-limit
|
|
208
|
+
* failures rotate directly through distinct siblings.
|
|
204
209
|
*
|
|
205
210
|
* Used by non-streaming consumers (image generation, web search, completion
|
|
206
211
|
* helpers). The streaming driver in `stream.ts` implements the same policy with
|
|
@@ -289,7 +294,7 @@ export interface WithOAuthAccessOptions {
|
|
|
289
294
|
* - initial → `getOAuthAccess` (or `opts.seed`).
|
|
290
295
|
* - 401/auth failure → one `getOAuthAccess` with `forceRefresh: true` for the
|
|
291
296
|
* current account, then sibling rotation.
|
|
292
|
-
* - usage-limit failure → `rotateSessionCredential` directly, without a
|
|
297
|
+
* - 403/usage-limit failure → `rotateSessionCredential` directly, without a
|
|
293
298
|
* force-refresh detour.
|
|
294
299
|
*
|
|
295
300
|
* A refresh-same step may retry a new bearer for the same credential identity;
|
package/src/auth-storage.ts
CHANGED
|
@@ -7923,6 +7923,14 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
7923
7923
|
this.#updateUsageHistoryStmt.finalize();
|
|
7924
7924
|
this.#insertUsageCostStmt.finalize();
|
|
7925
7925
|
this.#listUsageCostsStmt.finalize();
|
|
7926
|
+
this.#updateIfMatchesStmt.finalize();
|
|
7927
|
+
this.#updateIfMatchesWithLeaseStmt.finalize();
|
|
7928
|
+
this.#deleteIfMatchesWithLeaseStmt.finalize();
|
|
7929
|
+
this.#deleteCachePrefixStmt.finalize();
|
|
7930
|
+
this.#acquireCredentialRefreshLeaseStmt.finalize();
|
|
7931
|
+
this.#getCredentialRefreshLeaseStmt.finalize();
|
|
7932
|
+
this.#renewCredentialRefreshLeaseStmt.finalize();
|
|
7933
|
+
this.#releaseCredentialRefreshLeaseStmt.finalize();
|
|
7926
7934
|
this.#db.close();
|
|
7927
7935
|
}
|
|
7928
7936
|
}
|
|
@@ -26,9 +26,11 @@ export function isInvalidatedOAuthTokenError(error: unknown): boolean {
|
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Whether an upstream failure should rotate to a sibling credential: a hard
|
|
29
|
-
* `401`, a
|
|
30
|
-
* account
|
|
31
|
-
*
|
|
29
|
+
* `401`, a `403` (token valid but access denied — plan, model policy, or org
|
|
30
|
+
* restriction a sibling account may not share), a body-classified usage limit
|
|
31
|
+
* (Codex `usage_limit_reached`, Anthropic account rate-limit, Google
|
|
32
|
+
* `resource_exhausted`, OpenAI `insufficient_quota`, …), or a bare `429`
|
|
33
|
+
* whose payload did not preserve a richer quota code.
|
|
32
34
|
* Transient 429s (`Too many requests`, per-minute caps) stay in the
|
|
33
35
|
* upstream-backoff lane.
|
|
34
36
|
*/
|
|
@@ -36,9 +38,9 @@ export function isAuthRetryableError(error: unknown): boolean {
|
|
|
36
38
|
if (isUsageLimit(error)) return true;
|
|
37
39
|
if (isInvalidatedOAuthTokenError(error)) return true;
|
|
38
40
|
const httpStatus = extractHttpStatusFromError(error);
|
|
39
|
-
if (httpStatus === 401) return true;
|
|
41
|
+
if (httpStatus === 401 || httpStatus === 403) return true;
|
|
40
42
|
const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
|
|
41
43
|
const embeddedStatus = message ? extractHttpStatusFromError({ message }) : undefined;
|
|
42
|
-
if (embeddedStatus === 401) return true;
|
|
44
|
+
if (embeddedStatus === 401 || embeddedStatus === 403) return true;
|
|
43
45
|
return isUsageLimitOutcome(httpStatus ?? embeddedStatus, message);
|
|
44
46
|
}
|
package/src/providers/cursor.ts
CHANGED
|
@@ -503,7 +503,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
503
503
|
|
|
504
504
|
stream.push({ type: "start", partial: output });
|
|
505
505
|
|
|
506
|
-
let pendingBuffer = Buffer.alloc(0);
|
|
506
|
+
let pendingBuffer: Buffer = Buffer.alloc(0);
|
|
507
507
|
let currentTextBlock: (TextContent & { [kStreamingBlockIndex]: number }) | null = null;
|
|
508
508
|
let currentThinkingBlock: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null = null;
|
|
509
509
|
let currentToolCall: ToolCallState | null = null;
|
|
@@ -557,7 +557,9 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
557
557
|
log?.write(chunk);
|
|
558
558
|
});
|
|
559
559
|
}
|
|
560
|
-
|
|
560
|
+
// Steady state drains fully per chunk; alias the fresh h2 chunk instead
|
|
561
|
+
// of copying it through Buffer.concat (see aws-eventstream.ts).
|
|
562
|
+
pendingBuffer = pendingBuffer.length === 0 ? chunk : Buffer.concat([pendingBuffer, chunk]);
|
|
561
563
|
|
|
562
564
|
while (pendingBuffer.length >= 5) {
|
|
563
565
|
const flags = pendingBuffer[0];
|
package/src/providers/devin.ts
CHANGED
|
@@ -217,7 +217,12 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
|
|
|
217
217
|
for (;;) {
|
|
218
218
|
const { done, value } = await reader.read();
|
|
219
219
|
if (value && value.length > 0) {
|
|
220
|
-
|
|
220
|
+
// Steady state drains fully per chunk; view the fresh reader chunk
|
|
221
|
+
// instead of copying it through Buffer.concat (see aws-eventstream.ts).
|
|
222
|
+
pending =
|
|
223
|
+
pending.length === 0
|
|
224
|
+
? Buffer.from(value.buffer, value.byteOffset, value.byteLength)
|
|
225
|
+
: Buffer.concat([pending, value]);
|
|
221
226
|
}
|
|
222
227
|
|
|
223
228
|
while (pending.length >= 5) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveVertexEndpointHost } from "@oh-my-pi/pi-catalog/hosts";
|
|
1
2
|
import { $env } from "@oh-my-pi/pi-utils";
|
|
2
3
|
import * as AIError from "../error";
|
|
3
4
|
import type { Context, Model, StreamFunction } from "../types";
|
|
@@ -69,7 +70,7 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
|
|
|
69
70
|
// global-only request.
|
|
70
71
|
const explicitLocation = options?.location;
|
|
71
72
|
const location = explicitLocation ?? resolveAmbientLocation() ?? "global";
|
|
72
|
-
const host =
|
|
73
|
+
const host = resolveVertexEndpointHost(location);
|
|
73
74
|
const path = `${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent?alt=sse`;
|
|
74
75
|
const useGlobalFallback = !explicitLocation && host !== "aiplatform.googleapis.com";
|
|
75
76
|
return {
|
|
@@ -87,7 +88,7 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
|
|
|
87
88
|
const project = resolveProject(options);
|
|
88
89
|
const location = resolveLocation(options);
|
|
89
90
|
const accessToken = await getVertexAccessToken({ signal: options?.signal, fetch: options?.fetch });
|
|
90
|
-
const host =
|
|
91
|
+
const host = resolveVertexEndpointHost(location);
|
|
91
92
|
const url = `https://${host}/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent?alt=sse`;
|
|
92
93
|
return {
|
|
93
94
|
params,
|
|
@@ -117,9 +118,6 @@ function resolveProject(options?: GoogleVertexOptions): string {
|
|
|
117
118
|
return project;
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
function resolveEndpointHost(location: string): string {
|
|
121
|
-
return location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`;
|
|
122
|
-
}
|
|
123
121
|
function resolveAmbientLocation(): string | undefined {
|
|
124
122
|
return $env.GOOGLE_VERTEX_LOCATION || $env.GOOGLE_CLOUD_LOCATION || $env.VERTEX_LOCATION || undefined;
|
|
125
123
|
}
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
normalizeSystemPrompts,
|
|
52
52
|
sanitizeOpenAIResponsesAssistantFallbackItemsForReplay,
|
|
53
53
|
sanitizeOpenAIResponsesAssistantHistoryItemsForReplay,
|
|
54
|
+
stripOpenAIResponsesComputerLinkedReasoningIdsForReplay,
|
|
54
55
|
} from "../utils";
|
|
55
56
|
import { clearStreamingPartialJson, kStreamingLastParseLen, kStreamingPartialJson } from "../utils/block-symbols";
|
|
56
57
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
@@ -1172,8 +1173,9 @@ export function normalizeCodexToolChoice(
|
|
|
1172
1173
|
return undefined;
|
|
1173
1174
|
}
|
|
1174
1175
|
function unrollCodexComputerItems(items: ResponseInput, supportsImageDetailOriginal: boolean): ResponseInput {
|
|
1176
|
+
const replayItems = stripOpenAIResponsesComputerLinkedReasoningIdsForReplay(items);
|
|
1175
1177
|
const unrolled: ResponseInput = [];
|
|
1176
|
-
for (const item of
|
|
1178
|
+
for (const item of replayItems) {
|
|
1177
1179
|
if (item.type === "computer_call") {
|
|
1178
1180
|
const actions = item.actions ?? (item.action ? [item.action] : []);
|
|
1179
1181
|
unrolled.push({
|
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
sanitizeOpenAIResponsesAssistantFallbackItemsForReplay,
|
|
70
70
|
sanitizeOpenAIResponsesAssistantHistoryItemsForReplay,
|
|
71
71
|
sanitizeOpenAIResponsesHistoryItemsForReplay,
|
|
72
|
+
stripUnpairedOpenAIResponsesComputerReasoningIdsForReplay,
|
|
72
73
|
} from "../utils";
|
|
73
74
|
import {
|
|
74
75
|
clearStreamingPartialJson,
|
|
@@ -1646,6 +1647,7 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
|
|
|
1646
1647
|
if (historyItems && shouldReplayPayloadItems) {
|
|
1647
1648
|
const sanitizedItems = sanitizeOpenAIResponsesHistoryItemsForReplay(filterReasoning(historyItems), {
|
|
1648
1649
|
supportsImageDetailOriginal,
|
|
1650
|
+
supportsComputerUse: options.model.supportsComputerUse === true,
|
|
1649
1651
|
});
|
|
1650
1652
|
messages.push(
|
|
1651
1653
|
...adaptResponsesReplayItemsForModel(
|
|
@@ -1694,7 +1696,10 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
|
|
|
1694
1696
|
if (historyItems) {
|
|
1695
1697
|
const rawSanitizedHistoryItems = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(
|
|
1696
1698
|
filterReasoning(historyItems),
|
|
1697
|
-
{
|
|
1699
|
+
{
|
|
1700
|
+
supportsImageDetailOriginal,
|
|
1701
|
+
supportsComputerUse: options.model.supportsComputerUse === true,
|
|
1702
|
+
},
|
|
1698
1703
|
);
|
|
1699
1704
|
const sanitizedHistoryItems = rawSanitizedHistoryItems
|
|
1700
1705
|
? adaptResponsesReplayItemsForModel(
|
|
@@ -1755,7 +1760,8 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
|
|
|
1755
1760
|
}
|
|
1756
1761
|
|
|
1757
1762
|
const withRepairedOutputs = options.repairOrphanOutputs ? repairOrphanResponsesToolOutputs(messages) : messages;
|
|
1758
|
-
|
|
1763
|
+
const withRepairedCalls = repairOrphanResponsesToolCalls(withRepairedOutputs);
|
|
1764
|
+
return stripUnpairedOpenAIResponsesComputerReasoningIdsForReplay(withRepairedCalls);
|
|
1759
1765
|
}
|
|
1760
1766
|
|
|
1761
1767
|
type ResponsesReplayAssistantMessage = Omit<ResponseOutputMessage, "id"> & { id?: string };
|
package/src/stream.ts
CHANGED
|
@@ -5,7 +5,7 @@ import * as path from "node:path";
|
|
|
5
5
|
import { scheduler } from "node:timers/promises";
|
|
6
6
|
import { isOfficialAnthropicApiUrl } from "@oh-my-pi/pi-catalog/compat/anthropic";
|
|
7
7
|
import type { Effort } from "@oh-my-pi/pi-catalog/effort";
|
|
8
|
-
import { isVertexExpressOpenAIUrl, isVertexRawPredictUrl } from "@oh-my-pi/pi-catalog/hosts";
|
|
8
|
+
import { isVertexExpressOpenAIUrl, isVertexRawPredictUrl, resolveVertexEndpointHost } from "@oh-my-pi/pi-catalog/hosts";
|
|
9
9
|
import {
|
|
10
10
|
mapEffortToAnthropicAdaptiveEffort,
|
|
11
11
|
mapEffortToGoogleThinkingLevel,
|
|
@@ -662,7 +662,7 @@ function resolveVertexRequest(input: string | URL | Request): string | URL | Req
|
|
|
662
662
|
url.includes("{location}") ||
|
|
663
663
|
url.includes("%7Bproject%7D") ||
|
|
664
664
|
url.includes("%7Blocation%7D");
|
|
665
|
-
const host = location
|
|
665
|
+
const host = resolveVertexEndpointHost(location);
|
|
666
666
|
const rewritten = hasPlaceholder
|
|
667
667
|
? url
|
|
668
668
|
.replace("https://{location}-aiplatform.googleapis.com", `https://${host}`)
|
|
@@ -970,7 +970,9 @@ function extractStatusFromAssistantError(message: AssistantMessage): number | un
|
|
|
970
970
|
}
|
|
971
971
|
|
|
972
972
|
function isRetryableUpstreamError(error: unknown, status: number | undefined, message: string | undefined): boolean {
|
|
973
|
-
// 401 means the credential is bad
|
|
973
|
+
// 401 means the credential is bad; 403 is its valid-token twin (access
|
|
974
|
+
// denied by plan, model policy, or org restriction — a sibling account may
|
|
975
|
+
// not share it). Usage-limit phrasing (Codex's
|
|
974
976
|
// "You have hit your ChatGPT usage limit", Anthropic's "usage_limit_reached",
|
|
975
977
|
// Google's "resource_exhausted", OpenAI's "insufficient_quota") and 429s
|
|
976
978
|
// without transient rate-limit wording mean this account is parked but a
|
|
@@ -983,7 +985,7 @@ function isRetryableUpstreamError(error: unknown, status: number | undefined, me
|
|
|
983
985
|
// instead of burning siblings.
|
|
984
986
|
if (AIError.isUsageLimit(error)) return true;
|
|
985
987
|
if (isInvalidatedOAuthTokenError(error)) return true;
|
|
986
|
-
if (status === 401) return true;
|
|
988
|
+
if (status === 401 || status === 403) return true;
|
|
987
989
|
return isUsageLimitOutcome(status, message);
|
|
988
990
|
}
|
|
989
991
|
|
package/src/utils.ts
CHANGED
|
@@ -70,6 +70,7 @@ export function truncateResponseItemId(id: string, prefix: string): string {
|
|
|
70
70
|
|
|
71
71
|
interface OpenAIResponsesReplaySanitizeOptions {
|
|
72
72
|
supportsImageDetailOriginal?: boolean;
|
|
73
|
+
supportsComputerUse?: boolean;
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
/**
|
|
@@ -100,22 +101,185 @@ function clampReplayItemImageDetail(
|
|
|
100
101
|
return changed ? { ...item, content } : item;
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
function isOpenAIResponsesClientInputBoundary(item: Record<string, unknown>): boolean {
|
|
105
|
+
if (item.type === "message") return item.role !== "assistant";
|
|
106
|
+
if (item.type === undefined && typeof item.role === "string") return item.role !== "assistant";
|
|
107
|
+
|
|
108
|
+
switch (item.type) {
|
|
109
|
+
case "input_text":
|
|
110
|
+
case "input_image":
|
|
111
|
+
case "input_file":
|
|
112
|
+
case "input_audio":
|
|
113
|
+
case "function_call_output":
|
|
114
|
+
case "custom_tool_call_output":
|
|
115
|
+
case "computer_call_output":
|
|
116
|
+
case "local_shell_call_output":
|
|
117
|
+
case "shell_call_output":
|
|
118
|
+
case "apply_patch_call_output":
|
|
119
|
+
case "mcp_approval_response":
|
|
120
|
+
case "compaction":
|
|
121
|
+
case "compaction_summary":
|
|
122
|
+
case "compaction_trigger":
|
|
123
|
+
case "item_reference":
|
|
124
|
+
return true;
|
|
125
|
+
case "additional_tools":
|
|
126
|
+
return item.role !== "assistant";
|
|
127
|
+
case "tool_search_output":
|
|
128
|
+
return item.execution !== "server";
|
|
129
|
+
default:
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function collectOpenAIResponsesComputerLinkedReasoningItems(
|
|
135
|
+
items: Array<Record<string, unknown>>,
|
|
136
|
+
requireLaterOutput: boolean,
|
|
137
|
+
): Set<Record<string, unknown>> {
|
|
138
|
+
let computerCallsWithLaterOutputs: Set<Record<string, unknown>> | undefined;
|
|
139
|
+
if (requireLaterOutput) {
|
|
140
|
+
computerCallsWithLaterOutputs = new Set();
|
|
141
|
+
const laterComputerOutputCallIds = new Set<string>();
|
|
142
|
+
for (let index = items.length - 1; index >= 0; index--) {
|
|
143
|
+
const item = items[index]!;
|
|
144
|
+
if (item.type === "computer_call_output" && typeof item.call_id === "string") {
|
|
145
|
+
laterComputerOutputCallIds.add(item.call_id);
|
|
146
|
+
} else if (
|
|
147
|
+
item.type === "computer_call" &&
|
|
148
|
+
typeof item.id === "string" &&
|
|
149
|
+
typeof item.call_id === "string" &&
|
|
150
|
+
laterComputerOutputCallIds.has(item.call_id)
|
|
151
|
+
) {
|
|
152
|
+
computerCallsWithLaterOutputs.add(item);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const computerLinkedReasoningItems = new Set<Record<string, unknown>>();
|
|
158
|
+
const responseReasoningItems: Array<Record<string, unknown>> = [];
|
|
159
|
+
for (const item of items) {
|
|
160
|
+
if (isOpenAIResponsesClientInputBoundary(item)) {
|
|
161
|
+
responseReasoningItems.length = 0;
|
|
162
|
+
} else if (item.type === "reasoning") {
|
|
163
|
+
responseReasoningItems.push(item);
|
|
164
|
+
} else if (
|
|
165
|
+
item.type === "computer_call" &&
|
|
166
|
+
typeof item.id === "string" &&
|
|
167
|
+
(!computerCallsWithLaterOutputs || computerCallsWithLaterOutputs.has(item))
|
|
168
|
+
) {
|
|
169
|
+
for (const reasoningItem of responseReasoningItems) computerLinkedReasoningItems.add(reasoningItem);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return computerLinkedReasoningItems;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const provisionalOpenAIResponsesComputerReasoningItems = new WeakSet<object>();
|
|
176
|
+
|
|
103
177
|
export function sanitizeOpenAIResponsesHistoryItemsForReplay(
|
|
104
178
|
items: Array<Record<string, unknown>>,
|
|
105
179
|
options: OpenAIResponsesReplaySanitizeOptions = {},
|
|
106
180
|
): ResponseInput {
|
|
107
181
|
const normalizedCallIds = new Map<string, string>();
|
|
108
182
|
const supportsImageDetailOriginal = options.supportsImageDetailOriginal !== false;
|
|
183
|
+
const computerLinkedReasoningItems =
|
|
184
|
+
options.supportsComputerUse === false
|
|
185
|
+
? undefined
|
|
186
|
+
: collectOpenAIResponsesComputerLinkedReasoningItems(items, false);
|
|
109
187
|
return items.flatMap(item => {
|
|
188
|
+
const preserveForComputer = computerLinkedReasoningItems?.has(item) === true;
|
|
110
189
|
const sanitized = sanitizeOpenAIResponsesHistoryItemForReplay(
|
|
111
190
|
item,
|
|
112
191
|
normalizedCallIds,
|
|
113
192
|
supportsImageDetailOriginal,
|
|
193
|
+
preserveForComputer,
|
|
114
194
|
);
|
|
195
|
+
if (preserveForComputer && sanitized?.type === "reasoning") {
|
|
196
|
+
provisionalOpenAIResponsesComputerReasoningItems.add(sanitized);
|
|
197
|
+
}
|
|
115
198
|
return sanitized ? [sanitized] : [];
|
|
116
199
|
});
|
|
117
200
|
}
|
|
118
201
|
|
|
202
|
+
function collectOpenAIResponsesReasoningItemsWithSurvivingOutputIds(
|
|
203
|
+
items: Array<Record<string, unknown>>,
|
|
204
|
+
): Set<Record<string, unknown>> {
|
|
205
|
+
const retainedReasoningItems = new Set<Record<string, unknown>>();
|
|
206
|
+
let responseReasoningItems: Array<Record<string, unknown>> = [];
|
|
207
|
+
let hasSurvivingOutputId = false;
|
|
208
|
+
const finishResponse = (): void => {
|
|
209
|
+
if (hasSurvivingOutputId) {
|
|
210
|
+
for (const reasoningItem of responseReasoningItems) retainedReasoningItems.add(reasoningItem);
|
|
211
|
+
}
|
|
212
|
+
responseReasoningItems = [];
|
|
213
|
+
hasSurvivingOutputId = false;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
for (const item of items) {
|
|
217
|
+
if (isOpenAIResponsesClientInputBoundary(item)) {
|
|
218
|
+
finishResponse();
|
|
219
|
+
} else if (item.type === "reasoning") {
|
|
220
|
+
responseReasoningItems.push(item);
|
|
221
|
+
} else if (item.type !== "computer_call" && typeof item.id === "string") {
|
|
222
|
+
hasSurvivingOutputId = true;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
finishResponse();
|
|
226
|
+
return retainedReasoningItems;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Strip reasoning IDs whose only linked native output is a computer call that will be demoted. */
|
|
230
|
+
export function stripOpenAIResponsesComputerLinkedReasoningIdsForReplay(items: ResponseInput): ResponseInput {
|
|
231
|
+
const records = items as unknown as Array<Record<string, unknown>>;
|
|
232
|
+
const linkedReasoningItems = collectOpenAIResponsesComputerLinkedReasoningItems(records, false);
|
|
233
|
+
const retainedReasoningItems = collectOpenAIResponsesReasoningItemsWithSurvivingOutputIds(records);
|
|
234
|
+
let sanitized: ResponseInput | undefined;
|
|
235
|
+
|
|
236
|
+
for (let index = 0; index < items.length; index++) {
|
|
237
|
+
const item = items[index]!;
|
|
238
|
+
const record = records[index]!;
|
|
239
|
+
if (
|
|
240
|
+
item.type !== "reasoning" ||
|
|
241
|
+
typeof record.id !== "string" ||
|
|
242
|
+
!linkedReasoningItems.has(record) ||
|
|
243
|
+
retainedReasoningItems.has(record)
|
|
244
|
+
) {
|
|
245
|
+
sanitized?.push(item);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (!sanitized) sanitized = items.slice(0, index);
|
|
249
|
+
const { id: _id, ...withoutId } = record;
|
|
250
|
+
sanitized.push(withoutId as unknown as ResponseInput[number]);
|
|
251
|
+
}
|
|
252
|
+
return sanitized ?? items;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Finalize provisional native-computer reasoning IDs after the complete
|
|
257
|
+
* Responses input has been rebuilt, model-adapted, and orphan-repaired.
|
|
258
|
+
*/
|
|
259
|
+
export function stripUnpairedOpenAIResponsesComputerReasoningIdsForReplay(items: ResponseInput): ResponseInput {
|
|
260
|
+
const records = items as unknown as Array<Record<string, unknown>>;
|
|
261
|
+
const linkedReasoningItems = collectOpenAIResponsesComputerLinkedReasoningItems(records, true);
|
|
262
|
+
let sanitized: ResponseInput | undefined;
|
|
263
|
+
|
|
264
|
+
for (let index = 0; index < items.length; index++) {
|
|
265
|
+
const item = items[index]!;
|
|
266
|
+
const record = records[index]!;
|
|
267
|
+
if (
|
|
268
|
+
item.type !== "reasoning" ||
|
|
269
|
+
!provisionalOpenAIResponsesComputerReasoningItems.has(item) ||
|
|
270
|
+
typeof record.id !== "string" ||
|
|
271
|
+
linkedReasoningItems.has(record)
|
|
272
|
+
) {
|
|
273
|
+
sanitized?.push(item);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (!sanitized) sanitized = items.slice(0, index);
|
|
277
|
+
const { id: _id, ...withoutId } = record;
|
|
278
|
+
sanitized.push(withoutId as unknown as ResponseInput[number]);
|
|
279
|
+
}
|
|
280
|
+
return sanitized ?? items;
|
|
281
|
+
}
|
|
282
|
+
|
|
119
283
|
/**
|
|
120
284
|
* Sanitize assistant-native Responses history for replay.
|
|
121
285
|
*
|
|
@@ -198,11 +362,13 @@ function sanitizeOpenAIResponsesHistoryItemForReplay(
|
|
|
198
362
|
item: Record<string, unknown>,
|
|
199
363
|
normalizedCallIds: Map<string, string>,
|
|
200
364
|
supportsImageDetailOriginal: boolean,
|
|
365
|
+
preserveReasoningItemIds: boolean,
|
|
201
366
|
): OpenAIResponsesReplayItem | undefined {
|
|
202
367
|
if (item.type === "item_reference") return undefined;
|
|
203
368
|
if (item.type === "image_generation_call") return sanitizeOpenAIResponsesImageGenerationCallForReplay(item);
|
|
204
|
-
if (item.type === "reasoning")
|
|
205
|
-
|
|
369
|
+
if (item.type === "reasoning") {
|
|
370
|
+
return sanitizeOpenAIResponsesReasoningItemForReplay(item, preserveReasoningItemIds);
|
|
371
|
+
}
|
|
206
372
|
// Strip status only from item types whose replay input rejects output
|
|
207
373
|
// lifecycle metadata. Hosted built-in tool items require status for replay.
|
|
208
374
|
const { id: _id, ...sanitizedItem } = item;
|
|
@@ -220,8 +386,12 @@ function sanitizeOpenAIResponsesHistoryItemForReplay(
|
|
|
220
386
|
) as unknown as OpenAIResponsesReplayItem;
|
|
221
387
|
}
|
|
222
388
|
|
|
223
|
-
function sanitizeOpenAIResponsesReasoningItemForReplay(
|
|
389
|
+
function sanitizeOpenAIResponsesReasoningItemForReplay(
|
|
390
|
+
item: Record<string, unknown>,
|
|
391
|
+
preserveItemId: boolean,
|
|
392
|
+
): OpenAIResponsesReplayItem {
|
|
224
393
|
const sanitizedItem: Record<string, unknown> = { type: "reasoning" };
|
|
394
|
+
if (preserveItemId && typeof item.id === "string") sanitizedItem.id = item.id;
|
|
225
395
|
if (Array.isArray(item.summary)) sanitizedItem.summary = item.summary;
|
|
226
396
|
if (Array.isArray(item.content)) sanitizedItem.content = item.content;
|
|
227
397
|
if (typeof item.encrypted_content === "string" || item.encrypted_content === null) {
|