@oh-my-pi/pi-ai 15.9.1 → 15.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.9.2] - 2026-06-05
6
+
7
+ ### Added
8
+
9
+ - Added an AES-256-GCM auth-broker snapshot cache module and `RemoteAuthCredentialStoreOptions.onSnapshot` so broker clients can persist broker-sourced full snapshots without blocking startup on every run.
10
+ - Added `Model.omitMaxOutputTokens` so providers (notably Ollama proxies fronting cloud catalogs) can suppress `max_output_tokens` (Responses) and `max_tokens`/`max_completion_tokens` (Completions) on the wire while still using the catalog `maxTokens` for local budgeting. Without it, `applyCommonResponsesSamplingParams` unconditionally sent the catalog cap and HTTP-400'd against upstream APIs whose true output limit was unknown to OMP. ([#1881](https://github.com/can1357/oh-my-pi/issues/1881))
11
+
12
+ ### Changed
13
+
14
+ - Changed usage-ranked OAuth credential selection to pick deterministic session-sticky weighted buckets instead of always choosing the top-ranked account, capping the best account at 2x the baseline session likelihood while keeping equal-priority accounts evenly balanced.
15
+
16
+ ### Fixed
17
+
18
+ - Fixed parallel `function_call` items on the OpenAI Responses API losing arguments on every call except the last when the upstream server interleaves their stream events (observed against llama.cpp and other local Responses-compat hosts). `processResponsesStream` no longer routes `function_call_arguments.{delta,done}`, `output_item.done`, content_part/text/refusal/reasoning events through a singleton `currentItem`/`currentBlock` reference; it now tracks every open item in registries keyed by `output_index` and `item_id` so each event is folded into the matching block and the emitted `toolcall_end` carries the correct `contentIndex`. ([#1880](https://github.com/can1357/oh-my-pi/issues/1880))
19
+
5
20
  ## [15.9.1] - 2026-06-04
6
21
 
7
22
  ### Added
@@ -2,4 +2,5 @@ export * from "./client";
2
2
  export * from "./refresher";
3
3
  export * from "./remote-store";
4
4
  export * from "./server";
5
+ export * from "./snapshot-cache";
5
6
  export * from "./types";
@@ -16,6 +16,11 @@ export interface RemoteAuthCredentialStoreOptions {
16
16
  * to long-poll permanently when the broker returns 404. Default `true`.
17
17
  */
18
18
  streamSnapshots?: boolean;
19
+ /**
20
+ * Called after broker-sourced full snapshots are applied. The constructor's
21
+ * initial snapshot intentionally does not trigger this hook.
22
+ */
23
+ onSnapshot?: (snapshot: SnapshotResponse, generation: number) => void;
19
24
  }
20
25
  export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
21
26
  #private;
@@ -0,0 +1,17 @@
1
+ import type { SnapshotResponse } from "./types";
2
+ export interface ReadAuthBrokerSnapshotCacheOptions {
3
+ path: string;
4
+ token: string;
5
+ url: string;
6
+ ttlMs: number;
7
+ /** Override clock for deterministic tests. */
8
+ now?: () => number;
9
+ }
10
+ export interface WriteAuthBrokerSnapshotCacheOptions {
11
+ path: string;
12
+ token: string;
13
+ url: string;
14
+ snapshot: SnapshotResponse;
15
+ }
16
+ export declare function readAuthBrokerSnapshotCache(opts: ReadAuthBrokerSnapshotCacheOptions): Promise<SnapshotResponse | null>;
17
+ export declare function writeAuthBrokerSnapshotCache(opts: WriteAuthBrokerSnapshotCacheOptions): Promise<void>;
@@ -96,6 +96,8 @@ export declare const DEFAULT_AUTH_BROKER_BIND = "127.0.0.1:8765";
96
96
  export declare const DEFAULT_REFRESH_SKEW_MS: number;
97
97
  /** Default broker refresh-loop cadence. */
98
98
  export declare const DEFAULT_REFRESH_INTERVAL_MS = 60000;
99
+ /** Default freshness window for the encrypted local broker snapshot cache. */
100
+ export declare const DEFAULT_SNAPSHOT_CACHE_TTL_MS: number;
99
101
  /** Keepalive cadence for `GET /v1/snapshot/stream` SSE comments. */
100
102
  export declare const DEFAULT_STREAM_KEEPALIVE_MS = 20000;
101
103
  /**
@@ -377,11 +377,25 @@ type AuthApiKeyOptions = {
377
377
  */
378
378
  export interface OAuthAccess {
379
379
  accessToken: string;
380
+ credentialId?: number;
380
381
  accountId?: string;
381
382
  email?: string;
382
383
  projectId?: string;
383
384
  enterpriseUrl?: string;
384
385
  }
386
+ export interface OAuthAccessFailure {
387
+ credentialId?: number;
388
+ accountId?: string;
389
+ email?: string;
390
+ projectId?: string;
391
+ enterpriseUrl?: string;
392
+ error: string;
393
+ }
394
+ export type OAuthAccessResolution = ({
395
+ ok: true;
396
+ } & OAuthAccess) | ({
397
+ ok: false;
398
+ } & OAuthAccessFailure);
385
399
  export interface InvalidateCredentialMatchingOptions {
386
400
  signal?: AbortSignal;
387
401
  sessionId?: string;
@@ -622,6 +636,15 @@ export declare class AuthStorage {
622
636
  * OAuth with an explicit API key.
623
637
  */
624
638
  getOAuthAccess(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<OAuthAccess | undefined>;
639
+ /**
640
+ * Resolve every stored OAuth credential for `provider` independently.
641
+ *
642
+ * Refreshes credentials through the same broker/local path as
643
+ * {@link AuthStorage.getOAuthAccess}, but does not rank, round-robin, or
644
+ * stop after the first usable account. Intended for diagnostics that must
645
+ * exercise each stored account exactly once.
646
+ */
647
+ getOAuthAccesses(provider: string, options?: AuthApiKeyOptions): Promise<OAuthAccessResolution[]>;
625
648
  invalidateCredentialMatching(provider: string, apiKey: string, options?: InvalidateCredentialMatchingOptions): Promise<boolean>;
626
649
  invalidateCredentialMatching(provider: string, apiKey: string, signal?: AbortSignal): Promise<boolean>;
627
650
  /**
@@ -64,8 +64,13 @@ type CommonSamplingOptions = Pick<StreamOptions, "temperature" | "topP" | "topK"
64
64
  /**
65
65
  * Apply the common `StreamOptions` → Responses sampling-parameter mapping (max output tokens,
66
66
  * temperature, top-p/k, min-p, presence/repetition penalties, service tier). Mutates `params`.
67
+ *
68
+ * `max_output_tokens` is suppressed when {@link Model.omitMaxOutputTokens} is `true`, so
69
+ * proxies (notably Ollama) that forward to upstream APIs with an unknown output-token cap
70
+ * can let the upstream apply its own default instead of 400-ing on `maxTokens` values that
71
+ * reflect the model's context window rather than the upstream output limit.
67
72
  */
68
- export declare function applyCommonResponsesSamplingParams<P extends CommonResponsesParams>(params: P, options: CommonSamplingOptions | undefined, provider: string): void;
73
+ export declare function applyCommonResponsesSamplingParams<P extends CommonResponsesParams>(params: P, options: CommonSamplingOptions | undefined, model: Pick<Model, "provider" | "omitMaxOutputTokens">): void;
69
74
  type ReasoningOptions = {
70
75
  reasoning?: string;
71
76
  reasoningSummary?: "auto" | "detailed" | "concise" | null;
@@ -763,6 +763,18 @@ export interface Model<TApi extends Api = any> {
763
763
  premiumMultiplier?: number;
764
764
  contextWindow: number;
765
765
  maxTokens: number;
766
+ /**
767
+ * When `true`, providers MUST omit `max_output_tokens` (Responses) /
768
+ * `max_tokens` / `max_completion_tokens` (Completions) from the outbound
769
+ * request and let the upstream API decide the per-response cap. `maxTokens`
770
+ * is still used locally for budgeting (compaction, context promotion); only
771
+ * the wire field is suppressed.
772
+ *
773
+ * Use this for proxies (notably Ollama) that forward to a backend whose true
774
+ * output limit OMP cannot discover — sending the wrong value triggers 400s
775
+ * from the upstream provider.
776
+ */
777
+ omitMaxOutputTokens?: boolean;
766
778
  headers?: Record<string, string>;
767
779
  /**
768
780
  * Streaming transport override. When `"pi-native"`, `streamSimple` routes
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.9.1",
4
+ "version": "15.9.3",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@bufbuild/protobuf": "^2.12.0",
42
- "@oh-my-pi/pi-utils": "15.9.1",
42
+ "@oh-my-pi/pi-utils": "15.9.3",
43
43
  "openai": "^6.39.0",
44
44
  "partial-json": "^0.1.7",
45
45
  "zod": "4.4.3"
@@ -2,4 +2,5 @@ export * from "./client";
2
2
  export * from "./refresher";
3
3
  export * from "./remote-store";
4
4
  export * from "./server";
5
+ export * from "./snapshot-cache";
5
6
  export * from "./types";
@@ -73,11 +73,17 @@ export interface RemoteAuthCredentialStoreOptions {
73
73
  * to long-poll permanently when the broker returns 404. Default `true`.
74
74
  */
75
75
  streamSnapshots?: boolean;
76
+ /**
77
+ * Called after broker-sourced full snapshots are applied. The constructor's
78
+ * initial snapshot intentionally does not trigger this hook.
79
+ */
80
+ onSnapshot?: (snapshot: SnapshotResponse, generation: number) => void;
76
81
  }
77
82
 
78
83
  export class RemoteAuthCredentialStore implements AuthCredentialStore {
79
84
  readonly #client: AuthBrokerClient;
80
85
  readonly #streamSnapshots: boolean;
86
+ readonly #onSnapshot?: (snapshot: SnapshotResponse, generation: number) => void;
81
87
  #snapshot: SnapshotResponse = emptySnapshot();
82
88
  #snapshotReceivedAt = Date.now();
83
89
  #generation = 0;
@@ -100,6 +106,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
100
106
  this.#client = opts.client;
101
107
  this.#streamSnapshots = opts.streamSnapshots ?? true;
102
108
  this.#applySnapshot(opts.initialSnapshot ?? emptySnapshot(), opts.initialSnapshot?.generation ?? 0);
109
+ this.#onSnapshot = opts.onSnapshot;
103
110
  void this.#runBackground();
104
111
  }
105
112
 
@@ -115,6 +122,13 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
115
122
  this.#snapshot = snapshot;
116
123
  this.#generation = generation;
117
124
  this.#snapshotReceivedAt = Date.now();
125
+ const onSnapshot = this.#onSnapshot;
126
+ if (!onSnapshot) return;
127
+ try {
128
+ onSnapshot(snapshot, generation);
129
+ } catch (error) {
130
+ logger.debug("auth-broker snapshot callback failed", { error: String(error) });
131
+ }
118
132
  }
119
133
 
120
134
  async #runBackground(): Promise<void> {
@@ -0,0 +1,174 @@
1
+ /**
2
+ * AES-GCM encrypted local cache for auth-broker snapshots.
3
+ *
4
+ * The cache is defense-in-depth for at-rest snapshots: a copied cache file is
5
+ * useless without the matching broker bearer token and URL. The token itself is
6
+ * still the trust boundary; a process that can read both the token and this file
7
+ * can decrypt the snapshot.
8
+ */
9
+ import * as fs from "node:fs/promises";
10
+ import * as path from "node:path";
11
+ import { isEnoent, logger } from "@oh-my-pi/pi-utils";
12
+ import type { SnapshotResponse } from "./types";
13
+ import { snapshotResponseSchema } from "./wire-schemas";
14
+
15
+ const MAGIC = new Uint8Array([0x4f, 0x4d, 0x50, 0x53]); // "OMPS"
16
+ const VERSION = 1;
17
+ const VERSION_OFFSET = MAGIC.byteLength;
18
+ const IV_OFFSET = VERSION_OFFSET + 1;
19
+ const IV_LENGTH = 12;
20
+ const HEADER_LENGTH = IV_OFFSET + IV_LENGTH;
21
+ const AES_ALGORITHM = "AES-GCM";
22
+ const TEXT_ENCODER = new TextEncoder();
23
+ const TEXT_DECODER = new TextDecoder();
24
+ const HEX = "0123456789abcdef";
25
+
26
+ export interface ReadAuthBrokerSnapshotCacheOptions {
27
+ path: string;
28
+ token: string;
29
+ url: string;
30
+ ttlMs: number;
31
+ /** Override clock for deterministic tests. */
32
+ now?: () => number;
33
+ }
34
+
35
+ export interface WriteAuthBrokerSnapshotCacheOptions {
36
+ path: string;
37
+ token: string;
38
+ url: string;
39
+ snapshot: SnapshotResponse;
40
+ }
41
+
42
+ export async function readAuthBrokerSnapshotCache(
43
+ opts: ReadAuthBrokerSnapshotCacheOptions,
44
+ ): Promise<SnapshotResponse | null> {
45
+ if (opts.ttlMs <= 0) return null;
46
+ let data: Uint8Array;
47
+ try {
48
+ data = await fs.readFile(opts.path);
49
+ } catch (error) {
50
+ if (isEnoent(error)) return null;
51
+ throw error;
52
+ }
53
+
54
+ try {
55
+ const plaintext = await decryptCachePayload(data, opts.token, opts.url);
56
+ if (!plaintext) return null;
57
+ const parsed: unknown = JSON.parse(TEXT_DECODER.decode(plaintext));
58
+ const result = snapshotResponseSchema.safeParse(parsed);
59
+ if (!result.success) {
60
+ logger.debug("auth-broker snapshot cache schema invalid", { path: opts.path });
61
+ return null;
62
+ }
63
+ const snapshot = result.data;
64
+ const now = opts.now?.() ?? Date.now();
65
+ if (now - snapshot.generatedAt > opts.ttlMs) return null;
66
+ return snapshot;
67
+ } catch (error) {
68
+ logger.debug("auth-broker snapshot cache read failed", { path: opts.path, error: String(error) });
69
+ return null;
70
+ }
71
+ }
72
+
73
+ export async function writeAuthBrokerSnapshotCache(opts: WriteAuthBrokerSnapshotCacheOptions): Promise<void> {
74
+ const payload = await encryptCachePayload(opts.snapshot, opts.token, opts.url);
75
+ await fs.mkdir(path.dirname(opts.path), { recursive: true });
76
+ const tmpPath = `${opts.path}.${process.pid}.${randomHex(8)}.tmp`;
77
+ let removeTemp = false;
78
+ try {
79
+ const handle = await fs.open(tmpPath, "wx", 0o600);
80
+ removeTemp = true;
81
+ try {
82
+ await handle.writeFile(payload);
83
+ } finally {
84
+ await handle.close();
85
+ }
86
+ await fs.chmod(tmpPath, 0o600);
87
+ await fs.rename(tmpPath, opts.path);
88
+ removeTemp = false;
89
+ } finally {
90
+ if (removeTemp) await fs.rm(tmpPath, { force: true }).catch(() => {});
91
+ }
92
+ }
93
+
94
+ async function encryptCachePayload(snapshot: SnapshotResponse, token: string, url: string): Promise<Uint8Array> {
95
+ const key = await deriveAesKey(token, ["encrypt"]);
96
+ const iv = new Uint8Array(IV_LENGTH);
97
+ globalThis.crypto.getRandomValues(iv);
98
+ const plaintext = TEXT_ENCODER.encode(JSON.stringify(snapshot));
99
+ const ciphertext = new Uint8Array(
100
+ await globalThis.crypto.subtle.encrypt(
101
+ {
102
+ name: AES_ALGORITHM,
103
+ iv,
104
+ additionalData: TEXT_ENCODER.encode(url),
105
+ },
106
+ key,
107
+ plaintext,
108
+ ),
109
+ );
110
+ const payload = new Uint8Array(HEADER_LENGTH + ciphertext.byteLength);
111
+ payload.set(MAGIC, 0);
112
+ payload[VERSION_OFFSET] = VERSION;
113
+ payload.set(iv, IV_OFFSET);
114
+ payload.set(ciphertext, HEADER_LENGTH);
115
+ return payload;
116
+ }
117
+
118
+ async function decryptCachePayload(data: Uint8Array, token: string, url: string): Promise<Uint8Array | null> {
119
+ if (data.byteLength <= HEADER_LENGTH) {
120
+ logger.debug("auth-broker snapshot cache file too short");
121
+ return null;
122
+ }
123
+ for (let i = 0; i < MAGIC.byteLength; i++) {
124
+ if (data[i] !== MAGIC[i]) {
125
+ logger.debug("auth-broker snapshot cache magic mismatch");
126
+ return null;
127
+ }
128
+ }
129
+ if (data[VERSION_OFFSET] !== VERSION) {
130
+ logger.debug("auth-broker snapshot cache version mismatch", { version: data[VERSION_OFFSET] });
131
+ return null;
132
+ }
133
+ const key = await deriveAesKey(token, ["decrypt"]);
134
+ const iv = asStrict(data.subarray(IV_OFFSET, HEADER_LENGTH));
135
+ const ciphertext = asStrict(data.subarray(HEADER_LENGTH));
136
+ try {
137
+ return new Uint8Array(
138
+ await globalThis.crypto.subtle.decrypt(
139
+ {
140
+ name: AES_ALGORITHM,
141
+ iv,
142
+ additionalData: TEXT_ENCODER.encode(url),
143
+ },
144
+ key,
145
+ ciphertext,
146
+ ),
147
+ );
148
+ } catch (error) {
149
+ logger.debug("auth-broker snapshot cache decrypt failed", { error: String(error) });
150
+ return null;
151
+ }
152
+ }
153
+
154
+ async function deriveAesKey(token: string, usages: Array<"encrypt" | "decrypt">): Promise<CryptoKey> {
155
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", TEXT_ENCODER.encode(token));
156
+ return globalThis.crypto.subtle.importKey("raw", digest, AES_ALGORITHM, false, usages);
157
+ }
158
+
159
+ function asStrict(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
160
+ if (bytes.buffer instanceof ArrayBuffer && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
161
+ return bytes as Uint8Array<ArrayBuffer>;
162
+ }
163
+ const copy = new Uint8Array(bytes.byteLength);
164
+ copy.set(bytes);
165
+ return copy;
166
+ }
167
+
168
+ function randomHex(byteLength: number): string {
169
+ const bytes = new Uint8Array(byteLength);
170
+ globalThis.crypto.getRandomValues(bytes);
171
+ let out = "";
172
+ for (const byte of bytes) out += HEX[byte >> 4] + HEX[byte & 15];
173
+ return out;
174
+ }
@@ -117,6 +117,9 @@ export const DEFAULT_REFRESH_SKEW_MS = 5 * 60_000;
117
117
  /** Default broker refresh-loop cadence. */
118
118
  export const DEFAULT_REFRESH_INTERVAL_MS = 60_000;
119
119
 
120
+ /** Default freshness window for the encrypted local broker snapshot cache. */
121
+ export const DEFAULT_SNAPSHOT_CACHE_TTL_MS = 60 * 60_000;
122
+
120
123
  /** Keepalive cadence for `GET /v1/snapshot/stream` SSE comments. */
121
124
  export const DEFAULT_STREAM_KEEPALIVE_MS = 20_000;
122
125
 
@@ -557,11 +557,23 @@ type OAuthResolutionResult = { apiKey: string; credential: OAuthCredential };
557
557
  */
558
558
  export interface OAuthAccess {
559
559
  accessToken: string;
560
+ credentialId?: number;
560
561
  accountId?: string;
561
562
  email?: string;
562
563
  projectId?: string;
563
564
  enterpriseUrl?: string;
564
565
  }
566
+
567
+ export interface OAuthAccessFailure {
568
+ credentialId?: number;
569
+ accountId?: string;
570
+ email?: string;
571
+ projectId?: string;
572
+ enterpriseUrl?: string;
573
+ error: string;
574
+ }
575
+
576
+ export type OAuthAccessResolution = ({ ok: true } & OAuthAccess) | ({ ok: false } & OAuthAccessFailure);
565
577
  export interface InvalidateCredentialMatchingOptions {
566
578
  signal?: AbortSignal;
567
579
  sessionId?: string;
@@ -725,6 +737,25 @@ class AuthStorageUsageCache implements UsageCache {
725
737
  // ─────────────────────────────────────────────────────────────────────────────
726
738
 
727
739
  type StoredCredential = { id: number; credential: AuthCredential };
740
+ type OAuthSelection = { credential: OAuthCredential; index: number };
741
+
742
+ type OAuthCandidate = {
743
+ selection: OAuthSelection;
744
+ usage: UsageReport | null;
745
+ usageChecked: boolean;
746
+ };
747
+
748
+ type RankedOAuthCandidate = OAuthCandidate & {
749
+ blocked: boolean;
750
+ blockedUntil?: number;
751
+ hasPriorityBoost: boolean;
752
+ planPriority: number;
753
+ secondaryUsed: number;
754
+ secondaryDrainRate: number;
755
+ primaryUsed: number;
756
+ primaryDrainRate: number;
757
+ orderPos: number;
758
+ };
728
759
 
729
760
  // ─────────────────────────────────────────────────────────────────────────────
730
761
  // AuthStorage Class
@@ -2733,35 +2764,126 @@ export class AuthStorage {
2733
2764
  return usedFraction / elapsedHours;
2734
2765
  }
2735
2766
 
2767
+ #compareRankedOAuthCandidatePriority(
2768
+ left: RankedOAuthCandidate,
2769
+ right: RankedOAuthCandidate,
2770
+ provider: string,
2771
+ modelId: string | undefined,
2772
+ ): number {
2773
+ if (left.blocked !== right.blocked) return left.blocked ? 1 : -1;
2774
+ if (left.blocked && right.blocked) {
2775
+ const leftBlockedUntil = left.blockedUntil ?? Number.POSITIVE_INFINITY;
2776
+ const rightBlockedUntil = right.blockedUntil ?? Number.POSITIVE_INFINITY;
2777
+ if (leftBlockedUntil !== rightBlockedUntil) return leftBlockedUntil - rightBlockedUntil;
2778
+ return 0;
2779
+ }
2780
+ if (requiresOpenAICodexProModel(provider, modelId) && left.planPriority !== right.planPriority) {
2781
+ return left.planPriority - right.planPriority;
2782
+ }
2783
+ if (left.hasPriorityBoost !== right.hasPriorityBoost) return left.hasPriorityBoost ? -1 : 1;
2784
+ if (left.secondaryDrainRate !== right.secondaryDrainRate) {
2785
+ return left.secondaryDrainRate - right.secondaryDrainRate;
2786
+ }
2787
+ if (left.secondaryUsed !== right.secondaryUsed) return left.secondaryUsed - right.secondaryUsed;
2788
+ if (left.primaryDrainRate !== right.primaryDrainRate) return left.primaryDrainRate - right.primaryDrainRate;
2789
+ if (left.primaryUsed !== right.primaryUsed) return left.primaryUsed - right.primaryUsed;
2790
+ return 0;
2791
+ }
2792
+
2793
+ #compareRankedOAuthCandidates(
2794
+ left: RankedOAuthCandidate,
2795
+ right: RankedOAuthCandidate,
2796
+ provider: string,
2797
+ modelId: string | undefined,
2798
+ ): number {
2799
+ const priority = this.#compareRankedOAuthCandidatePriority(left, right, provider, modelId);
2800
+ return priority !== 0 ? priority : left.orderPos - right.orderPos;
2801
+ }
2802
+
2803
+ #orderRankedOAuthCandidates(
2804
+ candidates: RankedOAuthCandidate[],
2805
+ sessionId: string | undefined,
2806
+ provider: string,
2807
+ modelId: string | undefined,
2808
+ ): OAuthCandidate[] {
2809
+ candidates.sort((left, right) => this.#compareRankedOAuthCandidates(left, right, provider, modelId));
2810
+ if (!sessionId) {
2811
+ return candidates.map(candidate => ({
2812
+ selection: candidate.selection,
2813
+ usage: candidate.usage,
2814
+ usageChecked: candidate.usageChecked,
2815
+ }));
2816
+ }
2817
+
2818
+ const unblocked = candidates.filter(candidate => !candidate.blocked);
2819
+ if (unblocked.length <= 1) {
2820
+ return candidates.map(candidate => ({
2821
+ selection: candidate.selection,
2822
+ usage: candidate.usage,
2823
+ usageChecked: candidate.usageChecked,
2824
+ }));
2825
+ }
2826
+
2827
+ const priorityByCandidate = new Map<RankedOAuthCandidate, number>();
2828
+ let bucketIndex = 0;
2829
+ let previous = unblocked[0];
2830
+ const bucketByCandidate = new Map<RankedOAuthCandidate, number>();
2831
+ for (const candidate of unblocked) {
2832
+ if (
2833
+ candidate !== previous &&
2834
+ this.#compareRankedOAuthCandidatePriority(previous, candidate, provider, modelId) !== 0
2835
+ ) {
2836
+ bucketIndex += 1;
2837
+ }
2838
+ bucketByCandidate.set(candidate, bucketIndex);
2839
+ previous = candidate;
2840
+ }
2841
+ const maxBucket = bucketIndex;
2842
+ for (const candidate of unblocked) {
2843
+ const bucket = bucketByCandidate.get(candidate) ?? 0;
2844
+ priorityByCandidate.set(candidate, maxBucket === 0 ? 0 : 1 - bucket / maxBucket);
2845
+ }
2846
+
2847
+ let totalWeight = 0;
2848
+ for (const candidate of unblocked) {
2849
+ totalWeight += 1 + (priorityByCandidate.get(candidate) ?? 0);
2850
+ }
2851
+
2852
+ const hit = ((Bun.hash.xxHash32(sessionId) >>> 0) / 2 ** 32) * totalWeight;
2853
+ let cursor = 0;
2854
+ let selected = unblocked[unblocked.length - 1];
2855
+ for (const candidate of unblocked) {
2856
+ cursor += 1 + (priorityByCandidate.get(candidate) ?? 0);
2857
+ if (hit < cursor) {
2858
+ selected = candidate;
2859
+ break;
2860
+ }
2861
+ }
2862
+
2863
+ const ordered = [
2864
+ selected,
2865
+ ...unblocked.filter(candidate => candidate !== selected),
2866
+ ...candidates.filter(candidate => candidate.blocked),
2867
+ ];
2868
+ return ordered.map(candidate => ({
2869
+ selection: candidate.selection,
2870
+ usage: candidate.usage,
2871
+ usageChecked: candidate.usageChecked,
2872
+ }));
2873
+ }
2874
+
2736
2875
  async #rankOAuthSelections(args: {
2737
2876
  providerKey: string;
2738
2877
  provider: string;
2739
2878
  order: number[];
2740
- credentials: Array<{ credential: OAuthCredential; index: number }>;
2879
+ credentials: OAuthSelection[];
2741
2880
  options?: AuthApiKeyOptions;
2881
+ sessionId?: string;
2742
2882
  strategy: CredentialRankingStrategy;
2743
- }): Promise<
2744
- Array<{
2745
- selection: { credential: OAuthCredential; index: number };
2746
- usage: UsageReport | null;
2747
- usageChecked: boolean;
2748
- }>
2749
- > {
2883
+ }): Promise<OAuthCandidate[]> {
2750
2884
  const nowMs = Date.now();
2751
2885
  const { strategy } = args;
2752
- const ranked: Array<{
2753
- selection: { credential: OAuthCredential; index: number };
2754
- usage: UsageReport | null;
2755
- usageChecked: boolean;
2756
- blocked: boolean;
2757
- blockedUntil?: number;
2758
- hasPriorityBoost: boolean;
2759
- secondaryUsed: number;
2760
- secondaryDrainRate: number;
2761
- primaryUsed: number;
2762
- primaryDrainRate: number;
2763
- orderPos: number;
2764
- }> = [];
2886
+ const ranked: RankedOAuthCandidate[] = [];
2765
2887
  // Pre-fetch usage reports in parallel for non-blocked credentials.
2766
2888
  // Wrap with a timeout so slow/429'd fetches don't indefinitely block
2767
2889
  // credential selection — better to pick a credential without usage data
@@ -2821,6 +2943,7 @@ export class AuthStorage {
2821
2943
  blocked,
2822
2944
  blockedUntil,
2823
2945
  hasPriorityBoost: strategy.hasPriorityBoost?.(primary) ?? false,
2946
+ planPriority: getOpenAICodexPlanPriority(usage),
2824
2947
  secondaryUsed: this.#normalizeUsageFraction(secondaryTarget),
2825
2948
  secondaryDrainRate: this.#computeWindowDrainRate(
2826
2949
  secondaryTarget,
@@ -2832,32 +2955,7 @@ export class AuthStorage {
2832
2955
  orderPos,
2833
2956
  });
2834
2957
  }
2835
- ranked.sort((left, right) => {
2836
- if (left.blocked !== right.blocked) return left.blocked ? 1 : -1;
2837
- if (left.blocked && right.blocked) {
2838
- const leftBlockedUntil = left.blockedUntil ?? Number.POSITIVE_INFINITY;
2839
- const rightBlockedUntil = right.blockedUntil ?? Number.POSITIVE_INFINITY;
2840
- if (leftBlockedUntil !== rightBlockedUntil) return leftBlockedUntil - rightBlockedUntil;
2841
- return left.orderPos - right.orderPos;
2842
- }
2843
- if (requiresOpenAICodexProModel(args.provider, args.options?.modelId)) {
2844
- const leftPlanPriority = getOpenAICodexPlanPriority(left.usage);
2845
- const rightPlanPriority = getOpenAICodexPlanPriority(right.usage);
2846
- if (leftPlanPriority !== rightPlanPriority) return leftPlanPriority - rightPlanPriority;
2847
- }
2848
- if (left.hasPriorityBoost !== right.hasPriorityBoost) return left.hasPriorityBoost ? -1 : 1;
2849
- if (left.secondaryDrainRate !== right.secondaryDrainRate)
2850
- return left.secondaryDrainRate - right.secondaryDrainRate;
2851
- if (left.secondaryUsed !== right.secondaryUsed) return left.secondaryUsed - right.secondaryUsed;
2852
- if (left.primaryDrainRate !== right.primaryDrainRate) return left.primaryDrainRate - right.primaryDrainRate;
2853
- if (left.primaryUsed !== right.primaryUsed) return left.primaryUsed - right.primaryUsed;
2854
- return left.orderPos - right.orderPos;
2855
- });
2856
- return ranked.map(candidate => ({
2857
- selection: candidate.selection,
2858
- usage: candidate.usage,
2859
- usageChecked: candidate.usageChecked,
2860
- }));
2958
+ return this.#orderRankedOAuthCandidates(ranked, args.sessionId, args.provider, args.options?.modelId);
2861
2959
  }
2862
2960
 
2863
2961
  /**
@@ -2894,8 +2992,17 @@ export class AuthStorage {
2894
2992
  const sessionPreferredIsAvailable =
2895
2993
  sessionPreferredIndex !== undefined && !this.#isCredentialBlocked(providerKey, sessionPreferredIndex);
2896
2994
  const shouldRank = checkUsage && (!sessionPreferredIsAvailable || requiresProModel);
2995
+ const rankingOrder = shouldRank && sessionId ? credentials.map((_credential, index) => index) : order;
2897
2996
  const candidates = shouldRank
2898
- ? await this.#rankOAuthSelections({ providerKey, provider, order, credentials, options, strategy: strategy! })
2997
+ ? await this.#rankOAuthSelections({
2998
+ providerKey,
2999
+ provider,
3000
+ order: rankingOrder,
3001
+ credentials,
3002
+ options,
3003
+ sessionId,
3004
+ strategy: strategy!,
3005
+ })
2899
3006
  : order
2900
3007
  .map(idx => credentials[idx])
2901
3008
  .filter((selection): selection is { credential: OAuthCredential; index: number } => Boolean(selection))
@@ -3399,6 +3506,75 @@ export class AuthStorage {
3399
3506
  };
3400
3507
  }
3401
3508
 
3509
+ /**
3510
+ * Resolve every stored OAuth credential for `provider` independently.
3511
+ *
3512
+ * Refreshes credentials through the same broker/local path as
3513
+ * {@link AuthStorage.getOAuthAccess}, but does not rank, round-robin, or
3514
+ * stop after the first usable account. Intended for diagnostics that must
3515
+ * exercise each stored account exactly once.
3516
+ */
3517
+ async getOAuthAccesses(provider: string, options?: AuthApiKeyOptions): Promise<OAuthAccessResolution[]> {
3518
+ if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) {
3519
+ return [];
3520
+ }
3521
+ const providerKey = this.#getProviderTypeKey(provider, "oauth");
3522
+ const selections = this.#getStoredCredentials(provider)
3523
+ .map((entry, index) => ({ credentialId: entry.id, credential: entry.credential, index }))
3524
+ .filter(
3525
+ (entry): entry is { credentialId: number; credential: OAuthCredential; index: number } =>
3526
+ entry.credential.type === "oauth",
3527
+ );
3528
+ return Promise.all(
3529
+ selections.map(async (selection): Promise<OAuthAccessResolution> => {
3530
+ try {
3531
+ const resolved = await this.#tryOAuthCredential(
3532
+ provider,
3533
+ { credential: selection.credential, index: selection.index },
3534
+ providerKey,
3535
+ undefined,
3536
+ options,
3537
+ {
3538
+ checkUsage: false,
3539
+ allowBlocked: true,
3540
+ },
3541
+ );
3542
+ if (!resolved) {
3543
+ return {
3544
+ ok: false,
3545
+ credentialId: selection.credentialId,
3546
+ accountId: selection.credential.accountId,
3547
+ email: selection.credential.email,
3548
+ projectId: selection.credential.projectId,
3549
+ enterpriseUrl: selection.credential.enterpriseUrl,
3550
+ error: "OAuth access unavailable",
3551
+ };
3552
+ }
3553
+ const { credential } = resolved;
3554
+ return {
3555
+ ok: true,
3556
+ credentialId: selection.credentialId,
3557
+ accessToken: credential.access,
3558
+ accountId: credential.accountId,
3559
+ email: credential.email,
3560
+ projectId: credential.projectId,
3561
+ enterpriseUrl: credential.enterpriseUrl,
3562
+ };
3563
+ } catch (error) {
3564
+ return {
3565
+ ok: false,
3566
+ credentialId: selection.credentialId,
3567
+ accountId: selection.credential.accountId,
3568
+ email: selection.credential.email,
3569
+ projectId: selection.credential.projectId,
3570
+ enterpriseUrl: selection.credential.enterpriseUrl,
3571
+ error: error instanceof Error ? error.message : String(error),
3572
+ };
3573
+ }
3574
+ }),
3575
+ );
3576
+ }
3577
+
3402
3578
  #extractStructuredApiKeyToken(apiKey: string): string | undefined {
3403
3579
  if (!apiKey.startsWith("{")) return undefined;
3404
3580
  try {
@@ -296,7 +296,7 @@ function buildParams(
296
296
  prompt_cache_key: normalizeOpenAIResponsesPromptCacheKey(options?.promptCacheKey ?? options?.sessionId),
297
297
  };
298
298
 
299
- applyCommonResponsesSamplingParams(params, options, model.provider);
299
+ applyCommonResponsesSamplingParams(params, options, model);
300
300
 
301
301
  if (context.tools) {
302
302
  params.tools = convertTools(context.tools);
@@ -1180,7 +1180,7 @@ function buildParams(
1180
1180
  params.store = false;
1181
1181
  }
1182
1182
 
1183
- if (effectiveMaxTokens) {
1183
+ if (effectiveMaxTokens && !model.omitMaxOutputTokens) {
1184
1184
  if (compat.maxTokensField === "max_tokens") {
1185
1185
  params.max_tokens = effectiveMaxTokens;
1186
1186
  } else {
@@ -395,19 +395,54 @@ export async function processResponsesStream<TApi extends Api>(
395
395
  model: Model<TApi>,
396
396
  options?: ProcessResponsesStreamOptions,
397
397
  ): Promise<void> {
398
- let currentItem:
399
- | ResponseReasoningItem
400
- | ResponseOutputMessage
401
- | ResponseFunctionToolCall
402
- | ResponseCustomToolCall
403
- | null = null;
404
- let currentBlock:
405
- | ThinkingContent
406
- | TextContent
407
- | (ToolCall & { partialJson: string; lastParseLen?: number })
408
- | null = null;
409
- const blocks = output.content;
410
- const blockIndex = () => blocks.length - 1;
398
+ type StreamingToolCallBlock = ToolCall & { partialJson: string; lastParseLen?: number };
399
+ interface StreamingItem {
400
+ item: ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | ResponseCustomToolCall;
401
+ block: ThinkingContent | TextContent | StreamingToolCallBlock;
402
+ }
403
+
404
+ // Multiple items (parallel function_calls in particular) can be open at the same
405
+ // time. OpenAI's spec routes every per-item event by `output_index`/`item_id`;
406
+ // see https://github.com/can1357/oh-my-pi/issues/1880 — llama.cpp emits parallel
407
+ // function_call deltas interleaved, and a singleton `current` reference would
408
+ // fold them into the wrong block and drop arguments on every call but the last.
409
+ const openItemsByOutputIndex = new Map<number, StreamingItem>();
410
+ const openItemsByItemId = new Map<string, StreamingItem>();
411
+ let lastOpenItem: StreamingItem | null = null;
412
+
413
+ const registerOpenItem = (
414
+ outputIndex: number | undefined,
415
+ itemId: string | undefined,
416
+ entry: StreamingItem,
417
+ ): void => {
418
+ if (typeof outputIndex === "number") openItemsByOutputIndex.set(outputIndex, entry);
419
+ if (itemId) openItemsByItemId.set(itemId, entry);
420
+ lastOpenItem = entry;
421
+ };
422
+ const lookupOpenItem = (event: { output_index?: number; item_id?: string }): StreamingItem | undefined => {
423
+ if (typeof event.output_index === "number") {
424
+ const found = openItemsByOutputIndex.get(event.output_index);
425
+ if (found) return found;
426
+ }
427
+ if (event.item_id) {
428
+ const found = openItemsByItemId.get(event.item_id);
429
+ if (found) return found;
430
+ }
431
+ // Fallback for tests / mock providers that omit identifiers on stream events.
432
+ return lastOpenItem ?? undefined;
433
+ };
434
+ const closeOpenItem = (
435
+ outputIndex: number | undefined,
436
+ itemId: string | undefined,
437
+ entry: StreamingItem | undefined,
438
+ ): void => {
439
+ if (typeof outputIndex === "number") openItemsByOutputIndex.delete(outputIndex);
440
+ if (itemId) openItemsByItemId.delete(itemId);
441
+ if (entry && lastOpenItem === entry) lastOpenItem = null;
442
+ };
443
+ const contentIndexOf = (block: ThinkingContent | TextContent | StreamingToolCallBlock): number =>
444
+ output.content.indexOf(block);
445
+
411
446
  let sawFirstToken = false;
412
447
 
413
448
  for await (const event of openaiStream) {
@@ -420,29 +455,28 @@ export async function processResponsesStream<TApi extends Api>(
420
455
  }
421
456
  const item = event.item;
422
457
  if (item.type === "reasoning") {
423
- currentItem = item;
424
- currentBlock = { type: "thinking", thinking: "", itemId: item.id };
425
- output.content.push(currentBlock);
426
- stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
458
+ const block: ThinkingContent = { type: "thinking", thinking: "", itemId: item.id };
459
+ output.content.push(block);
460
+ registerOpenItem(event.output_index, item.id, { item, block });
461
+ stream.push({ type: "thinking_start", contentIndex: contentIndexOf(block), partial: output });
427
462
  } else if (item.type === "message") {
428
- currentItem = item;
429
- currentBlock = { type: "text", text: "" };
430
- output.content.push(currentBlock);
431
- stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
463
+ const block: TextContent = { type: "text", text: "" };
464
+ output.content.push(block);
465
+ registerOpenItem(event.output_index, item.id, { item, block });
466
+ stream.push({ type: "text_start", contentIndex: contentIndexOf(block), partial: output });
432
467
  } else if (item.type === "function_call") {
433
- currentItem = item;
434
- currentBlock = {
468
+ const block: StreamingToolCallBlock = {
435
469
  type: "toolCall",
436
470
  id: encodeResponsesToolCallId(item.call_id, item.id),
437
471
  name: item.name,
438
472
  arguments: {},
439
473
  partialJson: item.arguments || "",
440
474
  };
441
- output.content.push(currentBlock);
442
- stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
475
+ output.content.push(block);
476
+ registerOpenItem(event.output_index, item.id, { item, block });
477
+ stream.push({ type: "toolcall_start", contentIndex: contentIndexOf(block), partial: output });
443
478
  } else if (item.type === "custom_tool_call") {
444
- currentItem = item;
445
- currentBlock = {
479
+ const block: StreamingToolCallBlock = {
446
480
  type: "toolCall",
447
481
  id: encodeResponsesToolCallId(item.call_id, item.id),
448
482
  // Preserve the raw wire name (e.g. `apply_patch`). The agent-loop
@@ -456,39 +490,43 @@ export async function processResponsesStream<TApi extends Api>(
456
490
  // accumulation buffer so later code that inspects the field still works.
457
491
  partialJson: item.input ?? "",
458
492
  };
459
- output.content.push(currentBlock);
460
- stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
493
+ output.content.push(block);
494
+ registerOpenItem(event.output_index, item.id, { item, block });
495
+ stream.push({ type: "toolcall_start", contentIndex: contentIndexOf(block), partial: output });
461
496
  }
462
497
  } else if (event.type === "response.reasoning_summary_part.added") {
463
- if (currentItem?.type === "reasoning") {
464
- currentItem.summary = currentItem.summary || [];
465
- currentItem.summary.push(event.part);
498
+ const entry = lookupOpenItem(event);
499
+ if (entry?.item.type === "reasoning") {
500
+ entry.item.summary = entry.item.summary || [];
501
+ entry.item.summary.push(event.part);
466
502
  }
467
503
  } else if (event.type === "response.reasoning_summary_text.delta") {
468
- if (currentItem?.type === "reasoning" && currentBlock?.type === "thinking") {
469
- currentItem.summary = currentItem.summary || [];
470
- const lastPart = currentItem.summary[currentItem.summary.length - 1];
504
+ const entry = lookupOpenItem(event);
505
+ if (entry?.item.type === "reasoning" && entry.block.type === "thinking") {
506
+ entry.item.summary = entry.item.summary || [];
507
+ const lastPart = entry.item.summary[entry.item.summary.length - 1];
471
508
  if (lastPart) {
472
- currentBlock.thinking += event.delta;
509
+ entry.block.thinking += event.delta;
473
510
  lastPart.text += event.delta;
474
511
  stream.push({
475
512
  type: "thinking_delta",
476
- contentIndex: blockIndex(),
513
+ contentIndex: contentIndexOf(entry.block),
477
514
  delta: event.delta,
478
515
  partial: output,
479
516
  });
480
517
  }
481
518
  }
482
519
  } else if (event.type === "response.reasoning_summary_part.done") {
483
- if (currentItem?.type === "reasoning" && currentBlock?.type === "thinking") {
484
- currentItem.summary = currentItem.summary || [];
485
- const lastPart = currentItem.summary[currentItem.summary.length - 1];
520
+ const entry = lookupOpenItem(event);
521
+ if (entry?.item.type === "reasoning" && entry.block.type === "thinking") {
522
+ entry.item.summary = entry.item.summary || [];
523
+ const lastPart = entry.item.summary[entry.item.summary.length - 1];
486
524
  if (lastPart) {
487
- currentBlock.thinking += "\n\n";
525
+ entry.block.thinking += "\n\n";
488
526
  lastPart.text += "\n\n";
489
527
  stream.push({
490
528
  type: "thinking_delta",
491
- contentIndex: blockIndex(),
529
+ contentIndex: contentIndexOf(entry.block),
492
530
  delta: "\n\n",
493
531
  partial: output,
494
532
  });
@@ -497,91 +535,103 @@ export async function processResponsesStream<TApi extends Api>(
497
535
  } else if (event.type === "response.reasoning_text.delta") {
498
536
  // Raw reasoning text delta from local providers that stream thinking
499
537
  // directly rather than via the OpenAI summary tracking protocol.
500
- if (currentItem?.type === "reasoning" && currentBlock?.type === "thinking") {
501
- currentBlock.thinking += event.delta;
538
+ const entry = lookupOpenItem(event);
539
+ if (entry?.item.type === "reasoning" && entry.block.type === "thinking") {
540
+ entry.block.thinking += event.delta;
502
541
  stream.push({
503
542
  type: "thinking_delta",
504
- contentIndex: blockIndex(),
543
+ contentIndex: contentIndexOf(entry.block),
505
544
  delta: event.delta,
506
545
  partial: output,
507
546
  });
508
547
  }
509
548
  } else if (event.type === "response.content_part.added") {
510
- if (currentItem?.type === "message") {
511
- currentItem.content = currentItem.content || [];
549
+ const entry = lookupOpenItem(event);
550
+ if (entry?.item.type === "message") {
551
+ entry.item.content = entry.item.content || [];
512
552
  if (event.part.type === "output_text" || event.part.type === "refusal") {
513
- currentItem.content.push(event.part);
553
+ entry.item.content.push(event.part);
514
554
  }
515
555
  }
516
556
  } else if (event.type === "response.output_text.delta") {
517
- if (currentItem?.type === "message" && currentBlock?.type === "text") {
518
- const lastPart = currentItem.content?.[currentItem.content.length - 1];
557
+ const entry = lookupOpenItem(event);
558
+ if (entry?.item.type === "message" && entry.block.type === "text") {
559
+ const lastPart = entry.item.content?.[entry.item.content.length - 1];
519
560
  if (lastPart?.type === "output_text") {
520
- currentBlock.text += event.delta;
561
+ entry.block.text += event.delta;
521
562
  lastPart.text += event.delta;
522
563
  stream.push({
523
564
  type: "text_delta",
524
- contentIndex: blockIndex(),
565
+ contentIndex: contentIndexOf(entry.block),
525
566
  delta: event.delta,
526
567
  partial: output,
527
568
  });
528
569
  }
529
570
  }
530
571
  } else if (event.type === "response.refusal.delta") {
531
- if (currentItem?.type === "message" && currentBlock?.type === "text") {
532
- const lastPart = currentItem.content?.[currentItem.content.length - 1];
572
+ const entry = lookupOpenItem(event);
573
+ if (entry?.item.type === "message" && entry.block.type === "text") {
574
+ const lastPart = entry.item.content?.[entry.item.content.length - 1];
533
575
  if (lastPart?.type === "refusal") {
534
- currentBlock.text += event.delta;
576
+ entry.block.text += event.delta;
535
577
  lastPart.refusal += event.delta;
536
578
  stream.push({
537
579
  type: "text_delta",
538
- contentIndex: blockIndex(),
580
+ contentIndex: contentIndexOf(entry.block),
539
581
  delta: event.delta,
540
582
  partial: output,
541
583
  });
542
584
  }
543
585
  }
544
586
  } else if (event.type === "response.function_call_arguments.delta") {
545
- if (currentItem?.type === "function_call" && currentBlock?.type === "toolCall") {
546
- currentBlock.partialJson += event.delta;
547
- const throttled = parseStreamingJsonThrottled(currentBlock.partialJson, currentBlock.lastParseLen ?? 0);
587
+ const entry = lookupOpenItem(event);
588
+ if (entry?.item.type === "function_call" && entry.block.type === "toolCall") {
589
+ const block = entry.block;
590
+ block.partialJson += event.delta;
591
+ const throttled = parseStreamingJsonThrottled(block.partialJson, block.lastParseLen ?? 0);
548
592
  if (throttled) {
549
- currentBlock.arguments = throttled.value;
550
- currentBlock.lastParseLen = throttled.parsedLen;
593
+ block.arguments = throttled.value;
594
+ block.lastParseLen = throttled.parsedLen;
551
595
  }
552
596
  stream.push({
553
597
  type: "toolcall_delta",
554
- contentIndex: blockIndex(),
598
+ contentIndex: contentIndexOf(block),
555
599
  delta: event.delta,
556
600
  partial: output,
557
601
  });
558
602
  }
559
603
  } else if (event.type === "response.function_call_arguments.done") {
560
- if (currentItem?.type === "function_call" && currentBlock?.type === "toolCall") {
561
- currentBlock.partialJson = event.arguments;
562
- currentBlock.arguments = parseStreamingJson(currentBlock.partialJson);
563
- delete (currentBlock as { partialJson?: string }).partialJson;
564
- delete (currentBlock as { lastParseLen?: number }).lastParseLen;
604
+ const entry = lookupOpenItem(event);
605
+ if (entry?.item.type === "function_call" && entry.block.type === "toolCall") {
606
+ const block = entry.block;
607
+ block.partialJson = event.arguments;
608
+ block.arguments = parseStreamingJson(block.partialJson);
609
+ delete (block as { partialJson?: string }).partialJson;
610
+ delete (block as { lastParseLen?: number }).lastParseLen;
565
611
  }
566
612
  } else if (event.type === "response.custom_tool_call_input.delta") {
567
- if (currentItem?.type === "custom_tool_call" && currentBlock?.type === "toolCall") {
568
- currentBlock.partialJson += event.delta;
569
- currentBlock.arguments = { input: currentBlock.partialJson };
613
+ const entry = lookupOpenItem(event);
614
+ if (entry?.item.type === "custom_tool_call" && entry.block.type === "toolCall") {
615
+ const block = entry.block;
616
+ block.partialJson += event.delta;
617
+ block.arguments = { input: block.partialJson };
570
618
  stream.push({
571
619
  type: "toolcall_delta",
572
- contentIndex: blockIndex(),
620
+ contentIndex: contentIndexOf(block),
573
621
  delta: event.delta,
574
622
  partial: output,
575
623
  });
576
624
  }
577
625
  } else if (event.type === "response.custom_tool_call_input.done") {
578
- if (currentItem?.type === "custom_tool_call" && currentBlock?.type === "toolCall") {
579
- currentBlock.partialJson = event.input;
580
- currentBlock.arguments = { input: event.input };
626
+ const entry = lookupOpenItem(event);
627
+ if (entry?.item.type === "custom_tool_call" && entry.block.type === "toolCall") {
628
+ entry.block.partialJson = event.input;
629
+ entry.block.arguments = { input: event.input };
581
630
  }
582
631
  } else if (event.type === "response.output_item.done") {
583
632
  const item = structuredCloneJSON(event.item);
584
633
  options?.onOutputItemDone?.(item);
634
+ const entry = lookupOpenItem({ output_index: event.output_index, item_id: item.id });
585
635
  if (item.type === "reasoning") {
586
636
  const thinking =
587
637
  item.summary?.length > 0
@@ -595,54 +645,53 @@ export async function processResponsesStream<TApi extends Api>(
595
645
  if (reasoningBlock) {
596
646
  reasoningBlock.thinking = thinking;
597
647
  reasoningBlock.thinkingSignature = JSON.stringify(item);
598
- const reasoningBlockIndex = output.content.indexOf(reasoningBlock);
599
648
  stream.push({
600
649
  type: "thinking_end",
601
- contentIndex: reasoningBlockIndex,
650
+ contentIndex: contentIndexOf(reasoningBlock),
602
651
  content: thinking,
603
652
  partial: output,
604
653
  });
605
654
  }
606
- if ((currentBlock as ThinkingContent | null)?.itemId === item.id) currentBlock = null;
607
- } else if (item.type === "message" && currentBlock?.type === "text") {
608
- currentBlock.text = item.content
655
+ closeOpenItem(event.output_index, item.id, entry);
656
+ } else if (item.type === "message" && entry?.block.type === "text") {
657
+ const block = entry.block;
658
+ block.text = item.content
609
659
  .map(part => (part.type === "output_text" ? (part.text ?? "") : (part.refusal ?? "")))
610
660
  .join("");
611
- currentBlock.textSignature = encodeTextSignatureV1(item.id, item.phase ?? undefined);
661
+ block.textSignature = encodeTextSignatureV1(item.id, item.phase ?? undefined);
612
662
  stream.push({
613
663
  type: "text_end",
614
- contentIndex: blockIndex(),
615
- content: currentBlock.text,
664
+ contentIndex: contentIndexOf(block),
665
+ content: block.text,
616
666
  partial: output,
617
667
  });
618
- currentBlock = null;
668
+ closeOpenItem(event.output_index, item.id, entry);
619
669
  } else if (item.type === "function_call") {
620
- const args =
621
- currentBlock?.type === "toolCall" && currentBlock.partialJson
622
- ? parseStreamingJson(currentBlock.partialJson)
623
- : parseStreamingJson(item.arguments || "{}");
670
+ const block = entry?.block.type === "toolCall" ? entry.block : undefined;
671
+ const args = block?.partialJson
672
+ ? parseStreamingJson(block.partialJson)
673
+ : parseStreamingJson(item.arguments || "{}");
624
674
  const toolCall: ToolCall = {
625
675
  type: "toolCall",
626
676
  id: encodeResponsesToolCallId(item.call_id, item.id),
627
677
  name: item.name,
628
678
  arguments: args,
629
679
  };
630
- if (currentBlock?.type === "toolCall") {
680
+ if (block) {
631
681
  // Persist the authoritative final args on the stored block. The
632
682
  // throttled delta parser may have skipped the last partial parse,
633
- // leaving currentBlock.arguments stale (often `{}`); the emitted
634
- // toolCall and the persisted block must agree.
635
- currentBlock.arguments = args;
636
- delete (currentBlock as { partialJson?: string }).partialJson;
637
- delete (currentBlock as { lastParseLen?: number }).lastParseLen;
683
+ // leaving block.arguments stale (often `{}`); the emitted toolCall
684
+ // and the persisted block must agree.
685
+ block.arguments = args;
686
+ delete (block as { partialJson?: string }).partialJson;
687
+ delete (block as { lastParseLen?: number }).lastParseLen;
638
688
  }
639
- currentBlock = null;
640
- stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
689
+ const contentIndex = block ? contentIndexOf(block) : output.content.length - 1;
690
+ closeOpenItem(event.output_index, item.id, entry);
691
+ stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
641
692
  } else if (item.type === "custom_tool_call") {
642
- const rawInput =
643
- currentBlock?.type === "toolCall" && currentBlock.partialJson
644
- ? currentBlock.partialJson
645
- : (item.input ?? "");
693
+ const block = entry?.block.type === "toolCall" ? entry.block : undefined;
694
+ const rawInput = block?.partialJson ? block.partialJson : (item.input ?? "");
646
695
  const toolCall: ToolCall = {
647
696
  type: "toolCall",
648
697
  id: encodeResponsesToolCallId(item.call_id, item.id),
@@ -650,8 +699,9 @@ export async function processResponsesStream<TApi extends Api>(
650
699
  arguments: { input: rawInput },
651
700
  customWireName: item.name,
652
701
  };
653
- currentBlock = null;
654
- stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
702
+ const contentIndex = block ? contentIndexOf(block) : output.content.length - 1;
703
+ closeOpenItem(event.output_index, item.id, entry);
704
+ stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
655
705
  }
656
706
  } else if (event.type === "response.completed") {
657
707
  const response = event.response;
@@ -752,21 +802,26 @@ type CommonSamplingOptions = Pick<
752
802
  /**
753
803
  * Apply the common `StreamOptions` → Responses sampling-parameter mapping (max output tokens,
754
804
  * temperature, top-p/k, min-p, presence/repetition penalties, service tier). Mutates `params`.
805
+ *
806
+ * `max_output_tokens` is suppressed when {@link Model.omitMaxOutputTokens} is `true`, so
807
+ * proxies (notably Ollama) that forward to upstream APIs with an unknown output-token cap
808
+ * can let the upstream apply its own default instead of 400-ing on `maxTokens` values that
809
+ * reflect the model's context window rather than the upstream output limit.
755
810
  */
756
811
  export function applyCommonResponsesSamplingParams<P extends CommonResponsesParams>(
757
812
  params: P,
758
813
  options: CommonSamplingOptions | undefined,
759
- provider: string,
814
+ model: Pick<Model, "provider" | "omitMaxOutputTokens">,
760
815
  ): void {
761
- if (options?.maxTokens) params.max_output_tokens = options.maxTokens;
816
+ if (options?.maxTokens && !model.omitMaxOutputTokens) params.max_output_tokens = options.maxTokens;
762
817
  if (options?.temperature !== undefined) params.temperature = options.temperature;
763
818
  if (options?.topP !== undefined) params.top_p = options.topP;
764
819
  if (options?.topK !== undefined) params.top_k = options.topK;
765
820
  if (options?.minP !== undefined) params.min_p = options.minP;
766
821
  if (options?.presencePenalty !== undefined) params.presence_penalty = options.presencePenalty;
767
822
  if (options?.repetitionPenalty !== undefined) params.repetition_penalty = options.repetitionPenalty;
768
- if (shouldSendServiceTier(options?.serviceTier, provider)) {
769
- const resolved = resolveServiceTier(options?.serviceTier, provider);
823
+ if (shouldSendServiceTier(options?.serviceTier, model.provider)) {
824
+ const resolved = resolveServiceTier(options?.serviceTier, model.provider);
770
825
  if (resolved === "flex" || resolved === "scale" || resolved === "priority") {
771
826
  params.service_tier = resolved;
772
827
  }
@@ -463,7 +463,7 @@ function buildParams(
463
463
  stream_options: model.provider === "openai" ? { include_obfuscation: false } : undefined,
464
464
  };
465
465
 
466
- applyCommonResponsesSamplingParams(params, options, model.provider);
466
+ applyCommonResponsesSamplingParams(params, options, model);
467
467
  // TODO: openai responses has no top-level `stop`/`stop_sequences`; surface via reasoning.stop?
468
468
  // `StreamOptions.stopSequences` is intentionally dropped for this provider.
469
469
  // TODO: openai responses has no top-level `frequency_penalty` field as of the current SDK;
package/src/types.ts CHANGED
@@ -905,6 +905,18 @@ export interface Model<TApi extends Api = any> {
905
905
  premiumMultiplier?: number;
906
906
  contextWindow: number;
907
907
  maxTokens: number;
908
+ /**
909
+ * When `true`, providers MUST omit `max_output_tokens` (Responses) /
910
+ * `max_tokens` / `max_completion_tokens` (Completions) from the outbound
911
+ * request and let the upstream API decide the per-response cap. `maxTokens`
912
+ * is still used locally for budgeting (compaction, context promotion); only
913
+ * the wire field is suppressed.
914
+ *
915
+ * Use this for proxies (notably Ollama) that forward to a backend whose true
916
+ * output limit OMP cannot discover — sending the wrong value triggers 400s
917
+ * from the upstream provider.
918
+ */
919
+ omitMaxOutputTokens?: boolean;
908
920
  headers?: Record<string, string>;
909
921
  /**
910
922
  * Streaming transport override. When `"pi-native"`, `streamSimple` routes