@oh-my-pi/pi-ai 16.4.1 → 16.4.3
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 +17 -0
- package/dist/types/auth-storage.d.ts +42 -2
- package/dist/types/providers/openai-shared.d.ts +2 -2
- package/dist/types/utils.d.ts +5 -2
- package/package.json +5 -5
- package/src/auth-storage.ts +423 -12
- package/src/dialect/rendering.ts +2 -1
- package/src/providers/openai-codex-responses.ts +13 -0
- package/src/providers/openai-responses-server.ts +8 -2
- package/src/providers/openai-shared.ts +108 -9
- package/src/utils/schema/normalize.ts +15 -2
- package/src/utils.ts +49 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.4.3] - 2026-07-11
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed auth database upgrades from schema v5 by creating the OAuth credential refresh-lease table before lease statements are prepared.
|
|
10
|
+
- Fixed an issue in the Responses API where empty tool results were incorrectly serialized with a "(see attached image)" placeholder, causing models to look for non-existent attachments.
|
|
11
|
+
- Fixed OpenAI Responses server non-streaming envelopes to always include the required "incomplete_details" field, using null for completed responses.
|
|
12
|
+
- Preserved Cloud Code Assist tool schemas when mixed-type unions carry branch-local validation descriptions.
|
|
13
|
+
|
|
14
|
+
## [16.4.2] - 2026-07-10
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Fixed compatibility with xAI by automatically downgrading OpenAI-specific tool calls and image detail settings during message history replays.
|
|
19
|
+
- Fixed a race condition in shared SQLite OAuth token refreshes by implementing durable credential ownership and compare-and-set persistence to prevent stale refresh failures.
|
|
20
|
+
- Fixed OpenAI Codex requests to include the required version header for newly gated models.
|
|
21
|
+
|
|
5
22
|
## [16.4.1] - 2026-07-10
|
|
6
23
|
|
|
7
24
|
### Changed
|
|
@@ -227,12 +227,17 @@ export interface AuthCredentialSnapshot {
|
|
|
227
227
|
* a remote broker; mutating methods (`replace*`, `upsert*`, `delete*ForProvider`)
|
|
228
228
|
* throw because login flows route through the broker, not the client.
|
|
229
229
|
*/
|
|
230
|
+
export interface CredentialRefreshLeaseFence {
|
|
231
|
+
owner: string;
|
|
232
|
+
nowMs: number;
|
|
233
|
+
}
|
|
230
234
|
export interface AuthCredentialStore {
|
|
231
235
|
close(): void;
|
|
232
236
|
listAuthCredentials(provider?: string): StoredAuthCredential[];
|
|
233
237
|
updateAuthCredential(id: number, credential: AuthCredential): void;
|
|
234
238
|
deleteAuthCredential(id: number, disabledCause: string): void;
|
|
235
|
-
tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean;
|
|
239
|
+
tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string, lease?: CredentialRefreshLeaseFence): boolean;
|
|
240
|
+
tryUpdateAuthCredentialIfMatches?(id: number, expectedData: string, credential: AuthCredential, lease?: CredentialRefreshLeaseFence): boolean;
|
|
236
241
|
replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[];
|
|
237
242
|
upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[];
|
|
238
243
|
deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void;
|
|
@@ -253,6 +258,10 @@ export interface AuthCredentialStore {
|
|
|
253
258
|
cleanExpiredCredentialBlocks?(nowMs: number): void;
|
|
254
259
|
/** List non-expired blocks for broker snapshots. */
|
|
255
260
|
listCredentialBlocks?(credentialIds: readonly number[]): StoredCredentialBlock[];
|
|
261
|
+
tryAcquireCredentialRefreshLease?(credentialId: number, owner: string, expiresAtMs: number): boolean;
|
|
262
|
+
getCredentialRefreshLeaseExpiresAt?(credentialId: number): number | undefined;
|
|
263
|
+
releaseCredentialRefreshLease?(credentialId: number, owner: string): void;
|
|
264
|
+
renewCredentialRefreshLease?(credentialId: number, owner: string, expiresAtMs: number): boolean;
|
|
256
265
|
/**
|
|
257
266
|
* Append usage-limit snapshots for trend history. Optional: stores without
|
|
258
267
|
* durable storage (e.g. the broker remote store) omit it and recording is
|
|
@@ -517,6 +526,28 @@ export interface InvalidateCredentialMatchingOptions {
|
|
|
517
526
|
signal?: AbortSignal;
|
|
518
527
|
sessionId?: string;
|
|
519
528
|
}
|
|
529
|
+
/** Options for refreshing one stored OAuth row through durable ownership. */
|
|
530
|
+
export interface StoredOAuthRefreshOptions<T extends OAuthCredential = OAuthCredential> {
|
|
531
|
+
observedCredential?: T;
|
|
532
|
+
credentialFromRow: (credential: OAuthCredential) => T | undefined;
|
|
533
|
+
forceRefresh?: boolean;
|
|
534
|
+
canRefresh?: (credential: T) => boolean;
|
|
535
|
+
refreshSkewMs?: number;
|
|
536
|
+
signal?: AbortSignal;
|
|
537
|
+
keepCredentialOnRefreshFailure?: boolean | ((error: unknown) => boolean);
|
|
538
|
+
onRefreshFailure?: (error: unknown) => void;
|
|
539
|
+
refreshTimeoutMs?: number;
|
|
540
|
+
refresh: (credential: T, signal?: AbortSignal) => Promise<OAuthCredentials>;
|
|
541
|
+
mergeRefreshedCredential?: (credential: T, refreshed: OAuthCredentials) => T;
|
|
542
|
+
isDefinitiveFailure?: (error: unknown) => boolean;
|
|
543
|
+
disabledCause?: (error: unknown) => string;
|
|
544
|
+
}
|
|
545
|
+
/** Result of a stored OAuth refresh attempt. */
|
|
546
|
+
export interface StoredOAuthRefreshResult<T extends OAuthCredential = OAuthCredential> {
|
|
547
|
+
credential: T | undefined;
|
|
548
|
+
refreshed: boolean;
|
|
549
|
+
removed: boolean;
|
|
550
|
+
}
|
|
520
551
|
/**
|
|
521
552
|
* Identifies which stored account to redeem a saved rate-limit reset for.
|
|
522
553
|
* Any one field is enough; `credentialId` is the most precise.
|
|
@@ -647,6 +678,10 @@ export declare class AuthStorage {
|
|
|
647
678
|
* List stored credential rows, optionally filtered by provider.
|
|
648
679
|
*/
|
|
649
680
|
listStoredCredentials(provider?: string): StoredAuthCredential[];
|
|
681
|
+
/**
|
|
682
|
+
* Refresh one stored OAuth credential under durable row ownership.
|
|
683
|
+
*/
|
|
684
|
+
refreshStoredOAuthCredential<T extends OAuthCredential = OAuthCredential>(provider: string, options: StoredOAuthRefreshOptions<T>): Promise<StoredOAuthRefreshResult<T>>;
|
|
650
685
|
/**
|
|
651
686
|
* Remove credential for a provider.
|
|
652
687
|
*/
|
|
@@ -1025,6 +1060,7 @@ export declare class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1025
1060
|
replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[];
|
|
1026
1061
|
upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[];
|
|
1027
1062
|
updateAuthCredential(id: number, credential: AuthCredential): void;
|
|
1063
|
+
tryUpdateAuthCredentialIfMatches(id: number, expectedData: string, credential: AuthCredential, lease?: CredentialRefreshLeaseFence): boolean;
|
|
1028
1064
|
deleteAuthCredential(id: number, disabledCause: string): void;
|
|
1029
1065
|
/**
|
|
1030
1066
|
* CAS-style disable: only soft-deletes the row when its `data` column still
|
|
@@ -1032,7 +1068,7 @@ export declare class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1032
1068
|
* the OAuth refresh-failure path to avoid clobbering a peer that rotated the
|
|
1033
1069
|
* row between our pre-check and the disable.
|
|
1034
1070
|
*/
|
|
1035
|
-
tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean;
|
|
1071
|
+
tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string, lease?: CredentialRefreshLeaseFence): boolean;
|
|
1036
1072
|
deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void;
|
|
1037
1073
|
getCache(key: string, options?: {
|
|
1038
1074
|
includeExpired?: boolean;
|
|
@@ -1045,6 +1081,10 @@ export declare class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1045
1081
|
deleteCredentialBlocks(credentialId: number): void;
|
|
1046
1082
|
cleanExpiredCredentialBlocks(nowMs: number): void;
|
|
1047
1083
|
listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[];
|
|
1084
|
+
tryAcquireCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean;
|
|
1085
|
+
getCredentialRefreshLeaseExpiresAt(credentialId: number): number | undefined;
|
|
1086
|
+
renewCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean;
|
|
1087
|
+
releaseCredentialRefreshLease(credentialId: number, owner: string): void;
|
|
1048
1088
|
recordUsageSnapshots(entries: UsageHistoryEntry[]): void;
|
|
1049
1089
|
listUsageHistory(query?: UsageHistoryQuery): UsageHistoryEntry[];
|
|
1050
1090
|
recordUsageCosts(entries: UsageCostHistoryEntry[]): void;
|
|
@@ -377,8 +377,8 @@ export interface BuildResponsesInputOptions<TApi extends Api> {
|
|
|
377
377
|
preserveAssistantMessageIds?: boolean;
|
|
378
378
|
}
|
|
379
379
|
export declare function buildResponsesInput<TApi extends Api>(options: BuildResponsesInputOptions<TApi>): ResponseInput;
|
|
380
|
-
export declare function convertResponsesAssistantMessage<TApi extends Api>(assistantMsg: AssistantMessage, model: Model<TApi>, msgIndex: number, knownCallIds: Set<string>, includeThinkingSignatures?: boolean, customCallIds?: Set<string>, preserveMessageIds?: boolean): ResponseInput;
|
|
381
|
-
export declare function appendResponsesToolResultMessages<TApi extends Api>(messages: ResponseInput, toolResult: ToolResultMessage, model: Model<TApi>, strictResponsesPairing: boolean, supportsImageDetailOriginal: boolean, knownCallIds: ReadonlySet<string>, customCallIds?: ReadonlySet<string
|
|
380
|
+
export declare function convertResponsesAssistantMessage<TApi extends Api>(assistantMsg: AssistantMessage, model: Model<TApi>, msgIndex: number, knownCallIds: Set<string>, includeThinkingSignatures?: boolean, customCallIds?: Set<string>, preserveMessageIds?: boolean, supportsCustomToolCalls?: boolean, customToolWireNameMap?: ReadonlyMap<string, string>): ResponseInput;
|
|
381
|
+
export declare function appendResponsesToolResultMessages<TApi extends Api>(messages: ResponseInput, toolResult: ToolResultMessage, model: Model<TApi>, strictResponsesPairing: boolean, supportsImageDetailOriginal: boolean, knownCallIds: ReadonlySet<string>, customCallIds?: ReadonlySet<string>, supportsCustomToolCalls?: boolean): void;
|
|
382
382
|
/**
|
|
383
383
|
* Per-block accumulation helpers shared by the two Responses decode loops —
|
|
384
384
|
* {@link processResponsesStream} (generic Responses) and the Codex stream
|
package/dist/types/utils.d.ts
CHANGED
|
@@ -13,7 +13,10 @@ export declare function normalizeResponsesToolCallId(id: string, itemPrefix?: Re
|
|
|
13
13
|
* IDs exceeding the limit are replaced with a hash-based ID using the given prefix.
|
|
14
14
|
*/
|
|
15
15
|
export declare function truncateResponseItemId(id: string, prefix: string): string;
|
|
16
|
-
|
|
16
|
+
interface OpenAIResponsesReplaySanitizeOptions {
|
|
17
|
+
supportsImageDetailOriginal?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare function sanitizeOpenAIResponsesHistoryItemsForReplay(items: Array<Record<string, unknown>>, options?: OpenAIResponsesReplaySanitizeOptions): ResponseInput;
|
|
17
20
|
/**
|
|
18
21
|
* Sanitize assistant-native Responses history for replay.
|
|
19
22
|
*
|
|
@@ -21,7 +24,7 @@ export declare function sanitizeOpenAIResponsesHistoryItemsForReplay(items: Arra
|
|
|
21
24
|
* empty assistant message, allowing callers to rebuild visible transcript
|
|
22
25
|
* history instead of replaying stale native state.
|
|
23
26
|
*/
|
|
24
|
-
export declare function sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(items: Array<Record<string, unknown
|
|
27
|
+
export declare function sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(items: Array<Record<string, unknown>>, options?: OpenAIResponsesReplaySanitizeOptions): ResponseInput | undefined;
|
|
25
28
|
/**
|
|
26
29
|
* Drop hidden-only fallback assistant replay after a native Responses snapshot is rejected.
|
|
27
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": "16.4.
|
|
4
|
+
"version": "16.4.3",
|
|
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,10 +38,10 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.0",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "16.4.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.4.
|
|
43
|
-
"@oh-my-pi/pi-wire": "16.4.
|
|
44
|
-
"arktype": "
|
|
41
|
+
"@oh-my-pi/pi-catalog": "16.4.3",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.4.3",
|
|
43
|
+
"@oh-my-pi/pi-wire": "16.4.3",
|
|
44
|
+
"arktype": "2.2.2",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
package/src/auth-storage.ts
CHANGED
|
@@ -308,12 +308,28 @@ export interface AuthCredentialSnapshot {
|
|
|
308
308
|
* a remote broker; mutating methods (`replace*`, `upsert*`, `delete*ForProvider`)
|
|
309
309
|
* throw because login flows route through the broker, not the client.
|
|
310
310
|
*/
|
|
311
|
+
export interface CredentialRefreshLeaseFence {
|
|
312
|
+
owner: string;
|
|
313
|
+
nowMs: number;
|
|
314
|
+
}
|
|
315
|
+
|
|
311
316
|
export interface AuthCredentialStore {
|
|
312
317
|
close(): void;
|
|
313
318
|
listAuthCredentials(provider?: string): StoredAuthCredential[];
|
|
314
319
|
updateAuthCredential(id: number, credential: AuthCredential): void;
|
|
315
320
|
deleteAuthCredential(id: number, disabledCause: string): void;
|
|
316
|
-
tryDisableAuthCredentialIfMatches(
|
|
321
|
+
tryDisableAuthCredentialIfMatches(
|
|
322
|
+
id: number,
|
|
323
|
+
expectedData: string,
|
|
324
|
+
disabledCause: string,
|
|
325
|
+
lease?: CredentialRefreshLeaseFence,
|
|
326
|
+
): boolean;
|
|
327
|
+
tryUpdateAuthCredentialIfMatches?(
|
|
328
|
+
id: number,
|
|
329
|
+
expectedData: string,
|
|
330
|
+
credential: AuthCredential,
|
|
331
|
+
lease?: CredentialRefreshLeaseFence,
|
|
332
|
+
): boolean;
|
|
317
333
|
replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[];
|
|
318
334
|
upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[];
|
|
319
335
|
deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void;
|
|
@@ -332,6 +348,10 @@ export interface AuthCredentialStore {
|
|
|
332
348
|
cleanExpiredCredentialBlocks?(nowMs: number): void;
|
|
333
349
|
/** List non-expired blocks for broker snapshots. */
|
|
334
350
|
listCredentialBlocks?(credentialIds: readonly number[]): StoredCredentialBlock[];
|
|
351
|
+
tryAcquireCredentialRefreshLease?(credentialId: number, owner: string, expiresAtMs: number): boolean;
|
|
352
|
+
getCredentialRefreshLeaseExpiresAt?(credentialId: number): number | undefined;
|
|
353
|
+
releaseCredentialRefreshLease?(credentialId: number, owner: string): void;
|
|
354
|
+
renewCredentialRefreshLease?(credentialId: number, owner: string, expiresAtMs: number): boolean;
|
|
335
355
|
/**
|
|
336
356
|
* Append usage-limit snapshots for trend history. Optional: stores without
|
|
337
357
|
* durable storage (e.g. the broker remote store) omit it and recording is
|
|
@@ -593,6 +613,10 @@ const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 10_000;
|
|
|
593
613
|
* the rotation cadence by <4%.
|
|
594
614
|
*/
|
|
595
615
|
const OAUTH_REFRESH_SKEW_MS = 60_000;
|
|
616
|
+
const OAUTH_REFRESH_LEASE_TTL_MS = 15_000;
|
|
617
|
+
const OAUTH_REFRESH_LEASE_POLL_MS = 50;
|
|
618
|
+
const OAUTH_REFRESH_LEASE_RENEW_MS = 5_000;
|
|
619
|
+
const OAUTH_REFRESH_OPERATION_TIMEOUT_MS = 10_000;
|
|
596
620
|
/**
|
|
597
621
|
* Cap on the buffered credential_disabled backlog held while no handler is attached.
|
|
598
622
|
* In practice the backlog is 0–N where N ≈ active providers (≤ ~20). The cap exists so
|
|
@@ -718,6 +742,30 @@ export interface InvalidateCredentialMatchingOptions {
|
|
|
718
742
|
sessionId?: string;
|
|
719
743
|
}
|
|
720
744
|
|
|
745
|
+
/** Options for refreshing one stored OAuth row through durable ownership. */
|
|
746
|
+
export interface StoredOAuthRefreshOptions<T extends OAuthCredential = OAuthCredential> {
|
|
747
|
+
observedCredential?: T;
|
|
748
|
+
credentialFromRow: (credential: OAuthCredential) => T | undefined;
|
|
749
|
+
forceRefresh?: boolean;
|
|
750
|
+
canRefresh?: (credential: T) => boolean;
|
|
751
|
+
refreshSkewMs?: number;
|
|
752
|
+
signal?: AbortSignal;
|
|
753
|
+
keepCredentialOnRefreshFailure?: boolean | ((error: unknown) => boolean);
|
|
754
|
+
onRefreshFailure?: (error: unknown) => void;
|
|
755
|
+
refreshTimeoutMs?: number;
|
|
756
|
+
refresh: (credential: T, signal?: AbortSignal) => Promise<OAuthCredentials>;
|
|
757
|
+
mergeRefreshedCredential?: (credential: T, refreshed: OAuthCredentials) => T;
|
|
758
|
+
isDefinitiveFailure?: (error: unknown) => boolean;
|
|
759
|
+
disabledCause?: (error: unknown) => string;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Result of a stored OAuth refresh attempt. */
|
|
763
|
+
export interface StoredOAuthRefreshResult<T extends OAuthCredential = OAuthCredential> {
|
|
764
|
+
credential: T | undefined;
|
|
765
|
+
refreshed: boolean;
|
|
766
|
+
removed: boolean;
|
|
767
|
+
}
|
|
768
|
+
|
|
721
769
|
/**
|
|
722
770
|
* Identifies which stored account to redeem a saved rate-limit reset for.
|
|
723
771
|
* Any one field is enough; `credentialId` is the most precise.
|
|
@@ -1833,6 +1881,215 @@ export class AuthStorage {
|
|
|
1833
1881
|
return rows;
|
|
1834
1882
|
}
|
|
1835
1883
|
|
|
1884
|
+
/**
|
|
1885
|
+
* Refresh one stored OAuth credential under durable row ownership.
|
|
1886
|
+
*/
|
|
1887
|
+
async refreshStoredOAuthCredential<T extends OAuthCredential = OAuthCredential>(
|
|
1888
|
+
provider: string,
|
|
1889
|
+
options: StoredOAuthRefreshOptions<T>,
|
|
1890
|
+
): Promise<StoredOAuthRefreshResult<T>> {
|
|
1891
|
+
const refreshSkewMs = options.refreshSkewMs ?? OAUTH_REFRESH_SKEW_MS;
|
|
1892
|
+
const hasDurableLease =
|
|
1893
|
+
!!this.#store.tryAcquireCredentialRefreshLease &&
|
|
1894
|
+
!!this.#store.getCredentialRefreshLeaseExpiresAt &&
|
|
1895
|
+
!!this.#store.releaseCredentialRefreshLease &&
|
|
1896
|
+
!!this.#store.renewCredentialRefreshLease;
|
|
1897
|
+
const owner = crypto.randomUUID();
|
|
1898
|
+
let leasedCredentialId: number | undefined;
|
|
1899
|
+
|
|
1900
|
+
while (hasDurableLease) {
|
|
1901
|
+
if (options.signal?.aborted) throw new AIError.AbortError("OAuth refresh ownership aborted by caller");
|
|
1902
|
+
const rows = this.#store.listAuthCredentials(provider);
|
|
1903
|
+
this.#setStoredCredentials(
|
|
1904
|
+
provider,
|
|
1905
|
+
rows.map(row => ({ id: row.id, credential: row.credential })),
|
|
1906
|
+
);
|
|
1907
|
+
const row = rows.find(entry => entry.credential.type === "oauth");
|
|
1908
|
+
if (row?.credential.type !== "oauth") {
|
|
1909
|
+
return { credential: undefined, refreshed: false, removed: false };
|
|
1910
|
+
}
|
|
1911
|
+
const current = options.credentialFromRow(row.credential);
|
|
1912
|
+
if (!current) {
|
|
1913
|
+
return { credential: undefined, refreshed: false, removed: false };
|
|
1914
|
+
}
|
|
1915
|
+
if (options.observedCredential && !authCredentialEquals(current, options.observedCredential)) {
|
|
1916
|
+
return { credential: current, refreshed: false, removed: false };
|
|
1917
|
+
}
|
|
1918
|
+
if (!options.forceRefresh && Date.now() + refreshSkewMs < current.expires) {
|
|
1919
|
+
return { credential: current, refreshed: false, removed: false };
|
|
1920
|
+
}
|
|
1921
|
+
if (options.canRefresh && !options.canRefresh(current)) {
|
|
1922
|
+
return { credential: current, refreshed: false, removed: false };
|
|
1923
|
+
}
|
|
1924
|
+
if (this.#store.tryAcquireCredentialRefreshLease?.(row.id, owner, Date.now() + OAUTH_REFRESH_LEASE_TTL_MS)) {
|
|
1925
|
+
leasedCredentialId = row.id;
|
|
1926
|
+
break;
|
|
1927
|
+
}
|
|
1928
|
+
const leaseExpiresAt = this.#store.getCredentialRefreshLeaseExpiresAt?.(row.id);
|
|
1929
|
+
const waitMs =
|
|
1930
|
+
leaseExpiresAt === undefined
|
|
1931
|
+
? OAUTH_REFRESH_LEASE_POLL_MS
|
|
1932
|
+
: Math.min(Math.max(leaseExpiresAt - Date.now(), OAUTH_REFRESH_LEASE_POLL_MS), 250);
|
|
1933
|
+
await raceCredentialRefreshWithSignal(
|
|
1934
|
+
Bun.sleep(waitMs),
|
|
1935
|
+
options.signal,
|
|
1936
|
+
"OAuth refresh ownership wait aborted by caller",
|
|
1937
|
+
);
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
try {
|
|
1941
|
+
const rows = this.#store.listAuthCredentials(provider);
|
|
1942
|
+
this.#setStoredCredentials(
|
|
1943
|
+
provider,
|
|
1944
|
+
rows.map(row => ({ id: row.id, credential: row.credential })),
|
|
1945
|
+
);
|
|
1946
|
+
const row = rows.find(entry => entry.credential.type === "oauth");
|
|
1947
|
+
if (row?.credential.type !== "oauth") {
|
|
1948
|
+
return { credential: undefined, refreshed: false, removed: false };
|
|
1949
|
+
}
|
|
1950
|
+
const current = options.credentialFromRow(row.credential);
|
|
1951
|
+
if (!current) {
|
|
1952
|
+
return { credential: undefined, refreshed: false, removed: false };
|
|
1953
|
+
}
|
|
1954
|
+
if (options.observedCredential && !authCredentialEquals(current, options.observedCredential)) {
|
|
1955
|
+
return { credential: current, refreshed: false, removed: false };
|
|
1956
|
+
}
|
|
1957
|
+
if (!options.forceRefresh && Date.now() + refreshSkewMs < current.expires) {
|
|
1958
|
+
return { credential: current, refreshed: false, removed: false };
|
|
1959
|
+
}
|
|
1960
|
+
if (options.canRefresh && !options.canRefresh(current)) {
|
|
1961
|
+
return { credential: current, refreshed: false, removed: false };
|
|
1962
|
+
}
|
|
1963
|
+
const serialized = serializeCredential(provider, current);
|
|
1964
|
+
if (!serialized) return { credential: current, refreshed: false, removed: false };
|
|
1965
|
+
|
|
1966
|
+
let stopLeaseRenewal = false;
|
|
1967
|
+
let leaseRenewalError: unknown;
|
|
1968
|
+
const leaseRenewalStopped = Promise.withResolvers<void>();
|
|
1969
|
+
const leaseRenewal =
|
|
1970
|
+
leasedCredentialId !== undefined
|
|
1971
|
+
? (async () => {
|
|
1972
|
+
while (!stopLeaseRenewal) {
|
|
1973
|
+
await Promise.race([Bun.sleep(OAUTH_REFRESH_LEASE_RENEW_MS), leaseRenewalStopped.promise]);
|
|
1974
|
+
if (stopLeaseRenewal) return;
|
|
1975
|
+
const renewed = this.#store.renewCredentialRefreshLease?.(
|
|
1976
|
+
leasedCredentialId,
|
|
1977
|
+
owner,
|
|
1978
|
+
Date.now() + OAUTH_REFRESH_LEASE_TTL_MS,
|
|
1979
|
+
);
|
|
1980
|
+
if (!renewed) {
|
|
1981
|
+
throw new AIError.ConfigurationError("OAuth refresh ownership was lost before persistence");
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
})().catch(error => {
|
|
1985
|
+
leaseRenewalError = error;
|
|
1986
|
+
})
|
|
1987
|
+
: undefined;
|
|
1988
|
+
const refreshAbort = new AbortController();
|
|
1989
|
+
const refreshTimeout = setTimeout(() => {
|
|
1990
|
+
refreshAbort.abort(
|
|
1991
|
+
new AIError.OAuthError(`OAuth token refresh timed out for provider: ${provider}`, {
|
|
1992
|
+
kind: "timeout",
|
|
1993
|
+
provider,
|
|
1994
|
+
}),
|
|
1995
|
+
);
|
|
1996
|
+
}, options.refreshTimeoutMs ?? OAUTH_REFRESH_OPERATION_TIMEOUT_MS);
|
|
1997
|
+
|
|
1998
|
+
let refreshed: OAuthCredentials;
|
|
1999
|
+
try {
|
|
2000
|
+
try {
|
|
2001
|
+
refreshed = await options.refresh(current, refreshAbort.signal);
|
|
2002
|
+
} catch (error) {
|
|
2003
|
+
if (options.isDefinitiveFailure?.(error)) {
|
|
2004
|
+
const disabledCause = options.disabledCause?.(error) ?? `oauth refresh failed: ${String(error)}`;
|
|
2005
|
+
const disabled = this.#store.tryDisableAuthCredentialIfMatches(
|
|
2006
|
+
row.id,
|
|
2007
|
+
serialized.data,
|
|
2008
|
+
disabledCause,
|
|
2009
|
+
leasedCredentialId !== undefined ? { owner, nowMs: Date.now() } : undefined,
|
|
2010
|
+
);
|
|
2011
|
+
if (disabled) {
|
|
2012
|
+
this.#setStoredCredentials(
|
|
2013
|
+
provider,
|
|
2014
|
+
rows
|
|
2015
|
+
.filter(entry => entry.id !== row.id)
|
|
2016
|
+
.map(entry => ({ id: entry.id, credential: entry.credential })),
|
|
2017
|
+
);
|
|
2018
|
+
this.#resetProviderAssignments(provider);
|
|
2019
|
+
this.#emitCredentialDisabled({ provider, disabledCause });
|
|
2020
|
+
return { credential: undefined, refreshed: false, removed: true };
|
|
2021
|
+
}
|
|
2022
|
+
await this.reload();
|
|
2023
|
+
const latest = this.get(provider);
|
|
2024
|
+
return {
|
|
2025
|
+
credential: latest?.type === "oauth" ? options.credentialFromRow(latest) : undefined,
|
|
2026
|
+
refreshed: false,
|
|
2027
|
+
removed: false,
|
|
2028
|
+
};
|
|
2029
|
+
}
|
|
2030
|
+
options.onRefreshFailure?.(error);
|
|
2031
|
+
const keepCredential =
|
|
2032
|
+
typeof options.keepCredentialOnRefreshFailure === "function"
|
|
2033
|
+
? options.keepCredentialOnRefreshFailure(error)
|
|
2034
|
+
: options.keepCredentialOnRefreshFailure === true;
|
|
2035
|
+
if (keepCredential) {
|
|
2036
|
+
return { credential: current, refreshed: false, removed: false };
|
|
2037
|
+
}
|
|
2038
|
+
throw error;
|
|
2039
|
+
}
|
|
2040
|
+
} finally {
|
|
2041
|
+
stopLeaseRenewal = true;
|
|
2042
|
+
leaseRenewalStopped.resolve();
|
|
2043
|
+
await leaseRenewal;
|
|
2044
|
+
clearTimeout(refreshTimeout);
|
|
2045
|
+
}
|
|
2046
|
+
if (leaseRenewalError) throw leaseRenewalError;
|
|
2047
|
+
|
|
2048
|
+
const merged: T = options.mergeRefreshedCredential
|
|
2049
|
+
? options.mergeRefreshedCredential(current, refreshed)
|
|
2050
|
+
: {
|
|
2051
|
+
...current,
|
|
2052
|
+
access: refreshed.access,
|
|
2053
|
+
refresh: refreshed.refresh,
|
|
2054
|
+
expires: refreshed.expires,
|
|
2055
|
+
accountId: refreshed.accountId ?? current.accountId,
|
|
2056
|
+
email: refreshed.email ?? current.email,
|
|
2057
|
+
projectId: refreshed.projectId ?? current.projectId,
|
|
2058
|
+
enterpriseUrl: refreshed.enterpriseUrl ?? current.enterpriseUrl,
|
|
2059
|
+
apiEndpoint: refreshed.apiEndpoint ?? current.apiEndpoint,
|
|
2060
|
+
};
|
|
2061
|
+
if (this.#store.tryUpdateAuthCredentialIfMatches) {
|
|
2062
|
+
if (
|
|
2063
|
+
!this.#store.tryUpdateAuthCredentialIfMatches(
|
|
2064
|
+
row.id,
|
|
2065
|
+
serialized.data,
|
|
2066
|
+
merged,
|
|
2067
|
+
leasedCredentialId !== undefined ? { owner, nowMs: Date.now() } : undefined,
|
|
2068
|
+
)
|
|
2069
|
+
) {
|
|
2070
|
+
await this.reload();
|
|
2071
|
+
const latest = this.get(provider);
|
|
2072
|
+
return {
|
|
2073
|
+
credential: latest?.type === "oauth" ? options.credentialFromRow(latest) : undefined,
|
|
2074
|
+
refreshed: false,
|
|
2075
|
+
removed: false,
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
} else {
|
|
2079
|
+
this.#store.updateAuthCredential(row.id, merged);
|
|
2080
|
+
}
|
|
2081
|
+
this.#setStoredCredentials(
|
|
2082
|
+
provider,
|
|
2083
|
+
rows.map(entry => ({ id: entry.id, credential: entry.id === row.id ? merged : entry.credential })),
|
|
2084
|
+
);
|
|
2085
|
+
return { credential: merged, refreshed: true, removed: false };
|
|
2086
|
+
} finally {
|
|
2087
|
+
if (leasedCredentialId !== undefined) {
|
|
2088
|
+
this.#store.releaseCredentialRefreshLease?.(leasedCredentialId, owner);
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
|
|
1836
2093
|
async #upsertOAuthCredential(provider: string, credential: OAuthCredential): Promise<void> {
|
|
1837
2094
|
const stored = this.#store.upsertAuthCredentialRemote
|
|
1838
2095
|
? await this.#store.upsertAuthCredentialRemote(provider, credential)
|
|
@@ -5018,7 +5275,7 @@ type SerializedCredentialRecord = {
|
|
|
5018
5275
|
identityKey: string | null;
|
|
5019
5276
|
};
|
|
5020
5277
|
|
|
5021
|
-
const AUTH_SCHEMA_VERSION =
|
|
5278
|
+
const AUTH_SCHEMA_VERSION = 6;
|
|
5022
5279
|
const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
|
|
5023
5280
|
|
|
5024
5281
|
/**
|
|
@@ -5214,17 +5471,24 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5214
5471
|
#updateStmt: Statement;
|
|
5215
5472
|
#deleteStmt: Statement;
|
|
5216
5473
|
#deleteIfMatchesStmt: Statement;
|
|
5474
|
+
#updateIfMatchesStmt: Statement;
|
|
5217
5475
|
#deleteByProviderStmt: Statement;
|
|
5218
5476
|
#hardDeleteStmt: Statement;
|
|
5219
5477
|
#getCacheStmt: Statement;
|
|
5220
5478
|
#getCacheIncludingExpiredStmt: Statement;
|
|
5221
5479
|
#upsertCacheStmt: Statement;
|
|
5222
5480
|
#deleteExpiredCacheStmt: Statement;
|
|
5481
|
+
#updateIfMatchesWithLeaseStmt: Statement;
|
|
5482
|
+
#deleteIfMatchesWithLeaseStmt: Statement;
|
|
5223
5483
|
#getCredentialBlockStmt: Statement;
|
|
5224
5484
|
#listCredentialBlocksByCredentialStmt: Statement;
|
|
5225
5485
|
#upsertCredentialBlockStmt: Statement;
|
|
5226
5486
|
#deleteCredentialBlocksStmt: Statement;
|
|
5227
5487
|
#deleteExpiredCredentialBlocksStmt: Statement;
|
|
5488
|
+
#acquireCredentialRefreshLeaseStmt: Statement;
|
|
5489
|
+
#getCredentialRefreshLeaseStmt: Statement;
|
|
5490
|
+
#renewCredentialRefreshLeaseStmt: Statement;
|
|
5491
|
+
#releaseCredentialRefreshLeaseStmt: Statement;
|
|
5228
5492
|
#credentialBlockReconcileAfter: Map<string, number> = new Map();
|
|
5229
5493
|
#insertUsageHistoryStmt: Statement;
|
|
5230
5494
|
#insertUsageCostStmt: Statement;
|
|
@@ -5253,12 +5517,33 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5253
5517
|
this.#updateStmt = this.#db.prepare(
|
|
5254
5518
|
`UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
|
|
5255
5519
|
);
|
|
5520
|
+
this.#updateIfMatchesStmt = this.#db.prepare(
|
|
5521
|
+
`UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`,
|
|
5522
|
+
);
|
|
5523
|
+
this.#updateIfMatchesWithLeaseStmt = this.#db.prepare(
|
|
5524
|
+
`UPDATE auth_credentials
|
|
5525
|
+
SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH}
|
|
5526
|
+
WHERE id = ? AND data = ? AND disabled_cause IS NULL
|
|
5527
|
+
AND EXISTS (
|
|
5528
|
+
SELECT 1 FROM auth_credential_refresh_leases
|
|
5529
|
+
WHERE credential_id = ? AND owner = ? AND expires_at_ms > ?
|
|
5530
|
+
)`,
|
|
5531
|
+
);
|
|
5256
5532
|
this.#deleteStmt = this.#db.prepare(
|
|
5257
5533
|
`UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
|
|
5258
5534
|
);
|
|
5259
5535
|
this.#deleteIfMatchesStmt = this.#db.prepare(
|
|
5260
5536
|
`UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`,
|
|
5261
5537
|
);
|
|
5538
|
+
this.#deleteIfMatchesWithLeaseStmt = this.#db.prepare(
|
|
5539
|
+
`UPDATE auth_credentials
|
|
5540
|
+
SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH}
|
|
5541
|
+
WHERE id = ? AND data = ? AND disabled_cause IS NULL
|
|
5542
|
+
AND EXISTS (
|
|
5543
|
+
SELECT 1 FROM auth_credential_refresh_leases
|
|
5544
|
+
WHERE credential_id = ? AND owner = ? AND expires_at_ms > ?
|
|
5545
|
+
)`,
|
|
5546
|
+
);
|
|
5262
5547
|
this.#deleteByProviderStmt = this.#db.prepare(
|
|
5263
5548
|
`UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`,
|
|
5264
5549
|
);
|
|
@@ -5288,6 +5573,24 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5288
5573
|
this.#deleteExpiredCredentialBlocksStmt = this.#db.prepare(
|
|
5289
5574
|
"DELETE FROM auth_credential_blocks WHERE blocked_until_ms <= ?",
|
|
5290
5575
|
);
|
|
5576
|
+
this.#acquireCredentialRefreshLeaseStmt = this.#db.prepare(
|
|
5577
|
+
`INSERT INTO auth_credential_refresh_leases (credential_id, owner, expires_at_ms, updated_at)
|
|
5578
|
+
VALUES (?, ?, ?, ${SQLITE_NOW_EPOCH})
|
|
5579
|
+
ON CONFLICT(credential_id) DO UPDATE SET
|
|
5580
|
+
owner = excluded.owner,
|
|
5581
|
+
expires_at_ms = excluded.expires_at_ms,
|
|
5582
|
+
updated_at = excluded.updated_at
|
|
5583
|
+
WHERE auth_credential_refresh_leases.expires_at_ms <= ?`,
|
|
5584
|
+
);
|
|
5585
|
+
this.#getCredentialRefreshLeaseStmt = this.#db.prepare(
|
|
5586
|
+
"SELECT expires_at_ms FROM auth_credential_refresh_leases WHERE credential_id = ?",
|
|
5587
|
+
);
|
|
5588
|
+
this.#renewCredentialRefreshLeaseStmt = this.#db.prepare(
|
|
5589
|
+
`UPDATE auth_credential_refresh_leases SET expires_at_ms = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE credential_id = ? AND owner = ?`,
|
|
5590
|
+
);
|
|
5591
|
+
this.#releaseCredentialRefreshLeaseStmt = this.#db.prepare(
|
|
5592
|
+
"DELETE FROM auth_credential_refresh_leases WHERE credential_id = ? AND owner = ?",
|
|
5593
|
+
);
|
|
5291
5594
|
this.#insertUsageHistoryStmt = this.#db.prepare(
|
|
5292
5595
|
"INSERT INTO usage_history (recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
5293
5596
|
);
|
|
@@ -5334,6 +5637,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5334
5637
|
} catch {
|
|
5335
5638
|
// Ignore chmod failures (e.g., Windows)
|
|
5336
5639
|
}
|
|
5640
|
+
SqliteAuthCredentialStore.#ensureAuthCredentialRefreshLeasesTable(db);
|
|
5337
5641
|
return new SqliteAuthCredentialStore(db);
|
|
5338
5642
|
} catch (err) {
|
|
5339
5643
|
db?.close();
|
|
@@ -5352,6 +5656,18 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5352
5656
|
);
|
|
5353
5657
|
}
|
|
5354
5658
|
|
|
5659
|
+
static #ensureAuthCredentialRefreshLeasesTable(db: Database): void {
|
|
5660
|
+
db.run(`
|
|
5661
|
+
CREATE TABLE IF NOT EXISTS auth_credential_refresh_leases (
|
|
5662
|
+
credential_id INTEGER PRIMARY KEY,
|
|
5663
|
+
owner TEXT NOT NULL,
|
|
5664
|
+
expires_at_ms INTEGER NOT NULL,
|
|
5665
|
+
updated_at INTEGER NOT NULL
|
|
5666
|
+
);
|
|
5667
|
+
CREATE INDEX IF NOT EXISTS idx_auth_credential_refresh_leases_expires ON auth_credential_refresh_leases(expires_at_ms);
|
|
5668
|
+
`);
|
|
5669
|
+
}
|
|
5670
|
+
|
|
5355
5671
|
#initializeSchema(): void {
|
|
5356
5672
|
// Install the busy handler BEFORE any lock-taking statement (incl.
|
|
5357
5673
|
// `PRAGMA journal_mode=WAL`, which acquires an exclusive lock during WAL
|
|
@@ -5400,6 +5716,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5400
5716
|
if (!this.#authCredentialsTableExists()) {
|
|
5401
5717
|
this.#createAuthCredentialsTable();
|
|
5402
5718
|
this.#createAuthCredentialBlocksTable();
|
|
5719
|
+
this.#createAuthCredentialRefreshLeasesTable();
|
|
5403
5720
|
this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION);
|
|
5404
5721
|
return;
|
|
5405
5722
|
}
|
|
@@ -5417,6 +5734,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5417
5734
|
|
|
5418
5735
|
this.#createAuthCredentialIndexes();
|
|
5419
5736
|
this.#createAuthCredentialBlocksTable();
|
|
5737
|
+
this.#createAuthCredentialRefreshLeasesTable();
|
|
5420
5738
|
this.#backfillCredentialIdentityKeys();
|
|
5421
5739
|
// Rewriting an already-current version row is a no-op write transaction
|
|
5422
5740
|
// on every boot; only persist when the recorded version actually changes.
|
|
@@ -5514,6 +5832,10 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5514
5832
|
`);
|
|
5515
5833
|
}
|
|
5516
5834
|
|
|
5835
|
+
#createAuthCredentialRefreshLeasesTable(): void {
|
|
5836
|
+
SqliteAuthCredentialStore.#ensureAuthCredentialRefreshLeasesTable(this.#db);
|
|
5837
|
+
}
|
|
5838
|
+
|
|
5517
5839
|
#migrateAuthSchema(fromVersion: number): void {
|
|
5518
5840
|
if (fromVersion < 1) {
|
|
5519
5841
|
this.#migrateAuthSchemaV0ToV1();
|
|
@@ -5527,6 +5849,9 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5527
5849
|
if (fromVersion < 5) {
|
|
5528
5850
|
this.#migrateAuthSchemaV4ToV5();
|
|
5529
5851
|
}
|
|
5852
|
+
if (fromVersion < 6) {
|
|
5853
|
+
this.#migrateAuthSchemaV5ToV6();
|
|
5854
|
+
}
|
|
5530
5855
|
}
|
|
5531
5856
|
|
|
5532
5857
|
#migrateAuthSchemaV0ToV1(): void {
|
|
@@ -5620,6 +5945,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5620
5945
|
migrate();
|
|
5621
5946
|
}
|
|
5622
5947
|
|
|
5948
|
+
#migrateAuthSchemaV5ToV6(): void {
|
|
5949
|
+
const migrate = this.#db.transaction(() => {
|
|
5950
|
+
this.#createAuthCredentialRefreshLeasesTable();
|
|
5951
|
+
});
|
|
5952
|
+
migrate();
|
|
5953
|
+
}
|
|
5954
|
+
|
|
5623
5955
|
#backfillCredentialIdentityKeys(): void {
|
|
5624
5956
|
const selectRowsStmt = this.#db.prepare(
|
|
5625
5957
|
"SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
|
|
@@ -5826,6 +6158,47 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5826
6158
|
}
|
|
5827
6159
|
}
|
|
5828
6160
|
|
|
6161
|
+
tryUpdateAuthCredentialIfMatches(
|
|
6162
|
+
id: number,
|
|
6163
|
+
expectedData: string,
|
|
6164
|
+
credential: AuthCredential,
|
|
6165
|
+
lease?: CredentialRefreshLeaseFence,
|
|
6166
|
+
): boolean {
|
|
6167
|
+
const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?");
|
|
6168
|
+
let providerRow: { provider?: string } | undefined;
|
|
6169
|
+
try {
|
|
6170
|
+
providerRow = providerStmt.get(id) as { provider?: string } | undefined;
|
|
6171
|
+
} finally {
|
|
6172
|
+
providerStmt.finalize();
|
|
6173
|
+
}
|
|
6174
|
+
const provider = providerRow?.provider ?? "";
|
|
6175
|
+
const serialized = serializeCredential(provider, credential);
|
|
6176
|
+
if (!serialized) return false;
|
|
6177
|
+
const result = lease
|
|
6178
|
+
? (this.#updateIfMatchesWithLeaseStmt.run(
|
|
6179
|
+
serialized.credentialType,
|
|
6180
|
+
serialized.data,
|
|
6181
|
+
serialized.identityKey,
|
|
6182
|
+
id,
|
|
6183
|
+
expectedData,
|
|
6184
|
+
id,
|
|
6185
|
+
lease.owner,
|
|
6186
|
+
lease.nowMs,
|
|
6187
|
+
) as { changes: number })
|
|
6188
|
+
: (this.#updateIfMatchesStmt.run(
|
|
6189
|
+
serialized.credentialType,
|
|
6190
|
+
serialized.data,
|
|
6191
|
+
serialized.identityKey,
|
|
6192
|
+
id,
|
|
6193
|
+
expectedData,
|
|
6194
|
+
) as { changes: number });
|
|
6195
|
+
if (result.changes !== 1) return false;
|
|
6196
|
+
if (provider) {
|
|
6197
|
+
this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider));
|
|
6198
|
+
}
|
|
6199
|
+
return true;
|
|
6200
|
+
}
|
|
6201
|
+
|
|
5829
6202
|
deleteAuthCredential(id: number, disabledCause: string): void {
|
|
5830
6203
|
try {
|
|
5831
6204
|
this.#deleteStmt.run(normalizeDisabledCause(disabledCause), id);
|
|
@@ -5840,17 +6213,26 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5840
6213
|
* the OAuth refresh-failure path to avoid clobbering a peer that rotated the
|
|
5841
6214
|
* row between our pre-check and the disable.
|
|
5842
6215
|
*/
|
|
5843
|
-
tryDisableAuthCredentialIfMatches(
|
|
5844
|
-
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
|
|
5848
|
-
|
|
5849
|
-
|
|
5850
|
-
|
|
5851
|
-
|
|
6216
|
+
tryDisableAuthCredentialIfMatches(
|
|
6217
|
+
id: number,
|
|
6218
|
+
expectedData: string,
|
|
6219
|
+
disabledCause: string,
|
|
6220
|
+
lease?: CredentialRefreshLeaseFence,
|
|
6221
|
+
): boolean {
|
|
6222
|
+
const result = lease
|
|
6223
|
+
? (this.#deleteIfMatchesWithLeaseStmt.run(
|
|
6224
|
+
normalizeDisabledCause(disabledCause),
|
|
6225
|
+
id,
|
|
6226
|
+
expectedData,
|
|
6227
|
+
id,
|
|
6228
|
+
lease.owner,
|
|
6229
|
+
lease.nowMs,
|
|
6230
|
+
) as { changes: number })
|
|
6231
|
+
: (this.#deleteIfMatchesStmt.run(normalizeDisabledCause(disabledCause), id, expectedData) as {
|
|
6232
|
+
changes: number;
|
|
6233
|
+
});
|
|
6234
|
+
return result.changes === 1;
|
|
5852
6235
|
}
|
|
5853
|
-
|
|
5854
6236
|
deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void {
|
|
5855
6237
|
try {
|
|
5856
6238
|
this.#deleteByProviderStmt.run(normalizeDisabledCause(disabledCause), provider);
|
|
@@ -5959,6 +6341,35 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5959
6341
|
return blocks;
|
|
5960
6342
|
}
|
|
5961
6343
|
|
|
6344
|
+
tryAcquireCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean {
|
|
6345
|
+
const result = this.#acquireCredentialRefreshLeaseStmt.run(credentialId, owner, expiresAtMs, Date.now()) as {
|
|
6346
|
+
changes: number;
|
|
6347
|
+
};
|
|
6348
|
+
return result.changes === 1;
|
|
6349
|
+
}
|
|
6350
|
+
|
|
6351
|
+
getCredentialRefreshLeaseExpiresAt(credentialId: number): number | undefined {
|
|
6352
|
+
const row = this.#getCredentialRefreshLeaseStmt.get(credentialId) as { expires_at_ms?: number } | undefined;
|
|
6353
|
+
if (typeof row?.expires_at_ms !== "number") return undefined;
|
|
6354
|
+
if (row.expires_at_ms <= Date.now()) return undefined;
|
|
6355
|
+
return row.expires_at_ms;
|
|
6356
|
+
}
|
|
6357
|
+
|
|
6358
|
+
renewCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean {
|
|
6359
|
+
const result = this.#renewCredentialRefreshLeaseStmt.run(expiresAtMs, credentialId, owner) as {
|
|
6360
|
+
changes: number;
|
|
6361
|
+
};
|
|
6362
|
+
return result.changes === 1;
|
|
6363
|
+
}
|
|
6364
|
+
|
|
6365
|
+
releaseCredentialRefreshLease(credentialId: number, owner: string): void {
|
|
6366
|
+
try {
|
|
6367
|
+
this.#releaseCredentialRefreshLeaseStmt.run(credentialId, owner);
|
|
6368
|
+
} catch {
|
|
6369
|
+
// Ignore lease release failures; expired leases are stealable.
|
|
6370
|
+
}
|
|
6371
|
+
}
|
|
6372
|
+
|
|
5962
6373
|
recordUsageSnapshots(entries: UsageHistoryEntry[]): void {
|
|
5963
6374
|
try {
|
|
5964
6375
|
for (const entry of entries) {
|
package/src/dialect/rendering.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stringifyJson as stringifyJsonValue } from "@oh-my-pi/pi-utils";
|
|
1
2
|
import type { AssistantMessage, Message, ToolCall, ToolResultMessage } from "../types";
|
|
2
3
|
import type { DialectRenderOptions, DialectToolResult } from "./types";
|
|
3
4
|
|
|
@@ -15,7 +16,7 @@ export function harmonyRecipient(name: string): string {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export function stringifyJson(value: unknown): string {
|
|
18
|
-
return
|
|
19
|
+
return stringifyJsonValue(value) ?? "null";
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export function escapeXmlAttr(value: string): string {
|
|
@@ -3,6 +3,7 @@ import { scheduler } from "node:timers/promises";
|
|
|
3
3
|
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
|
|
4
4
|
import {
|
|
5
5
|
CODEX_BASE_URL,
|
|
6
|
+
CODEX_CLIENT_VERSION,
|
|
6
7
|
getCodexAccountId,
|
|
7
8
|
OPENAI_HEADER_VALUES,
|
|
8
9
|
OPENAI_HEADERS,
|
|
@@ -632,6 +633,7 @@ interface CodexRequestContext {
|
|
|
632
633
|
baseUrl: string;
|
|
633
634
|
url: string;
|
|
634
635
|
requestHeaders: Record<string, string>;
|
|
636
|
+
codexClientVersion: string;
|
|
635
637
|
transportSessionId?: string;
|
|
636
638
|
providerSessionState?: CodexProviderSessionState;
|
|
637
639
|
isolatedTransportState?: CodexProviderSessionState;
|
|
@@ -1200,6 +1202,7 @@ async function buildCodexRequestContext(
|
|
|
1200
1202
|
const url = resolveCodexResponsesUrl(baseUrl);
|
|
1201
1203
|
const promptCacheKey = normalizeOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId);
|
|
1202
1204
|
const transportSessionId = normalizeOpenAIPromptCacheKey(options?.sessionId);
|
|
1205
|
+
const codexClientVersion = CODEX_CLIENT_VERSION;
|
|
1203
1206
|
const transformedBody = await buildTransformedCodexRequestBody(model, context, options, promptCacheKey);
|
|
1204
1207
|
|
|
1205
1208
|
const requestHeaders = { ...(model.headers ?? {}), ...(options?.headers ?? {}) };
|
|
@@ -1275,6 +1278,7 @@ async function buildCodexRequestContext(
|
|
|
1275
1278
|
websocketState,
|
|
1276
1279
|
responsesLite,
|
|
1277
1280
|
requestMetadata,
|
|
1281
|
+
codexClientVersion,
|
|
1278
1282
|
transformedBody,
|
|
1279
1283
|
rawRequestDump,
|
|
1280
1284
|
};
|
|
@@ -1427,6 +1431,7 @@ async function openCodexWebSocketTransport(
|
|
|
1427
1431
|
requestContext.requestHeaders,
|
|
1428
1432
|
requestContext.accountId,
|
|
1429
1433
|
requestContext.apiKey,
|
|
1434
|
+
requestContext.codexClientVersion,
|
|
1430
1435
|
requestContext.transportSessionId,
|
|
1431
1436
|
"websocket",
|
|
1432
1437
|
websocketState,
|
|
@@ -1527,6 +1532,7 @@ async function openCodexSseTransport(
|
|
|
1527
1532
|
wireBody,
|
|
1528
1533
|
state,
|
|
1529
1534
|
requestContext.responsesLite,
|
|
1535
|
+
requestContext.codexClientVersion,
|
|
1530
1536
|
requestContext.requestMetadata,
|
|
1531
1537
|
requestSetup.requestSignal,
|
|
1532
1538
|
requestSetup.firstEventTimeoutMs,
|
|
@@ -2496,6 +2502,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
|
|
|
2496
2502
|
baseUrl: model.baseUrl || CODEX_BASE_URL,
|
|
2497
2503
|
url: "",
|
|
2498
2504
|
requestHeaders: {},
|
|
2505
|
+
codexClientVersion: CODEX_CLIENT_VERSION,
|
|
2499
2506
|
responsesLite: options?.responsesLite === true,
|
|
2500
2507
|
transformedBody: { model: model.id },
|
|
2501
2508
|
rawRequestDump: {
|
|
@@ -2559,6 +2566,7 @@ export async function prewarmOpenAICodexResponses(
|
|
|
2559
2566
|
transportSessionId ?? crypto.randomUUID(),
|
|
2560
2567
|
providerSessionState,
|
|
2561
2568
|
);
|
|
2569
|
+
const codexClientVersion = CODEX_CLIENT_VERSION;
|
|
2562
2570
|
const requestIdentity = createCodexCompatibilityIdentity(metadataSession);
|
|
2563
2571
|
const headers = logger.time(
|
|
2564
2572
|
"prewarmCodex:createHeaders",
|
|
@@ -2566,6 +2574,7 @@ export async function prewarmOpenAICodexResponses(
|
|
|
2566
2574
|
{ ...(model.headers ?? {}), ...(options?.headers ?? {}) },
|
|
2567
2575
|
accountId,
|
|
2568
2576
|
apiKey,
|
|
2577
|
+
codexClientVersion,
|
|
2569
2578
|
promptCacheKey,
|
|
2570
2579
|
"websocket",
|
|
2571
2580
|
state,
|
|
@@ -3685,6 +3694,7 @@ async function openCodexSseEventStream(
|
|
|
3685
3694
|
body: RequestBody,
|
|
3686
3695
|
state: CodexWebSocketSessionState | undefined,
|
|
3687
3696
|
responsesLite: boolean,
|
|
3697
|
+
codexClientVersion: string,
|
|
3688
3698
|
requestMetadata: CodexRequestMetadata | undefined,
|
|
3689
3699
|
signal: AbortSignal | undefined,
|
|
3690
3700
|
firstEventTimeoutMs: number | undefined,
|
|
@@ -3695,6 +3705,7 @@ async function openCodexSseEventStream(
|
|
|
3695
3705
|
requestHeaders,
|
|
3696
3706
|
accountId,
|
|
3697
3707
|
apiKey,
|
|
3708
|
+
codexClientVersion,
|
|
3698
3709
|
sessionId,
|
|
3699
3710
|
"sse",
|
|
3700
3711
|
state,
|
|
@@ -3756,6 +3767,7 @@ function createCodexHeaders(
|
|
|
3756
3767
|
initHeaders: Record<string, string> | undefined,
|
|
3757
3768
|
accountId: string | undefined,
|
|
3758
3769
|
accessToken: string,
|
|
3770
|
+
codexClientVersion: string,
|
|
3759
3771
|
sessionId?: string,
|
|
3760
3772
|
transport: CodexTransport = "sse",
|
|
3761
3773
|
state?: CodexWebSocketSessionState,
|
|
@@ -3774,6 +3786,7 @@ function createCodexHeaders(
|
|
|
3774
3786
|
headers.delete("openai-beta");
|
|
3775
3787
|
headers.set(OPENAI_HEADERS.BETA, betaHeader);
|
|
3776
3788
|
headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
|
|
3789
|
+
headers.set(OPENAI_HEADERS.VERSION, codexClientVersion);
|
|
3777
3790
|
headers.set("User-Agent", `pi/${packageJson.version} (${os.platform()} ${os.release()}; ${os.arch()})`);
|
|
3778
3791
|
if (sessionId) {
|
|
3779
3792
|
headers.set(OPENAI_HEADERS.CONVERSATION_ID, sessionId);
|
|
@@ -568,6 +568,10 @@ function responseStatusForStopReason(message: AssistantMessage): ResponseStatus
|
|
|
568
568
|
return "completed";
|
|
569
569
|
}
|
|
570
570
|
|
|
571
|
+
function incompleteDetailsForStatus(status: ResponseStatus): { reason: "max_output_tokens" } | null {
|
|
572
|
+
return status === "incomplete" ? { reason: "max_output_tokens" } : null;
|
|
573
|
+
}
|
|
574
|
+
|
|
571
575
|
function buildReasoningItem(part: ThinkingContent): ReasoningOutputItem {
|
|
572
576
|
const baseId = part.itemId ?? makeReasoningId();
|
|
573
577
|
if (part.thinkingSignature) {
|
|
@@ -718,7 +722,7 @@ function buildResponseEnvelope(
|
|
|
718
722
|
model: requestedModelId,
|
|
719
723
|
output: items,
|
|
720
724
|
usage,
|
|
721
|
-
|
|
725
|
+
incomplete_details: incompleteDetailsForStatus(status),
|
|
722
726
|
...(status === "failed" ? { error: { message: message.errorMessage ?? "response failed" } } : {}),
|
|
723
727
|
};
|
|
724
728
|
}
|
|
@@ -812,6 +816,7 @@ export function encodeStream(
|
|
|
812
816
|
model: requestedModelId,
|
|
813
817
|
output,
|
|
814
818
|
usage: null,
|
|
819
|
+
incomplete_details: incompleteDetailsForStatus(status),
|
|
815
820
|
});
|
|
816
821
|
|
|
817
822
|
const openMessage = (signature?: MessageSignature): OpenMessage => {
|
|
@@ -1242,7 +1247,7 @@ export function encodeStream(
|
|
|
1242
1247
|
model: requestedModelId,
|
|
1243
1248
|
output: items,
|
|
1244
1249
|
usage,
|
|
1245
|
-
|
|
1250
|
+
incomplete_details: incompleteDetailsForStatus(status),
|
|
1246
1251
|
...(status === "failed"
|
|
1247
1252
|
? { error: { message: message?.errorMessage ?? "response failed" } }
|
|
1248
1253
|
: {}),
|
|
@@ -1267,6 +1272,7 @@ export function encodeStream(
|
|
|
1267
1272
|
model: requestedModelId,
|
|
1268
1273
|
output: [],
|
|
1269
1274
|
error: { message: err instanceof Error ? err.message : String(err) },
|
|
1275
|
+
incomplete_details: null,
|
|
1270
1276
|
},
|
|
1271
1277
|
}),
|
|
1272
1278
|
),
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
logger,
|
|
28
28
|
parseStreamingJson,
|
|
29
29
|
parseStreamingJsonThrottled,
|
|
30
|
+
stringifyJson,
|
|
30
31
|
structuredCloneJSON,
|
|
31
32
|
} from "@oh-my-pi/pi-utils";
|
|
32
33
|
import * as AIError from "../error";
|
|
@@ -1356,6 +1357,65 @@ export function convertResponsesInputContent(
|
|
|
1356
1357
|
return normalizedContent.length > 0 ? normalizedContent : undefined;
|
|
1357
1358
|
}
|
|
1358
1359
|
|
|
1360
|
+
/**
|
|
1361
|
+
* Map freeform custom-tool wire names back to the internal tool name for
|
|
1362
|
+
* providers that only accept function_call / function_call_output.
|
|
1363
|
+
* Built once per request; `apply_patch` → `edit` is the OMP default.
|
|
1364
|
+
*/
|
|
1365
|
+
function buildCustomToolWireNameMap(tools: readonly Tool[] | undefined): ReadonlyMap<string, string> | undefined {
|
|
1366
|
+
if (!tools?.length) return undefined;
|
|
1367
|
+
const map = new Map<string, string>();
|
|
1368
|
+
for (const tool of tools) {
|
|
1369
|
+
if (tool.customWireName) map.set(tool.customWireName, tool.name);
|
|
1370
|
+
}
|
|
1371
|
+
return map.size > 0 ? map : undefined;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function resolveReplayCustomToolName(wireName: string, wireNameMap: ReadonlyMap<string, string> | undefined): string {
|
|
1375
|
+
return wireNameMap?.get(wireName) ?? (wireName === "apply_patch" ? "edit" : wireName);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/**
|
|
1379
|
+
* Downgrade OpenAI-only custom tool items when the target model does not
|
|
1380
|
+
* advertise freeform custom tools (`applyPatchToolType === "freeform"`).
|
|
1381
|
+
* No-op (returns the same array reference) when freeform is supported.
|
|
1382
|
+
*/
|
|
1383
|
+
function adaptResponsesReplayItemsForModel(
|
|
1384
|
+
input: ResponseInput,
|
|
1385
|
+
supportsCustomToolCalls: boolean,
|
|
1386
|
+
wireNameMap: ReadonlyMap<string, string> | undefined,
|
|
1387
|
+
): ResponseInput {
|
|
1388
|
+
if (supportsCustomToolCalls) return input;
|
|
1389
|
+
|
|
1390
|
+
let changed = false;
|
|
1391
|
+
const adapted: ResponseInput = [];
|
|
1392
|
+
for (const item of input) {
|
|
1393
|
+
if (item.type === "custom_tool_call") {
|
|
1394
|
+
changed = true;
|
|
1395
|
+
adapted.push({
|
|
1396
|
+
type: "function_call",
|
|
1397
|
+
...(item.id ? { id: item.id } : {}),
|
|
1398
|
+
call_id: item.call_id,
|
|
1399
|
+
name: resolveReplayCustomToolName(item.name, wireNameMap),
|
|
1400
|
+
arguments: JSON.stringify({ input: item.input }),
|
|
1401
|
+
...(item.namespace ? { namespace: item.namespace } : {}),
|
|
1402
|
+
});
|
|
1403
|
+
continue;
|
|
1404
|
+
}
|
|
1405
|
+
if (item.type === "custom_tool_call_output") {
|
|
1406
|
+
changed = true;
|
|
1407
|
+
adapted.push({
|
|
1408
|
+
type: "function_call_output",
|
|
1409
|
+
call_id: item.call_id,
|
|
1410
|
+
output: item.output,
|
|
1411
|
+
});
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
adapted.push(item);
|
|
1415
|
+
}
|
|
1416
|
+
return changed ? adapted : input;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1359
1419
|
export interface BuildResponsesInputOptions<TApi extends Api> {
|
|
1360
1420
|
model: Model<TApi>;
|
|
1361
1421
|
context: Context;
|
|
@@ -1380,6 +1440,15 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
|
|
|
1380
1440
|
messages.push({ role: options.systemRole as "system" | "developer", content: systemPrompt });
|
|
1381
1441
|
}
|
|
1382
1442
|
|
|
1443
|
+
// Compat is resolved by the catalog (e.g. Copilot / xai-oauth reject
|
|
1444
|
+
// `detail: "original"`). Do not re-branch on provider id here.
|
|
1445
|
+
const supportsImageDetailOriginal = options.supportsImageDetailOriginal;
|
|
1446
|
+
// Freeform custom tools (`custom_tool_call`) only when the catalog says so;
|
|
1447
|
+
// same gate as tool conversion (`applyPatchToolType === "freeform"`).
|
|
1448
|
+
const supportsCustomToolCalls = options.model.applyPatchToolType === "freeform";
|
|
1449
|
+
const customToolWireNameMap = supportsCustomToolCalls
|
|
1450
|
+
? undefined
|
|
1451
|
+
: buildCustomToolWireNameMap(options.context.tools);
|
|
1383
1452
|
let knownCallIds = new Set<string>();
|
|
1384
1453
|
const customCallIds = new Set<string>();
|
|
1385
1454
|
const transformedMessages = transformMessages(
|
|
@@ -1407,7 +1476,12 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
|
|
|
1407
1476
|
}) ??
|
|
1408
1477
|
false);
|
|
1409
1478
|
if (historyItems && shouldReplayPayloadItems) {
|
|
1410
|
-
|
|
1479
|
+
const sanitizedItems = sanitizeOpenAIResponsesHistoryItemsForReplay(filterReasoning(historyItems), {
|
|
1480
|
+
supportsImageDetailOriginal,
|
|
1481
|
+
});
|
|
1482
|
+
messages.push(
|
|
1483
|
+
...adaptResponsesReplayItemsForModel(sanitizedItems, supportsCustomToolCalls, customToolWireNameMap),
|
|
1484
|
+
);
|
|
1411
1485
|
knownCallIds = collectKnownCallIds(messages);
|
|
1412
1486
|
for (const id of collectCustomCallIds(messages)) customCallIds.add(id);
|
|
1413
1487
|
msgIndex++;
|
|
@@ -1416,7 +1490,7 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
|
|
|
1416
1490
|
const content = convertResponsesInputContent(
|
|
1417
1491
|
msg.content,
|
|
1418
1492
|
options.model.input.includes("image"),
|
|
1419
|
-
|
|
1493
|
+
supportsImageDetailOriginal,
|
|
1420
1494
|
);
|
|
1421
1495
|
if (!content) continue;
|
|
1422
1496
|
messages.push({
|
|
@@ -1444,9 +1518,17 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
|
|
|
1444
1518
|
const historyItems = providerPayload?.items;
|
|
1445
1519
|
let suppressHiddenEmptyFallback = false;
|
|
1446
1520
|
if (historyItems) {
|
|
1447
|
-
const
|
|
1521
|
+
const rawSanitizedHistoryItems = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(
|
|
1448
1522
|
filterReasoning(historyItems),
|
|
1523
|
+
{ supportsImageDetailOriginal },
|
|
1449
1524
|
);
|
|
1525
|
+
const sanitizedHistoryItems = rawSanitizedHistoryItems
|
|
1526
|
+
? adaptResponsesReplayItemsForModel(
|
|
1527
|
+
rawSanitizedHistoryItems,
|
|
1528
|
+
supportsCustomToolCalls,
|
|
1529
|
+
customToolWireNameMap,
|
|
1530
|
+
)
|
|
1531
|
+
: undefined;
|
|
1450
1532
|
if (nativeReplayEnabled && sanitizedHistoryItems) {
|
|
1451
1533
|
if (providerPayload?.dt) {
|
|
1452
1534
|
messages.push(...sanitizedHistoryItems);
|
|
@@ -1469,6 +1551,8 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
|
|
|
1469
1551
|
suppressHiddenEmptyFallback ? false : includeThinkingSignatures,
|
|
1470
1552
|
customCallIds,
|
|
1471
1553
|
options.preserveAssistantMessageIds,
|
|
1554
|
+
supportsCustomToolCalls,
|
|
1555
|
+
customToolWireNameMap,
|
|
1472
1556
|
);
|
|
1473
1557
|
const outputItems = suppressHiddenEmptyFallback
|
|
1474
1558
|
? sanitizeOpenAIResponsesAssistantFallbackItemsForReplay(convertedOutputItems)
|
|
@@ -1481,9 +1565,10 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
|
|
|
1481
1565
|
msg,
|
|
1482
1566
|
options.model,
|
|
1483
1567
|
options.strictResponsesPairing,
|
|
1484
|
-
|
|
1568
|
+
supportsImageDetailOriginal,
|
|
1485
1569
|
knownCallIds,
|
|
1486
1570
|
customCallIds,
|
|
1571
|
+
supportsCustomToolCalls,
|
|
1487
1572
|
);
|
|
1488
1573
|
}
|
|
1489
1574
|
msgIndex++;
|
|
@@ -1516,6 +1601,8 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
|
|
|
1516
1601
|
includeThinkingSignatures = true,
|
|
1517
1602
|
customCallIds?: Set<string>,
|
|
1518
1603
|
preserveMessageIds = false,
|
|
1604
|
+
supportsCustomToolCalls = true,
|
|
1605
|
+
customToolWireNameMap?: ReadonlyMap<string, string>,
|
|
1519
1606
|
): ResponseInput {
|
|
1520
1607
|
const outputItems: ResponseInput = [];
|
|
1521
1608
|
let unsignedTextBlocks = 0;
|
|
@@ -1587,7 +1674,7 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
|
|
|
1587
1674
|
itemId = undefined;
|
|
1588
1675
|
}
|
|
1589
1676
|
knownCallIds.add(normalized.callId);
|
|
1590
|
-
if (block.customWireName) {
|
|
1677
|
+
if (block.customWireName && supportsCustomToolCalls) {
|
|
1591
1678
|
const rawInput = typeof block.arguments?.input === "string" ? block.arguments.input : "";
|
|
1592
1679
|
customCallIds?.add(normalized.callId);
|
|
1593
1680
|
outputItems.push({
|
|
@@ -1599,12 +1686,16 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
|
|
|
1599
1686
|
} as ResponseInput[number]);
|
|
1600
1687
|
continue;
|
|
1601
1688
|
}
|
|
1689
|
+
const functionName =
|
|
1690
|
+
block.customWireName && !supportsCustomToolCalls
|
|
1691
|
+
? resolveReplayCustomToolName(block.customWireName, customToolWireNameMap)
|
|
1692
|
+
: block.name;
|
|
1602
1693
|
outputItems.push({
|
|
1603
1694
|
type: "function_call",
|
|
1604
1695
|
...(itemId ? { id: itemId } : {}),
|
|
1605
1696
|
call_id: normalized.callId,
|
|
1606
|
-
name:
|
|
1607
|
-
arguments:
|
|
1697
|
+
name: functionName,
|
|
1698
|
+
arguments: stringifyJson(block.arguments) ?? "null",
|
|
1608
1699
|
});
|
|
1609
1700
|
}
|
|
1610
1701
|
|
|
@@ -1619,6 +1710,7 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
|
|
|
1619
1710
|
supportsImageDetailOriginal: boolean,
|
|
1620
1711
|
knownCallIds: ReadonlySet<string>,
|
|
1621
1712
|
customCallIds?: ReadonlySet<string>,
|
|
1713
|
+
supportsCustomToolCalls = true,
|
|
1622
1714
|
): void {
|
|
1623
1715
|
const supportsImages = model.input.includes("image");
|
|
1624
1716
|
const textResult = toolResult.content
|
|
@@ -1628,12 +1720,19 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
|
|
|
1628
1720
|
const hasImages = toolResult.content.some((block): block is ImageContent => block.type === "image");
|
|
1629
1721
|
const omittedImages = hasImages && !supportsImages;
|
|
1630
1722
|
const normalized = normalizeResponsesToolCallId(toolResult.toolCallId);
|
|
1723
|
+
// "(see attached image)" is only truthful when the result actually carries
|
|
1724
|
+
// images (they ride as a separate user message on the Responses API). A
|
|
1725
|
+
// genuinely empty text result (empty file read, silent tool) must stay
|
|
1726
|
+
// empty — the placeholder sent models chasing an attachment that never
|
|
1727
|
+
// existed.
|
|
1631
1728
|
const output = (
|
|
1632
1729
|
omittedImages
|
|
1633
1730
|
? joinTextWithImagePlaceholder(textResult, true)
|
|
1634
1731
|
: textResult.length > 0
|
|
1635
1732
|
? textResult
|
|
1636
|
-
:
|
|
1733
|
+
: hasImages
|
|
1734
|
+
? "(see attached image)"
|
|
1735
|
+
: ""
|
|
1637
1736
|
).toWellFormed();
|
|
1638
1737
|
if (strictResponsesPairing && !knownCallIds.has(normalized.callId)) {
|
|
1639
1738
|
// Strict backends (Azure, Copilot) reject unpaired outputs outright, but
|
|
@@ -1648,7 +1747,7 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
|
|
|
1648
1747
|
} as ResponseInput[number]);
|
|
1649
1748
|
return;
|
|
1650
1749
|
}
|
|
1651
|
-
if (customCallIds?.has(normalized.callId)) {
|
|
1750
|
+
if (supportsCustomToolCalls && customCallIds?.has(normalized.callId)) {
|
|
1652
1751
|
messages.push({
|
|
1653
1752
|
type: "custom_tool_call_output",
|
|
1654
1753
|
call_id: normalized.callId,
|
|
@@ -547,7 +547,11 @@ function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf"
|
|
|
547
547
|
|
|
548
548
|
const existingValue = mergedVariantFields[key];
|
|
549
549
|
if (existingValue !== undefined && !areJsonValuesEqual(existingValue, variantValue)) {
|
|
550
|
-
return schema;
|
|
550
|
+
if (key !== "description") return schema;
|
|
551
|
+
// Descriptions are annotations, so merge branch-local spill text instead of
|
|
552
|
+
// treating it as a structural incompatibility.
|
|
553
|
+
mergedVariantFields[key] = mergeSchemaDescriptions(existingValue, variantValue);
|
|
554
|
+
continue;
|
|
551
555
|
}
|
|
552
556
|
mergedVariantFields[key] = variantValue;
|
|
553
557
|
}
|
|
@@ -588,7 +592,9 @@ function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf"
|
|
|
588
592
|
const value = mergedVariantFields[key];
|
|
589
593
|
const existingValue = nextSchema[key];
|
|
590
594
|
if (existingValue !== undefined && !areJsonValuesEqual(existingValue, value)) {
|
|
591
|
-
return schema;
|
|
595
|
+
if (key !== "description") return schema;
|
|
596
|
+
nextSchema[key] = mergeSchemaDescriptions(existingValue, value);
|
|
597
|
+
continue;
|
|
592
598
|
}
|
|
593
599
|
if (existingValue === undefined) {
|
|
594
600
|
nextSchema[key] = value;
|
|
@@ -597,6 +603,13 @@ function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf"
|
|
|
597
603
|
return nextSchema;
|
|
598
604
|
}
|
|
599
605
|
|
|
606
|
+
function mergeSchemaDescriptions(existing: unknown, incoming: unknown): string {
|
|
607
|
+
if (typeof existing !== "string") return typeof incoming === "string" ? incoming : "";
|
|
608
|
+
if (typeof incoming !== "string" || incoming.length === 0 || existing === incoming) return existing;
|
|
609
|
+
if (existing.length === 0) return incoming;
|
|
610
|
+
return `${existing}\n\n${incoming}`;
|
|
611
|
+
}
|
|
612
|
+
|
|
600
613
|
function collapseSameTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" | "oneOf"): JsonObject {
|
|
601
614
|
const variantsRaw = schema[combiner];
|
|
602
615
|
if (!Array.isArray(variantsRaw) || variantsRaw.length === 0) return schema;
|
package/src/utils.ts
CHANGED
|
@@ -65,10 +65,50 @@ export function truncateResponseItemId(id: string, prefix: string): string {
|
|
|
65
65
|
return `${prefix}_${Bun.hash(id).toString(36)}`;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
interface OpenAIResponsesReplaySanitizeOptions {
|
|
69
|
+
supportsImageDetailOriginal?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Clamp `detail: "original"` only where Responses input_image parts live —
|
|
74
|
+
* top-level items and `message.content[]`. Avoids a deep tree walk/clone of
|
|
75
|
+
* every history node on providers that reject native-resolution images.
|
|
76
|
+
*/
|
|
77
|
+
function clampReplayItemImageDetail(
|
|
78
|
+
item: Record<string, unknown>,
|
|
79
|
+
supportsImageDetailOriginal: boolean,
|
|
80
|
+
): Record<string, unknown> {
|
|
81
|
+
if (supportsImageDetailOriginal) return item;
|
|
82
|
+
|
|
83
|
+
if (item.type === "input_image" && item.detail === "original") {
|
|
84
|
+
return { ...item, detail: "auto" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (item.type !== "message" || !Array.isArray(item.content)) return item;
|
|
88
|
+
|
|
89
|
+
let changed = false;
|
|
90
|
+
const content = item.content.map(part => {
|
|
91
|
+
if (!part || typeof part !== "object" || Array.isArray(part)) return part;
|
|
92
|
+
const record = part as Record<string, unknown>;
|
|
93
|
+
if (record.type !== "input_image" || record.detail !== "original") return part;
|
|
94
|
+
changed = true;
|
|
95
|
+
return { ...record, detail: "auto" };
|
|
96
|
+
});
|
|
97
|
+
return changed ? { ...item, content } : item;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function sanitizeOpenAIResponsesHistoryItemsForReplay(
|
|
101
|
+
items: Array<Record<string, unknown>>,
|
|
102
|
+
options: OpenAIResponsesReplaySanitizeOptions = {},
|
|
103
|
+
): ResponseInput {
|
|
69
104
|
const normalizedCallIds = new Map<string, string>();
|
|
105
|
+
const supportsImageDetailOriginal = options.supportsImageDetailOriginal !== false;
|
|
70
106
|
return items.flatMap(item => {
|
|
71
|
-
const sanitized = sanitizeOpenAIResponsesHistoryItemForReplay(
|
|
107
|
+
const sanitized = sanitizeOpenAIResponsesHistoryItemForReplay(
|
|
108
|
+
item,
|
|
109
|
+
normalizedCallIds,
|
|
110
|
+
supportsImageDetailOriginal,
|
|
111
|
+
);
|
|
72
112
|
return sanitized ? [sanitized] : [];
|
|
73
113
|
});
|
|
74
114
|
}
|
|
@@ -82,8 +122,9 @@ export function sanitizeOpenAIResponsesHistoryItemsForReplay(items: Array<Record
|
|
|
82
122
|
*/
|
|
83
123
|
export function sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(
|
|
84
124
|
items: Array<Record<string, unknown>>,
|
|
125
|
+
options: OpenAIResponsesReplaySanitizeOptions = {},
|
|
85
126
|
): ResponseInput | undefined {
|
|
86
|
-
const sanitized = sanitizeOpenAIResponsesHistoryItemsForReplay(items);
|
|
127
|
+
const sanitized = sanitizeOpenAIResponsesHistoryItemsForReplay(items, options);
|
|
87
128
|
let hasReplayableAssistantOutput = false;
|
|
88
129
|
|
|
89
130
|
for (const item of sanitized) {
|
|
@@ -153,6 +194,7 @@ export function sanitizeOpenAIResponsesAssistantFallbackItemsForReplay(items: Re
|
|
|
153
194
|
function sanitizeOpenAIResponsesHistoryItemForReplay(
|
|
154
195
|
item: Record<string, unknown>,
|
|
155
196
|
normalizedCallIds: Map<string, string>,
|
|
197
|
+
supportsImageDetailOriginal: boolean,
|
|
156
198
|
): OpenAIResponsesReplayItem | undefined {
|
|
157
199
|
if (item.type === "item_reference") return undefined;
|
|
158
200
|
if (item.type === "image_generation_call") return sanitizeOpenAIResponsesImageGenerationCallForReplay(item);
|
|
@@ -164,7 +206,10 @@ function sanitizeOpenAIResponsesHistoryItemForReplay(
|
|
|
164
206
|
sanitizedItem.call_id = normalizeReplayedResponsesHistoryCallId(item.call_id, normalizedCallIds);
|
|
165
207
|
}
|
|
166
208
|
|
|
167
|
-
return
|
|
209
|
+
return clampReplayItemImageDetail(
|
|
210
|
+
sanitizedItem,
|
|
211
|
+
supportsImageDetailOriginal,
|
|
212
|
+
) as unknown as OpenAIResponsesReplayItem;
|
|
168
213
|
}
|
|
169
214
|
|
|
170
215
|
function sanitizeOpenAIResponsesReasoningItemForReplay(item: Record<string, unknown>): OpenAIResponsesReplayItem {
|