@oh-my-pi/pi-ai 15.11.6 → 15.11.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 +36 -1
- package/dist/types/providers/anthropic.d.ts +5 -0
- package/dist/types/providers/google-gemini-cli.d.ts +16 -0
- package/dist/types/providers/openai-codex-responses.d.ts +0 -6
- package/package.json +3 -3
- package/src/providers/anthropic.ts +33 -8
- package/src/providers/google-gemini-cli.ts +24 -1
- package/src/providers/openai-codex-responses.ts +77 -133
- package/src/providers/openai-completions.ts +11 -7
- package/src/providers/openai-responses.ts +6 -1
- package/src/stream.ts +81 -41
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.11.8] - 2026-06-12
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- Removed the Codex SSE stateful transport path, so SSE turns no longer send `previous_response_id` with delta input and now always send the full transcript
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Scoped `x-codex-turn-state` handling to within-turn continuations so only tool-loop follow-ups include the turn-state header and new user turns start without it
|
|
14
|
+
|
|
15
|
+
### Removed
|
|
16
|
+
|
|
17
|
+
- Removed the `statefulResponses` option from `OpenAICodexResponsesOptions`, and SSE stateful mode is no longer controlled by the `PI_CODEX_STATEFUL`-style flag
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Fixed the platform OpenAI Responses and Codex websocket stale-chain classifiers missing the "Unsupported parameter: previous_response_id" rejection phrasing (FastAPI-style `detail` body with no `error.code`), so a chained turn now falls back to a full-transcript replay instead of surfacing the 400
|
|
22
|
+
- Fixed the HTTP-400 raw-request dump for Codex SSE to record the body actually sent on the wire instead of the pre-transport request body, which made chained-request failures look like the rejected parameter was never sent
|
|
23
|
+
|
|
24
|
+
## [15.11.7] - 2026-06-12
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Added `requestModelId` and `thinking.suppress` options to `google-gemini-cli` so collapsed effort-tier variants serialize their per-effort upstream wire id, and thinking-off requests on models with `thinking.suppressWhenOff` send an explicit `thinkingConfig` (`includeThoughts: false` with `thinkingLevel: "MINIMAL"` or `thinkingBudget: 0`) — Cloud Code Assist re-applies the per-id baked server default when the config is omitted, silently thinking and billing the tokens
|
|
29
|
+
- Added mandatory-reasoning clamping: models baked with `thinking.requiresEffort` floor omitted or disabled reasoning to the lowest supported effort in every api mapping, and `disableReasoning` no longer emits OpenRouter `reasoning: { enabled: false }` for them — fixes `omp bench` and utility requests 400ing with "Reasoning is mandatory for this endpoint and cannot be disabled" on OpenRouter Gemini 3.x
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Changed `google-gemini-cli` request mapping to route per-request wire ids via `resolveWireModelId`: the session effort picks the backing variant id (collapsed `gemini-3.5-flash` at high → `gemini-3.5-flash-low`; claude pairs route off → bare id, efforts → `-thinking`) while `AssistantMessage.model` and usage attribution stay on the logical id. A thinking budget clamped to zero now falls through to the thinking-off path (off routing plus suppression) instead of only disabling thinking
|
|
34
|
+
- Changed `openai-completions` and `anthropic-messages` to serialize per-request wire ids via `resolveWireModelId`, so collapsed `X`/`X-thinking` pairs on aggregators and custom providers switch to the thinking SKU when reasoning is enabled (previously only `google-gemini-cli` routed effort-tier variants)
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- Fixed `google-gemini-cli` ignoring `Model.requestModelId` when serializing the request model id
|
|
39
|
+
|
|
5
40
|
## [15.11.5] - 2026-06-12
|
|
6
41
|
|
|
7
42
|
### Added
|
|
@@ -3322,4 +3357,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
|
|
|
3322
3357
|
|
|
3323
3358
|
## [0.9.4] - 2025-11-26
|
|
3324
3359
|
|
|
3325
|
-
Initial release with multi-provider LLM support.
|
|
3360
|
+
Initial release with multi-provider LLM support.
|
|
@@ -83,6 +83,11 @@ export interface AnthropicOptions extends StreamOptions {
|
|
|
83
83
|
* Ignored for adaptive-capable models.
|
|
84
84
|
*/
|
|
85
85
|
thinkingBudgetTokens?: number;
|
|
86
|
+
/**
|
|
87
|
+
* Upstream wire model id override for collapsed effort-tier variants.
|
|
88
|
+
* Serialized as `requestModelId ?? model.requestModelId ?? model.id`.
|
|
89
|
+
*/
|
|
90
|
+
requestModelId?: string;
|
|
86
91
|
/**
|
|
87
92
|
* Effort level for adaptive thinking.
|
|
88
93
|
* Controls how much thinking Claude allocates:
|
|
@@ -34,7 +34,23 @@ export interface GoogleGeminiCliOptions extends StreamOptions {
|
|
|
34
34
|
budgetTokens?: number;
|
|
35
35
|
/** Thinking level. Use for Gemini 3 models (LOW/HIGH for Pro, MINIMAL/LOW/MEDIUM/HIGH for Flash). */
|
|
36
36
|
level?: GoogleThinkingLevel;
|
|
37
|
+
/**
|
|
38
|
+
* Explicit wire suppression when `enabled` is false. Cloud Code Assist
|
|
39
|
+
* re-applies the per-id baked server default when thinkingConfig is
|
|
40
|
+
* omitted, so models with `thinking.suppressWhenOff` must send
|
|
41
|
+
* `includeThoughts: false` plus a MINIMAL level (or zero budget).
|
|
42
|
+
*/
|
|
43
|
+
suppress?: {
|
|
44
|
+
level: GoogleThinkingLevel;
|
|
45
|
+
} | {
|
|
46
|
+
budget: number;
|
|
47
|
+
};
|
|
37
48
|
};
|
|
49
|
+
/**
|
|
50
|
+
* Upstream wire model id override for collapsed effort-tier variants.
|
|
51
|
+
* Serialized as `requestModelId ?? model.requestModelId ?? model.id`.
|
|
52
|
+
*/
|
|
53
|
+
requestModelId?: string;
|
|
38
54
|
projectId?: string;
|
|
39
55
|
}
|
|
40
56
|
export { ANTIGRAVITY_SYSTEM_INSTRUCTION, getAntigravityUserAgent, getGeminiCliHeaders, getGeminiCliUserAgent, } from "@oh-my-pi/pi-catalog/wire/gemini-headers";
|
|
@@ -11,12 +11,6 @@ export interface OpenAICodexResponsesOptions extends StreamOptions {
|
|
|
11
11
|
codexMode?: boolean;
|
|
12
12
|
toolChoice?: ToolChoice;
|
|
13
13
|
preferWebsockets?: boolean;
|
|
14
|
-
/**
|
|
15
|
-
* Enable stateful SSE turns: chain via `previous_response_id` + delta input
|
|
16
|
-
* instead of replaying the full transcript. Requires `sessionId` +
|
|
17
|
-
* `providerSessionState`. `false` vetoes the `PI_CODEX_STATEFUL` env flag.
|
|
18
|
-
*/
|
|
19
|
-
statefulResponses?: boolean;
|
|
20
14
|
serviceTier?: ServiceTier;
|
|
21
15
|
/**
|
|
22
16
|
* Opt into the Responses Lite transport contract. Sends
|
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.11.
|
|
4
|
+
"version": "15.11.8",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.0",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "15.11.
|
|
42
|
-
"@oh-my-pi/pi-utils": "15.11.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "15.11.8",
|
|
42
|
+
"@oh-my-pi/pi-utils": "15.11.8",
|
|
43
43
|
"openai": "^6.39.0",
|
|
44
44
|
"partial-json": "^0.1.7",
|
|
45
45
|
"zod": "4.4.3"
|
|
@@ -973,6 +973,11 @@ export interface AnthropicOptions extends StreamOptions {
|
|
|
973
973
|
* Ignored for adaptive-capable models.
|
|
974
974
|
*/
|
|
975
975
|
thinkingBudgetTokens?: number;
|
|
976
|
+
/**
|
|
977
|
+
* Upstream wire model id override for collapsed effort-tier variants.
|
|
978
|
+
* Serialized as `requestModelId ?? model.requestModelId ?? model.id`.
|
|
979
|
+
*/
|
|
980
|
+
requestModelId?: string;
|
|
976
981
|
/**
|
|
977
982
|
* Effort level for adaptive thinking.
|
|
978
983
|
* Controls how much thinking Claude allocates:
|
|
@@ -2816,7 +2821,7 @@ function buildParams(
|
|
|
2816
2821
|
// Build params in the canonical field order: model → messages → system → tools →
|
|
2817
2822
|
// metadata → max_tokens → thinking → context_management → output_config → stream.
|
|
2818
2823
|
const params: MessageCreateParamsStreaming = {
|
|
2819
|
-
model: model.requestModelId ?? model.id,
|
|
2824
|
+
model: options?.requestModelId ?? model.requestModelId ?? model.id,
|
|
2820
2825
|
messages: convertAnthropicMessages(context.messages, model, isOAuthToken),
|
|
2821
2826
|
...(systemBlocks && { system: systemBlocks }),
|
|
2822
2827
|
...(tools !== undefined && { tools }),
|
|
@@ -2906,11 +2911,22 @@ function ensureErrorToolResultWireContent(
|
|
|
2906
2911
|
: [{ type: "text", text: EMPTY_ERROR_TOOL_RESULT_TEXT }];
|
|
2907
2912
|
}
|
|
2908
2913
|
|
|
2909
|
-
function buildToolResultBlock(
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
+
function buildToolResultBlock(
|
|
2915
|
+
model: Model<"anthropic-messages">,
|
|
2916
|
+
msg: ToolResultMessage,
|
|
2917
|
+
hoistedImages: ContentBlockParam[],
|
|
2918
|
+
): ContentBlockParam {
|
|
2919
|
+
let content = convertContentBlocks(msg.content, model.input.includes("image"));
|
|
2920
|
+
// Anthropic rejects images inside error tool results ("all content must be
|
|
2921
|
+
// type `text` if `is_error` is true") — keep the text in the block and
|
|
2922
|
+
// hoist the images after the message's tool_result run.
|
|
2923
|
+
if (msg.isError && typeof content !== "string" && content.some(block => block.type === "image")) {
|
|
2924
|
+
for (const block of content) {
|
|
2925
|
+
if (block.type === "image") hoistedImages.push(block);
|
|
2926
|
+
}
|
|
2927
|
+
content = content.filter(block => block.type === "text");
|
|
2928
|
+
}
|
|
2929
|
+
content = ensureErrorToolResultWireContent(content, msg.isError);
|
|
2914
2930
|
const block: ContentBlockParam = {
|
|
2915
2931
|
type: "tool_result",
|
|
2916
2932
|
tool_use_id: msg.toolCallId,
|
|
@@ -3077,21 +3093,30 @@ export function convertAnthropicMessages(
|
|
|
3077
3093
|
} else if (msg.role === "toolResult") {
|
|
3078
3094
|
// Collect all consecutive toolResult messages, needed for z.ai Anthropic endpoint
|
|
3079
3095
|
const toolResults: ContentBlockParam[] = [];
|
|
3096
|
+
// Images stripped out of error tool results, re-attached after the run.
|
|
3097
|
+
const hoistedImages: ContentBlockParam[] = [];
|
|
3080
3098
|
|
|
3081
3099
|
// Add the current tool result
|
|
3082
|
-
toolResults.push(buildToolResultBlock(model, msg));
|
|
3100
|
+
toolResults.push(buildToolResultBlock(model, msg, hoistedImages));
|
|
3083
3101
|
|
|
3084
3102
|
// Look ahead for consecutive toolResult messages
|
|
3085
3103
|
let j = i + 1;
|
|
3086
3104
|
while (j < transformedMessages.length && transformedMessages[j].role === "toolResult") {
|
|
3087
3105
|
const nextMsg = transformedMessages[j] as ToolResultMessage; // We know it's a toolResult
|
|
3088
|
-
toolResults.push(buildToolResultBlock(model, nextMsg));
|
|
3106
|
+
toolResults.push(buildToolResultBlock(model, nextMsg, hoistedImages));
|
|
3089
3107
|
j++;
|
|
3090
3108
|
}
|
|
3091
3109
|
|
|
3092
3110
|
// Skip the messages we've already processed
|
|
3093
3111
|
i = j - 1;
|
|
3094
3112
|
|
|
3113
|
+
if (hoistedImages.length > 0) {
|
|
3114
|
+
toolResults.push(
|
|
3115
|
+
{ type: "text", text: "Attached image(s) from the tool result(s) above:" },
|
|
3116
|
+
...hoistedImages,
|
|
3117
|
+
);
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3095
3120
|
// Add a single user message with all tool results
|
|
3096
3121
|
params.push({
|
|
3097
3122
|
role: "user",
|
|
@@ -76,7 +76,19 @@ export interface GoogleGeminiCliOptions extends StreamOptions {
|
|
|
76
76
|
budgetTokens?: number;
|
|
77
77
|
/** Thinking level. Use for Gemini 3 models (LOW/HIGH for Pro, MINIMAL/LOW/MEDIUM/HIGH for Flash). */
|
|
78
78
|
level?: GoogleThinkingLevel;
|
|
79
|
+
/**
|
|
80
|
+
* Explicit wire suppression when `enabled` is false. Cloud Code Assist
|
|
81
|
+
* re-applies the per-id baked server default when thinkingConfig is
|
|
82
|
+
* omitted, so models with `thinking.suppressWhenOff` must send
|
|
83
|
+
* `includeThoughts: false` plus a MINIMAL level (or zero budget).
|
|
84
|
+
*/
|
|
85
|
+
suppress?: { level: GoogleThinkingLevel } | { budget: number };
|
|
79
86
|
};
|
|
87
|
+
/**
|
|
88
|
+
* Upstream wire model id override for collapsed effort-tier variants.
|
|
89
|
+
* Serialized as `requestModelId ?? model.requestModelId ?? model.id`.
|
|
90
|
+
*/
|
|
91
|
+
requestModelId?: string;
|
|
80
92
|
projectId?: string;
|
|
81
93
|
}
|
|
82
94
|
|
|
@@ -770,6 +782,17 @@ export function buildRequest(
|
|
|
770
782
|
} else if (options.thinking.budgetTokens !== undefined) {
|
|
771
783
|
generationConfig.thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
|
|
772
784
|
}
|
|
785
|
+
} else if (options.thinking?.suppress && model.reasoning) {
|
|
786
|
+
// Explicit off: omitting thinkingConfig re-applies the per-id baked
|
|
787
|
+
// server default (the model silently thinks and bills the tokens).
|
|
788
|
+
const suppress = options.thinking.suppress;
|
|
789
|
+
generationConfig.thinkingConfig = { includeThoughts: false };
|
|
790
|
+
if ("level" in suppress) {
|
|
791
|
+
// Cast to any since our GoogleThinkingLevel mirrors Google's ThinkingLevel enum values
|
|
792
|
+
generationConfig.thinkingConfig.thinkingLevel = suppress.level as any;
|
|
793
|
+
} else {
|
|
794
|
+
generationConfig.thinkingConfig.thinkingBudget = suppress.budget;
|
|
795
|
+
}
|
|
773
796
|
}
|
|
774
797
|
|
|
775
798
|
const request: CloudCodeAssistRequest["request"] = {
|
|
@@ -840,7 +863,7 @@ export function buildRequest(
|
|
|
840
863
|
|
|
841
864
|
return {
|
|
842
865
|
project: projectId,
|
|
843
|
-
model: model.id,
|
|
866
|
+
model: options.requestModelId ?? model.requestModelId ?? model.id,
|
|
844
867
|
request,
|
|
845
868
|
...(isAntigravity
|
|
846
869
|
? {
|
|
@@ -96,12 +96,6 @@ export interface OpenAICodexResponsesOptions extends StreamOptions {
|
|
|
96
96
|
codexMode?: boolean;
|
|
97
97
|
toolChoice?: ToolChoice;
|
|
98
98
|
preferWebsockets?: boolean;
|
|
99
|
-
/**
|
|
100
|
-
* Enable stateful SSE turns: chain via `previous_response_id` + delta input
|
|
101
|
-
* instead of replaying the full transcript. Requires `sessionId` +
|
|
102
|
-
* `providerSessionState`. `false` vetoes the `PI_CODEX_STATEFUL` env flag.
|
|
103
|
-
*/
|
|
104
|
-
statefulResponses?: boolean;
|
|
105
99
|
serviceTier?: ServiceTier;
|
|
106
100
|
/**
|
|
107
101
|
* Opt into the Responses Lite transport contract. Sends
|
|
@@ -190,9 +184,6 @@ const CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_EVENT_LIMIT = 256;
|
|
|
190
184
|
const CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_CHAR_LIMIT = 16 * 1024;
|
|
191
185
|
const CODEX_WHITESPACE_LOOP_RETRY_LIMIT = 2;
|
|
192
186
|
const CODEX_WHITESPACE_LOOP_RETRY_DELAY_MS = 250;
|
|
193
|
-
/** Consecutive stale-previous-response SSE failures before chaining is disabled for the session. */
|
|
194
|
-
const CODEX_SSE_CHAIN_STALE_FAILURE_LIMIT = 3;
|
|
195
|
-
const CODEX_STATEFUL_DEFAULT = true;
|
|
196
187
|
|
|
197
188
|
function isCodexStreamProgressEvent(event: unknown): boolean {
|
|
198
189
|
if (isOpenAIResponsesProgressEvent(event)) return true;
|
|
@@ -237,9 +228,9 @@ export interface OpenAICodexWebSocketDebugStats {
|
|
|
237
228
|
}
|
|
238
229
|
|
|
239
230
|
/**
|
|
240
|
-
* Per-session transport state shared by BOTH transports: turn
|
|
241
|
-
* (`previous_response_id` baseline), turn-state/models-etag headers,
|
|
242
|
-
* connection pooling, and debug stats. The name is historical — SSE-only
|
|
231
|
+
* Per-session transport state shared by BOTH transports: websocket turn
|
|
232
|
+
* chaining (`previous_response_id` baseline), turn-state/models-etag headers,
|
|
233
|
+
* websocket connection pooling, and debug stats. The name is historical — SSE-only
|
|
243
234
|
* sessions use it too.
|
|
244
235
|
*/
|
|
245
236
|
type CodexWebSocketSessionState = {
|
|
@@ -248,12 +239,6 @@ type CodexWebSocketSessionState = {
|
|
|
248
239
|
lastResponseId?: string;
|
|
249
240
|
lastResponseItems?: InputItem[];
|
|
250
241
|
canAppend: boolean;
|
|
251
|
-
/** Transport that minted lastResponseId; chaining requires the next request to use the same transport. */
|
|
252
|
-
chainTransport?: CodexTransport;
|
|
253
|
-
/** Set once SSE chaining is judged unsupported for this session (circuit breaker). */
|
|
254
|
-
disableSseChaining: boolean;
|
|
255
|
-
/** Consecutive stale-previous-response failures on SSE; reset on a successful chained completion. */
|
|
256
|
-
sseChainStaleFailures: number;
|
|
257
242
|
turnState?: string;
|
|
258
243
|
modelsEtag?: string;
|
|
259
244
|
connection?: CodexWebSocketConnection;
|
|
@@ -295,10 +280,6 @@ interface CodexStreamRuntime {
|
|
|
295
280
|
eventStream: AsyncGenerator<Record<string, unknown>>;
|
|
296
281
|
requestBodyForState: RequestBody;
|
|
297
282
|
transport: CodexTransport;
|
|
298
|
-
/** Whether this request may capture/reset SSE chain state (stateful SSE gating + session state present). */
|
|
299
|
-
sseChainingEnabled: boolean;
|
|
300
|
-
/** previous_response_id sent on the wire for the currently open stream, if the request was a delta. */
|
|
301
|
-
sentPreviousResponseId?: string;
|
|
302
283
|
websocketState?: CodexWebSocketSessionState;
|
|
303
284
|
currentItem: CodexEventItem | null;
|
|
304
285
|
currentBlock: CodexOutputBlock | null;
|
|
@@ -357,11 +338,6 @@ function isCodexWebSocketEnvEnabled(): boolean {
|
|
|
357
338
|
return $flag("PI_CODEX_WEBSOCKET");
|
|
358
339
|
}
|
|
359
340
|
|
|
360
|
-
function isCodexSseStatefulEnabled(options: OpenAICodexResponsesOptions | undefined): boolean {
|
|
361
|
-
if (options?.statefulResponses === false) return false;
|
|
362
|
-
return options?.statefulResponses === true || $flag("PI_CODEX_STATEFUL", CODEX_STATEFUL_DEFAULT);
|
|
363
|
-
}
|
|
364
|
-
|
|
365
341
|
function getCodexWebSocketRetryBudget(): number {
|
|
366
342
|
return parseCodexNonNegativeInteger($env.PI_CODEX_WEBSOCKET_RETRY_BUDGET, CODEX_WEBSOCKET_RETRY_BUDGET);
|
|
367
343
|
}
|
|
@@ -754,6 +730,11 @@ async function buildCodexRequestContext(
|
|
|
754
730
|
}
|
|
755
731
|
const websocketState =
|
|
756
732
|
sessionKey && providerSessionState ? getCodexWebSocketSessionState(sessionKey, providerSessionState) : undefined;
|
|
733
|
+
if (websocketState && !isCodexWithinTurnContinuation(context)) {
|
|
734
|
+
// codex-rs scopes `x-codex-turn-state` to a single user turn: tool-loop
|
|
735
|
+
// follow-ups echo it, a new user turn starts without it.
|
|
736
|
+
websocketState.turnState = undefined;
|
|
737
|
+
}
|
|
757
738
|
return {
|
|
758
739
|
apiKey,
|
|
759
740
|
accountId,
|
|
@@ -856,7 +837,6 @@ async function openInitialCodexEventStream(
|
|
|
856
837
|
eventStream: AsyncGenerator<Record<string, unknown>>;
|
|
857
838
|
requestBodyForState: RequestBody;
|
|
858
839
|
transport: CodexTransport;
|
|
859
|
-
sentPreviousResponseId?: string;
|
|
860
840
|
}> {
|
|
861
841
|
const { transformedBody, websocketState } = requestContext;
|
|
862
842
|
if (websocketState && shouldUseCodexWebSocket(model, websocketState, options?.preferWebsockets)) {
|
|
@@ -906,18 +886,17 @@ async function openCodexWebSocketTransport(
|
|
|
906
886
|
eventStream: AsyncGenerator<Record<string, unknown>>;
|
|
907
887
|
requestBodyForState: RequestBody;
|
|
908
888
|
transport: CodexTransport;
|
|
909
|
-
sentPreviousResponseId?: string;
|
|
910
889
|
}> {
|
|
911
|
-
const
|
|
890
|
+
const chainedBody = buildCodexChainedRequestBody(requestContext.transformedBody, websocketState);
|
|
912
891
|
// WebSocket frames cannot carry per-request HTTP headers, so the Responses
|
|
913
892
|
// Lite marker rides in `client_metadata` on every `response.create`.
|
|
914
893
|
const websocketRequest: Record<string, unknown> = {
|
|
915
894
|
type: "response.create",
|
|
916
|
-
...
|
|
895
|
+
...chainedBody,
|
|
917
896
|
...(requestContext.responsesLite
|
|
918
897
|
? {
|
|
919
898
|
client_metadata: {
|
|
920
|
-
...(
|
|
899
|
+
...(chainedBody.client_metadata ?? {}),
|
|
921
900
|
[CODEX_WS_RESPONSES_LITE_CLIENT_METADATA_KEY]: "true",
|
|
922
901
|
},
|
|
923
902
|
}
|
|
@@ -960,10 +939,24 @@ async function openCodexWebSocketTransport(
|
|
|
960
939
|
eventStream,
|
|
961
940
|
requestBodyForState,
|
|
962
941
|
transport: "websocket",
|
|
963
|
-
sentPreviousResponseId: chained.previousResponseId,
|
|
964
942
|
};
|
|
965
943
|
}
|
|
966
944
|
|
|
945
|
+
/**
|
|
946
|
+
* True when the request continues the current turn (everything after the
|
|
947
|
+
* last assistant message is tool results), false when a new user turn starts.
|
|
948
|
+
* Mirrors codex-rs, which scopes `x-codex-turn-state` to a single turn and
|
|
949
|
+
* clears it when the next one begins.
|
|
950
|
+
*/
|
|
951
|
+
function isCodexWithinTurnContinuation(context: Context): boolean {
|
|
952
|
+
for (let i = context.messages.length - 1; i >= 0; i--) {
|
|
953
|
+
const role = context.messages[i]?.role;
|
|
954
|
+
if (role === "toolResult") continue;
|
|
955
|
+
return role === "assistant";
|
|
956
|
+
}
|
|
957
|
+
return false;
|
|
958
|
+
}
|
|
959
|
+
|
|
967
960
|
async function openCodexSseTransport(
|
|
968
961
|
model: Model<"openai-codex-responses">,
|
|
969
962
|
requestContext: CodexRequestContext,
|
|
@@ -975,10 +968,11 @@ async function openCodexSseTransport(
|
|
|
975
968
|
eventStream: AsyncGenerator<Record<string, unknown>>;
|
|
976
969
|
requestBodyForState: RequestBody;
|
|
977
970
|
transport: CodexTransport;
|
|
978
|
-
sentPreviousResponseId?: string;
|
|
979
971
|
}> {
|
|
980
|
-
const open = async (wireBody: RequestBody) =>
|
|
981
|
-
|
|
972
|
+
const open = async (wireBody: RequestBody) => {
|
|
973
|
+
// Keep the 400 dump honest: record the body actually sent on the wire.
|
|
974
|
+
requestContext.rawRequestDump.body = wireBody;
|
|
975
|
+
return requestSetup.wrapCodexSseStream(
|
|
982
976
|
await openCodexSseEventStream(
|
|
983
977
|
requestContext.url,
|
|
984
978
|
requestContext.requestHeaders,
|
|
@@ -993,24 +987,8 @@ async function openCodexSseTransport(
|
|
|
993
987
|
options?.fetch,
|
|
994
988
|
),
|
|
995
989
|
);
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
if (state && chained.previousResponseId) {
|
|
999
|
-
try {
|
|
1000
|
-
return {
|
|
1001
|
-
eventStream: await open(chained.body),
|
|
1002
|
-
requestBodyForState: structuredCloneJSON(body),
|
|
1003
|
-
transport: "sse",
|
|
1004
|
-
sentPreviousResponseId: chained.previousResponseId,
|
|
1005
|
-
};
|
|
1006
|
-
} catch (error) {
|
|
1007
|
-
if (options?.signal?.aborted || !isCodexStalePreviousResponseError(error)) throw error;
|
|
1008
|
-
// Server rejected the chain baseline: reset and retry this open once
|
|
1009
|
-
// with the full transcript. Structurally cannot loop — the retry body
|
|
1010
|
-
// carries no previous_response_id.
|
|
1011
|
-
registerCodexSseChainStaleFailure(state, error);
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
990
|
+
};
|
|
991
|
+
recordCodexWebSocketRequestStats(state, body);
|
|
1014
992
|
return { eventStream: await open(body), requestBodyForState: structuredCloneJSON(body), transport: "sse" };
|
|
1015
993
|
}
|
|
1016
994
|
|
|
@@ -1030,7 +1008,6 @@ async function reopenCodexWebSocketRuntimeStream(
|
|
|
1030
1008
|
runtime.eventStream = next.eventStream;
|
|
1031
1009
|
runtime.requestBodyForState = next.requestBodyForState;
|
|
1032
1010
|
runtime.transport = next.transport;
|
|
1033
|
-
runtime.sentPreviousResponseId = next.sentPreviousResponseId;
|
|
1034
1011
|
state.lastTransport = next.transport;
|
|
1035
1012
|
} catch (error) {
|
|
1036
1013
|
const wsError = error instanceof Error ? error : new Error(String(error));
|
|
@@ -1062,7 +1039,6 @@ async function reopenCodexSseRuntimeStream(
|
|
|
1062
1039
|
runtime.eventStream = next.eventStream;
|
|
1063
1040
|
runtime.requestBodyForState = next.requestBodyForState;
|
|
1064
1041
|
runtime.transport = next.transport;
|
|
1065
|
-
runtime.sentPreviousResponseId = next.sentPreviousResponseId;
|
|
1066
1042
|
if (state) {
|
|
1067
1043
|
state.lastTransport = next.transport;
|
|
1068
1044
|
}
|
|
@@ -1072,16 +1048,12 @@ function createCodexStreamRuntime(initial: {
|
|
|
1072
1048
|
eventStream: AsyncGenerator<Record<string, unknown>>;
|
|
1073
1049
|
requestBodyForState: RequestBody;
|
|
1074
1050
|
transport: CodexTransport;
|
|
1075
|
-
sentPreviousResponseId?: string;
|
|
1076
1051
|
websocketState?: CodexWebSocketSessionState;
|
|
1077
|
-
sseChainingEnabled: boolean;
|
|
1078
1052
|
}): CodexStreamRuntime {
|
|
1079
1053
|
return {
|
|
1080
1054
|
eventStream: initial.eventStream,
|
|
1081
1055
|
requestBodyForState: initial.requestBodyForState,
|
|
1082
1056
|
transport: initial.transport,
|
|
1083
|
-
sseChainingEnabled: initial.sseChainingEnabled,
|
|
1084
|
-
sentPreviousResponseId: initial.sentPreviousResponseId,
|
|
1085
1057
|
websocketState: initial.websocketState,
|
|
1086
1058
|
currentItem: null,
|
|
1087
1059
|
currentBlock: null,
|
|
@@ -1594,12 +1566,7 @@ function handleOutputItemDone(
|
|
|
1594
1566
|
function handleResponseCreated(runtime: CodexStreamRuntime, rawEvent: Record<string, unknown>): void {
|
|
1595
1567
|
const response = (rawEvent as { response?: { id?: string } }).response;
|
|
1596
1568
|
const state = runtime.websocketState;
|
|
1597
|
-
if (
|
|
1598
|
-
state &&
|
|
1599
|
-
(runtime.transport === "websocket" || runtime.sseChainingEnabled) &&
|
|
1600
|
-
typeof response?.id === "string" &&
|
|
1601
|
-
response.id.length > 0
|
|
1602
|
-
) {
|
|
1569
|
+
if (state && runtime.transport === "websocket" && typeof response?.id === "string" && response.id.length > 0) {
|
|
1603
1570
|
state.lastResponseId = response.id;
|
|
1604
1571
|
}
|
|
1605
1572
|
}
|
|
@@ -1635,17 +1602,22 @@ function handleResponseCompleted(
|
|
|
1635
1602
|
}
|
|
1636
1603
|
|
|
1637
1604
|
const state = runtime.websocketState;
|
|
1638
|
-
if (state
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
state
|
|
1644
|
-
state.chainTransport = runtime.transport;
|
|
1645
|
-
if (runtime.sentPreviousResponseId) state.sseChainStaleFailures = 0;
|
|
1605
|
+
if (state) {
|
|
1606
|
+
if (runtime.transport !== "websocket") {
|
|
1607
|
+
// SSE turns never chain (previous_response_id is websocket-only on this
|
|
1608
|
+
// endpoint); a completed SSE turn also invalidates any websocket append
|
|
1609
|
+
// baseline, which no longer matches the transcript.
|
|
1610
|
+
resetCodexWebSocketAppendState(state);
|
|
1646
1611
|
} else {
|
|
1647
|
-
|
|
1648
|
-
|
|
1612
|
+
state.lastRequest = structuredCloneJSON(runtime.requestBodyForState);
|
|
1613
|
+
if (typeof response?.id === "string" && response.id.length > 0) {
|
|
1614
|
+
state.lastResponseId = response.id;
|
|
1615
|
+
state.lastResponseItems = stripInputItemIds(structuredCloneJSON(runtime.nativeOutputItems));
|
|
1616
|
+
state.canAppend = rawEvent.type === "response.done" || rawEvent.type === "response.completed";
|
|
1617
|
+
} else {
|
|
1618
|
+
// Without a response id the append baseline cannot be trusted.
|
|
1619
|
+
state.canAppend = false;
|
|
1620
|
+
}
|
|
1649
1621
|
}
|
|
1650
1622
|
}
|
|
1651
1623
|
|
|
@@ -1753,7 +1725,7 @@ async function tryRecoverCodexWhitespaceToolCallLoop(
|
|
|
1753
1725
|
|
|
1754
1726
|
runtime.whitespaceLoopRetries += 1;
|
|
1755
1727
|
const websocketState = context.requestContext.websocketState;
|
|
1756
|
-
if (websocketState
|
|
1728
|
+
if (websocketState) {
|
|
1757
1729
|
resetCodexWebSocketAppendState(websocketState);
|
|
1758
1730
|
resetCodexSessionMetadata(websocketState);
|
|
1759
1731
|
}
|
|
@@ -1860,21 +1832,14 @@ function isCodexStalePreviousResponseError(error: unknown): boolean {
|
|
|
1860
1832
|
if (error instanceof CodexProviderStreamError) return error.code === "previous_response_not_found";
|
|
1861
1833
|
if (!(error instanceof Error)) return false;
|
|
1862
1834
|
if ((error as { code?: string }).code === "previous_response_not_found") return true;
|
|
1863
|
-
|
|
1864
|
-
}
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
state.disableSseChaining = true;
|
|
1872
|
-
}
|
|
1873
|
-
logCodexDebug("codex sse previous_response_id rejected; falling back to full context", {
|
|
1874
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1875
|
-
consecutiveFailures: state.sseChainStaleFailures,
|
|
1876
|
-
disabled: state.disableSseChaining,
|
|
1877
|
-
});
|
|
1835
|
+
// "unsupported": the backend intermittently rejects the parameter outright
|
|
1836
|
+
// with `{"detail":"Unsupported parameter: previous_response_id"}` (no
|
|
1837
|
+
// `error.code`); treat it like a stale chain so the turn replays with full
|
|
1838
|
+
// context instead of surfacing the 400.
|
|
1839
|
+
return (
|
|
1840
|
+
/previous[ _]?response/i.test(error.message) &&
|
|
1841
|
+
/not[ _]?found|invalid|expired|stale|unsupported/i.test(error.message)
|
|
1842
|
+
);
|
|
1878
1843
|
}
|
|
1879
1844
|
|
|
1880
1845
|
async function tryRecoverCodexPreviousResponseNotFound(
|
|
@@ -1892,18 +1857,14 @@ async function tryRecoverCodexPreviousResponseNotFound(
|
|
|
1892
1857
|
) {
|
|
1893
1858
|
return false;
|
|
1894
1859
|
}
|
|
1895
|
-
if (runtime.transport !== "websocket"
|
|
1896
|
-
// SSE
|
|
1860
|
+
if (runtime.transport !== "websocket") {
|
|
1861
|
+
// SSE never sends previous_response_id; let other recovery handle it.
|
|
1897
1862
|
return false;
|
|
1898
1863
|
}
|
|
1899
1864
|
|
|
1900
1865
|
runtime.providerRetryAttempt += 1;
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
resetCodexSessionMetadata(websocketState);
|
|
1904
|
-
} else {
|
|
1905
|
-
registerCodexSseChainStaleFailure(websocketState, error);
|
|
1906
|
-
}
|
|
1866
|
+
resetCodexWebSocketAppendState(websocketState);
|
|
1867
|
+
resetCodexSessionMetadata(websocketState);
|
|
1907
1868
|
runtime.currentItem = null;
|
|
1908
1869
|
runtime.currentBlock = null;
|
|
1909
1870
|
runtime.sawTerminalEvent = false;
|
|
@@ -1913,13 +1874,8 @@ async function tryRecoverCodexPreviousResponseNotFound(
|
|
|
1913
1874
|
|
|
1914
1875
|
logCodexDebug("codex previous_response_id expired; retrying with full context", {
|
|
1915
1876
|
retry: runtime.providerRetryAttempt,
|
|
1916
|
-
transport: runtime.transport,
|
|
1917
1877
|
});
|
|
1918
|
-
|
|
1919
|
-
await reopenCodexWebSocketRuntimeStream(context, runtime, websocketState);
|
|
1920
|
-
} else {
|
|
1921
|
-
await reopenCodexSseRuntimeStream(context, runtime, websocketState);
|
|
1922
|
-
}
|
|
1878
|
+
await reopenCodexWebSocketRuntimeStream(context, runtime, websocketState);
|
|
1923
1879
|
return true;
|
|
1924
1880
|
}
|
|
1925
1881
|
|
|
@@ -1996,7 +1952,7 @@ async function tryRetryCodexProviderError(
|
|
|
1996
1952
|
|
|
1997
1953
|
runtime.providerRetryAttempt += 1;
|
|
1998
1954
|
const websocketState = context.requestContext.websocketState;
|
|
1999
|
-
if (websocketState
|
|
1955
|
+
if (websocketState) {
|
|
2000
1956
|
resetCodexWebSocketAppendState(websocketState);
|
|
2001
1957
|
resetCodexSessionMetadata(websocketState);
|
|
2002
1958
|
}
|
|
@@ -2037,7 +1993,7 @@ function finalizeCodexResponse(
|
|
|
2037
1993
|
throw new Error("Request was aborted");
|
|
2038
1994
|
}
|
|
2039
1995
|
if (!runtime.sawTerminalEvent) {
|
|
2040
|
-
if (context.requestContext.websocketState
|
|
1996
|
+
if (context.requestContext.websocketState) {
|
|
2041
1997
|
resetCodexWebSocketAppendState(context.requestContext.websocketState);
|
|
2042
1998
|
resetCodexSessionMetadata(context.requestContext.websocketState);
|
|
2043
1999
|
}
|
|
@@ -2102,7 +2058,6 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
|
|
|
2102
2058
|
const runtime = createCodexStreamRuntime({
|
|
2103
2059
|
...initialTransport,
|
|
2104
2060
|
websocketState: requestContext.websocketState,
|
|
2105
|
-
sseChainingEnabled: isCodexSseStatefulEnabled(options) && requestContext.websocketState !== undefined,
|
|
2106
2061
|
});
|
|
2107
2062
|
if (requestContext.websocketState) {
|
|
2108
2063
|
requestContext.websocketState.lastTransport = initialTransport.transport;
|
|
@@ -2262,8 +2217,6 @@ function getCodexWebSocketSessionState(
|
|
|
2262
2217
|
const created: CodexWebSocketSessionState = {
|
|
2263
2218
|
disableWebsocket: false,
|
|
2264
2219
|
canAppend: false,
|
|
2265
|
-
disableSseChaining: false,
|
|
2266
|
-
sseChainStaleFailures: 0,
|
|
2267
2220
|
fallbackCount: 0,
|
|
2268
2221
|
prewarmed: false,
|
|
2269
2222
|
stats: {
|
|
@@ -2281,7 +2234,6 @@ function resetCodexWebSocketAppendState(state: CodexWebSocketSessionState): void
|
|
|
2281
2234
|
state.lastRequest = undefined;
|
|
2282
2235
|
state.lastResponseId = undefined;
|
|
2283
2236
|
state.lastResponseItems = undefined;
|
|
2284
|
-
state.chainTransport = undefined;
|
|
2285
2237
|
}
|
|
2286
2238
|
|
|
2287
2239
|
function resetCodexSessionMetadata(state: CodexWebSocketSessionState): void {
|
|
@@ -2413,40 +2365,32 @@ function recordCodexWebSocketRequestStats(
|
|
|
2413
2365
|
state.stats.lastPreviousResponseId = undefined;
|
|
2414
2366
|
}
|
|
2415
2367
|
|
|
2416
|
-
interface CodexChainedRequest {
|
|
2417
|
-
body: RequestBody;
|
|
2418
|
-
/** Set iff the body carries previous_response_id (delta request). */
|
|
2419
|
-
previousResponseId?: string;
|
|
2420
|
-
}
|
|
2421
|
-
|
|
2422
2368
|
/**
|
|
2423
|
-
* Shape the next turn's request
|
|
2424
|
-
*
|
|
2425
|
-
*
|
|
2426
|
-
*
|
|
2369
|
+
* Shape the next websocket turn's request body: when the session's append
|
|
2370
|
+
* baseline is intact (same options, strict history prefix), chain via
|
|
2371
|
+
* `previous_response_id` + delta-only `input`; otherwise break the chain and
|
|
2372
|
+
* replay the full transcript. SSE requests never chain — the HTTP endpoint's
|
|
2373
|
+
* request schema has no `previous_response_id` (codex-rs carries it only on
|
|
2374
|
+
* websocket `response.create` frames) and strict gateway validators 400 it
|
|
2375
|
+
* with `{"detail":"Unsupported parameter: previous_response_id"}`.
|
|
2427
2376
|
*/
|
|
2428
2377
|
function buildCodexChainedRequestBody(
|
|
2429
2378
|
requestBody: RequestBody,
|
|
2430
2379
|
state: CodexWebSocketSessionState | undefined,
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
const chainable = state?.canAppend === true && state.chainTransport === transport;
|
|
2380
|
+
): RequestBody {
|
|
2381
|
+
const chainable = state?.canAppend === true;
|
|
2434
2382
|
const appendInput = chainable
|
|
2435
2383
|
? buildResponsesDeltaInput<InputItem>(state.lastRequest, state.lastResponseItems, requestBody)
|
|
2436
2384
|
: null;
|
|
2437
2385
|
if (appendInput && appendInput.length > 0 && state?.lastResponseId) {
|
|
2438
2386
|
const body: RequestBody = { ...requestBody, previous_response_id: state.lastResponseId, input: appendInput };
|
|
2439
2387
|
recordCodexWebSocketRequestStats(state, body);
|
|
2440
|
-
return
|
|
2388
|
+
return body;
|
|
2441
2389
|
}
|
|
2442
2390
|
if (chainable && state) {
|
|
2443
|
-
// Chaining was eligible
|
|
2444
|
-
//
|
|
2445
|
-
// transport flip (chainTransport mismatch) deliberately does NOT reset:
|
|
2446
|
-
// turn-state/models-etag must keep replaying on the other transport, and
|
|
2447
|
-
// the next completion overwrites the baseline anyway.
|
|
2391
|
+
// Chaining was eligible but the prefix/options check failed: history
|
|
2392
|
+
// mutated or options changed — break the chain.
|
|
2448
2393
|
logCodexDebug("codex append reset", {
|
|
2449
|
-
transport,
|
|
2450
2394
|
hadTurnStateHeader: Boolean(state.turnState),
|
|
2451
2395
|
hadModelsEtagHeader: Boolean(state.modelsEtag),
|
|
2452
2396
|
});
|
|
@@ -2454,7 +2398,7 @@ function buildCodexChainedRequestBody(
|
|
|
2454
2398
|
resetCodexSessionMetadata(state);
|
|
2455
2399
|
}
|
|
2456
2400
|
recordCodexWebSocketRequestStats(state, requestBody);
|
|
2457
|
-
return
|
|
2401
|
+
return requestBody;
|
|
2458
2402
|
}
|
|
2459
2403
|
|
|
2460
2404
|
function toWebSocketUrl(url: string): string {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Effort } from "@oh-my-pi/pi-catalog/effort";
|
|
2
2
|
import { toFirepassWireModelId, toFireworksWireModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
|
|
3
3
|
import { isDeepseekModelIdOrName } from "@oh-my-pi/pi-catalog/identity";
|
|
4
|
-
import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
4
|
+
import { getSupportedEfforts, resolveWireModelId } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
5
5
|
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
|
|
6
6
|
import type { ResolvedOpenAICompat } from "@oh-my-pi/pi-catalog/types";
|
|
7
7
|
import { parseGitHubCopilotApiKey } from "@oh-my-pi/pi-catalog/wire/github-copilot";
|
|
@@ -113,12 +113,16 @@ function resolveOpenAICompletionsModelId(
|
|
|
113
113
|
model: Model<"openai-completions">,
|
|
114
114
|
options: OpenAICompletionsOptions | undefined,
|
|
115
115
|
): string {
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
116
|
+
// Effort-tier variants route per request effort (off → bare id, efforts →
|
|
117
|
+
// the thinking backing id); catalog variants (Copilot long-context `-1m`
|
|
118
|
+
// entries) pin via `requestModelId`; everything else serializes `model.id`.
|
|
119
|
+
const effort =
|
|
120
|
+
options?.reasoning && !options.disableReasoning && model.reasoning ? (options.reasoning as Effort) : undefined;
|
|
121
|
+
const wireId = resolveWireModelId(model, effort);
|
|
122
|
+
if (model.provider === "firepass") return toFirepassWireModelId(wireId);
|
|
123
|
+
if (model.provider === "fireworks") return toFireworksWireModelId(wireId);
|
|
124
|
+
if (model.provider === "openrouter") return applyOpenRouterRoutingVariant(wireId, options?.openrouterVariant);
|
|
125
|
+
return wireId;
|
|
122
126
|
}
|
|
123
127
|
|
|
124
128
|
/**
|
|
@@ -285,7 +285,12 @@ function buildOpenAIResponsesChainedParams(
|
|
|
285
285
|
function isOpenAIResponsesStalePreviousResponseError(error: unknown): boolean {
|
|
286
286
|
if (!(error instanceof Error)) return false;
|
|
287
287
|
if ((error as { code?: string }).code === "previous_response_not_found") return true;
|
|
288
|
-
|
|
288
|
+
// "unsupported" covers endpoints that reject the parameter outright
|
|
289
|
+
// (e.g. "Unsupported parameter: previous_response_id").
|
|
290
|
+
return (
|
|
291
|
+
/previous[ _]?response/i.test(error.message) &&
|
|
292
|
+
/not[ _]?found|invalid|expired|stale|unsupported/i.test(error.message)
|
|
293
|
+
);
|
|
289
294
|
}
|
|
290
295
|
|
|
291
296
|
/**
|
package/src/stream.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { isVertexExpressOpenAIUrl, isVertexRawPredictUrl } from "@oh-my-pi/pi-ca
|
|
|
3
3
|
import {
|
|
4
4
|
mapEffortToAnthropicAdaptiveEffort,
|
|
5
5
|
mapEffortToGoogleThinkingLevel,
|
|
6
|
+
minimumSupportedEffort,
|
|
6
7
|
requireSupportedEffort,
|
|
8
|
+
resolveWireModelId,
|
|
7
9
|
} from "@oh-my-pi/pi-catalog/model-thinking";
|
|
8
10
|
import { CATALOG_PROVIDERS, type ProviderCatalogEntry } from "@oh-my-pi/pi-catalog/provider-models";
|
|
9
11
|
import { $env, $pickenv, extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
|
|
@@ -667,11 +669,39 @@ function resolveOpenAiReasoningEffort<TApi extends Api>(
|
|
|
667
669
|
|
|
668
670
|
const castApi = <TApi extends Api>(api: OptionsForApi<TApi>): OptionsForApi<Api> => api as OptionsForApi<Api>;
|
|
669
671
|
|
|
670
|
-
|
|
672
|
+
/**
|
|
673
|
+
* Mandatory-reasoning endpoints (`thinking.requiresEffort`) reject disabled
|
|
674
|
+
* or omitted thinking ("Reasoning is mandatory for this endpoint and cannot
|
|
675
|
+
* be disabled") — clamp to the lowest supported effort instead.
|
|
676
|
+
* `suppressWhenOff` models handle off provider-side via explicit wire
|
|
677
|
+
* suppression. Collapsed pairs interplay: pair derivation strips member
|
|
678
|
+
* flags (off routes to a bare SKU that CAN disable), while identity backfill
|
|
679
|
+
* re-flags pairs whose logical id is itself mandatory (Gemini 3.x) — there
|
|
680
|
+
* the clamp wins and the floored effort routes to the thinking SKU.
|
|
681
|
+
*/
|
|
682
|
+
function normalizeMandatoryReasoningOptions<TApi extends Api>(
|
|
671
683
|
model: Model<TApi>,
|
|
672
684
|
options?: SimpleStreamOptions,
|
|
685
|
+
): SimpleStreamOptions | undefined {
|
|
686
|
+
if (
|
|
687
|
+
!model.reasoning ||
|
|
688
|
+
!model.thinking?.requiresEffort ||
|
|
689
|
+
model.thinking.suppressWhenOff ||
|
|
690
|
+
(options?.reasoning !== undefined && !options.disableReasoning)
|
|
691
|
+
) {
|
|
692
|
+
return options;
|
|
693
|
+
}
|
|
694
|
+
const floor = minimumSupportedEffort(model);
|
|
695
|
+
if (floor === undefined) return options;
|
|
696
|
+
return { ...options, reasoning: floor, disableReasoning: undefined };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function mapOptionsForApi<TApi extends Api>(
|
|
700
|
+
model: Model<TApi>,
|
|
701
|
+
rawOptions?: SimpleStreamOptions,
|
|
673
702
|
apiKey?: string,
|
|
674
703
|
): OptionsForApi<TApi> {
|
|
704
|
+
const options = normalizeMandatoryReasoningOptions(model, rawOptions);
|
|
675
705
|
const base = {
|
|
676
706
|
temperature: options?.temperature,
|
|
677
707
|
topP: options?.topP,
|
|
@@ -707,6 +737,7 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
707
737
|
if (!reasoning || !model.reasoning) {
|
|
708
738
|
return castApi<"anthropic-messages">({
|
|
709
739
|
...base,
|
|
740
|
+
requestModelId: resolveWireModelId(model, undefined),
|
|
710
741
|
thinkingEnabled: false,
|
|
711
742
|
toolChoice: mapAnthropicToolChoice(options?.toolChoice),
|
|
712
743
|
thinkingDisplay: options?.hideThinkingSummary ? "omitted" : undefined,
|
|
@@ -718,6 +749,7 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
718
749
|
if (thinkingBudget <= 0) {
|
|
719
750
|
return castApi<"anthropic-messages">({
|
|
720
751
|
...base,
|
|
752
|
+
requestModelId: resolveWireModelId(model, undefined),
|
|
721
753
|
thinkingEnabled: false,
|
|
722
754
|
toolChoice: mapAnthropicToolChoice(options?.toolChoice),
|
|
723
755
|
thinkingDisplay: options?.hideThinkingSummary ? "omitted" : undefined,
|
|
@@ -731,6 +763,7 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
731
763
|
const effort = mapEffortToAnthropicAdaptiveEffort(model, reasoning);
|
|
732
764
|
return castApi<"anthropic-messages">({
|
|
733
765
|
...base,
|
|
766
|
+
requestModelId: resolveWireModelId(model, reasoning),
|
|
734
767
|
thinkingEnabled: true,
|
|
735
768
|
effort,
|
|
736
769
|
toolChoice: mapAnthropicToolChoice(options?.toolChoice),
|
|
@@ -742,6 +775,7 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
742
775
|
if (ANTHROPIC_USE_INTERLEAVED_THINKING) {
|
|
743
776
|
return castApi<"anthropic-messages">({
|
|
744
777
|
...base,
|
|
778
|
+
requestModelId: resolveWireModelId(model, reasoning),
|
|
745
779
|
thinkingEnabled: true,
|
|
746
780
|
thinkingBudgetTokens: thinkingBudget,
|
|
747
781
|
toolChoice: mapAnthropicToolChoice(options?.toolChoice),
|
|
@@ -762,6 +796,7 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
762
796
|
if (thinkingBudget <= 0) {
|
|
763
797
|
return castApi<"anthropic-messages">({
|
|
764
798
|
...base,
|
|
799
|
+
requestModelId: resolveWireModelId(model, undefined),
|
|
765
800
|
thinkingEnabled: false,
|
|
766
801
|
toolChoice: mapAnthropicToolChoice(options?.toolChoice),
|
|
767
802
|
thinkingDisplay: options?.hideThinkingSummary ? "omitted" : undefined,
|
|
@@ -771,6 +806,7 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
771
806
|
return castApi<"anthropic-messages">({
|
|
772
807
|
...base,
|
|
773
808
|
maxTokens,
|
|
809
|
+
requestModelId: resolveWireModelId(model, reasoning),
|
|
774
810
|
thinkingEnabled: true,
|
|
775
811
|
thinkingBudgetTokens: thinkingBudget,
|
|
776
812
|
toolChoice: mapAnthropicToolChoice(options?.toolChoice),
|
|
@@ -887,53 +923,57 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
887
923
|
|
|
888
924
|
case "google-gemini-cli": {
|
|
889
925
|
const reasoning = options?.reasoning;
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
926
|
+
const toolChoice = mapGoogleToolChoice(options?.toolChoice);
|
|
927
|
+
if (reasoning && model.reasoning) {
|
|
928
|
+
const effort = requireSupportedEffort(model, reasoning);
|
|
929
|
+
|
|
930
|
+
// Gemini 3+ models use thinkingLevel instead of thinkingBudget
|
|
931
|
+
if (model.thinking?.mode === "google-level") {
|
|
932
|
+
return castApi<"google-gemini-cli">({
|
|
933
|
+
...base,
|
|
934
|
+
requestModelId: resolveWireModelId(model, effort),
|
|
935
|
+
thinking: {
|
|
936
|
+
enabled: true,
|
|
937
|
+
level: mapEffortToGoogleThinkingLevel(effort),
|
|
938
|
+
},
|
|
939
|
+
toolChoice,
|
|
940
|
+
});
|
|
941
|
+
}
|
|
899
942
|
|
|
900
|
-
|
|
901
|
-
if (model.thinking?.mode === "google-level") {
|
|
902
|
-
return castApi<"google-gemini-cli">({
|
|
903
|
-
...base,
|
|
904
|
-
thinking: {
|
|
905
|
-
enabled: true,
|
|
906
|
-
level: mapEffortToGoogleThinkingLevel(effort),
|
|
907
|
-
},
|
|
908
|
-
toolChoice: mapGoogleToolChoice(options?.toolChoice),
|
|
909
|
-
});
|
|
910
|
-
}
|
|
943
|
+
let thinkingBudget = options.thinkingBudgets?.[effort] ?? GOOGLE_THINKING[effort];
|
|
911
944
|
|
|
912
|
-
|
|
945
|
+
// Caller's maxTokens is the desired output; add thinking budget on top, capped at model limit
|
|
946
|
+
const maxTokens = Math.min((base.maxTokens || 0) + thinkingBudget, model.maxTokens);
|
|
913
947
|
|
|
914
|
-
|
|
915
|
-
|
|
948
|
+
// If not enough room for thinking + output, reduce thinking budget
|
|
949
|
+
if (maxTokens <= thinkingBudget) {
|
|
950
|
+
thinkingBudget = Math.max(0, maxTokens - MIN_OUTPUT_TOKENS);
|
|
951
|
+
}
|
|
916
952
|
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
953
|
+
if (thinkingBudget > 0) {
|
|
954
|
+
return castApi<"google-gemini-cli">({
|
|
955
|
+
...base,
|
|
956
|
+
maxTokens,
|
|
957
|
+
requestModelId: resolveWireModelId(model, effort),
|
|
958
|
+
thinking: { enabled: true, budgetTokens: thinkingBudget },
|
|
959
|
+
toolChoice,
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
// Budget clamped to zero — fall through to the thinking-off path.
|
|
920
963
|
}
|
|
921
964
|
|
|
922
|
-
|
|
923
|
-
if (
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
toolChoice: mapGoogleToolChoice(options?.toolChoice),
|
|
928
|
-
});
|
|
929
|
-
} else {
|
|
930
|
-
return castApi<"google-gemini-cli">({
|
|
931
|
-
...base,
|
|
932
|
-
maxTokens,
|
|
933
|
-
thinking: { enabled: true, budgetTokens: thinkingBudget },
|
|
934
|
-
toolChoice: mapGoogleToolChoice(options?.toolChoice),
|
|
935
|
-
});
|
|
965
|
+
const thinking: GoogleGeminiCliOptions["thinking"] = { enabled: false };
|
|
966
|
+
if (model.reasoning && model.thinking?.suppressWhenOff) {
|
|
967
|
+
// CCA re-applies the per-id baked server default when the config
|
|
968
|
+
// is omitted; suppression must be explicit on the wire.
|
|
969
|
+
thinking.suppress = model.thinking.mode === "google-level" ? { level: "MINIMAL" } : { budget: 0 };
|
|
936
970
|
}
|
|
971
|
+
return castApi<"google-gemini-cli">({
|
|
972
|
+
...base,
|
|
973
|
+
requestModelId: resolveWireModelId(model, undefined),
|
|
974
|
+
thinking,
|
|
975
|
+
toolChoice,
|
|
976
|
+
});
|
|
937
977
|
}
|
|
938
978
|
|
|
939
979
|
case "google-vertex": {
|