@oh-my-pi/pi-ai 18.14.2 → 18.15.0

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,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.15.0] - 2026-11-01
6
+
7
+ ### Fixed
8
+
9
+ - Fixed Codex conversation replay failing with an unknown `internal_chat_message_metadata_passthrough.content_token_count` parameter by omitting backend-internal response metadata from replayed input ([#17590](https://github.com/can1357/oh-my-pi/pull/17590), [#17597](https://github.com/can1357/oh-my-pi/pull/17597) by [@briankeefe](https://github.com/briankeefe)).
10
+ - Fixed `service_tier: "ultrafast"` being dropped for OpenAI-family models on custom providers even when the model's `serviceTiers` lists it ([#17577](https://github.com/can1357/oh-my-pi/pull/17577) by [@jakehemmerle](https://github.com/jakehemmerle))
11
+ - Fixed Google Vertex requests with Workforce Identity Federation ADC failing token refresh against the wrong endpoint ([#17447](https://github.com/can1357/oh-my-pi/issues/17447)).
12
+ - Fixed Anthropic (and other rotating-token) OAuth accounts being signed out automatically when a background usage poll's token refresh got `invalid_grant`, including refreshes a client delegates to an auth broker; usage polling no longer disables credentials, so only a real request's refresh can do that ([#17468](https://github.com/can1357/oh-my-pi/issues/17468))
13
+
5
14
  ## [18.14.2] - 2026-10-31
6
15
 
7
16
  ### Breaking Changes
@@ -279,8 +279,13 @@ export interface CredentialDisabledEvent {
279
279
  orgId?: string;
280
280
  orgName?: string;
281
281
  }
282
- /** Why a delegated OAuth refresh was requested. */
283
- export type OAuthRefreshReason = "auth-recovery";
282
+ /**
283
+ * Why an OAuth refresh was requested.
284
+ * - `auth-recovery`: a provider rejected the bearer with a 401.
285
+ * - `usage-probe`: a background usage poll; advisory, so a definitive refresh
286
+ * failure never disables the credential (an interactive request decides).
287
+ */
288
+ export type OAuthRefreshReason = "auth-recovery" | "usage-probe";
284
289
  /** Configuration supplied when constructing credential storage. */
285
290
  export type AuthStorageOptions = {
286
291
  usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
@@ -979,9 +984,10 @@ export interface OAuthRefreshByIdOptions {
979
984
  */
980
985
  reuseRecentMint?: boolean;
981
986
  /**
982
- * Provider-401 recovery intent. Forwarded to a delegated refresh (an auth
983
- * broker), which may then return a token it minted in the last five minutes.
984
- * Local reuse still requires {@link reuseRecentMint}.
987
+ * Why the refresh was requested. `auth-recovery` is forwarded to a delegated
988
+ * refresh (an auth broker), which may then return a token it minted in the
989
+ * last five minutes; local reuse still requires {@link reuseRecentMint}.
990
+ * `usage-probe` never disables the row on a definitive refresh failure.
985
991
  */
986
992
  reason?: OAuthRefreshReason;
987
993
  /** The access token a provider 401 rejected; see {@link AuthApiKeyOptions.rejectedAccessToken}. */
@@ -17,6 +17,11 @@ export * from "./claude-code-fingerprint.js";
17
17
  * pass through untouched, so installing it on every OAuth flow is safe.
18
18
  */
19
19
  export declare function wrapFetchForCch(base: FetchImpl): FetchImpl;
20
+ /**
21
+ * Whether a request to `model` sends the tool `name` for the host to run itself, never dispatching the call to the
22
+ * client: the same decision `encodeAnthropicToolName` makes on the wire.
23
+ */
24
+ export declare function anthropicHostRunsTool(model: Model<"anthropic-messages">, name: string, headers?: Record<string, string>): boolean;
20
25
  export type AnthropicEffort = AnthropicOutputEffort | "adaptive";
21
26
  export type AnthropicThinkingDisplay = "summarized" | "omitted";
22
27
  export interface AnthropicOptions extends StreamOptions {
@@ -3,10 +3,9 @@
3
3
  *
4
4
  * Replaces `google-auth-library` with a direct WebCrypto + REST implementation.
5
5
  * Sources, in priority order:
6
- * 1. `GOOGLE_APPLICATION_CREDENTIALS` env → file with `type: "service_account"` (RS256 JWT exchange)
7
- * or `type: "authorized_user"` (refresh-token exchange).
6
+ * 1. `GOOGLE_APPLICATION_CREDENTIALS` env → service account, user, or workforce ADC file.
8
7
  * 2. gcloud user ADC: `%APPDATA%\gcloud\application_default_credentials.json` on Windows,
9
- * `~/.config/gcloud/application_default_credentials.json` elsewhere (authorized_user flow).
8
+ * `~/.config/gcloud/application_default_credentials.json` elsewhere.
10
9
  * 3. GCE / Cloud Run metadata server (`metadata.google.internal`).
11
10
  *
12
11
  * Tokens are cached per source key and refreshed `GOOGLE_VERTEX_REFRESH_SKEW_MS` before expiry
@@ -31,6 +31,7 @@ export interface InputItem {
31
31
  type?: string | null;
32
32
  role?: string;
33
33
  content?: unknown;
34
+ internal_chat_message_metadata_passthrough?: unknown;
34
35
  call_id?: string | null;
35
36
  name?: string;
36
37
  output?: unknown;
@@ -144,8 +144,10 @@ export declare function resolveModelServiceTier(tiers: ServiceTierByFamily | nul
144
144
  * `service_tier_for_request`); an empty or missing list counts as "not
145
145
  * reported" — accounts whose `/models` lists no tiers keep `/fast` — so the
146
146
  * provider-level answer stands. `flex` and `default` are never gated.
147
- * First-party OpenAI takes `ultrafast` as-is. A bare provider string cannot
148
- * carry the list, so it answers for the provider alone.
147
+ * First-party OpenAI takes `ultrafast` as-is. An OpenAI-family relay on the
148
+ * Responses/Completions APIs gets `ultrafast` only when its discovered
149
+ * `service_tiers` lists it. A bare provider string cannot carry the list, so
150
+ * it answers for the provider alone.
149
151
  */
150
152
  export declare function shouldSendServiceTier(serviceTier: ServiceTier | null | undefined, target: Provider | ServiceTierModel | undefined): boolean;
151
153
  /**
@@ -34,6 +34,11 @@ export declare function stripOpenAIResponsesOutputOnlyStatusesForReplay<TItem ex
34
34
  type?: unknown;
35
35
  status?: unknown;
36
36
  }>(items: TItem[]): TItem[];
37
+ /** Remove Codex-rejected backend metadata; preserve clean-item identity by default, or copy while keeping/dropping id. */
38
+ export declare function stripOpenAIResponsesInternalMetadataForReplay<TItem extends {
39
+ id?: unknown;
40
+ internal_chat_message_metadata_passthrough?: unknown;
41
+ }>(item: TItem, mode?: "preserve" | "copy" | "drop-id"): TItem;
37
42
  /** Clamp replayed images without changing item identities or other native replay fields. */
38
43
  export declare function clampOpenAIResponsesImageDetailForReplay<TItem extends {
39
44
  type?: unknown;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.14.2",
3
+ "version": "18.15.0",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.14.2",
159
- "@oh-my-pi/pi-catalog": "18.14.2",
160
- "@oh-my-pi/pi-natives": "18.14.2",
161
- "@oh-my-pi/pi-utils": "18.14.2",
162
- "@oh-my-pi/pi-wire": "18.14.2"
158
+ "@oh-my-pi/omptype": "18.15.0",
159
+ "@oh-my-pi/pi-catalog": "18.15.0",
160
+ "@oh-my-pi/pi-natives": "18.15.0",
161
+ "@oh-my-pi/pi-utils": "18.15.0",
162
+ "@oh-my-pi/pi-wire": "18.15.0"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -1006,7 +1006,9 @@ export class OAuthRefresher {
1006
1006
  // `refreshStored` owns the caller budget and the exchange backstop.
1007
1007
  refresh: (current, refreshSignal) =>
1008
1008
  this.#requestOAuthCredentialRefresh(provider, current, credentialId, refreshSignal, reason, undefined),
1009
- isDefinitiveFailure: error => AIError.isDefinitiveOAuthFailure(String(error)),
1009
+ // A usage probe is advisory: leave a dead grant for an interactive request to disable.
1010
+ isDefinitiveFailure:
1011
+ reason === "usage-probe" ? undefined : error => AIError.isDefinitiveOAuthFailure(String(error)),
1010
1012
  disabledCause: error => `oauth refresh failed: ${String(error)}`,
1011
1013
  });
1012
1014
  if (result.credential) {
@@ -1199,7 +1201,8 @@ export class OAuthRefresher {
1199
1201
  // A definitively-dead grant tears the row down here, where the
1200
1202
  // attempted credential is known. CAS on the persisted credential so a
1201
1203
  // peer/login rotation in flight leaves the freshly-rotated row intact.
1202
- if (AIError.isDefinitiveOAuthFailure(String(error))) {
1204
+ // A usage probe is advisory: leave a dead grant for an interactive request to disable.
1205
+ if (reason !== "usage-probe" && AIError.isDefinitiveOAuthFailure(String(error))) {
1203
1206
  // CAS-loss (false) means a peer/login rotated the row mid-refresh, so
1204
1207
  // our #data copy is stale — reload so the next caller serves the
1205
1208
  // freshly-rotated credential rather than the dead token we attempted.
package/src/auth/types.ts CHANGED
@@ -329,8 +329,13 @@ export interface CredentialDisabledEvent {
329
329
  orgName?: string;
330
330
  }
331
331
 
332
- /** Why a delegated OAuth refresh was requested. */
333
- export type OAuthRefreshReason = "auth-recovery";
332
+ /**
333
+ * Why an OAuth refresh was requested.
334
+ * - `auth-recovery`: a provider rejected the bearer with a 401.
335
+ * - `usage-probe`: a background usage poll; advisory, so a definitive refresh
336
+ * failure never disables the credential (an interactive request decides).
337
+ */
338
+ export type OAuthRefreshReason = "auth-recovery" | "usage-probe";
334
339
 
335
340
  /** Configuration supplied when constructing credential storage. */
336
341
  export type AuthStorageOptions = {
@@ -1064,9 +1069,10 @@ export interface OAuthRefreshByIdOptions {
1064
1069
  */
1065
1070
  reuseRecentMint?: boolean;
1066
1071
  /**
1067
- * Provider-401 recovery intent. Forwarded to a delegated refresh (an auth
1068
- * broker), which may then return a token it minted in the last five minutes.
1069
- * Local reuse still requires {@link reuseRecentMint}.
1072
+ * Why the refresh was requested. `auth-recovery` is forwarded to a delegated
1073
+ * refresh (an auth broker), which may then return a token it minted in the
1074
+ * last five minutes; local reuse still requires {@link reuseRecentMint}.
1075
+ * `usage-probe` never disables the row on a definitive refresh failure.
1070
1076
  */
1071
1077
  reason?: OAuthRefreshReason;
1072
1078
  /** The access token a provider 401 rejected; see {@link AuthApiKeyOptions.rejectedAccessToken}. */
package/src/auth/usage.ts CHANGED
@@ -232,6 +232,7 @@ export class UsageService implements UsageApi {
232
232
  refreshableCredential,
233
233
  refreshableCredentialId,
234
234
  timeoutSignal,
235
+ "usage-probe",
235
236
  );
236
237
  const refreshedCredential = mergeRefreshedUsageCredential(request.credential, refreshed);
237
238
  this.persistRefreshedCredential(
@@ -327,7 +327,7 @@ export class AuthBrokerClient {
327
327
  signal?: AbortSignal,
328
328
  reason?: OAuthRefreshReason,
329
329
  ): Promise<CredentialRefreshResponse> {
330
- const suffix = reason === "auth-recovery" ? "?reason=auth-recovery" : "";
330
+ const suffix = reason ? `?reason=${reason}` : "";
331
331
  return this.#request<CredentialRefreshResponse>("POST", `/v1/credential/${id}/refresh${suffix}`, {
332
332
  schema: "credentialRefreshResponseSchema",
333
333
  signal,
@@ -53,6 +53,7 @@ import {
53
53
 
54
54
  const DEFAULT_EXTERNAL_CHANGE_POLL_MS = 250;
55
55
  const AUTH_RECOVERY_REFRESH_OPTIONS = { reuseRecentMint: true } as const;
56
+ const USAGE_PROBE_REFRESH_OPTIONS = { reason: "usage-probe" } as const;
56
57
  const TEXT_ENCODER = new TextEncoder();
57
58
  const SSE_KEEPALIVE = TEXT_ENCODER.encode(": keepalive\n\n");
58
59
 
@@ -935,12 +936,16 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
935
936
  const refreshMatch = req.method === "POST" ? pathname.match(REFRESH_ROUTE) : null;
936
937
  if (refreshMatch) {
937
938
  const id = Number.parseInt(refreshMatch[1], 10);
938
- const authRecovery = url.searchParams.get("reason") === "auth-recovery";
939
+ const reason = url.searchParams.get("reason");
939
940
  try {
940
941
  const entry = await opts.storage.oauth.refresh(
941
942
  id,
942
943
  req.signal,
943
- authRecovery ? AUTH_RECOVERY_REFRESH_OPTIONS : undefined,
944
+ reason === "auth-recovery"
945
+ ? AUTH_RECOVERY_REFRESH_OPTIONS
946
+ : reason === "usage-probe"
947
+ ? USAGE_PROBE_REFRESH_OPTIONS
948
+ : undefined,
944
949
  );
945
950
  const body: CredentialRefreshResponse = { entry };
946
951
  logger.info("auth-broker credential refreshed", {
@@ -652,6 +652,19 @@ function getUmansWebSearchHeader(
652
652
  return provider ? { [UMANS_WEBSEARCH_PROVIDER_HEADER]: provider } : undefined;
653
653
  }
654
654
 
655
+ /**
656
+ * Whether a request to `model` sends the tool `name` for the host to run itself, never dispatching the call to the
657
+ * client: the same decision `encodeAnthropicToolName` makes on the wire.
658
+ */
659
+ export function anthropicHostRunsTool(
660
+ model: Model<"anthropic-messages">,
661
+ name: string,
662
+ headers?: Record<string, string>,
663
+ ): boolean {
664
+ const gatewayWebSearch = getUmansWebSearchHeader(model, mergeHeaders(model.headers, headers)) !== undefined;
665
+ return shouldUseUmansGatewayWebSearch(name, gatewayWebSearch);
666
+ }
667
+
655
668
  function shouldUseUmansGatewayWebSearch(name: string, enabled: boolean): boolean {
656
669
  return enabled && name.toLowerCase() === UMANS_WEBSEARCH_TOOL_NAME;
657
670
  }
@@ -3,10 +3,9 @@
3
3
  *
4
4
  * Replaces `google-auth-library` with a direct WebCrypto + REST implementation.
5
5
  * Sources, in priority order:
6
- * 1. `GOOGLE_APPLICATION_CREDENTIALS` env → file with `type: "service_account"` (RS256 JWT exchange)
7
- * or `type: "authorized_user"` (refresh-token exchange).
6
+ * 1. `GOOGLE_APPLICATION_CREDENTIALS` env → service account, user, or workforce ADC file.
8
7
  * 2. gcloud user ADC: `%APPDATA%\gcloud\application_default_credentials.json` on Windows,
9
- * `~/.config/gcloud/application_default_credentials.json` elsewhere (authorized_user flow).
8
+ * `~/.config/gcloud/application_default_credentials.json` elsewhere.
10
9
  * 3. GCE / Cloud Run metadata server (`metadata.google.internal`).
11
10
  *
12
11
  * Tokens are cached per source key and refreshed `GOOGLE_VERTEX_REFRESH_SKEW_MS` before expiry
@@ -45,6 +44,14 @@ interface AuthorizedUserCredentials {
45
44
  refresh_token: string;
46
45
  }
47
46
 
47
+ interface ExternalAccountAuthorizedUserCredentials {
48
+ type: "external_account_authorized_user";
49
+ client_id: string;
50
+ client_secret: string;
51
+ refresh_token: string;
52
+ token_url: string;
53
+ }
54
+
48
55
  interface ImpersonatedServiceAccountCredentials {
49
56
  type: "impersonated_service_account";
50
57
  service_account_impersonation_url: string;
@@ -52,16 +59,28 @@ interface ImpersonatedServiceAccountCredentials {
52
59
  delegates?: string[];
53
60
  }
54
61
 
55
- type AdcFileCredentials = ServiceAccountCredentials | AuthorizedUserCredentials | ImpersonatedServiceAccountCredentials;
62
+ type AdcFileCredentials =
63
+ | ServiceAccountCredentials
64
+ | AuthorizedUserCredentials
65
+ | ExternalAccountAuthorizedUserCredentials
66
+ | ImpersonatedServiceAccountCredentials;
56
67
 
57
68
  interface TokenResponse {
58
69
  access_token: string;
59
70
  expires_in: number;
60
71
  token_type?: string;
72
+ /** Replacement refresh token; STS may rotate workforce refresh tokens on exchange. */
73
+ refresh_token?: string;
61
74
  }
62
75
 
63
76
  const tokenCache = new Map<string, CachedToken>();
64
77
  const inflight = new Map<string, Promise<string>>();
78
+ /**
79
+ * Latest workforce refresh token, keyed by the token stored in the ADC file. The file is never
80
+ * rewritten here when STS rotates the token, so later refreshes must use the replacement;
81
+ * a re-login writes a new file token and bypasses the stale entry.
82
+ */
83
+ const rotatedRefreshTokens = new Map<string, string>();
65
84
 
66
85
  function getRefreshSkewMs(): number {
67
86
  return $envpos("GOOGLE_VERTEX_REFRESH_SKEW_MS", 60_000);
@@ -168,6 +187,19 @@ async function exchangeRefreshToken(
168
187
  return postForToken(OAUTH_TOKEN_URL, body, signal, fetchImpl);
169
188
  }
170
189
 
190
+ async function exchangeWorkforceRefreshToken(
191
+ creds: ExternalAccountAuthorizedUserCredentials,
192
+ signal: AbortSignal | undefined,
193
+ fetchImpl: FetchImpl,
194
+ ): Promise<TokenResponse> {
195
+ const refreshToken = rotatedRefreshTokens.get(creds.refresh_token) ?? creds.refresh_token;
196
+ const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken });
197
+ const authorization = `Basic ${Buffer.from(`${creds.client_id}:${creds.client_secret}`, "utf8").toString("base64")}`;
198
+ const token = await postForToken(creds.token_url, body, signal, fetchImpl, { Authorization: authorization });
199
+ if (token.refresh_token) rotatedRefreshTokens.set(creds.refresh_token, token.refresh_token);
200
+ return token;
201
+ }
202
+
171
203
  async function fetchMetadataToken(
172
204
  signal: AbortSignal | undefined,
173
205
  fetchImpl: FetchImpl,
@@ -192,10 +224,11 @@ async function postForToken(
192
224
  body: URLSearchParams,
193
225
  signal: AbortSignal | undefined,
194
226
  fetchImpl: FetchImpl,
227
+ extraHeaders?: Record<string, string>,
195
228
  ): Promise<TokenResponse> {
196
229
  const response = await fetchImpl(url, {
197
230
  method: "POST",
198
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
231
+ headers: { "Content-Type": "application/x-www-form-urlencoded", ...extraHeaders },
199
232
  body: body.toString(),
200
233
  signal,
201
234
  });
@@ -217,6 +250,7 @@ async function resolveAccessTokenUncached(
217
250
  const adc = await loadAdcCredentials();
218
251
  if (adc) {
219
252
  const creds = adc.creds;
253
+ const credentialType: string = creds.type;
220
254
  let token: TokenResponse;
221
255
 
222
256
  if (creds.type === "impersonated_service_account") {
@@ -228,10 +262,16 @@ async function resolveAccessTokenUncached(
228
262
  throw new RangeError(`Cannot extract target principal from ${creds.service_account_impersonation_url}`);
229
263
  }
230
264
 
231
- const sourceToken =
232
- creds.source_credentials.type === "service_account"
233
- ? await exchangeJwtForToken(creds.source_credentials, signal, fetchImpl)
234
- : await exchangeRefreshToken(creds.source_credentials, signal, fetchImpl);
265
+ const sourceCredentials = creds.source_credentials;
266
+ const sourceType: string = sourceCredentials.type;
267
+ let sourceToken: TokenResponse;
268
+ if (sourceCredentials.type === "service_account") {
269
+ sourceToken = await exchangeJwtForToken(sourceCredentials, signal, fetchImpl);
270
+ } else if (sourceCredentials.type === "authorized_user") {
271
+ sourceToken = await exchangeRefreshToken(sourceCredentials, signal, fetchImpl);
272
+ } else {
273
+ throw new AIError.ConfigurationError(`Unsupported ADC credential type: ${sourceType}`);
274
+ }
235
275
 
236
276
  const response = await fetchImpl(
237
277
  `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${targetPrincipal}:generateAccessToken`,
@@ -260,11 +300,14 @@ async function resolveAccessTokenUncached(
260
300
  const data = (await response.json()) as { accessToken: string; expireTime: string };
261
301
  const expiresIn = Math.max(0, Math.floor((new Date(data.expireTime).getTime() - Date.now()) / 1000));
262
302
  token = { access_token: data.accessToken, expires_in: expiresIn, token_type: "Bearer" };
303
+ } else if (creds.type === "service_account") {
304
+ token = await exchangeJwtForToken(creds, signal, fetchImpl);
305
+ } else if (creds.type === "authorized_user") {
306
+ token = await exchangeRefreshToken(creds, signal, fetchImpl);
307
+ } else if (creds.type === "external_account_authorized_user") {
308
+ token = await exchangeWorkforceRefreshToken(creds, signal, fetchImpl);
263
309
  } else {
264
- token =
265
- creds.type === "service_account"
266
- ? await exchangeJwtForToken(creds, signal, fetchImpl)
267
- : await exchangeRefreshToken(creds, signal, fetchImpl);
310
+ throw new AIError.ConfigurationError(`Unsupported ADC credential type: ${credentialType}`);
268
311
  }
269
312
  return { source: adc.source, token };
270
313
  }
@@ -334,4 +377,5 @@ export async function getVertexAccessToken(options?: { signal?: AbortSignal; fet
334
377
  export function __resetVertexTokenCache(): void {
335
378
  tokenCache.clear();
336
379
  inflight.clear();
380
+ rotatedRefreshTokens.clear();
337
381
  }
@@ -2,6 +2,7 @@ import { Effort } from "@oh-my-pi/pi-catalog/effort";
2
2
  import { requireSupportedEffort } from "@oh-my-pi/pi-catalog/model-thinking";
3
3
  import { $env } from "@oh-my-pi/pi-utils";
4
4
  import type { Model } from "../../types";
5
+ import { stripOpenAIResponsesInternalMetadataForReplay } from "../../utils";
5
6
  import { mapOpenAIReasoningEffort } from "../openai-shared";
6
7
 
7
8
  /** Reasoning replay scope for the Codex Responses API (`reasoning.context`). */
@@ -50,6 +51,7 @@ export interface InputItem {
50
51
  type?: string | null;
51
52
  role?: string;
52
53
  content?: unknown;
54
+ internal_chat_message_metadata_passthrough?: unknown;
53
55
  call_id?: string | null;
54
56
  name?: string;
55
57
  output?: unknown;
@@ -179,14 +181,12 @@ function filterInput(input: InputItem[] | undefined): InputItem[] | undefined {
179
181
 
180
182
  return input
181
183
  .filter(item => item.type !== "item_reference")
182
- .map(item => {
183
- if (item.type === "computer_call") return item;
184
- if (item.id != null) {
185
- const { id: _id, ...rest } = item;
186
- return rest as InputItem;
187
- }
188
- return item;
189
- });
184
+ .map(item =>
185
+ stripOpenAIResponsesInternalMetadataForReplay(
186
+ item,
187
+ item.type !== "computer_call" && item.id != null ? "drop-id" : "preserve",
188
+ ),
189
+ );
190
190
  }
191
191
 
192
192
  const CODEX_ORPHAN_OUTPUT_LIMIT = 16_000;
package/src/types.ts CHANGED
@@ -240,8 +240,10 @@ export function resolveModelServiceTier(
240
240
  * `service_tier_for_request`); an empty or missing list counts as "not
241
241
  * reported" — accounts whose `/models` lists no tiers keep `/fast` — so the
242
242
  * provider-level answer stands. `flex` and `default` are never gated.
243
- * First-party OpenAI takes `ultrafast` as-is. A bare provider string cannot
244
- * carry the list, so it answers for the provider alone.
243
+ * First-party OpenAI takes `ultrafast` as-is. An OpenAI-family relay on the
244
+ * Responses/Completions APIs gets `ultrafast` only when its discovered
245
+ * `service_tiers` lists it. A bare provider string cannot carry the list, so
246
+ * it answers for the provider alone.
245
247
  */
246
248
  export function shouldSendServiceTier(
247
249
  serviceTier: ServiceTier | null | undefined,
@@ -260,7 +262,11 @@ export function shouldSendServiceTier(
260
262
  if (advertised !== undefined && advertised.length > 0) return advertised.includes(serviceTier);
261
263
  }
262
264
  if (serviceTier === "ultrafast") {
263
- return provider === "openai" || (typeof target === "string" && provider === "openai-codex");
265
+ if (typeof target === "string") return provider === "openai" || provider === "openai-codex";
266
+ if (provider === "openai") return true;
267
+ return (
268
+ target !== undefined && isOpenAIServiceTierModel(target) && target.serviceTiers?.includes(serviceTier) === true
269
+ );
264
270
  }
265
271
  if (provider === "openai" || provider === "openai-codex") return true;
266
272
  if (provider === "openrouter") {
package/src/utils.ts CHANGED
@@ -111,6 +111,17 @@ export function stripOpenAIResponsesOutputOnlyStatusesForReplay<TItem extends {
111
111
  return sanitized ?? items;
112
112
  }
113
113
 
114
+ /** Remove Codex-rejected backend metadata; preserve clean-item identity by default, or copy while keeping/dropping id. */
115
+ export function stripOpenAIResponsesInternalMetadataForReplay<
116
+ TItem extends { id?: unknown; internal_chat_message_metadata_passthrough?: unknown },
117
+ >(item: TItem, mode: "preserve" | "copy" | "drop-id" = "preserve"): TItem {
118
+ if (mode === "preserve" && !Object.hasOwn(item, "internal_chat_message_metadata_passthrough")) return item;
119
+ const sanitized = { ...item };
120
+ delete sanitized.internal_chat_message_metadata_passthrough;
121
+ if (mode === "drop-id") delete sanitized.id;
122
+ return sanitized;
123
+ }
124
+
114
125
  /**
115
126
  * Clamp `detail: "original"` only where Responses input_image parts live —
116
127
  * top-level items and `message.content[]`. Avoids a deep tree walk/clone of
@@ -614,8 +625,10 @@ function sanitizeOpenAIResponsesHistoryItemForReplay(
614
625
  if (item.type === "reasoning") {
615
626
  return sanitizeOpenAIResponsesReasoningItemForReplay(item, preserveReasoningItemIds);
616
627
  }
617
- const { id: _id, ...sanitizedItem } = item;
618
- if (item.type === "computer_call" && typeof item.id === "string") sanitizedItem.id = item.id;
628
+ const sanitizedItem = stripOpenAIResponsesInternalMetadataForReplay(
629
+ item,
630
+ item.type === "computer_call" && typeof item.id === "string" ? "copy" : "drop-id",
631
+ );
619
632
  if (item.type === "function_call") sanitizedItem.arguments = replayFunctionCallArguments(item);
620
633
 
621
634
  return clampReplayItemImageDetail(