@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 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. The `all_turns` value is gated to gpt-5.4+ Codex models older ids reject it, so it is suppressed and `context` omitted. */
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>): void;
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
@@ -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
- export declare function sanitizeOpenAIResponsesHistoryItemsForReplay(items: Array<Record<string, unknown>>): ResponseInput;
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>>): ResponseInput | undefined;
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.0",
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.0",
42
- "@oh-my-pi/pi-utils": "16.4.0",
43
- "@oh-my-pi/pi-wire": "16.4.0",
44
- "arktype": "^2.2.0",
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": {
@@ -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(id: number, expectedData: string, disabledCause: string): boolean;
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 = 5;
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(id: number, expectedData: string, disabledCause: string): boolean {
5844
- try {
5845
- const result = this.#deleteIfMatchesStmt.run(normalizeDisabledCause(disabledCause), id, expectedData) as {
5846
- changes: number;
5847
- };
5848
- return result.changes === 1;
5849
- } catch {
5850
- return false;
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) {
@@ -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 JSON.stringify(value) ?? "null";
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. The `all_turns` value is gated to gpt-5.4+ Codex models older ids reject it, so it is suppressed and `context` omitted. */
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 = getReasoningConfig(model, options.reasoningEffort, options);
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
- const context = options.reasoningContext ?? "all_turns";
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
- messages.push(...sanitizeOpenAIResponsesHistoryItemsForReplay(filterReasoning(historyItems)));
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
- options.supportsImageDetailOriginal,
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 sanitizedHistoryItems = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(
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
- options.supportsImageDetailOriginal,
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: block.name,
1607
- arguments: JSON.stringify(block.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
- export function sanitizeOpenAIResponsesHistoryItemsForReplay(items: Array<Record<string, unknown>>): ResponseInput {
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(item, normalizedCallIds);
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 sanitizedItem as unknown as OpenAIResponsesReplayItem;
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 {