@oh-my-pi/pi-ai 15.10.0 → 15.10.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/dist/types/auth-retry.d.ts +72 -0
  3. package/dist/types/auth-storage.d.ts +67 -0
  4. package/dist/types/index.d.ts +1 -0
  5. package/dist/types/model-thinking.d.ts +0 -7
  6. package/dist/types/provider-models/openai-compat.d.ts +5 -0
  7. package/dist/types/providers/__tests__/google-auth.test.d.ts +1 -0
  8. package/dist/types/providers/anthropic.d.ts +0 -1
  9. package/dist/types/providers/transform-messages.d.ts +1 -1
  10. package/dist/types/stream.d.ts +8 -0
  11. package/dist/types/types.d.ts +10 -7
  12. package/dist/types/utils/schema/json-schema-validator.d.ts +8 -0
  13. package/dist/types/utils/sse-debug.d.ts +0 -5
  14. package/dist/types/utils/stream-markup-healing.d.ts +2 -0
  15. package/package.json +2 -2
  16. package/src/auth-gateway/server.ts +73 -25
  17. package/src/auth-retry.ts +141 -0
  18. package/src/auth-storage.ts +153 -4
  19. package/src/index.ts +1 -0
  20. package/src/model-thinking.ts +0 -12
  21. package/src/models.json +411 -39
  22. package/src/provider-models/descriptors.ts +1 -1
  23. package/src/provider-models/openai-compat.ts +27 -1
  24. package/src/providers/__tests__/google-auth.test.ts +144 -0
  25. package/src/providers/anthropic.ts +71 -37
  26. package/src/providers/azure-openai-responses.ts +34 -22
  27. package/src/providers/gitlab-duo.ts +3 -2
  28. package/src/providers/google-auth.ts +54 -5
  29. package/src/providers/openai-anthropic-shim.ts +5 -2
  30. package/src/providers/openai-completions.ts +90 -27
  31. package/src/providers/openai-responses-server.ts +82 -55
  32. package/src/providers/openai-responses-shared.ts +19 -5
  33. package/src/providers/openai-responses.ts +38 -26
  34. package/src/providers/pi-native-client.ts +4 -1
  35. package/src/providers/transform-messages.ts +208 -98
  36. package/src/stream.ts +49 -18
  37. package/src/types.ts +10 -7
  38. package/src/utils/http-inspector.ts +4 -2
  39. package/src/utils/request-debug.ts +12 -7
  40. package/src/utils/schema/json-schema-validator.ts +19 -1
  41. package/src/utils/sse-debug.ts +0 -271
  42. package/src/utils/stream-markup-healing.ts +6 -1
  43. package/src/utils/validation.ts +26 -10
package/CHANGELOG.md CHANGED
@@ -2,6 +2,52 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.10.2] - 2026-06-08
6
+ ### Added
7
+
8
+ - Added support for `impersonated_service_account` Application Default Credentials (ADC) in Vertex AI to enable chained impersonation without failing via 401 `invalid_client`.
9
+ - Added `AuthStorage.getCredentialOrigin(provider)` (returning a structured `CredentialOrigin` / `CredentialOriginKind`) and `getEnvApiKeyName(provider)`, so callers can render where a provider's auth comes from — runtime override, config, stored OAuth/api-key, env var (with the backing variable name), or fallback resolver — without parsing the prose of `describeCredentialSource`.
10
+
11
+ ### Changed
12
+
13
+ - Changed `onSseEvent` recording for OpenAI Responses, Azure OpenAI Responses, OpenAI Completions, and Anthropic stream providers to emit reconstructed SSE events from decoded SDK stream items instead of wrapping raw fetch responses
14
+ - Changed OpenAI Completions SSE diagnostics to include `event: "chat.completion.chunk"` in `onSseEvent` records for chunked responses
15
+ - Changed the default Anthropic model in `DEFAULT_MODEL_PER_PROVIDER` from `claude-sonnet-4-6` to `claude-opus-4-6`, so sessions that fall back to the provider default (no configured `default` role, no `--model`, no restored session) now start on Claude Opus 4.6.
16
+
17
+ ### Fixed
18
+
19
+ - Fixed duplicate upstream `tool_call_id` values collapsing distinct tool calls during message transformation, preserving one call/result pairing per emitted tool call before provider replay and keeping generated duplicate IDs distinct after OpenAI/Mistral wire-length caps. ([#2055](https://github.com/can1357/oh-my-pi/issues/2055))
20
+ - Fixed the Anthropic provider retrying persistent account usage/quota limits (e.g. `429 "This request would exceed your account's rate limit"`, `usage_limit_reached`) as if they were transient. Because the error text contains "rate limit", `isProviderRetryableError` matched it and the stream retry loop looped through its 2s/4s/8s backoff (then the `streamSimple` a/b/c policy re-minted the credential and ran the whole thing again) before surfacing the failure — even though the server's `retry-after` parked the account for minutes-to-hours. These errors are now recognized via `isUsageLimitError` and surfaced immediately to the credential-rotation layer, so e.g. `omp dry-balance --bench` reports a rate-limited account as failed at once instead of appearing to hang.
21
+ - Fixed MiniMax-compatible OpenAI-completions hosts losing tool-call argument content when `function.arguments` is streamed as an object across more than one delta. The accumulator added in #1776 wrote `block.partialArgs = rawArgs` per chunk, so every chunk but the last was overwritten — for an `edit` call this surfaced as a tail-slice of the patch text being applied (e.g. a single-line `replace 91..91:` body extending the deletion across the surrounding rows). Chunks are now shallow-merged; for shared string keys, `startsWith` distinguishes cumulative restatements (take the latest) from per-chunk-delta fragments (concatenate). Per-chunk `toolcall_delta` emission for the object branch is suppressed (the previous code emitted `JSON.stringify(rawArgs)` per chunk, which fed downstream concat consumers — `packages/agent/src/proxy.ts`, `openai-chat-server`, `openai-responses-server`, `anthropic-messages-server` — an invalid sequence like `{"input":"a"}{"input":"b"}`); the merged object is flushed instead as a single concat-safe delta in `finishToolCallBlock` before `toolcall_end`, so accumulators reconstruct the args correctly. The single-chunk shape covered by the existing #1776 regression test stays correct end-to-end. ([#2080](https://github.com/can1357/oh-my-pi/issues/2080))
22
+ - Fixed the OpenAI Responses compatibility server misrouting late `toolcall_delta` events for earlier parallel tool calls after a later `toolcall_start`. The encoder now keeps OpenFunctionCall state by content index, allocates output indexes at item start, and closes each tool item by its own `toolcall_end`, preserving deferred MiniMax object-argument flushes for the matching call. ([#2080](https://github.com/can1357/oh-my-pi/issues/2080))
23
+
24
+ ## [15.10.1] - 2026-06-07
25
+
26
+ ### Breaking Changes
27
+
28
+ - Removed the `onAuthError` option from stream request options and shifted auth retry handling to resolver-based `apiKey` behavior, requiring callers using custom auth-retry hooks to migrate
29
+
30
+ ### Added
31
+
32
+ - Added `ApiKeyResolver` and `ApiKey` auth helpers, including `isApiKeyResolver`, `isAuthRetryableError`, `resolveApiKeyOnce`, and `withAuth`, and exported them from the package root
33
+ - Added support for a function-valued `apiKey` in `SimpleStreamOptions` so a single stream request can refresh or rotate credentials during retry
34
+ - Added `forceRefresh` credential option to `AuthStorage.getApiKey` and `rotateSessionCredential` support for session-level credential rotation after auth failures
35
+ - Added `AuthStorage.resolver(provider, options)` method that builds an `ApiKeyResolver` implementing the a/b/c auth-retry policy directly on the storage instance
36
+
37
+ ### Changed
38
+
39
+ - Changed gateway and stream auth flows to share the a/b/c retry policy, refreshing the same session credential first and then switching to a sibling credential on repeated auth failures
40
+
41
+ ### Fixed
42
+
43
+ - Fixed streaming auth retries to handle `401` and usage-limit errors before replay-unsafe content is emitted, including failures surfaced only via `errorStatus`
44
+ - Fixed tool argument validation to coerce singleton non-string values into arrays when the schema expects an array, preventing Anthropic-compatible models that emit `todo.ops` as an object from getting stuck in repeated validation-error loops. ([#2026](https://github.com/can1357/oh-my-pi/issues/2026))
45
+ - Fixed streaming retries to buffer and suppress partial `start` events from failed auth attempts so only clean retried events are delivered
46
+ - Fixed the HTTP 400 raw-request dumper (`appendRawHttpRequestDumpFor400`) littering the real `~/.omp/logs/http-400-requests` directory during tests. Provider suites exercise the 400 error path with mocked `fetch` responses, which the dumper could not distinguish from genuine failures; it now skips persistence under the Bun test runner (`isBunTestRuntime()`).
47
+ - Fixed Anthropic Opus requests unnecessarily forcing `tool_choice.disable_parallel_tool_use`, allowing Claude Opus to use the provider's default parallel tool-calling behavior again.
48
+ - Fixed parallel `function_call` items losing arguments against llama.cpp's OpenAI Responses endpoint (`/v1/responses`), where every call but the last finalized with `{}` and the agent rejected them with `path: Invalid input: expected string, received undefined`. llama.cpp's `to_json_oaicompat_resp` emits `output_item.added` with only `item.call_id` (no `item.id`, no `output_index`) while the matching `function_call_arguments.delta` carries `item_id: "fc_<call_id>"`. `processResponsesStream` now registers function-call and custom-tool-call items under `item.call_id` as a secondary lookup key (alongside `item.id`/`output_index`) so identifier-deviant hosts route deltas and done events to the right block. ([#2015](https://github.com/can1357/oh-my-pi/issues/2015))
49
+ - Fixed `PI_REQ_DEBUG` response recording truncating the captured body when a streamed response was cancelled mid-flight. The response tee in `wrapResponse` could call `FileRequestDebugResponseLog.close()` from both the `cancel` callback and the resumed `pull` (which observes `done` once the source reader is cancelled); the second caller saw the handle already nulled and returned before the first caller's pending write flushed, so the `.res.log` lost the already-buffered chunk. `close()` now memoizes its flush-and-close promise so every caller awaits the same completion.
50
+
5
51
  ## [15.10.0] - 2026-06-06
6
52
 
7
53
  ### Added
@@ -2980,4 +3026,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
2980
3026
 
2981
3027
  ## [0.9.4] - 2025-11-26
2982
3028
 
2983
- Initial release with multi-provider LLM support.
3029
+ Initial release with multi-provider LLM support.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Context passed to an {@link ApiKeyResolver} on each resolution attempt.
3
+ *
4
+ * The `error`/`lastChance` pair drives the central a/b/c retry policy shared by
5
+ * the streaming ({@link streamSimple}) and non-streaming ({@link withAuth})
6
+ * drivers:
7
+ * - `error === undefined` → **initial resolve** (no force-refresh; cheap, may
8
+ * return a locally-cached not-yet-expired token).
9
+ * - `error !== undefined && !lastChance` → **step (b): refresh the SAME
10
+ * account** (force a token re-mint / await an in-flight broker refresh).
11
+ * - `error !== undefined && lastChance` → **step (c): switch account**
12
+ * (invalidate/usage-limit the current credential and rotate to a sibling).
13
+ *
14
+ * The resolver returns the bearer to send, or `undefined` to stop retrying and
15
+ * surface the last error to the caller.
16
+ */
17
+ export interface ApiKeyResolveContext {
18
+ /** True on the final retry step — the resolver should rotate to a sibling credential. */
19
+ lastChance: boolean;
20
+ /** The auth error that triggered this re-resolution, or `undefined` on the initial resolve. */
21
+ error: unknown;
22
+ /** Caller cancel signal, threaded into any credential refresh / rotation work. */
23
+ signal?: AbortSignal;
24
+ }
25
+ /**
26
+ * Resolves the API key to send for a request, retried through the a/b/c policy
27
+ * described on {@link ApiKeyResolveContext}.
28
+ */
29
+ export type ApiKeyResolver = (ctx: ApiKeyResolveContext) => Promise<string | undefined> | string | undefined;
30
+ /** A static bearer string, or a {@link ApiKeyResolver} that mints/rotates one. */
31
+ export type ApiKey = string | ApiKeyResolver;
32
+ /** Narrows {@link ApiKey} to its resolver form. */
33
+ export declare function isApiKeyResolver(key: ApiKey | undefined): key is ApiKeyResolver;
34
+ /**
35
+ * Performs the initial resolve of an {@link ApiKey} (`error: undefined`,
36
+ * `lastChance: false`). Static keys pass through unchanged.
37
+ */
38
+ export declare function resolveApiKeyOnce(key: ApiKey | undefined, signal?: AbortSignal): Promise<string | undefined>;
39
+ /**
40
+ * Classifies whether an error should trigger a credential refresh/rotation
41
+ * retry: a hard `401`, or a rotatable usage-limit ("usage_limit_reached",
42
+ * Codex's "you have hit your ChatGPT usage limit", etc.).
43
+ */
44
+ export declare function isAuthRetryableError(error: unknown): boolean;
45
+ /**
46
+ * The ordered `lastChance` values for the retry steps after the initial
47
+ * attempt fails: `false` → step (b) refresh-same, `true` → step (c) switch.
48
+ * Shared by {@link withAuth} and the streaming retry driver so both run the
49
+ * same policy.
50
+ */
51
+ export declare const AUTH_RETRY_STEPS: readonly boolean[];
52
+ /** Resolve a single retry step, swallowing resolver failures into `undefined`. */
53
+ export declare function resolveRetryKey(resolver: ApiKeyResolver, lastChance: boolean, error: unknown, signal?: AbortSignal): Promise<string | undefined>;
54
+ /**
55
+ * Runs an auth-protected operation through the central a/b/c retry policy.
56
+ *
57
+ * - A static string key (or any non-resolver) → a single `attempt` with no
58
+ * retry (identical to the legacy static-key path).
59
+ * - A resolver → initial `attempt`, then on a retryable auth error up to two
60
+ * more attempts (refresh-same, then switch). A step is skipped when the
61
+ * resolver returns the same key it just tried or `undefined`; non-auth errors
62
+ * propagate immediately.
63
+ *
64
+ * Used by non-streaming consumers (image generation, web search, completion
65
+ * helpers). The streaming driver in `stream.ts` implements the same policy with
66
+ * its replay-safe buffering machinery.
67
+ */
68
+ export declare function withAuth<T>(key: ApiKey | undefined, attempt: (key: string) => Promise<T>, opts?: {
69
+ isAuthError?: (error: unknown) => boolean;
70
+ signal?: AbortSignal;
71
+ missingKeyMessage?: string;
72
+ }): Promise<T>;
@@ -8,6 +8,7 @@
8
8
  * - `SqliteAuthCredentialStore`: concrete SQLite-backed implementation
9
9
  */
10
10
  import { Database } from "bun:sqlite";
11
+ import type { ApiKeyResolver } from "./auth-retry";
11
12
  import type { Provider } from "./types";
12
13
  import type { CredentialRankingStrategy, UsageLogger, UsageProvider, UsageReport } from "./usage";
13
14
  import type { OAuthController, OAuthCredentials, OAuthProviderId } from "./utils/oauth/types";
@@ -21,6 +22,21 @@ export type OAuthCredential = {
21
22
  export type AuthCredential = ApiKeyCredential | OAuthCredential;
22
23
  export type AuthCredentialEntry = AuthCredential | AuthCredential[];
23
24
  export type AuthStorageData = Record<string, AuthCredentialEntry>;
25
+ /**
26
+ * Cascade leg that supplies a provider's active credential, highest precedence
27
+ * first — mirrors {@link AuthStorage.getApiKey}'s resolution order.
28
+ */
29
+ export type CredentialOriginKind = "runtime" | "config" | "oauth" | "api_key" | "env" | "fallback";
30
+ /**
31
+ * Structured provenance for a provider's auth, for UI that needs a machine
32
+ * tag (the `/login` provider list) rather than the prose of
33
+ * {@link AuthStorage.describeCredentialSource}.
34
+ */
35
+ export interface CredentialOrigin {
36
+ kind: CredentialOriginKind;
37
+ /** Env var name when `kind === "env"` and a single named variable backs it. */
38
+ envVar?: string;
39
+ }
24
40
  /**
25
41
  * Serialized representation of AuthStorage for passing to subagent workers.
26
42
  * Contains only the essential credential data, not runtime state.
@@ -366,6 +382,13 @@ type AuthApiKeyOptions = {
366
382
  * stranding the caller for `timeoutMs * (maxRetries + 1)`.
367
383
  */
368
384
  signal?: AbortSignal;
385
+ /**
386
+ * Force a re-mint of the session-preferred OAuth credential's access token,
387
+ * bypassing the not-yet-expired short-circuit. Powers step (b) of the
388
+ * auth-retry policy ("refresh the SAME account") so a locally-cached token
389
+ * that a peer/broker rotated out from under us is replaced before retrying.
390
+ */
391
+ forceRefresh?: boolean;
369
392
  };
370
393
  /**
371
394
  * Refreshed OAuth access plus identity metadata returned by
@@ -517,6 +540,15 @@ export declare class AuthStorage {
517
540
  * silently satisfies xai-oauth and routes around `providers.xai.baseUrl`.
518
541
  */
519
542
  hasNonEnvCredential(provider: string): boolean;
543
+ /**
544
+ * Classify where a provider's auth comes from, following the same precedence
545
+ * as {@link AuthStorage.getApiKey}: runtime override → config override →
546
+ * stored credential (api_key before oauth, matching getApiKey) → env var →
547
+ * fallback resolver. Returns undefined when no auth is configured.
548
+ *
549
+ * Compact, structured counterpart to {@link describeCredentialSource}.
550
+ */
551
+ getCredentialOrigin(provider: string): CredentialOrigin | undefined;
520
552
  /**
521
553
  * Check if OAuth credentials are configured for a provider.
522
554
  */
@@ -647,6 +679,41 @@ export declare class AuthStorage {
647
679
  getOAuthAccesses(provider: string, options?: AuthApiKeyOptions): Promise<OAuthAccessResolution[]>;
648
680
  invalidateCredentialMatching(provider: string, apiKey: string, options?: InvalidateCredentialMatchingOptions): Promise<boolean>;
649
681
  invalidateCredentialMatching(provider: string, apiKey: string, signal?: AbortSignal): Promise<boolean>;
682
+ /**
683
+ * Rotate away from the session's current credential after a retryable auth
684
+ * error — step (c) of the auth-retry policy. Stateless: looks up the
685
+ * session-sticky credential (no API-key matching needed), applies the
686
+ * storage action for the error class, then clears the sticky so the next
687
+ * {@link AuthStorage.getApiKey} for this session picks a sibling.
688
+ *
689
+ * - usage-limit / account-rate-limit error → {@link AuthStorage.markUsageLimitReached}
690
+ * (temporary block via its own backoff — default plus server usage-report
691
+ * reset; sticky left intact so the next resolve re-ranks around the block).
692
+ * - otherwise (hard 401 / auth failure) → mark the credential suspect (or
693
+ * reload when no broker hook is wired) and block it, then drop the sticky.
694
+ *
695
+ * Returns whether another usable credential of the same type remains.
696
+ */
697
+ rotateSessionCredential(provider: string, sessionId: string | undefined, options?: {
698
+ error?: unknown;
699
+ signal?: AbortSignal;
700
+ }): Promise<boolean>;
701
+ /**
702
+ * Build an {@link ApiKeyResolver} backed by this storage, implementing the
703
+ * central a/b/c auth-retry policy:
704
+ *
705
+ * - initial (`error: undefined`) → resolve the session credential.
706
+ * - step (b) `!lastChance` → force-refresh the SAME session-sticky credential.
707
+ * - step (c) `lastChance` → rotate to a sibling credential, then re-resolve.
708
+ *
709
+ * Used by web-search providers and other consumers that hold an AuthStorage
710
+ * directly (no ModelRegistry in scope).
711
+ */
712
+ resolver(provider: string, options?: {
713
+ sessionId?: string;
714
+ baseUrl?: string;
715
+ modelId?: string;
716
+ }): ApiKeyResolver;
650
717
  /**
651
718
  * Build a redacted snapshot of all loaded credentials for the auth-broker
652
719
  * wire. OAuth refresh tokens are replaced with {@link REMOTE_REFRESH_SENTINEL}
@@ -3,6 +3,7 @@ export * from "./api-registry";
3
3
  export * from "./auth-broker";
4
4
  export { type AuthGatewayBootOptions, type ModelResolver, startAuthGateway } from "./auth-gateway/server";
5
5
  export * from "./auth-gateway/types";
6
+ export * from "./auth-retry";
6
7
  export * from "./auth-storage";
7
8
  export * from "./effort";
8
9
  export * from "./model-cache";
@@ -83,10 +83,3 @@ export declare function hasOpus47ApiRestrictions(modelId: string): boolean;
83
83
  * @see https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages
84
84
  */
85
85
  export declare function supportsMidConversationSystemMessages(modelId: string): boolean;
86
- /**
87
- * Claude Opus 4.8 must emit at most one tool call per turn: the Anthropic
88
- * Messages provider sends `tool_choice.disable_parallel_tool_use = true` for
89
- * this model. Scoped to exactly 4.8 — earlier and later Opus versions keep
90
- * Anthropic's default parallel tool-calling.
91
- */
92
- export declare function disablesParallelToolUse(modelId: string): boolean;
@@ -131,6 +131,11 @@ export declare function isFireworksKimiK2ModelId(modelId: string): boolean;
131
131
  * on Fireworks-backed providers, leaving every other model untouched.
132
132
  */
133
133
  export declare function clampFireworksKimiMaxTokens(modelId: string, candidate: number): number;
134
+ /**
135
+ * Fireworks DeepSeek V4 accepts effort via `reasoning_effort` but rejects the
136
+ * DeepSeek-native binary `thinking` toggle when both are present.
137
+ */
138
+ export declare function stripFireworksDeepSeekThinkingToggle(model: Model<"openai-completions">, publicModelId: string): Model<"openai-completions">;
134
139
  export interface FireworksModelManagerConfig {
135
140
  apiKey?: string;
136
141
  baseUrl?: string;
@@ -117,7 +117,6 @@ export type AnthropicClientOptionsArgs = {
117
117
  hasTools?: boolean;
118
118
  thinkingEnabled?: boolean;
119
119
  thinkingDisplay?: AnthropicThinkingDisplay;
120
- onSseEvent?: AnthropicOptions["onSseEvent"];
121
120
  fetch?: FetchImpl;
122
121
  claudeCodeSessionId?: string;
123
122
  };
@@ -9,4 +9,4 @@ import type { Api, AssistantMessage, Message, Model } from "../types";
9
9
  * - Injects synthetic "aborted" tool results
10
10
  * - Adds a <turn-aborted> guidance marker for the model
11
11
  */
12
- export declare function transformMessages<TApi extends Api>(messages: Message[], model: Model<TApi>, normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string): Message[];
12
+ export declare function transformMessages<TApi extends Api>(messages: Message[], model: Model<TApi>, normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string, maxNormalizedToolCallIdLength?: number, duplicateToolCallIdSuffixPrefix?: string): Message[];
@@ -12,6 +12,14 @@ import { AssistantMessageEventStream } from "./utils/event-stream";
12
12
  * Checks Bun.env, then cwd/.env, then ~/.env.
13
13
  */
14
14
  export declare function getEnvApiKey(provider: string): string | undefined;
15
+ /**
16
+ * Name of the environment variable that backs `getEnvApiKey` for a provider,
17
+ * when that provider maps to a single named variable (e.g. `github-copilot` →
18
+ * `COPILOT_GITHUB_TOKEN`). Returns undefined for providers whose env fallback
19
+ * is computed (multi-var pickers, Vertex ADC / Bedrock probes, …) since no
20
+ * single variable name describes the source.
21
+ */
22
+ export declare function getEnvApiKeyName(provider: string): string | undefined;
15
23
  /**
16
24
  * Enumerate every provider that has an env-var fallback for `getEnvApiKey`.
17
25
  * Used by `omp auth-broker migrate --include-env` to discover env-sourced keys
@@ -1,4 +1,5 @@
1
1
  import type { ZodType, z } from "zod/v4";
2
+ import type { ApiKey } from "./auth-retry";
2
3
  import type { BedrockOptions } from "./providers/amazon-bedrock";
3
4
  import type { AnthropicOptions } from "./providers/anthropic";
4
5
  import type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses";
@@ -159,12 +160,6 @@ export interface StreamOptions {
159
160
  maxTokens?: number;
160
161
  signal?: AbortSignal;
161
162
  apiKey?: string;
162
- /**
163
- * Called when a provider returns 401 before any replay-unsafe assistant
164
- * event has been emitted. Returning a different key retries the provider
165
- * request once.
166
- */
167
- onAuthError?: (provider: string, apiKey: string, error: unknown) => Promise<string | undefined>;
168
163
  cacheRetention?: CacheRetention;
169
164
  /**
170
165
  * Additional headers to include in provider requests.
@@ -274,7 +269,15 @@ export interface StreamOptions {
274
269
  /** Cursor exec/MCP tool handlers (cursor-agent only). */
275
270
  execHandlers?: CursorExecHandlers;
276
271
  }
277
- export interface SimpleStreamOptions extends StreamOptions {
272
+ export interface SimpleStreamOptions extends Omit<StreamOptions, "apiKey"> {
273
+ /**
274
+ * API key for the request: either a static bearer string, or an
275
+ * {@link ApiKeyResolver} that mints/rotates the key across the central
276
+ * a/b/c auth-retry policy. `streamSimple`/`completeSimple` resolve a
277
+ * resolver to a string before per-provider dispatch, so providers only
278
+ * ever see the resolved {@link StreamOptions.apiKey} string.
279
+ */
280
+ apiKey?: ApiKey;
278
281
  reasoning?: Effort;
279
282
  /**
280
283
  * Force-disable reasoning for the request even when the model supports it.
@@ -3,6 +3,14 @@ export interface JsonSchemaValidationIssue {
3
3
  message: string;
4
4
  expectedTypes?: string[];
5
5
  keyword?: string;
6
+ /**
7
+ * Marks issues that originate inside a failed `anyOf` / `oneOf` branch.
8
+ * Consumers such as the tool-argument coercion layer use this to avoid
9
+ * applying type repairs (e.g. singleton-array wrapping) that would be
10
+ * authoritative outside of a combinator but are only one candidate
11
+ * branch's expectation here.
12
+ */
13
+ fromUnionBranch?: boolean;
6
14
  }
7
15
  export interface JsonSchemaValidationResult {
8
16
  success: boolean;
@@ -1,10 +1,5 @@
1
1
  import type { ServerSentEvent } from "@oh-my-pi/pi-utils";
2
2
  import type { RawSseEvent } from "../types";
3
- type FetchFunction = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
4
- type FetchWithPreconnect = FetchFunction & {
5
- preconnect?: typeof fetch.preconnect;
6
- };
7
3
  type RawSseObserver = (event: RawSseEvent) => void;
8
4
  export declare function notifyRawSseEvent(observer: RawSseObserver | undefined, event: ServerSentEvent | RawSseEvent): void;
9
- export declare function wrapFetchForSseDebug(fetchImpl: FetchWithPreconnect, observer: RawSseObserver | undefined): FetchWithPreconnect;
10
5
  export {};
@@ -75,6 +75,8 @@ export declare class StreamMarkupHealing {
75
75
  export declare function modelMayLeakKimiToolCalls(provider: string, modelId: string): boolean;
76
76
  /** Cheap model/provider gate for DeepSeek DSML envelope leaks. */
77
77
  export declare function modelMayLeakDsmlToolCalls(provider: string, modelId: string): boolean;
78
+ /** Cheap model/provider gate for MiniMax plain thinking tag leaks. */
79
+ export declare function modelMayLeakThinkingTags(provider: string, modelId: string): boolean;
78
80
  export declare function getStreamMarkupHealingPattern(provider: string, modelId: string, options?: {
79
81
  readonly parseThinkingTags?: boolean;
80
82
  }): StreamMarkupHealingPattern | undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "15.10.0",
4
+ "version": "15.10.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",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@bufbuild/protobuf": "^2.12.0",
42
- "@oh-my-pi/pi-utils": "15.10.0",
42
+ "@oh-my-pi/pi-utils": "15.10.2",
43
43
  "openai": "^6.39.0",
44
44
  "partial-json": "^0.1.7",
45
45
  "zod": "4.4.3"
@@ -18,6 +18,7 @@
18
18
  * POST /v1/responses → OpenAI Responses in/out
19
19
  */
20
20
  import { extractRetryHint, logger } from "@oh-my-pi/pi-utils";
21
+ import type { ApiKeyResolver } from "../auth-retry";
21
22
  import type { AuthStorage } from "../auth-storage";
22
23
  import { Effort } from "../effort";
23
24
  import * as anthropicMessages from "../providers/anthropic-messages-server";
@@ -340,6 +341,60 @@ async function refreshGatewayApiKeyAfterAuthError(
340
341
  return storage.getApiKey(provider, sessionId, { modelId: model.id, signal });
341
342
  }
342
343
 
344
+ /**
345
+ * Build the {@link ApiKeyResolver} handed to `streamSimple` for a gateway
346
+ * request. Drives the central a/b/c auth-retry policy server-side:
347
+ *
348
+ * - initial resolve → the credential already resolved for this request.
349
+ * - step (b) `!lastChance` → force-refresh the SAME session-sticky credential
350
+ * (a peer/broker may have rotated its token out from under our cached copy).
351
+ * - step (c) `lastChance` → {@link refreshGatewayApiKeyAfterAuthError} switches
352
+ * to a sibling (usage-limit block vs credential invalidation by error class).
353
+ *
354
+ * `lastKey` tracks the most recent bearer so the switch step invalidates the
355
+ * credential that actually failed.
356
+ */
357
+ function buildGatewayApiKeyResolver(
358
+ storage: AuthStorage,
359
+ model: Model<Api>,
360
+ sessionId: string,
361
+ initialKey: string,
362
+ requestSignal: AbortSignal,
363
+ format: string,
364
+ peer: string,
365
+ ): ApiKeyResolver {
366
+ let lastKey = initialKey;
367
+ return async ({ lastChance, error, signal }) => {
368
+ const sig = signal ?? requestSignal;
369
+ if (error === undefined) {
370
+ lastKey = initialKey;
371
+ return initialKey;
372
+ }
373
+ if (!lastChance) {
374
+ const refreshed = await storage.getApiKey(model.provider, sessionId, {
375
+ modelId: model.id,
376
+ signal: sig,
377
+ forceRefresh: true,
378
+ });
379
+ lastKey = refreshed ?? lastKey;
380
+ return refreshed;
381
+ }
382
+ const next = await refreshGatewayApiKeyAfterAuthError(
383
+ storage,
384
+ model,
385
+ sessionId,
386
+ model.provider,
387
+ lastKey,
388
+ error,
389
+ sig,
390
+ format,
391
+ peer,
392
+ );
393
+ lastKey = next ?? lastKey;
394
+ return next;
395
+ };
396
+ }
397
+
343
398
  function clientClosedResponse(route: { module: FormatModule }): Response {
344
399
  return route.module.formatError(499, "request_aborted", "client closed request");
345
400
  }
@@ -447,19 +502,15 @@ async function handleFormatEndpoint(
447
502
  }
448
503
 
449
504
  const streamOpts = buildStreamOptions(parsed, model.api, controller.signal);
450
- streamOpts.apiKey = apiKey;
451
- streamOpts.onAuthError = (provider, oldKey, error) =>
452
- refreshGatewayApiKeyAfterAuthError(
453
- bootOpts.storage,
454
- model,
455
- sessionId,
456
- provider,
457
- oldKey,
458
- error,
459
- controller.signal,
460
- route.label,
461
- peer,
462
- );
505
+ streamOpts.apiKey = buildGatewayApiKeyResolver(
506
+ bootOpts.storage,
507
+ model,
508
+ sessionId,
509
+ apiKey,
510
+ controller.signal,
511
+ route.label,
512
+ peer,
513
+ );
463
514
 
464
515
  logger.info("auth-gateway request", {
465
516
  format: route.label,
@@ -604,18 +655,15 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
604
655
  // only inject server-controlled fields. The codex temperature/topP strip
605
656
  // matches `buildStreamOptions` — Codex rejects them with a 400.
606
657
  const streamOpts: SimpleStreamOptions = { ...parsed.options, apiKey, signal: controller.signal };
607
- streamOpts.onAuthError = (provider, oldKey, error) =>
608
- refreshGatewayApiKeyAfterAuthError(
609
- bootOpts.storage,
610
- model,
611
- sessionId,
612
- provider,
613
- oldKey,
614
- error,
615
- controller.signal,
616
- "pi-native",
617
- peer,
618
- );
658
+ streamOpts.apiKey = buildGatewayApiKeyResolver(
659
+ bootOpts.storage,
660
+ model,
661
+ sessionId,
662
+ apiKey,
663
+ controller.signal,
664
+ "pi-native",
665
+ peer,
666
+ );
619
667
  if (model.api === "openai-codex-responses") {
620
668
  delete streamOpts.temperature;
621
669
  delete streamOpts.topP;