@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 +15 -0
- package/dist/types/auth-broker/index.d.ts +1 -0
- package/dist/types/auth-broker/remote-store.d.ts +5 -0
- package/dist/types/auth-broker/snapshot-cache.d.ts +17 -0
- package/dist/types/auth-broker/types.d.ts +2 -0
- package/dist/types/auth-storage.d.ts +23 -0
- package/dist/types/providers/openai-responses-shared.d.ts +6 -1
- package/dist/types/types.d.ts +12 -0
- package/package.json +2 -2
- package/src/auth-broker/index.ts +1 -0
- package/src/auth-broker/remote-store.ts +14 -0
- package/src/auth-broker/snapshot-cache.ts +174 -0
- package/src/auth-broker/types.ts +3 -0
- package/src/auth-storage.ts +224 -48
- package/src/providers/azure-openai-responses.ts +1 -1
- package/src/providers/openai-completions.ts +1 -1
- package/src/providers/openai-responses-shared.ts +160 -105
- package/src/providers/openai-responses.ts +1 -1
- package/src/types.ts +12 -0
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
|
|
@@ -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,
|
|
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;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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"
|
package/src/auth-broker/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/auth-broker/types.ts
CHANGED
|
@@ -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
|
|
package/src/auth-storage.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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.
|
|
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({
|
|
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
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
| ResponseOutputMessage
|
|
401
|
-
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const
|
|
410
|
-
const
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
stream.push({ type: "thinking_start", contentIndex:
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
stream.push({ type: "text_start", contentIndex:
|
|
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
|
-
|
|
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(
|
|
442
|
-
|
|
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
|
-
|
|
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(
|
|
460
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
509
|
+
entry.block.thinking += event.delta;
|
|
473
510
|
lastPart.text += event.delta;
|
|
474
511
|
stream.push({
|
|
475
512
|
type: "thinking_delta",
|
|
476
|
-
contentIndex:
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
525
|
+
entry.block.thinking += "\n\n";
|
|
488
526
|
lastPart.text += "\n\n";
|
|
489
527
|
stream.push({
|
|
490
528
|
type: "thinking_delta",
|
|
491
|
-
contentIndex:
|
|
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
|
-
|
|
501
|
-
|
|
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:
|
|
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
|
-
|
|
511
|
-
|
|
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
|
-
|
|
553
|
+
entry.item.content.push(event.part);
|
|
514
554
|
}
|
|
515
555
|
}
|
|
516
556
|
} else if (event.type === "response.output_text.delta") {
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
561
|
+
entry.block.text += event.delta;
|
|
521
562
|
lastPart.text += event.delta;
|
|
522
563
|
stream.push({
|
|
523
564
|
type: "text_delta",
|
|
524
|
-
contentIndex:
|
|
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
|
-
|
|
532
|
-
|
|
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
|
-
|
|
576
|
+
entry.block.text += event.delta;
|
|
535
577
|
lastPart.refusal += event.delta;
|
|
536
578
|
stream.push({
|
|
537
579
|
type: "text_delta",
|
|
538
|
-
contentIndex:
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
const
|
|
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
|
-
|
|
550
|
-
|
|
593
|
+
block.arguments = throttled.value;
|
|
594
|
+
block.lastParseLen = throttled.parsedLen;
|
|
551
595
|
}
|
|
552
596
|
stream.push({
|
|
553
597
|
type: "toolcall_delta",
|
|
554
|
-
contentIndex:
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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:
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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:
|
|
650
|
+
contentIndex: contentIndexOf(reasoningBlock),
|
|
602
651
|
content: thinking,
|
|
603
652
|
partial: output,
|
|
604
653
|
});
|
|
605
654
|
}
|
|
606
|
-
|
|
607
|
-
} else if (item.type === "message" &&
|
|
608
|
-
|
|
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
|
-
|
|
661
|
+
block.textSignature = encodeTextSignatureV1(item.id, item.phase ?? undefined);
|
|
612
662
|
stream.push({
|
|
613
663
|
type: "text_end",
|
|
614
|
-
contentIndex:
|
|
615
|
-
content:
|
|
664
|
+
contentIndex: contentIndexOf(block),
|
|
665
|
+
content: block.text,
|
|
616
666
|
partial: output,
|
|
617
667
|
});
|
|
618
|
-
|
|
668
|
+
closeOpenItem(event.output_index, item.id, entry);
|
|
619
669
|
} else if (item.type === "function_call") {
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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 (
|
|
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
|
|
634
|
-
//
|
|
635
|
-
|
|
636
|
-
delete (
|
|
637
|
-
delete (
|
|
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
|
-
|
|
640
|
-
|
|
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
|
|
643
|
-
|
|
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
|
-
|
|
654
|
-
|
|
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
|
-
|
|
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
|
|
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
|