@oh-my-pi/pi-ai 18.8.6 → 18.8.8
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 +21 -0
- package/dist/types/auth/select.d.ts +3 -3
- package/dist/types/providers/google-shared.d.ts +7 -0
- package/dist/types/providers/openai-configuration-update.d.ts +5 -2
- package/dist/types/providers/openai-responses.d.ts +8 -0
- package/dist/types/providers/openai-shared.d.ts +2 -1
- package/dist/types/types.d.ts +2 -0
- package/package.json +6 -6
- package/src/auth/select.ts +31 -14
- package/src/error/rate-limit.ts +8 -6
- package/src/providers/amazon-bedrock.ts +15 -5
- package/src/providers/anthropic.ts +22 -1
- package/src/providers/factory-droid/gemini.ts +24 -6
- package/src/providers/google-shared.ts +18 -13
- package/src/providers/openai-codex-responses.ts +33 -12
- package/src/providers/openai-configuration-update.ts +17 -3
- package/src/providers/openai-responses.ts +31 -5
- package/src/providers/openai-shared.ts +15 -7
- package/src/types.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.8.8] - 2026-10-10
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed OpenAI Responses and Codex tool calls running with empty `{}` arguments when a compatible host or proxy ends the call with an empty arguments payload after streaming the real ones ([#15099](https://github.com/can1357/oh-my-pi/pull/15099) by [@will-bogusz](https://github.com/will-bogusz))
|
|
10
|
+
- Fixed truncated Factory Droid Gemini responses ending as a hard error or a half-received tool call instead of being retried ([#15103](https://github.com/can1357/oh-my-pi/pull/15103) by [@will-bogusz](https://github.com/will-bogusz))
|
|
11
|
+
- Fixed Factory Droid Gemini ignoring an error the server reports mid-response, which hid its status and retried errors that cannot succeed ([#15103](https://github.com/can1357/oh-my-pi/pull/15103) by [@will-bogusz](https://github.com/will-bogusz))
|
|
12
|
+
- Fixed resumed OpenAI Responses sessions (xAI, Factory Droid, OpenAI and other hosts) dropping all earlier encrypted reasoning on their first request; GitHub Copilot still rebuilds history until its first response ([#15148](https://github.com/can1357/oh-my-pi/pull/15148) by [@will-bogusz](https://github.com/will-bogusz))
|
|
13
|
+
|
|
14
|
+
## [18.8.7] - 2026-10-09
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Added routing-session cleanup for OpenAI Responses and Codex while preserving shared provider fallbacks ([#14334](https://github.com/can1357/oh-my-pi/pull/14334) by [@iliaal](https://github.com/iliaal)).
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Fixed Claude Haiku 5.5 requests silently enabling adaptive thinking when reasoning is off, on native Bedrock (main and helper calls) and the Anthropic API; conversations whose earlier effort controls rule out disabled thinking fall back to lowest-effort adaptive thinking instead of failing ([#14996](https://github.com/can1357/oh-my-pi/pull/14996) by [@bse-ai](https://github.com/bse-ai)).
|
|
23
|
+
- Fixed `/session pin` being ignored when every stored account is quota-blocked, which routed the next request to a different exhausted account instead of the pinned one ([#14997](https://github.com/can1357/oh-my-pi/issues/14997)).
|
|
24
|
+
- Fixed `minimax-code-cn` sessions staying pinned to a key whose Token Plan quota is exhausted (`用量上限` 429) instead of rotating to a sibling credential ([#15053](https://github.com/can1357/oh-my-pi/issues/15053)).
|
|
25
|
+
|
|
5
26
|
## [18.8.6] - 2026-10-08
|
|
6
27
|
|
|
7
28
|
### Fixed
|
|
@@ -71,9 +71,9 @@ export declare class CredentialSelector {
|
|
|
71
71
|
* 1. strict: unblocked credentials only, usage limits respected, plan
|
|
72
72
|
* filter enforced (when any account is confirmed eligible);
|
|
73
73
|
* 2. plan-fitting last resort: same plan filter, but blocked/exhausted
|
|
74
|
-
* accounts are allowed (
|
|
75
|
-
* first) so the caller gets real
|
|
76
|
-
* instead of a missing key;
|
|
74
|
+
* accounts are allowed (an explicit session pin first, then blocked
|
|
75
|
+
* candidates earliest-unblocking first) so the caller gets real
|
|
76
|
+
* usage-limit semantics from the wire instead of a missing key;
|
|
77
77
|
* 3. unfiltered last resort: the plan filter matched nothing usable —
|
|
78
78
|
* skip it and try every account once; the server is the final arbiter
|
|
79
79
|
* of model access.
|
|
@@ -153,6 +153,13 @@ export declare function pushToolCallEvents(toolCall: ToolCall, contentIndex: num
|
|
|
153
153
|
export declare function startTextOrThinkingBlock(isThinking: true, output: AssistantMessage, stream: AssistantMessageEventStream, onBeforeStartEvent?: () => void): ThinkingContent;
|
|
154
154
|
export declare function startTextOrThinkingBlock(isThinking: false, output: AssistantMessage, stream: AssistantMessageEventStream, onBeforeStartEvent?: () => void): TextContent;
|
|
155
155
|
export declare function startTextOrThinkingBlock(isThinking: boolean, output: AssistantMessage, stream: AssistantMessageEventStream, onBeforeStartEvent?: () => void): TextContent | ThinkingContent;
|
|
156
|
+
/**
|
|
157
|
+
* Error for an in-band `error` frame in a 200 generateContent stream. Keeps the
|
|
158
|
+
* RPC status alongside the message: an in-band quota failure is classified
|
|
159
|
+
* from this text, and `RESOURCE_EXHAUSTED` is the only account-exhaustion
|
|
160
|
+
* signal some of these chunks carry (#13090).
|
|
161
|
+
*/
|
|
162
|
+
export declare function googleStreamChunkError(error: NonNullable<GenerateContentResponse["error"]>, provider: string): Error;
|
|
156
163
|
/**
|
|
157
164
|
* Drives the chunked `generateContentStream` iterator into an `AssistantMessage` and
|
|
158
165
|
* the corresponding `AssistantMessageEventStream`. Shared between `streamGoogle` and
|
|
@@ -35,16 +35,19 @@ interface EffortTransition<TEffort extends string> {
|
|
|
35
35
|
}
|
|
36
36
|
/** Per-conversation effort baseline and recorded transitions. */
|
|
37
37
|
export interface OpenAIEffortControlState<TEffort extends string = string> {
|
|
38
|
+
sessionId: string;
|
|
38
39
|
baseEffort?: TEffort;
|
|
39
40
|
currentEffort?: TEffort;
|
|
40
41
|
transitions: EffortTransition<TEffort>[];
|
|
41
42
|
}
|
|
42
|
-
export declare function createOpenAIEffortControlState<TEffort extends string>(): OpenAIEffortControlState<TEffort>;
|
|
43
|
+
export declare function createOpenAIEffortControlState<TEffort extends string>(sessionId: string): OpenAIEffortControlState<TEffort>;
|
|
43
44
|
/**
|
|
44
45
|
* Fetch (or create) the control state for one conversation from a provider's
|
|
45
46
|
* bounded per-session map, refreshing its LRU slot.
|
|
46
47
|
*/
|
|
47
|
-
export declare function getOpenAIEffortControlState<TEffort extends string>(states: Map<string, OpenAIEffortControlState<TEffort>>, key: string): OpenAIEffortControlState<TEffort>;
|
|
48
|
+
export declare function getOpenAIEffortControlState<TEffort extends string>(states: Map<string, OpenAIEffortControlState<TEffort>>, key: string, sessionId: string): OpenAIEffortControlState<TEffort>;
|
|
49
|
+
/** Release one conversation's effort state from the Responses and Codex `releaseSession` implementations. */
|
|
50
|
+
export declare function releaseOpenAIEffortControlSession<TEffort extends string>(states: Map<string, OpenAIEffortControlState<TEffort>>, sessionId: string): void;
|
|
48
51
|
interface AnchorableItem {
|
|
49
52
|
type?: string | null;
|
|
50
53
|
role?: string;
|
|
@@ -82,7 +82,14 @@ export declare function pollOpenAIResponsesResultForCompletion(args: {
|
|
|
82
82
|
wait?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
83
83
|
}): Promise<Record<string, unknown> | undefined>;
|
|
84
84
|
interface OpenAIResponsesProviderSessionState extends ProviderSessionState, OpenAIStrictToolsState, OpenAIReasoningEffortFallbackState {
|
|
85
|
+
/**
|
|
86
|
+
* Replay native history items. Starts false only on connection-bound hosts;
|
|
87
|
+
* `close()` clears it so the next request rebuilds history from message
|
|
88
|
+
* content; the first successful response sets it.
|
|
89
|
+
*/
|
|
85
90
|
nativeHistoryReplayWarmed: boolean;
|
|
91
|
+
/** Bumped by `close()`; a response warms the state only if no close happened since its request started. */
|
|
92
|
+
closeCount: number;
|
|
86
93
|
/** Stateful `previous_response_id` chain baselines, keyed by baseUrl/model/session. */
|
|
87
94
|
chains: Map<string, OpenAIResponsesChainState>;
|
|
88
95
|
/** `configuration_update` effort baselines, keyed by baseUrl/model/session. */
|
|
@@ -91,6 +98,7 @@ interface OpenAIResponsesProviderSessionState extends ProviderSessionState, Open
|
|
|
91
98
|
/** Wire efforts a `configuration_update` can carry: every real tier, never `none`/null. */
|
|
92
99
|
type ResponsesStableEffort = Exclude<ReasoningEffort, "none" | null>;
|
|
93
100
|
interface OpenAIResponsesChainState {
|
|
101
|
+
sessionId: string;
|
|
94
102
|
/**
|
|
95
103
|
* Wire params of the last successful turn; never carries
|
|
96
104
|
* `previous_response_id`.
|
|
@@ -622,7 +622,8 @@ export declare function accumulateToolCallArgumentsDelta(block: ResponsesToolCal
|
|
|
622
622
|
* Finalize streamed function-call arguments from the authoritative `.done`
|
|
623
623
|
* payload. The caller owns the `argumentsDone` flag (generic Responses sets it;
|
|
624
624
|
* Codex's block shape has no such field), so this only rewrites `arguments` and
|
|
625
|
-
* drops the transient accumulation fields.
|
|
625
|
+
* drops the transient accumulation fields. A blank payload is not authoritative:
|
|
626
|
+
* it parses the buffered deltas and keeps them so the final item can still win.
|
|
626
627
|
*/
|
|
627
628
|
export declare function finalizeToolCallArgumentsDone(block: ResponsesToolCallBlock, args: string): void;
|
|
628
629
|
export declare function accumulateCustomToolCallInputDelta(block: ResponsesToolCallBlock, delta: string, stream: AssistantMessageEventStream, output: AssistantMessage, contentIndex: number): void;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -191,6 +191,8 @@ export declare function parseServiceTier(value: unknown): ServiceTier | undefine
|
|
|
191
191
|
export declare function coerceServiceTierByFamily(value: unknown): ServiceTierByFamily | undefined;
|
|
192
192
|
export interface ProviderSessionState {
|
|
193
193
|
close(): void;
|
|
194
|
+
/** Release one routing session after its final request, preserving shared provider fallbacks. */
|
|
195
|
+
releaseSession?(sessionId: string): void;
|
|
194
196
|
}
|
|
195
197
|
export interface ProviderResponseMetadata {
|
|
196
198
|
status: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-ai",
|
|
3
|
-
"version": "18.8.
|
|
3
|
+
"version": "18.8.8",
|
|
4
4
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -155,11 +155,11 @@
|
|
|
155
155
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
156
156
|
},
|
|
157
157
|
"dependencies": {
|
|
158
|
-
"@oh-my-pi/omptype": "18.8.
|
|
159
|
-
"@oh-my-pi/pi-catalog": "18.8.
|
|
160
|
-
"@oh-my-pi/pi-natives": "18.8.
|
|
161
|
-
"@oh-my-pi/pi-utils": "18.8.
|
|
162
|
-
"@oh-my-pi/pi-wire": "18.8.
|
|
158
|
+
"@oh-my-pi/omptype": "18.8.8",
|
|
159
|
+
"@oh-my-pi/pi-catalog": "18.8.8",
|
|
160
|
+
"@oh-my-pi/pi-natives": "18.8.8",
|
|
161
|
+
"@oh-my-pi/pi-utils": "18.8.8",
|
|
162
|
+
"@oh-my-pi/pi-wire": "18.8.8"
|
|
163
163
|
},
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@types/bun": "^1.3.14"
|
package/src/auth/select.ts
CHANGED
|
@@ -505,9 +505,9 @@ export class CredentialSelector {
|
|
|
505
505
|
* 1. strict: unblocked credentials only, usage limits respected, plan
|
|
506
506
|
* filter enforced (when any account is confirmed eligible);
|
|
507
507
|
* 2. plan-fitting last resort: same plan filter, but blocked/exhausted
|
|
508
|
-
* accounts are allowed (
|
|
509
|
-
* first) so the caller gets real
|
|
510
|
-
* instead of a missing key;
|
|
508
|
+
* accounts are allowed (an explicit session pin first, then blocked
|
|
509
|
+
* candidates earliest-unblocking first) so the caller gets real
|
|
510
|
+
* usage-limit semantics from the wire instead of a missing key;
|
|
511
511
|
* 3. unfiltered last resort: the plan filter matched nothing usable —
|
|
512
512
|
* skip it and try every account once; the server is the final arbiter
|
|
513
513
|
* of model access.
|
|
@@ -535,6 +535,9 @@ export class CredentialSelector {
|
|
|
535
535
|
const credentials = stored.filter(entry => this.#deps.affinity.allows(provider, sessionId, entry.credential));
|
|
536
536
|
|
|
537
537
|
if (credentials.length === 0) return undefined;
|
|
538
|
+
// Ranking and preflight both await provider work; preserve the row each
|
|
539
|
+
// selection started with even if a peer reorders the live pool.
|
|
540
|
+
const credentialIds = this.#deps.pool.entries(provider).map(entry => entry.id);
|
|
538
541
|
this.#deps.policies.validateUsageCapability(provider, this.#deps.usage.canFetchOAuthUsage(provider));
|
|
539
542
|
|
|
540
543
|
const providerKey = providerTypeKey(provider, "oauth");
|
|
@@ -659,6 +662,11 @@ export class CredentialSelector {
|
|
|
659
662
|
? { selection, usage: sessionPreferredUsage, usageChecked: true }
|
|
660
663
|
: { selection, usage: null, usageChecked: false },
|
|
661
664
|
);
|
|
665
|
+
// Keep the candidate object: preflight may rebind its positional index
|
|
666
|
+
// after a peer changes the credential pool.
|
|
667
|
+
const explicitPin = sessionPinIsExplicit
|
|
668
|
+
? candidates.find(candidate => candidate.selection.index === sessionPreferredIndex)
|
|
669
|
+
: undefined;
|
|
662
670
|
const preflightFailures = new Set<OAuthCandidate>();
|
|
663
671
|
// The last retryable refresh error (network, timeout, 5xx) that removed a candidate.
|
|
664
672
|
// When no candidate resolves, it is rethrown so callers retry instead of reporting
|
|
@@ -714,21 +722,24 @@ export class CredentialSelector {
|
|
|
714
722
|
const forceRefreshIndex = options?.forceRefresh
|
|
715
723
|
? (sessionPreferredIndex ?? candidates[0]?.selection.index)
|
|
716
724
|
: undefined;
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
// Resyncs after an await (refresh, disable) re-read the store.
|
|
725
|
+
// Candidate IDs were captured before ranking. Their synchronous rebinds
|
|
726
|
+
// share one provider re-list; resyncs after refresh await re-read the store.
|
|
720
727
|
let preflightRows: StoredAuthCredential[] | undefined;
|
|
721
728
|
await Promise.all(
|
|
722
729
|
candidates.map(async candidate => {
|
|
723
730
|
const force = forceRefreshIndex !== undefined && candidate.selection.index === forceRefreshIndex;
|
|
724
|
-
const initialCredentialId =
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
+
const initialCredentialId = credentialIds[candidate.selection.index];
|
|
732
|
+
if (initialCredentialId === undefined) {
|
|
733
|
+
preflightFailures.add(candidate);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
const beforeSync = candidate.selection.credential;
|
|
737
|
+
preflightRows ??= this.#reloadProviderRows(provider);
|
|
738
|
+
if (!this.#rebindOAuthSelection(preflightRows, candidate.selection, initialCredentialId)) {
|
|
739
|
+
preflightFailures.add(candidate);
|
|
740
|
+
return;
|
|
731
741
|
}
|
|
742
|
+
const syncedPeerCredential = !authCredentialEquals(beforeSync, candidate.selection.credential);
|
|
732
743
|
const hasFreshAccess = Date.now() + OAUTH_REFRESH_SKEW_MS < candidate.selection.credential.expires;
|
|
733
744
|
if ((!force || syncedPeerCredential) && hasFreshAccess) return;
|
|
734
745
|
const latestCredential = this.#deps.pool.credentials(provider)[candidate.selection.index];
|
|
@@ -884,9 +895,15 @@ export class CredentialSelector {
|
|
|
884
895
|
];
|
|
885
896
|
if (enforcePlanRequirement) passes.push({ allowBlocked: true, enforcePlanRequirement: false, enforceAccounts });
|
|
886
897
|
if (enforceAccounts) passes.push({ allowBlocked: true, enforcePlanRequirement: false, enforceAccounts: false });
|
|
898
|
+
// Blocked candidates rank earliest-unblock first, which would route a
|
|
899
|
+
// blocked explicit pin to an equally blocked sibling. Once the strict
|
|
900
|
+
// pass finds no unblocked account, the user's pin goes first.
|
|
901
|
+
const lastResortCandidates = explicitPin
|
|
902
|
+
? [explicitPin, ...candidates.filter(candidate => candidate !== explicitPin)]
|
|
903
|
+
: candidates;
|
|
887
904
|
|
|
888
905
|
for (const pass of passes) {
|
|
889
|
-
for (const candidate of candidates) {
|
|
906
|
+
for (const candidate of pass.allowBlocked ? lastResortCandidates : candidates) {
|
|
890
907
|
if (preflightFailures.has(candidate)) continue;
|
|
891
908
|
const candidateAccountKey = oauthAccountKey(candidate.selection.credential);
|
|
892
909
|
if (pass.enforceAccounts && (candidateAccountKey === undefined || !accountIds?.has(candidateAccountKey)))
|
package/src/error/rate-limit.ts
CHANGED
|
@@ -78,15 +78,17 @@ const ACCOUNT_SCOPED_403_PATTERN =
|
|
|
78
78
|
/\b(?:overall|account|organization|team|workspace)\b[^\n]{0,40}\b(?:message |request )?rate.?limit\b|\byour\b[^\n]{0,30}\b(?:limit )?will reset\b/i;
|
|
79
79
|
// Simplified Chinese account-quota exhaustion phrasing. Zhipu Coding Plan
|
|
80
80
|
// returns e.g. "429 已达到 5 小时的使用上限。您的限额将在 2026-08-06 20:06:00 重置。"
|
|
81
|
-
// (type=1308) when the 5h window is spent;
|
|
81
|
+
// (type=1308) when the 5h window is spent; MiniMax CN returns
|
|
82
|
+
// "当前已达到 Token Plan 用量上限。…" (2067); other CN providers use 额度已用完 /
|
|
82
83
|
// 配额已耗尽 / 余额不足. These are persistent account-local caps that must
|
|
83
84
|
// rotate to a sibling credential, not transient rate limits, so they are
|
|
84
85
|
// matched before the RATE_LIMIT_EXCEEDED branch. The 上限 arm is anchored on
|
|
85
|
-
// the 使用
|
|
86
|
-
// 并发请求数已达上限 / 速率达到上限 (
|
|
87
|
-
// healthy sibling credential as a false quota. "速率限制" is absent for
|
|
88
|
-
// same reason.
|
|
89
|
-
const CN_QUOTA_EXHAUSTED_PATTERN =
|
|
86
|
+
// the 使用 / 用量 tokens: a rate/concurrency cap phrased as 每分钟请求数已达上限 /
|
|
87
|
+
// 并发请求数已达上限 / 速率达到上限 (neither token) must NOT match, or it would
|
|
88
|
+
// burn a healthy sibling credential as a false quota. "速率限制" is absent for
|
|
89
|
+
// the same reason.
|
|
90
|
+
const CN_QUOTA_EXHAUSTED_PATTERN =
|
|
91
|
+
/(?:使用|用量).{0,30}?上限|(?:额度|配额)已?(?:用|耗)(?:完|尽)|限额.{0,30}重置|余额不足/;
|
|
90
92
|
// Simplified Chinese rate/concurrency caps can contain both 使用 and 上限, but
|
|
91
93
|
// remain transient rather than account quota exhaustion.
|
|
92
94
|
const CN_TRANSIENT_CAP_PATTERN =
|
|
@@ -450,16 +450,20 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
|
|
|
450
450
|
let toolConfig = toolPlan.toolConfig;
|
|
451
451
|
const sentinelInjected = toolPlan.sentinelInjected;
|
|
452
452
|
let additionalModelRequestFields = buildAdditionalModelRequestFields(model, options);
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
453
|
+
const thinkingDisabled =
|
|
454
|
+
isRecord(additionalModelRequestFields?.thinking) &&
|
|
455
|
+
additionalModelRequestFields.thinking.type === "disabled";
|
|
456
|
+
const prefixMismatchBehavior =
|
|
457
|
+
!thinkingDisabled && model.thinking?.prefixBinding
|
|
458
|
+
? (options.anthropicPrefixMismatchBehavior ?? "drop_block")
|
|
459
|
+
: undefined;
|
|
456
460
|
|
|
457
461
|
// Some models (Opus/Sonnet 5.5) reject forced tool use outright; keep the
|
|
458
462
|
// tools offered under `auto` and leave thinking intact.
|
|
459
463
|
const forcedChoice = toolConfig?.toolChoice?.any || toolConfig?.toolChoice?.tool;
|
|
460
464
|
if (toolConfig && forcedChoice && !model.compat.supportsForcedToolChoice) {
|
|
461
465
|
toolConfig = { ...toolConfig, toolChoice: { auto: {} } };
|
|
462
|
-
} else if (toolConfig && forcedChoice && additionalModelRequestFields) {
|
|
466
|
+
} else if (toolConfig && forcedChoice && additionalModelRequestFields && !thinkingDisabled) {
|
|
463
467
|
// Bedrock rejects thinking + forced tool_choice. Fable's adaptive
|
|
464
468
|
// thinking cannot be disabled, so downgrade its forced choice instead.
|
|
465
469
|
if (prefixMismatchBehavior) toolConfig = { ...toolConfig, toolChoice: { auto: {} } };
|
|
@@ -1335,7 +1339,13 @@ function buildAdditionalModelRequestFields(
|
|
|
1335
1339
|
options: BedrockOptions,
|
|
1336
1340
|
): Record<string, unknown> | undefined {
|
|
1337
1341
|
const reasoning = options.reasoning;
|
|
1338
|
-
if (!
|
|
1342
|
+
if (!model.reasoning) return undefined;
|
|
1343
|
+
if (!reasoning) {
|
|
1344
|
+
if (model.compat.disabledThinking === "disabled") {
|
|
1345
|
+
return { thinking: { type: "disabled" }, output_config: { effort: "low" } };
|
|
1346
|
+
}
|
|
1347
|
+
return undefined;
|
|
1348
|
+
}
|
|
1339
1349
|
|
|
1340
1350
|
const mode = model.thinking?.mode;
|
|
1341
1351
|
if (mode === "anthropic-adaptive") {
|
|
@@ -4514,6 +4514,23 @@ export function resolveAnthropicCompactionEffort(
|
|
|
4514
4514
|
);
|
|
4515
4515
|
}
|
|
4516
4516
|
|
|
4517
|
+
/**
|
|
4518
|
+
* Whether effort controls earlier requests left in `messages` rule out
|
|
4519
|
+
* `thinking: {type: "disabled"}`: Anthropic rejects a per-message effort
|
|
4520
|
+
* control with thinking off, and `disabled` above `high` effort. Both stay in
|
|
4521
|
+
* force across a thinking toggle, so such a request falls back to the
|
|
4522
|
+
* adaptive-only off path (lowest effort) instead of a 400.
|
|
4523
|
+
*/
|
|
4524
|
+
function effortControlsBlockDisabledThinking(
|
|
4525
|
+
model: Model<"anthropic-messages">,
|
|
4526
|
+
messages: readonly Message[],
|
|
4527
|
+
records: readonly AnthropicControlRecord[],
|
|
4528
|
+
): boolean {
|
|
4529
|
+
if (model.compat.supportsPerMessageEffort !== true) return false;
|
|
4530
|
+
const plan = planAnthropicEffortControls(undefined, messages, records, true, undefined, false);
|
|
4531
|
+
return plan.inserts.length > 0 || plan.topLevel === "xhigh" || plan.topLevel === "max";
|
|
4532
|
+
}
|
|
4533
|
+
|
|
4517
4534
|
/**
|
|
4518
4535
|
* Splice control markers into `messages`. Inserts sharing an index come from
|
|
4519
4536
|
* one request and merge into one control: tool changes first, then the effort.
|
|
@@ -4732,7 +4749,11 @@ function buildParams(
|
|
|
4732
4749
|
let outputConfigEffort: AnthropicOutputEffort | undefined;
|
|
4733
4750
|
if (model.reasoning) {
|
|
4734
4751
|
const disabledThinking = model.compat.disabledThinking;
|
|
4735
|
-
if (
|
|
4752
|
+
if (
|
|
4753
|
+
options?.thinkingEnabled === false &&
|
|
4754
|
+
disabledThinking !== undefined &&
|
|
4755
|
+
!(disabledThinking === "disabled" && effortControlsBlockDisabledThinking(model, context.messages, records))
|
|
4756
|
+
) {
|
|
4736
4757
|
if (disabledThinking !== "omit") {
|
|
4737
4758
|
thinking =
|
|
4738
4759
|
disabledThinking === "adaptive"
|
|
@@ -15,6 +15,7 @@ import { notifyProviderResponse } from "../../utils/provider-response";
|
|
|
15
15
|
import { dereferenceJsonSchema, normalizeSchemaForFactoryDroid, toolWireSchema } from "../../utils/schema";
|
|
16
16
|
import {
|
|
17
17
|
extractGoogleErrorMessage,
|
|
18
|
+
googleStreamChunkError,
|
|
18
19
|
mapGoogleUsage,
|
|
19
20
|
mapStopReasonString,
|
|
20
21
|
nextToolCallId,
|
|
@@ -87,22 +88,22 @@ const FACTORY_DROID_BLOCK_REASONS: Record<string, true> = {
|
|
|
87
88
|
* "unknown" bucket has no StopReason equivalent, so unknown terminators
|
|
88
89
|
* surface as errors instead of masquerading as a clean stop.
|
|
89
90
|
*/
|
|
90
|
-
function mapFactoryDroidFinishReason(reason: string
|
|
91
|
+
function mapFactoryDroidFinishReason(reason: string): {
|
|
91
92
|
stopReason: "stop" | "length" | "error";
|
|
92
93
|
errorMessage?: string;
|
|
93
94
|
} {
|
|
94
95
|
// mapStopReasonString already implements the CLI's outcome table
|
|
95
96
|
// (STOP→stop, MAX_TOKENS→length, everything else→error); the cast narrows
|
|
96
97
|
// its wide StopReason return to the three values it ever produces.
|
|
97
|
-
const stopReason = mapStopReasonString(reason
|
|
98
|
+
const stopReason = mapStopReasonString(reason) as "stop" | "length" | "error";
|
|
98
99
|
if (stopReason !== "error") return { stopReason };
|
|
99
|
-
if (
|
|
100
|
+
if (FACTORY_DROID_BLOCK_REASONS[reason]) {
|
|
100
101
|
return { stopReason: "error", errorMessage: `Generation was blocked by content filters (${reason})` };
|
|
101
102
|
}
|
|
102
103
|
if (reason === "MALFORMED_FUNCTION_CALL") {
|
|
103
104
|
return { stopReason: "error", errorMessage: `Generation failed with finish reason: ${reason}` };
|
|
104
105
|
}
|
|
105
|
-
return { stopReason: "error", errorMessage: `Unknown finish reason: ${reason
|
|
106
|
+
return { stopReason: "error", errorMessage: `Unknown finish reason: ${reason}` };
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
/** Transports whose outputs carry Gemini-verifiable signatures (Factory's own outputs are stamped google-generative-ai). */
|
|
@@ -414,6 +415,9 @@ export function streamFactoryDroidGemini(
|
|
|
414
415
|
},
|
|
415
416
|
);
|
|
416
417
|
for await (const chunk of chunks) {
|
|
418
|
+
// A server-declared error keeps its status and the same classification
|
|
419
|
+
// as the Google provider; only a stream that just stops is a premature close.
|
|
420
|
+
if (chunk.error) throw googleStreamChunkError(chunk.error, model.provider);
|
|
417
421
|
if (firstTokenTime === undefined && chunk.candidates?.[0]?.content?.parts?.some(part => part.text)) {
|
|
418
422
|
firstTokenTime = performance.now();
|
|
419
423
|
}
|
|
@@ -484,6 +488,20 @@ export function streamFactoryDroidGemini(
|
|
|
484
488
|
if (firstTokenTime !== undefined) output.ttft = firstTokenTime - startTime;
|
|
485
489
|
|
|
486
490
|
closeBlock();
|
|
491
|
+
// A stream that reaches EOF without a finishReason (and without a
|
|
492
|
+
// promptFeedback block) was truncated, even when it already carried
|
|
493
|
+
// functionCall parts; fail it as retryable instead of finishing the turn.
|
|
494
|
+
if (finishReason === undefined && !blockReason) {
|
|
495
|
+
// Worded like the other incomplete-stream errors so turn recovery
|
|
496
|
+
// continues a stream that already rendered text.
|
|
497
|
+
throw new AIError.ProviderResponseError(
|
|
498
|
+
"Factory Droid Gemini stream closed before a finish_reason was received",
|
|
499
|
+
{
|
|
500
|
+
provider: model.provider,
|
|
501
|
+
kind: "incomplete-stream",
|
|
502
|
+
},
|
|
503
|
+
);
|
|
504
|
+
}
|
|
487
505
|
for (const contentIndex of toolCallIndices) {
|
|
488
506
|
const toolCall = output.content[contentIndex] as ToolCall;
|
|
489
507
|
stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
|
|
@@ -499,12 +517,12 @@ export function streamFactoryDroidGemini(
|
|
|
499
517
|
output.errorMessage = `Generation was blocked by content filters (${blockReason})`;
|
|
500
518
|
output.stopDetails = { type: "content_filter", category: blockReason };
|
|
501
519
|
stream.push({ type: "error", reason: "error", error: output });
|
|
502
|
-
} else {
|
|
520
|
+
} else if (finishReason !== undefined) {
|
|
503
521
|
const mapped = mapFactoryDroidFinishReason(finishReason);
|
|
504
522
|
output.stopReason = mapped.stopReason;
|
|
505
523
|
if (mapped.errorMessage) {
|
|
506
524
|
output.errorMessage = mapped.errorMessage;
|
|
507
|
-
if (mapped.stopReason === "error" &&
|
|
525
|
+
if (mapped.stopReason === "error" && FACTORY_DROID_BLOCK_REASONS[finishReason]) {
|
|
508
526
|
output.stopDetails = { type: "content_filter", category: finishReason };
|
|
509
527
|
}
|
|
510
528
|
}
|
|
@@ -595,6 +595,23 @@ export function startTextOrThinkingBlock(
|
|
|
595
595
|
return block;
|
|
596
596
|
}
|
|
597
597
|
|
|
598
|
+
/**
|
|
599
|
+
* Error for an in-band `error` frame in a 200 generateContent stream. Keeps the
|
|
600
|
+
* RPC status alongside the message: an in-band quota failure is classified
|
|
601
|
+
* from this text, and `RESOURCE_EXHAUSTED` is the only account-exhaustion
|
|
602
|
+
* signal some of these chunks carry (#13090).
|
|
603
|
+
*/
|
|
604
|
+
export function googleStreamChunkError(error: NonNullable<GenerateContentResponse["error"]>, provider: string): Error {
|
|
605
|
+
const detail =
|
|
606
|
+
error.message && error.status
|
|
607
|
+
? `${error.message} (${error.status})`
|
|
608
|
+
: error.message || error.status || "unknown error";
|
|
609
|
+
const message = `Google API stream error: ${detail}`;
|
|
610
|
+
return typeof error.code === "number" && error.code >= 400
|
|
611
|
+
? new AIError.GoogleApiError(message, error.code)
|
|
612
|
+
: new AIError.ProviderResponseError(message, { provider, kind: "output" });
|
|
613
|
+
}
|
|
614
|
+
|
|
598
615
|
/**
|
|
599
616
|
* Drives the chunked `generateContentStream` iterator into an `AssistantMessage` and
|
|
600
617
|
* the corresponding `AssistantMessageEventStream`. Shared between `streamGoogle` and
|
|
@@ -645,19 +662,7 @@ export async function consumeGoogleStream<T extends GoogleApiType>(args: {
|
|
|
645
662
|
};
|
|
646
663
|
|
|
647
664
|
for await (const chunk of googleStream) {
|
|
648
|
-
if (chunk.error)
|
|
649
|
-
// Keep the RPC status alongside the message: an in-band quota failure
|
|
650
|
-
// is classified from this text, and `RESOURCE_EXHAUSTED` is the only
|
|
651
|
-
// account-exhaustion signal some of these chunks carry (#13090).
|
|
652
|
-
const detail =
|
|
653
|
-
chunk.error.message && chunk.error.status
|
|
654
|
-
? `${chunk.error.message} (${chunk.error.status})`
|
|
655
|
-
: chunk.error.message || chunk.error.status || "unknown error";
|
|
656
|
-
const message = `Google API stream error: ${detail}`;
|
|
657
|
-
throw typeof chunk.error.code === "number" && chunk.error.code >= 400
|
|
658
|
-
? new AIError.GoogleApiError(message, chunk.error.code)
|
|
659
|
-
: new AIError.ProviderResponseError(message, { provider: model.provider, kind: "output" });
|
|
660
|
-
}
|
|
665
|
+
if (chunk.error) throw googleStreamChunkError(chunk.error, model.provider);
|
|
661
666
|
if (!chunk.candidates?.length && chunk.promptFeedback?.blockReason) {
|
|
662
667
|
const detail = chunk.promptFeedback.blockReasonMessage;
|
|
663
668
|
throw new AIError.ProviderResponseError(
|
|
@@ -102,6 +102,7 @@ export { setCodexAttestationProvider } from "./openai-codex-attestation";
|
|
|
102
102
|
export type { CodexAttestationProvider } from "./openai-codex-attestation";
|
|
103
103
|
import {
|
|
104
104
|
getOpenAIEffortControlState,
|
|
105
|
+
releaseOpenAIEffortControlSession,
|
|
105
106
|
type OpenAIEffortControlState,
|
|
106
107
|
planStableOpenAIEffort,
|
|
107
108
|
} from "./openai-configuration-update";
|
|
@@ -434,6 +435,7 @@ export interface OpenAICodexWebSocketDebugStats {
|
|
|
434
435
|
* is historical — SSE-only sessions use it too.
|
|
435
436
|
*/
|
|
436
437
|
type CodexWebSocketSessionState = {
|
|
438
|
+
sessionId: string;
|
|
437
439
|
disableWebsocket: boolean;
|
|
438
440
|
lastRequest?: RequestBody;
|
|
439
441
|
/** Last completed response; an in-progress response cannot replace the retry baseline. */
|
|
@@ -1058,6 +1060,20 @@ function createCodexProviderSessionState(): CodexProviderSessionState {
|
|
|
1058
1060
|
webSocketPublicToPrivate: new Map(),
|
|
1059
1061
|
metadataSessions: new Map(),
|
|
1060
1062
|
effortControls: new Map(),
|
|
1063
|
+
releaseSession: sessionId => {
|
|
1064
|
+
const normalizedSessionId = normalizeOpenAIPromptCacheKey(sessionId);
|
|
1065
|
+
if (!normalizedSessionId) return;
|
|
1066
|
+
for (const [key, session] of state.webSocketSessions) {
|
|
1067
|
+
if (session.sessionId !== normalizedSessionId) continue;
|
|
1068
|
+
session.connection?.close("session_disposed");
|
|
1069
|
+
state.webSocketSessions.delete(key);
|
|
1070
|
+
for (const [publicKey, privateKey] of state.webSocketPublicToPrivate) {
|
|
1071
|
+
if (privateKey === key) state.webSocketPublicToPrivate.delete(publicKey);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
state.metadataSessions.delete(normalizedSessionId);
|
|
1075
|
+
releaseOpenAIEffortControlSession(state.effortControls, normalizedSessionId);
|
|
1076
|
+
},
|
|
1061
1077
|
close: () => {
|
|
1062
1078
|
for (const session of state.webSocketSessions.values()) {
|
|
1063
1079
|
session.connection?.close("session_disposed");
|
|
@@ -1472,14 +1488,14 @@ function createCodexRequestContext(
|
|
|
1472
1488
|
transportProviderSessionState?.webSocketPublicToPrivate.set(publicSessionKey, sessionKey);
|
|
1473
1489
|
}
|
|
1474
1490
|
const sharedWebsocketState =
|
|
1475
|
-
sessionKey && providerSessionState
|
|
1491
|
+
sessionKey && transportSessionId && providerSessionState
|
|
1476
1492
|
? isolatedTransportState
|
|
1477
1493
|
? providerSessionState.webSocketSessions.get(sessionKey)
|
|
1478
|
-
: getCodexWebSocketSessionState(sessionKey, providerSessionState)
|
|
1494
|
+
: getCodexWebSocketSessionState(sessionKey, transportSessionId, providerSessionState)
|
|
1479
1495
|
: undefined;
|
|
1480
1496
|
const websocketState =
|
|
1481
|
-
sessionKey && isolatedTransportState
|
|
1482
|
-
? getCodexWebSocketSessionState(sessionKey, isolatedTransportState)
|
|
1497
|
+
sessionKey && transportSessionId && isolatedTransportState
|
|
1498
|
+
? getCodexWebSocketSessionState(sessionKey, transportSessionId, isolatedTransportState)
|
|
1483
1499
|
: sharedWebsocketState;
|
|
1484
1500
|
if (isolatedTransportState && websocketState && sharedWebsocketState) {
|
|
1485
1501
|
websocketState.disableWebsocket = sharedWebsocketState.disableWebsocket;
|
|
@@ -1616,7 +1632,7 @@ function applyCodexStableEffort(
|
|
|
1616
1632
|
const providerState = getCodexProviderSessionState(options?.providerSessionState);
|
|
1617
1633
|
const sessionId = normalizeOpenAIPromptCacheKey(options?.sessionId);
|
|
1618
1634
|
if (!providerState || !sessionId) return;
|
|
1619
|
-
const state = getOpenAIEffortControlState(providerState.effortControls, `${model.id}\u0000${sessionId}
|
|
1635
|
+
const state = getOpenAIEffortControlState(providerState.effortControls, `${model.id}\u0000${sessionId}`, sessionId);
|
|
1620
1636
|
body.reasoning = { ...body.reasoning, effort: planStableOpenAIEffort(state, body.input, effort) };
|
|
1621
1637
|
}
|
|
1622
1638
|
|
|
@@ -2621,11 +2637,17 @@ class CodexStreamProcessor {
|
|
|
2621
2637
|
}
|
|
2622
2638
|
|
|
2623
2639
|
if (item.type === "function_call") {
|
|
2640
|
+
const partial = block?.type === "toolCall" ? block[kStreamingPartialJson] : undefined;
|
|
2624
2641
|
const toolCall: ToolCall = {
|
|
2625
2642
|
type: "toolCall",
|
|
2626
2643
|
id: encodeResponsesToolCallId(item.call_id, item.id),
|
|
2627
2644
|
name: item.name,
|
|
2628
|
-
|
|
2645
|
+
// An empty terminal item falls back to what `.delta`/`.done` already delivered.
|
|
2646
|
+
arguments: item.arguments
|
|
2647
|
+
? parseToolCallArguments(item.arguments)
|
|
2648
|
+
: block?.type === "toolCall" && !partial
|
|
2649
|
+
? block.arguments
|
|
2650
|
+
: parseToolCallArguments(partial),
|
|
2629
2651
|
};
|
|
2630
2652
|
item.arguments = replayableToolCallArguments(item.arguments, toolCall.arguments);
|
|
2631
2653
|
if (block?.type === "toolCall") {
|
|
@@ -3361,13 +3383,10 @@ export async function prewarmOpenAICodexResponses(
|
|
|
3361
3383
|
if (publicSessionKey && sessionKey) {
|
|
3362
3384
|
providerSessionState?.webSocketPublicToPrivate.set(publicSessionKey, sessionKey);
|
|
3363
3385
|
}
|
|
3364
|
-
if (!sessionKey || !providerSessionState) return;
|
|
3365
|
-
const state = getCodexWebSocketSessionState(sessionKey, providerSessionState);
|
|
3386
|
+
if (!sessionKey || !transportSessionId || !providerSessionState) return;
|
|
3387
|
+
const state = getCodexWebSocketSessionState(sessionKey, transportSessionId, providerSessionState);
|
|
3366
3388
|
if (!shouldUseCodexWebSocket(model, state, options?.preferWebsockets)) return;
|
|
3367
|
-
const metadataSession = getOrCreateCodexMetadataSessionState(
|
|
3368
|
-
transportSessionId ?? crypto.randomUUID(),
|
|
3369
|
-
providerSessionState,
|
|
3370
|
-
);
|
|
3389
|
+
const metadataSession = getOrCreateCodexMetadataSessionState(transportSessionId, providerSessionState);
|
|
3371
3390
|
const turnState = getOrCreateCodexTurnState(metadataSession, sessionKey);
|
|
3372
3391
|
const codexClientVersion = CODEX_CLIENT_VERSION;
|
|
3373
3392
|
const requestIdentity = createCodexCompatibilityIdentity(metadataSession);
|
|
@@ -3418,11 +3437,13 @@ function getCodexWebSocketSessionKey(
|
|
|
3418
3437
|
|
|
3419
3438
|
function getCodexWebSocketSessionState(
|
|
3420
3439
|
sessionKey: string,
|
|
3440
|
+
sessionId: string,
|
|
3421
3441
|
providerSessionState: CodexProviderSessionState,
|
|
3422
3442
|
): CodexWebSocketSessionState {
|
|
3423
3443
|
const existing = providerSessionState.webSocketSessions.get(sessionKey);
|
|
3424
3444
|
if (existing) return existing;
|
|
3425
3445
|
const created: CodexWebSocketSessionState = {
|
|
3446
|
+
sessionId,
|
|
3426
3447
|
disableWebsocket: false,
|
|
3427
3448
|
canAppend: false,
|
|
3428
3449
|
fallbackCount: 0,
|
|
@@ -36,13 +36,16 @@ interface EffortTransition<TEffort extends string> {
|
|
|
36
36
|
|
|
37
37
|
/** Per-conversation effort baseline and recorded transitions. */
|
|
38
38
|
export interface OpenAIEffortControlState<TEffort extends string = string> {
|
|
39
|
+
sessionId: string;
|
|
39
40
|
baseEffort?: TEffort;
|
|
40
41
|
currentEffort?: TEffort;
|
|
41
42
|
transitions: EffortTransition<TEffort>[];
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
export function createOpenAIEffortControlState<TEffort extends string>(
|
|
45
|
-
|
|
45
|
+
export function createOpenAIEffortControlState<TEffort extends string>(
|
|
46
|
+
sessionId: string,
|
|
47
|
+
): OpenAIEffortControlState<TEffort> {
|
|
48
|
+
return { sessionId, transitions: [] };
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
const MAX_EFFORT_CONTROL_STATES = 16;
|
|
@@ -54,6 +57,7 @@ const MAX_EFFORT_CONTROL_STATES = 16;
|
|
|
54
57
|
export function getOpenAIEffortControlState<TEffort extends string>(
|
|
55
58
|
states: Map<string, OpenAIEffortControlState<TEffort>>,
|
|
56
59
|
key: string,
|
|
60
|
+
sessionId: string,
|
|
57
61
|
): OpenAIEffortControlState<TEffort> {
|
|
58
62
|
const existing = states.get(key);
|
|
59
63
|
if (existing) {
|
|
@@ -61,7 +65,7 @@ export function getOpenAIEffortControlState<TEffort extends string>(
|
|
|
61
65
|
states.set(key, existing);
|
|
62
66
|
return existing;
|
|
63
67
|
}
|
|
64
|
-
const created = createOpenAIEffortControlState<TEffort>();
|
|
68
|
+
const created = createOpenAIEffortControlState<TEffort>(sessionId);
|
|
65
69
|
states.set(key, created);
|
|
66
70
|
if (states.size > MAX_EFFORT_CONTROL_STATES) {
|
|
67
71
|
const oldest = states.keys().next().value;
|
|
@@ -70,6 +74,16 @@ export function getOpenAIEffortControlState<TEffort extends string>(
|
|
|
70
74
|
return created;
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
/** Release one conversation's effort state from the Responses and Codex `releaseSession` implementations. */
|
|
78
|
+
export function releaseOpenAIEffortControlSession<TEffort extends string>(
|
|
79
|
+
states: Map<string, OpenAIEffortControlState<TEffort>>,
|
|
80
|
+
sessionId: string,
|
|
81
|
+
): void {
|
|
82
|
+
for (const [key, state] of states) {
|
|
83
|
+
if (state.sessionId === sessionId) states.delete(key);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
73
87
|
interface AnchorableItem {
|
|
74
88
|
type?: string | null;
|
|
75
89
|
role?: string;
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
import { compactGrammarDefinition } from "./grammar";
|
|
57
57
|
import {
|
|
58
58
|
getOpenAIEffortControlState,
|
|
59
|
+
releaseOpenAIEffortControlSession,
|
|
59
60
|
type OpenAIEffortControlState,
|
|
60
61
|
planStableOpenAIEffort,
|
|
61
62
|
} from "./openai-configuration-update";
|
|
@@ -95,6 +96,7 @@ import {
|
|
|
95
96
|
getJuiceValue,
|
|
96
97
|
getOpenAIPromptCacheKey,
|
|
97
98
|
getOpenAIResponsesRoutingSessionId,
|
|
99
|
+
normalizeOpenAIPromptCacheKey,
|
|
98
100
|
getOpenAIStrictToolsScope,
|
|
99
101
|
getOpenRouterResponsesSessionId,
|
|
100
102
|
isCompiledGrammarTooLargeStrictError,
|
|
@@ -393,7 +395,14 @@ async function* resumeOpenAIResponsesEventStream(
|
|
|
393
395
|
|
|
394
396
|
interface OpenAIResponsesProviderSessionState
|
|
395
397
|
extends ProviderSessionState, OpenAIStrictToolsState, OpenAIReasoningEffortFallbackState {
|
|
398
|
+
/**
|
|
399
|
+
* Replay native history items. Starts false only on connection-bound hosts;
|
|
400
|
+
* `close()` clears it so the next request rebuilds history from message
|
|
401
|
+
* content; the first successful response sets it.
|
|
402
|
+
*/
|
|
396
403
|
nativeHistoryReplayWarmed: boolean;
|
|
404
|
+
/** Bumped by `close()`; a response warms the state only if no close happened since its request started. */
|
|
405
|
+
closeCount: number;
|
|
397
406
|
/** Stateful `previous_response_id` chain baselines, keyed by baseUrl/model/session. */
|
|
398
407
|
chains: Map<string, OpenAIResponsesChainState>;
|
|
399
408
|
/** `configuration_update` effort baselines, keyed by baseUrl/model/session. */
|
|
@@ -404,6 +413,7 @@ interface OpenAIResponsesProviderSessionState
|
|
|
404
413
|
type ResponsesStableEffort = Exclude<ReasoningEffort, "none" | null>;
|
|
405
414
|
|
|
406
415
|
interface OpenAIResponsesChainState {
|
|
416
|
+
sessionId: string;
|
|
407
417
|
/**
|
|
408
418
|
* Wire params of the last successful turn; never carries
|
|
409
419
|
* `previous_response_id`.
|
|
@@ -420,17 +430,29 @@ interface OpenAIResponsesChainState {
|
|
|
420
430
|
disabled: boolean;
|
|
421
431
|
}
|
|
422
432
|
|
|
423
|
-
function createOpenAIResponsesProviderSessionState(
|
|
433
|
+
function createOpenAIResponsesProviderSessionState(
|
|
434
|
+
model: Model<"openai-responses">,
|
|
435
|
+
): OpenAIResponsesProviderSessionState {
|
|
424
436
|
const strictToolsState = createOpenAIStrictToolsState();
|
|
425
437
|
const reasoningEffortFallbackState = createOpenAIReasoningEffortFallbackState();
|
|
426
438
|
const state: OpenAIResponsesProviderSessionState = {
|
|
427
439
|
...strictToolsState,
|
|
428
440
|
...reasoningEffortFallbackState,
|
|
429
|
-
nativeHistoryReplayWarmed:
|
|
441
|
+
nativeHistoryReplayWarmed: !model.compat.connectionBoundNativeHistory,
|
|
442
|
+
closeCount: 0,
|
|
430
443
|
chains: new Map(),
|
|
431
444
|
effortControls: new Map(),
|
|
445
|
+
releaseSession: sessionId => {
|
|
446
|
+
const normalizedSessionId = normalizeOpenAIPromptCacheKey(sessionId);
|
|
447
|
+
if (!normalizedSessionId) return;
|
|
448
|
+
for (const [key, chain] of state.chains) {
|
|
449
|
+
if (chain.sessionId === normalizedSessionId) state.chains.delete(key);
|
|
450
|
+
}
|
|
451
|
+
releaseOpenAIEffortControlSession(state.effortControls, normalizedSessionId);
|
|
452
|
+
},
|
|
432
453
|
close: () => {
|
|
433
454
|
state.nativeHistoryReplayWarmed = false;
|
|
455
|
+
state.closeCount++;
|
|
434
456
|
state.chains.clear();
|
|
435
457
|
state.effortControls.clear();
|
|
436
458
|
clearOpenAIStrictToolsState(state);
|
|
@@ -448,7 +470,7 @@ function getOpenAIResponsesProviderSessionState(
|
|
|
448
470
|
const key = `${OPENAI_RESPONSES_PROVIDER_SESSION_STATE_PREFIX}${model.provider}`;
|
|
449
471
|
const existing = providerSessionState.get(key) as OpenAIResponsesProviderSessionState | undefined;
|
|
450
472
|
if (existing) return existing;
|
|
451
|
-
const created = createOpenAIResponsesProviderSessionState();
|
|
473
|
+
const created = createOpenAIResponsesProviderSessionState(model);
|
|
452
474
|
providerSessionState.set(key, created);
|
|
453
475
|
return created;
|
|
454
476
|
}
|
|
@@ -511,7 +533,7 @@ function getOpenAIResponsesChainState(
|
|
|
511
533
|
const key = `${resolvedBaseUrl ?? model.baseUrl ?? ""}\u0000${model.id}\u0000${sessionId}`;
|
|
512
534
|
const existing = providerSessionState.chains.get(key);
|
|
513
535
|
if (existing) return existing;
|
|
514
|
-
const created: OpenAIResponsesChainState = { canAppend: false, staleFailures: 0, disabled: false };
|
|
536
|
+
const created: OpenAIResponsesChainState = { sessionId, canAppend: false, staleFailures: 0, disabled: false };
|
|
515
537
|
providerSessionState.chains.set(key, created);
|
|
516
538
|
return created;
|
|
517
539
|
}
|
|
@@ -722,6 +744,7 @@ const streamOpenAIResponsesOnce = (
|
|
|
722
744
|
});
|
|
723
745
|
const premiumRequestsTotal = copilotPremiumRequests;
|
|
724
746
|
const providerSessionState = getOpenAIResponsesProviderSessionState(model, options?.providerSessionState);
|
|
747
|
+
const closeCountAtStart = providerSessionState?.closeCount;
|
|
725
748
|
const strictToolsScope = getOpenAIStrictToolsScope(model, baseUrl);
|
|
726
749
|
const promptCacheBreakpointPolicy =
|
|
727
750
|
resolveCacheRetention(options?.cacheRetention) !== "none" && options?.promptCache?.mode === "explicit"
|
|
@@ -1239,7 +1262,9 @@ const streamOpenAIResponsesOnce = (
|
|
|
1239
1262
|
{ supportsImageDetailOriginal: model.compat.supportsImageDetailOriginal },
|
|
1240
1263
|
);
|
|
1241
1264
|
if (replayableResponseItems) {
|
|
1242
|
-
if (providerSessionState
|
|
1265
|
+
if (providerSessionState && providerSessionState.closeCount === closeCountAtStart) {
|
|
1266
|
+
providerSessionState.nativeHistoryReplayWarmed = true;
|
|
1267
|
+
}
|
|
1243
1268
|
if (chainState) {
|
|
1244
1269
|
chainState.lastParams = cloneJsonTree(
|
|
1245
1270
|
activeTrailingScaffoldingItems > 0 && Array.isArray(activeParams.input)
|
|
@@ -1744,6 +1769,7 @@ function applyResponsesStableEffort(
|
|
|
1744
1769
|
const state = getOpenAIEffortControlState(
|
|
1745
1770
|
providerSessionState.effortControls,
|
|
1746
1771
|
`${model.baseUrl ?? ""}\u0000${model.id}\u0000${sessionId}`,
|
|
1772
|
+
sessionId,
|
|
1747
1773
|
);
|
|
1748
1774
|
params.reasoning = { ...reasoning, effort: planStableOpenAIEffort(state, input, effort) };
|
|
1749
1775
|
}
|
|
@@ -3052,7 +3052,8 @@ export function accumulateToolCallArgumentsDelta(
|
|
|
3052
3052
|
contentIndex: number,
|
|
3053
3053
|
): void {
|
|
3054
3054
|
delta = optionalResponsesText(delta, "function call arguments delta") ?? "";
|
|
3055
|
-
|
|
3055
|
+
// A non-blank `.done` consumed the buffer; a late delta must not reopen it.
|
|
3056
|
+
if (!delta || block[kStreamingPartialJson] === undefined) return;
|
|
3056
3057
|
block[kStreamingPartialJson] += delta;
|
|
3057
3058
|
const throttled = parseStreamingJsonThrottled(block[kStreamingPartialJson], block[kStreamingLastParseLen] ?? 0);
|
|
3058
3059
|
if (throttled) {
|
|
@@ -3066,9 +3067,14 @@ export function accumulateToolCallArgumentsDelta(
|
|
|
3066
3067
|
* Finalize streamed function-call arguments from the authoritative `.done`
|
|
3067
3068
|
* payload. The caller owns the `argumentsDone` flag (generic Responses sets it;
|
|
3068
3069
|
* Codex's block shape has no such field), so this only rewrites `arguments` and
|
|
3069
|
-
* drops the transient accumulation fields.
|
|
3070
|
+
* drops the transient accumulation fields. A blank payload is not authoritative:
|
|
3071
|
+
* it parses the buffered deltas and keeps them so the final item can still win.
|
|
3070
3072
|
*/
|
|
3071
3073
|
export function finalizeToolCallArgumentsDone(block: ResponsesToolCallBlock, args: string): void {
|
|
3074
|
+
if (!args.trim()) {
|
|
3075
|
+
block.arguments = parseToolCallArguments(block[kStreamingPartialJson]);
|
|
3076
|
+
return;
|
|
3077
|
+
}
|
|
3072
3078
|
block[kStreamingPartialJson] = args;
|
|
3073
3079
|
block.arguments = parseToolCallArguments(block[kStreamingPartialJson]);
|
|
3074
3080
|
clearStreamingPartialJson(block);
|
|
@@ -3638,11 +3644,13 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
3638
3644
|
closeOpenItem(event.output_index, item.id, entry);
|
|
3639
3645
|
} else if (item.type === "function_call") {
|
|
3640
3646
|
const block = entry?.block.type === "toolCall" ? entry.block : undefined;
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
?
|
|
3645
|
-
:
|
|
3647
|
+
// A blank `.done` leaves the delta buffer in place, so the item's own arguments still win.
|
|
3648
|
+
const args =
|
|
3649
|
+
block?.[kStreamingArgumentsDone] && block[kStreamingPartialJson] === undefined
|
|
3650
|
+
? block.arguments
|
|
3651
|
+
: item.arguments
|
|
3652
|
+
? parseToolCallArguments(item.arguments)
|
|
3653
|
+
: parseToolCallArguments(block?.[kStreamingPartialJson]);
|
|
3646
3654
|
item.arguments = replayableToolCallArguments(item.arguments, args);
|
|
3647
3655
|
const toolCall: ToolCall = {
|
|
3648
3656
|
type: "toolCall",
|
package/src/types.ts
CHANGED
|
@@ -405,6 +405,8 @@ export function coerceServiceTierByFamily(value: unknown): ServiceTierByFamily |
|
|
|
405
405
|
|
|
406
406
|
export interface ProviderSessionState {
|
|
407
407
|
close(): void;
|
|
408
|
+
/** Release one routing session after its final request, preserving shared provider fallbacks. */
|
|
409
|
+
releaseSession?(sessionId: string): void;
|
|
408
410
|
}
|
|
409
411
|
|
|
410
412
|
export interface ProviderResponseMetadata {
|