@oh-my-pi/pi-ai 16.4.0 → 16.4.2
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 +14 -0
- package/dist/types/auth-storage.d.ts +42 -2
- package/dist/types/providers/openai-codex/request-transformer.d.ts +1 -1
- 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 +418 -12
- package/src/dialect/rendering.ts +2 -1
- package/src/providers/openai-codex/request-transformer.ts +6 -4
- package/src/providers/openai-codex-responses.ts +13 -0
- package/src/providers/openai-shared.ts +100 -8
- package/src/utils.ts +49 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.4.2] - 2026-07-10
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed compatibility with xAI by automatically downgrading OpenAI-specific tool calls and image detail settings during message history replays.
|
|
10
|
+
- 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.
|
|
11
|
+
- Fixed OpenAI Codex requests to include the required version header for newly gated models.
|
|
12
|
+
|
|
13
|
+
## [16.4.1] - 2026-07-10
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Enforced `all_turns` reasoning context for all Responses Lite requests
|
|
18
|
+
|
|
5
19
|
## [16.4.0] - 2026-07-10
|
|
6
20
|
|
|
7
21
|
### Added
|
|
@@ -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;
|
|
@@ -14,7 +14,7 @@ export interface CodexRequestOptions {
|
|
|
14
14
|
/** User-facing effort; maps 1:1 onto the wire tier of the same name. */
|
|
15
15
|
reasoningEffort?: CodexCallerEffort | "none";
|
|
16
16
|
reasoningSummary?: ReasoningConfig["summary"] | null;
|
|
17
|
-
/** Explicit `reasoning.context` override; defaults to `all_turns` when unset.
|
|
17
|
+
/** Explicit `reasoning.context` override; defaults to `all_turns` when unset. Gated to gpt-5.4+ Codex models (older ids reject it, so it is suppressed and `context` omitted). Note that under Responses Lite (`responsesLite`), the server strictly requires `reasoning.context` to be `all_turns`, which overrides this option and forces `all_turns`. */
|
|
18
18
|
reasoningContext?: CodexReasoningContext;
|
|
19
19
|
textVerbosity?: "low" | "medium" | "high";
|
|
20
20
|
include?: string[];
|
|
@@ -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.2",
|
|
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.2",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.4.2",
|
|
43
|
+
"@oh-my-pi/pi-wire": "16.4.2",
|
|
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
|
);
|
|
@@ -5400,6 +5703,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5400
5703
|
if (!this.#authCredentialsTableExists()) {
|
|
5401
5704
|
this.#createAuthCredentialsTable();
|
|
5402
5705
|
this.#createAuthCredentialBlocksTable();
|
|
5706
|
+
this.#createAuthCredentialRefreshLeasesTable();
|
|
5403
5707
|
this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION);
|
|
5404
5708
|
return;
|
|
5405
5709
|
}
|
|
@@ -5417,6 +5721,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5417
5721
|
|
|
5418
5722
|
this.#createAuthCredentialIndexes();
|
|
5419
5723
|
this.#createAuthCredentialBlocksTable();
|
|
5724
|
+
this.#createAuthCredentialRefreshLeasesTable();
|
|
5420
5725
|
this.#backfillCredentialIdentityKeys();
|
|
5421
5726
|
// Rewriting an already-current version row is a no-op write transaction
|
|
5422
5727
|
// on every boot; only persist when the recorded version actually changes.
|
|
@@ -5514,6 +5819,18 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5514
5819
|
`);
|
|
5515
5820
|
}
|
|
5516
5821
|
|
|
5822
|
+
#createAuthCredentialRefreshLeasesTable(): void {
|
|
5823
|
+
this.#db.run(`
|
|
5824
|
+
CREATE TABLE IF NOT EXISTS auth_credential_refresh_leases (
|
|
5825
|
+
credential_id INTEGER PRIMARY KEY,
|
|
5826
|
+
owner TEXT NOT NULL,
|
|
5827
|
+
expires_at_ms INTEGER NOT NULL,
|
|
5828
|
+
updated_at INTEGER NOT NULL
|
|
5829
|
+
);
|
|
5830
|
+
CREATE INDEX IF NOT EXISTS idx_auth_credential_refresh_leases_expires ON auth_credential_refresh_leases(expires_at_ms);
|
|
5831
|
+
`);
|
|
5832
|
+
}
|
|
5833
|
+
|
|
5517
5834
|
#migrateAuthSchema(fromVersion: number): void {
|
|
5518
5835
|
if (fromVersion < 1) {
|
|
5519
5836
|
this.#migrateAuthSchemaV0ToV1();
|
|
@@ -5527,6 +5844,9 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5527
5844
|
if (fromVersion < 5) {
|
|
5528
5845
|
this.#migrateAuthSchemaV4ToV5();
|
|
5529
5846
|
}
|
|
5847
|
+
if (fromVersion < 6) {
|
|
5848
|
+
this.#migrateAuthSchemaV5ToV6();
|
|
5849
|
+
}
|
|
5530
5850
|
}
|
|
5531
5851
|
|
|
5532
5852
|
#migrateAuthSchemaV0ToV1(): void {
|
|
@@ -5620,6 +5940,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5620
5940
|
migrate();
|
|
5621
5941
|
}
|
|
5622
5942
|
|
|
5943
|
+
#migrateAuthSchemaV5ToV6(): void {
|
|
5944
|
+
const migrate = this.#db.transaction(() => {
|
|
5945
|
+
this.#createAuthCredentialRefreshLeasesTable();
|
|
5946
|
+
});
|
|
5947
|
+
migrate();
|
|
5948
|
+
}
|
|
5949
|
+
|
|
5623
5950
|
#backfillCredentialIdentityKeys(): void {
|
|
5624
5951
|
const selectRowsStmt = this.#db.prepare(
|
|
5625
5952
|
"SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
|
|
@@ -5826,6 +6153,47 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5826
6153
|
}
|
|
5827
6154
|
}
|
|
5828
6155
|
|
|
6156
|
+
tryUpdateAuthCredentialIfMatches(
|
|
6157
|
+
id: number,
|
|
6158
|
+
expectedData: string,
|
|
6159
|
+
credential: AuthCredential,
|
|
6160
|
+
lease?: CredentialRefreshLeaseFence,
|
|
6161
|
+
): boolean {
|
|
6162
|
+
const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?");
|
|
6163
|
+
let providerRow: { provider?: string } | undefined;
|
|
6164
|
+
try {
|
|
6165
|
+
providerRow = providerStmt.get(id) as { provider?: string } | undefined;
|
|
6166
|
+
} finally {
|
|
6167
|
+
providerStmt.finalize();
|
|
6168
|
+
}
|
|
6169
|
+
const provider = providerRow?.provider ?? "";
|
|
6170
|
+
const serialized = serializeCredential(provider, credential);
|
|
6171
|
+
if (!serialized) return false;
|
|
6172
|
+
const result = lease
|
|
6173
|
+
? (this.#updateIfMatchesWithLeaseStmt.run(
|
|
6174
|
+
serialized.credentialType,
|
|
6175
|
+
serialized.data,
|
|
6176
|
+
serialized.identityKey,
|
|
6177
|
+
id,
|
|
6178
|
+
expectedData,
|
|
6179
|
+
id,
|
|
6180
|
+
lease.owner,
|
|
6181
|
+
lease.nowMs,
|
|
6182
|
+
) as { changes: number })
|
|
6183
|
+
: (this.#updateIfMatchesStmt.run(
|
|
6184
|
+
serialized.credentialType,
|
|
6185
|
+
serialized.data,
|
|
6186
|
+
serialized.identityKey,
|
|
6187
|
+
id,
|
|
6188
|
+
expectedData,
|
|
6189
|
+
) as { changes: number });
|
|
6190
|
+
if (result.changes !== 1) return false;
|
|
6191
|
+
if (provider) {
|
|
6192
|
+
this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider));
|
|
6193
|
+
}
|
|
6194
|
+
return true;
|
|
6195
|
+
}
|
|
6196
|
+
|
|
5829
6197
|
deleteAuthCredential(id: number, disabledCause: string): void {
|
|
5830
6198
|
try {
|
|
5831
6199
|
this.#deleteStmt.run(normalizeDisabledCause(disabledCause), id);
|
|
@@ -5840,17 +6208,26 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5840
6208
|
* the OAuth refresh-failure path to avoid clobbering a peer that rotated the
|
|
5841
6209
|
* row between our pre-check and the disable.
|
|
5842
6210
|
*/
|
|
5843
|
-
tryDisableAuthCredentialIfMatches(
|
|
5844
|
-
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
|
|
5848
|
-
|
|
5849
|
-
|
|
5850
|
-
|
|
5851
|
-
|
|
6211
|
+
tryDisableAuthCredentialIfMatches(
|
|
6212
|
+
id: number,
|
|
6213
|
+
expectedData: string,
|
|
6214
|
+
disabledCause: string,
|
|
6215
|
+
lease?: CredentialRefreshLeaseFence,
|
|
6216
|
+
): boolean {
|
|
6217
|
+
const result = lease
|
|
6218
|
+
? (this.#deleteIfMatchesWithLeaseStmt.run(
|
|
6219
|
+
normalizeDisabledCause(disabledCause),
|
|
6220
|
+
id,
|
|
6221
|
+
expectedData,
|
|
6222
|
+
id,
|
|
6223
|
+
lease.owner,
|
|
6224
|
+
lease.nowMs,
|
|
6225
|
+
) as { changes: number })
|
|
6226
|
+
: (this.#deleteIfMatchesStmt.run(normalizeDisabledCause(disabledCause), id, expectedData) as {
|
|
6227
|
+
changes: number;
|
|
6228
|
+
});
|
|
6229
|
+
return result.changes === 1;
|
|
5852
6230
|
}
|
|
5853
|
-
|
|
5854
6231
|
deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void {
|
|
5855
6232
|
try {
|
|
5856
6233
|
this.#deleteByProviderStmt.run(normalizeDisabledCause(disabledCause), provider);
|
|
@@ -5959,6 +6336,35 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
5959
6336
|
return blocks;
|
|
5960
6337
|
}
|
|
5961
6338
|
|
|
6339
|
+
tryAcquireCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean {
|
|
6340
|
+
const result = this.#acquireCredentialRefreshLeaseStmt.run(credentialId, owner, expiresAtMs, Date.now()) as {
|
|
6341
|
+
changes: number;
|
|
6342
|
+
};
|
|
6343
|
+
return result.changes === 1;
|
|
6344
|
+
}
|
|
6345
|
+
|
|
6346
|
+
getCredentialRefreshLeaseExpiresAt(credentialId: number): number | undefined {
|
|
6347
|
+
const row = this.#getCredentialRefreshLeaseStmt.get(credentialId) as { expires_at_ms?: number } | undefined;
|
|
6348
|
+
if (typeof row?.expires_at_ms !== "number") return undefined;
|
|
6349
|
+
if (row.expires_at_ms <= Date.now()) return undefined;
|
|
6350
|
+
return row.expires_at_ms;
|
|
6351
|
+
}
|
|
6352
|
+
|
|
6353
|
+
renewCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean {
|
|
6354
|
+
const result = this.#renewCredentialRefreshLeaseStmt.run(expiresAtMs, credentialId, owner) as {
|
|
6355
|
+
changes: number;
|
|
6356
|
+
};
|
|
6357
|
+
return result.changes === 1;
|
|
6358
|
+
}
|
|
6359
|
+
|
|
6360
|
+
releaseCredentialRefreshLease(credentialId: number, owner: string): void {
|
|
6361
|
+
try {
|
|
6362
|
+
this.#releaseCredentialRefreshLeaseStmt.run(credentialId, owner);
|
|
6363
|
+
} catch {
|
|
6364
|
+
// Ignore lease release failures; expired leases are stealable.
|
|
6365
|
+
}
|
|
6366
|
+
}
|
|
6367
|
+
|
|
5962
6368
|
recordUsageSnapshots(entries: UsageHistoryEntry[]): void {
|
|
5963
6369
|
try {
|
|
5964
6370
|
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 {
|
|
@@ -32,7 +32,7 @@ export interface CodexRequestOptions {
|
|
|
32
32
|
/** User-facing effort; maps 1:1 onto the wire tier of the same name. */
|
|
33
33
|
reasoningEffort?: CodexCallerEffort | "none";
|
|
34
34
|
reasoningSummary?: ReasoningConfig["summary"] | null;
|
|
35
|
-
/** Explicit `reasoning.context` override; defaults to `all_turns` when unset.
|
|
35
|
+
/** Explicit `reasoning.context` override; defaults to `all_turns` when unset. Gated to gpt-5.4+ Codex models (older ids reject it, so it is suppressed and `context` omitted). Note that under Responses Lite (`responsesLite`), the server strictly requires `reasoning.context` to be `all_turns`, which overrides this option and forces `all_turns`. */
|
|
36
36
|
reasoningContext?: CodexReasoningContext;
|
|
37
37
|
textVerbosity?: "low" | "medium" | "high";
|
|
38
38
|
include?: string[];
|
|
@@ -379,8 +379,9 @@ export async function transformRequestBody(
|
|
|
379
379
|
applyCodexResponsesLiteShape(body);
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
-
if (options.reasoningEffort !== undefined) {
|
|
383
|
-
const reasoningConfig =
|
|
382
|
+
if (options.reasoningEffort !== undefined || responsesLite) {
|
|
383
|
+
const reasoningConfig =
|
|
384
|
+
options.reasoningEffort !== undefined ? getReasoningConfig(model, options.reasoningEffort, options) : {};
|
|
384
385
|
body.reasoning = {
|
|
385
386
|
...body.reasoning,
|
|
386
387
|
...reasoningConfig,
|
|
@@ -394,7 +395,8 @@ export async function transformRequestBody(
|
|
|
394
395
|
// default. The version gate is authoritative: even an explicit
|
|
395
396
|
// `all_turns` override is suppressed on unsupported models, while
|
|
396
397
|
// `current_turn`/`auto` (universally supported) always pass through.
|
|
397
|
-
|
|
398
|
+
// Note: Responses Lite forces `all_turns` to satisfy the transport's server invariant.
|
|
399
|
+
const context = responsesLite ? "all_turns" : (options.reasoningContext ?? "all_turns");
|
|
398
400
|
if (context === "all_turns" && !supportsAllTurnsReasoningContext(model.id)) {
|
|
399
401
|
delete body.reasoning.context;
|
|
400
402
|
} else {
|
|
@@ -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);
|
|
@@ -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
|
|
@@ -1648,7 +1740,7 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
|
|
|
1648
1740
|
} as ResponseInput[number]);
|
|
1649
1741
|
return;
|
|
1650
1742
|
}
|
|
1651
|
-
if (customCallIds?.has(normalized.callId)) {
|
|
1743
|
+
if (supportsCustomToolCalls && customCallIds?.has(normalized.callId)) {
|
|
1652
1744
|
messages.push({
|
|
1653
1745
|
type: "custom_tool_call_output",
|
|
1654
1746
|
call_id: normalized.callId,
|
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 {
|