@oh-my-pi/pi-ai 15.9.5 → 15.10.0
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 +23 -0
- package/dist/types/auth-gateway/types.d.ts +1 -1
- package/dist/types/effort.d.ts +9 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/model-thinking.d.ts +1 -9
- package/dist/types/providers/amazon-bedrock.d.ts +1 -1
- package/dist/types/providers/openai-responses-shared.d.ts +15 -0
- package/dist/types/stream.d.ts +1 -1
- package/dist/types/types.d.ts +1 -1
- package/dist/types/utils/schema/fields.d.ts +5 -0
- package/package.json +2 -2
- package/src/auth-gateway/server.ts +1 -1
- package/src/auth-gateway/types.ts +1 -1
- package/src/auth-storage.ts +37 -6
- package/src/effort.ts +16 -0
- package/src/index.ts +1 -0
- package/src/model-thinking.ts +1 -17
- package/src/provider-models/ollama.ts +1 -1
- package/src/provider-models/openai-compat.ts +19 -1
- package/src/providers/amazon-bedrock.ts +1 -1
- package/src/providers/anthropic.ts +23 -18
- package/src/providers/azure-openai-responses.ts +2 -1
- package/src/providers/openai-codex/request-transformer.ts +79 -33
- package/src/providers/openai-completions.ts +2 -1
- package/src/providers/openai-responses-shared.ts +87 -6
- package/src/providers/openai-responses.ts +2 -1
- package/src/stream.ts +1 -1
- package/src/types.ts +1 -1
- package/src/usage/google-antigravity.ts +77 -24
- package/src/utils/schema/fields.ts +16 -0
- package/src/utils/schema/normalize.ts +23 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.10.0] - 2026-06-06
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added a dependency-free `@oh-my-pi/pi-ai/effort` module exporting the `Effort` enum and `THINKING_EFFORTS`, split out of `model-thinking` so hot-path consumers can import the thinking levels without pulling in `model-thinking` and its provider-compat dependency graph. The package barrel still re-exports both names, so existing imports are unaffected.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed Antigravity usage provider emitting one bar per model instead of deduplicating by tier — a single account's 15+ model entries now collapse to one bar per tier, matching the shared-quota reality of the upstream API.
|
|
14
|
+
- Fixed Antigravity usage reports missing `email` and `accountId` in metadata, so the `/usage` display and the deduplicator can associate reports with their credentials.
|
|
15
|
+
- Fixed usage-report dedup ignoring `projectId` for Google Cloud providers, preventing duplicate credential entries from being recognized as the same account.
|
|
16
|
+
|
|
17
|
+
- Fixed Cloud Code Assist (Antigravity / Gemini CLI) rejecting the `github` tool with HTTP 400 when the `pr` parameter schema contained `anyOf: [string, array]`. The CCA mixed-type combiner collapse picked the first non-null type (`string`) but indiscriminately copied type-specific keys from variant branches — `items` from the array variant leaked onto the string-typed result, producing `{type: "string", items: {...}}` which Google's API rejects as invalid. The collapse now filters merged variant fields against the winning type's allowed key set. ([#2002](https://github.com/can1357/oh-my-pi/pull/2002))
|
|
18
|
+
- Fixed OpenAI Responses-family providers (Codex, OpenAI Responses, Azure Responses) rejecting requests with `400 No tool output found for function call …` after the user branched/navigated the session tree to a node that ends on a tool call (the tool-result child is dropped from the reconstructed history) or after a turn was aborted/crashed between the call streaming and its result persisting. The converters now synthesize a placeholder `function_call_output`/`custom_tool_call_output` immediately after any unpaired `function_call`/`custom_tool_call`, symmetric to the existing orphan-output repair, so the model still sees the call and can recover instead of the whole request 400ing.
|
|
19
|
+
- Fixed Anthropic-compatible reasoning endpoints losing prior-turn reasoning on continuation requests when they emit unsigned `thinking` blocks. `convertAnthropicMessages` treated unknown endpoints as signature-enforcing and demoted unsigned reasoning to `type: "text"`, which destabilized tool-call argument serialization on the next turn — the upstream symptom behind the `args?.ops?.map is not a function` crash reported against the `todo` tool. Official `api.anthropic.com` keeps the conservative text fallback; non-official `anthropic-messages` reasoning models now replay unsigned reasoning as native `type: "thinking"` ([#2005](https://github.com/can1357/oh-my-pi/issues/2005)).
|
|
20
|
+
|
|
21
|
+
## [15.9.67] - 2026-06-06
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Fixed llama.cpp/OpenAI Responses parallel tool calls losing arguments when `function_call_arguments.done` events omit `output_index` and `item_id`, by routing those identifierless final-argument events through the open function calls in item order. ([#1970](https://github.com/can1357/oh-my-pi/issues/1970))
|
|
26
|
+
- Fixed local Ollama (`openai-responses`) turns failing with HTTP 400 `invalid reasoning value: "minimal"` when a discovered model ran with `minimal` (or `xhigh`) thinking. Ollama's OpenAI-compatible `reasoning.effort` only accepts `high|medium|low|max|none`, so discovered reasoning-capable Ollama models now carry a `compat.reasoningEffortMap` remapping `minimal → low` and `xhigh → max`; non-reasoning models are left untouched.
|
|
27
|
+
|
|
5
28
|
## [15.9.2] - 2026-06-05
|
|
6
29
|
|
|
7
30
|
### Added
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Effort } from "../
|
|
1
|
+
import type { Effort } from "../effort";
|
|
2
2
|
import type { AssistantMessage, AssistantMessageEventStream, CacheRetention, Context, ServiceTier, TokenTaskBudget } from "../types";
|
|
3
3
|
/**
|
|
4
4
|
* Wire types for the omp auth-gateway.
|
package/dist/types/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from "./auth-broker";
|
|
|
4
4
|
export { type AuthGatewayBootOptions, type ModelResolver, startAuthGateway } from "./auth-gateway/server";
|
|
5
5
|
export * from "./auth-gateway/types";
|
|
6
6
|
export * from "./auth-storage";
|
|
7
|
+
export * from "./effort";
|
|
7
8
|
export * from "./model-cache";
|
|
8
9
|
export * from "./model-manager";
|
|
9
10
|
export * from "./model-thinking";
|
|
@@ -1,13 +1,5 @@
|
|
|
1
|
+
import { Effort } from "./effort";
|
|
1
2
|
import type { Api, Model as ApiModel } from "./types";
|
|
2
|
-
/** User-facing thinking levels, ordered least to most intensive. */
|
|
3
|
-
export declare const enum Effort {
|
|
4
|
-
Minimal = "minimal",
|
|
5
|
-
Low = "low",
|
|
6
|
-
Medium = "medium",
|
|
7
|
-
High = "high",
|
|
8
|
-
XHigh = "xhigh"
|
|
9
|
-
}
|
|
10
|
-
export declare const THINKING_EFFORTS: readonly Effort[];
|
|
11
3
|
/**
|
|
12
4
|
* Static fallback model injected when Cloudflare AI Gateway discovery
|
|
13
5
|
* returns no results. Ensures the provider always has at least one usable
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* No `@aws-sdk/*`, no `@smithy/*`, no `proxy-agent`. Proxies are honored via
|
|
7
7
|
* Bun's native `HTTPS_PROXY` support.
|
|
8
8
|
*/
|
|
9
|
-
import type { Effort } from "../
|
|
9
|
+
import type { Effort } from "../effort";
|
|
10
10
|
import type { StreamFunction, StreamOptions, ThinkingBudgets } from "../types";
|
|
11
11
|
export type BedrockThinkingDisplay = "summarized" | "omitted";
|
|
12
12
|
export interface BedrockOptions extends StreamOptions {
|
|
@@ -38,6 +38,21 @@ export declare function collectCustomCallIds(messages: ResponseInput): Set<strin
|
|
|
38
38
|
* codex provider — issue #1351 / regression of #472.
|
|
39
39
|
*/
|
|
40
40
|
export declare function repairOrphanResponsesToolOutputs(input: ResponseInput): ResponseInput;
|
|
41
|
+
/**
|
|
42
|
+
* Synthesize a placeholder `function_call_output` / `custom_tool_call_output`
|
|
43
|
+
* for every `function_call` / `custom_tool_call` whose `call_id` has no matching
|
|
44
|
+
* output later in the same input. The Responses API rejects an unpaired call
|
|
45
|
+
* with `400 No tool output found for function call …`.
|
|
46
|
+
*
|
|
47
|
+
* Orphan calls surface when the user branches/navigates the session tree to a
|
|
48
|
+
* node that ends on a tool call (the tool-result child is excluded from the
|
|
49
|
+
* reconstructed history) or when a turn is aborted/crashes after the call
|
|
50
|
+
* streamed but before its result persisted. Dropping the call would erase the
|
|
51
|
+
* assistant's action; a placeholder output keeps the call visible so the model
|
|
52
|
+
* can recover (e.g. re-issue the call). Symmetric to
|
|
53
|
+
* {@link repairOrphanResponsesToolOutputs}.
|
|
54
|
+
*/
|
|
55
|
+
export declare function repairOrphanResponsesToolCalls(input: ResponseInput): ResponseInput;
|
|
41
56
|
export declare function convertResponsesInputContent(content: string | Array<TextContent | ImageContent>, supportsImages: boolean): ResponseInputContent[] | undefined;
|
|
42
57
|
export declare function convertResponsesAssistantMessage<TApi extends Api>(assistantMsg: AssistantMessage, model: Model<TApi>, msgIndex: number, knownCallIds: Set<string>, includeThinkingSignatures?: boolean, customCallIds?: Set<string>): ResponseInput;
|
|
43
58
|
export declare function appendResponsesToolResultMessages<TApi extends Api>(messages: ResponseInput, toolResult: ToolResultMessage, model: Model<TApi>, strictResponsesPairing: boolean, knownCallIds: ReadonlySet<string>, customCallIds?: ReadonlySet<string>): void;
|
package/dist/types/stream.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Effort } from "./
|
|
1
|
+
import type { Effort } from "./effort";
|
|
2
2
|
import type { AnthropicOptions } from "./providers/anthropic";
|
|
3
3
|
import type { GoogleOptions } from "./providers/google";
|
|
4
4
|
import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli";
|
package/dist/types/types.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export interface ThinkingConfig {
|
|
|
50
50
|
}
|
|
51
51
|
export type KnownProvider = "alibaba-coding-plan" | "amazon-bedrock" | "anthropic" | "google" | "google-gemini-cli" | "google-antigravity" | "google-vertex" | "openai" | "openai-codex" | "kimi-code" | "minimax-code" | "minimax-code-cn" | "github-copilot" | "fireworks" | "firepass" | "gitlab-duo" | "cursor" | "deepseek" | "xai" | "xai-oauth" | "groq" | "cerebras" | "openrouter" | "kilo" | "vercel-ai-gateway" | "zai" | "zhipu-coding-plan" | "mistral" | "minimax" | "opencode-go" | "opencode-zen" | "synthetic" | "cloudflare-ai-gateway" | "huggingface" | "litellm" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "qianfan" | "qwen-portal" | "together" | "venice" | "vllm" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "wafer-pass" | "wafer-serverless" | "zenmux" | "lm-studio";
|
|
52
52
|
export type Provider = KnownProvider | string;
|
|
53
|
-
import type { Effort } from "./
|
|
53
|
+
import type { Effort } from "./effort";
|
|
54
54
|
/** Token budgets for each thinking level (token-based providers only) */
|
|
55
55
|
export type ThinkingBudgets = {
|
|
56
56
|
[key in Effort]?: number;
|
|
@@ -30,6 +30,11 @@ export declare const NON_STRUCTURAL_SCHEMA_KEYS: Record<string, true>;
|
|
|
30
30
|
* Used when collapsing mixed-type combiner variants for CCA Claude.
|
|
31
31
|
*/
|
|
32
32
|
export declare const CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS: Record<string, Record<string, true>>;
|
|
33
|
+
/**
|
|
34
|
+
* Flat set of every type-specific key across all CCA types.
|
|
35
|
+
* Used to identify sibling keys that need filtering during mixed-type collapse.
|
|
36
|
+
*/
|
|
37
|
+
export declare const ALL_CCA_TYPE_SPECIFIC_KEYS: Record<string, true>;
|
|
33
38
|
/**
|
|
34
39
|
* Cloud Code Assist shared schema keys allowed on any type.
|
|
35
40
|
* Used alongside CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS for CCA combiner collapsing.
|
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.
|
|
4
|
+
"version": "15.10.0",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@bufbuild/protobuf": "^2.12.0",
|
|
42
|
-
"@oh-my-pi/pi-utils": "15.
|
|
42
|
+
"@oh-my-pi/pi-utils": "15.10.0",
|
|
43
43
|
"openai": "^6.39.0",
|
|
44
44
|
"partial-json": "^0.1.7",
|
|
45
45
|
"zod": "4.4.3"
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { extractRetryHint, logger } from "@oh-my-pi/pi-utils";
|
|
21
21
|
import type { AuthStorage } from "../auth-storage";
|
|
22
|
-
import { Effort } from "../
|
|
22
|
+
import { Effort } from "../effort";
|
|
23
23
|
import * as anthropicMessages from "../providers/anthropic-messages-server";
|
|
24
24
|
import * as openaiChat from "../providers/openai-chat-server";
|
|
25
25
|
import * as openaiResponses from "../providers/openai-responses-server";
|
package/src/auth-storage.ts
CHANGED
|
@@ -36,6 +36,8 @@ import { loginOpenAICodexDevice } from "./utils/oauth/openai-codex";
|
|
|
36
36
|
import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./utils/oauth/types";
|
|
37
37
|
import { loginXiaomi, loginXiaomiTokenPlan } from "./utils/oauth/xiaomi";
|
|
38
38
|
|
|
39
|
+
const USAGE_RANKING_METRIC_EPSILON = 1e-9;
|
|
40
|
+
|
|
39
41
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
40
42
|
// Credential Types
|
|
41
43
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -606,6 +608,14 @@ function hasOpenAICodexProPlan(report: UsageReport | null): boolean {
|
|
|
606
608
|
return getUsagePlanType(report)?.includes("pro") === true;
|
|
607
609
|
}
|
|
608
610
|
|
|
611
|
+
function compareUsageRankingMetric(left: number, right: number): number {
|
|
612
|
+
if (left === right) return 0;
|
|
613
|
+
if (!Number.isFinite(left) || !Number.isFinite(right)) return left < right ? -1 : 1;
|
|
614
|
+
const delta = left - right;
|
|
615
|
+
const tolerance = Math.max(USAGE_RANKING_METRIC_EPSILON, Math.max(Math.abs(left), Math.abs(right)) * 0.000001);
|
|
616
|
+
return Math.abs(delta) <= tolerance ? 0 : delta;
|
|
617
|
+
}
|
|
618
|
+
|
|
609
619
|
function resolveDefaultUsageProvider(provider: Provider): UsageProvider | undefined {
|
|
610
620
|
return DEFAULT_USAGE_PROVIDER_MAP.get(provider);
|
|
611
621
|
}
|
|
@@ -2288,6 +2298,16 @@ export class AuthStorage {
|
|
|
2288
2298
|
return undefined;
|
|
2289
2299
|
}
|
|
2290
2300
|
|
|
2301
|
+
#getUsageReportScopeProjectId(report: UsageReport): string | undefined {
|
|
2302
|
+
const ids = new Set<string>();
|
|
2303
|
+
for (const limit of report.limits) {
|
|
2304
|
+
const projectId = limit.scope.projectId?.trim();
|
|
2305
|
+
if (projectId) ids.add(projectId);
|
|
2306
|
+
}
|
|
2307
|
+
if (ids.size === 1) return [...ids][0];
|
|
2308
|
+
return undefined;
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2291
2311
|
#getUsageReportIdentifiers(report: UsageReport): string[] {
|
|
2292
2312
|
const identifiers: string[] = [];
|
|
2293
2313
|
const email = this.#getUsageReportMetadataValue(report, "email");
|
|
@@ -2295,6 +2315,11 @@ export class AuthStorage {
|
|
|
2295
2315
|
if (report.provider === "openai-codex" || report.provider === "anthropic") {
|
|
2296
2316
|
return identifiers.map(identifier => `${report.provider}:${identifier.toLowerCase()}`);
|
|
2297
2317
|
}
|
|
2318
|
+
const projectId =
|
|
2319
|
+
this.#getUsageReportMetadataValue(report, "projectId") ?? this.#getUsageReportScopeProjectId(report);
|
|
2320
|
+
// Only add project as a fallback when no email is available — two users
|
|
2321
|
+
// with different emails on the same GCP project must not merge.
|
|
2322
|
+
if (projectId && !email) identifiers.push(`project:${projectId}`);
|
|
2298
2323
|
const accountId = this.#getUsageReportMetadataValue(report, "accountId");
|
|
2299
2324
|
if (accountId) identifiers.push(`account:${accountId}`);
|
|
2300
2325
|
const account = this.#getUsageReportMetadataValue(report, "account");
|
|
@@ -2781,12 +2806,14 @@ export class AuthStorage {
|
|
|
2781
2806
|
return left.planPriority - right.planPriority;
|
|
2782
2807
|
}
|
|
2783
2808
|
if (left.hasPriorityBoost !== right.hasPriorityBoost) return left.hasPriorityBoost ? -1 : 1;
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
if (
|
|
2788
|
-
|
|
2789
|
-
if (
|
|
2809
|
+
let metric = compareUsageRankingMetric(left.secondaryDrainRate, right.secondaryDrainRate);
|
|
2810
|
+
if (metric !== 0) return metric;
|
|
2811
|
+
metric = compareUsageRankingMetric(left.secondaryUsed, right.secondaryUsed);
|
|
2812
|
+
if (metric !== 0) return metric;
|
|
2813
|
+
metric = compareUsageRankingMetric(left.primaryDrainRate, right.primaryDrainRate);
|
|
2814
|
+
if (metric !== 0) return metric;
|
|
2815
|
+
metric = compareUsageRankingMetric(left.primaryUsed, right.primaryUsed);
|
|
2816
|
+
if (metric !== 0) return metric;
|
|
2790
2817
|
return 0;
|
|
2791
2818
|
}
|
|
2792
2819
|
|
|
@@ -3932,6 +3959,8 @@ function resolveProviderCredentialIdentityKey(provider: string, identifiers: str
|
|
|
3932
3959
|
const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:"));
|
|
3933
3960
|
if (accountIdentifier) return accountIdentifier;
|
|
3934
3961
|
if (emailIdentifier) return emailIdentifier;
|
|
3962
|
+
const projectIdentifier = identifiers.find(identifier => identifier.startsWith("project:"));
|
|
3963
|
+
if (projectIdentifier) return projectIdentifier;
|
|
3935
3964
|
return null;
|
|
3936
3965
|
}
|
|
3937
3966
|
|
|
@@ -3967,6 +3996,8 @@ function extractOAuthCredentialIdentifiers(credential: OAuthCredential): string[
|
|
|
3967
3996
|
if (accountId) identifiers.add(`account:${accountId}`);
|
|
3968
3997
|
const email = normalizeStoredEmail(credential.email);
|
|
3969
3998
|
if (email) identifiers.add(`email:${email}`);
|
|
3999
|
+
const projectId = normalizeStoredAccountId(credential.projectId);
|
|
4000
|
+
if (projectId) identifiers.add(`project:${projectId}`);
|
|
3970
4001
|
const accessIdentifiers = extractOAuthTokenIdentifiers(credential.access) ?? [];
|
|
3971
4002
|
for (const identifier of accessIdentifiers) {
|
|
3972
4003
|
identifiers.add(identifier);
|
package/src/effort.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** User-facing thinking levels, ordered least to most intensive. */
|
|
2
|
+
export const enum Effort {
|
|
3
|
+
Minimal = "minimal",
|
|
4
|
+
Low = "low",
|
|
5
|
+
Medium = "medium",
|
|
6
|
+
High = "high",
|
|
7
|
+
XHigh = "xhigh",
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const THINKING_EFFORTS: readonly Effort[] = [
|
|
11
|
+
Effort.Minimal,
|
|
12
|
+
Effort.Low,
|
|
13
|
+
Effort.Medium,
|
|
14
|
+
Effort.High,
|
|
15
|
+
Effort.XHigh,
|
|
16
|
+
];
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from "./auth-broker";
|
|
|
4
4
|
export { type AuthGatewayBootOptions, type ModelResolver, startAuthGateway } from "./auth-gateway/server";
|
|
5
5
|
export * from "./auth-gateway/types";
|
|
6
6
|
export * from "./auth-storage";
|
|
7
|
+
export * from "./effort";
|
|
7
8
|
export * from "./model-cache";
|
|
8
9
|
export * from "./model-manager";
|
|
9
10
|
export * from "./model-thinking";
|
package/src/model-thinking.ts
CHANGED
|
@@ -1,23 +1,7 @@
|
|
|
1
|
+
import { Effort, THINKING_EFFORTS } from "./effort";
|
|
1
2
|
import { resolveOpenAICompat } from "./providers/openai-completions-compat";
|
|
2
3
|
import type { Api, Model as ApiModel, ThinkingConfig } from "./types";
|
|
3
4
|
|
|
4
|
-
/** User-facing thinking levels, ordered least to most intensive. */
|
|
5
|
-
export const enum Effort {
|
|
6
|
-
Minimal = "minimal",
|
|
7
|
-
Low = "low",
|
|
8
|
-
Medium = "medium",
|
|
9
|
-
High = "high",
|
|
10
|
-
XHigh = "xhigh",
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export const THINKING_EFFORTS: readonly Effort[] = [
|
|
14
|
-
Effort.Minimal,
|
|
15
|
-
Effort.Low,
|
|
16
|
-
Effort.Medium,
|
|
17
|
-
Effort.High,
|
|
18
|
-
Effort.XHigh,
|
|
19
|
-
];
|
|
20
|
-
|
|
21
5
|
const DEFAULT_REASONING_EFFORTS: readonly Effort[] = [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High];
|
|
22
6
|
const DEFAULT_REASONING_EFFORTS_WITH_XHIGH: readonly Effort[] = [
|
|
23
7
|
Effort.Minimal,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { fetchWithRetry } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import { Effort } from "../effort";
|
|
2
3
|
import type { ModelManagerOptions } from "../model-manager";
|
|
3
|
-
import { Effort } from "../model-thinking";
|
|
4
4
|
import type { ThinkingConfig } from "../types";
|
|
5
5
|
import { createBundledReferenceMap, createReferenceResolver } from "./bundled-references";
|
|
6
6
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { Effort } from "../effort";
|
|
1
2
|
import type { ModelManagerOptions } from "../model-manager";
|
|
2
|
-
import { Effort } from "../model-thinking";
|
|
3
3
|
import { getBundledModels } from "../models";
|
|
4
4
|
import type { Api, Model, Provider, ThinkingConfig } from "../types";
|
|
5
5
|
import { isAnthropicOAuthToken, isRecord, toBoolean, toNumber, toPositiveNumber } from "../utils";
|
|
@@ -242,6 +242,22 @@ async function fetchOllamaNativeModels(
|
|
|
242
242
|
const OLLAMA_FALLBACK_CONTEXT_WINDOW = 128_000;
|
|
243
243
|
/** Cap max output tokens at a value that matches OMP's other openai-responses defaults. */
|
|
244
244
|
const OLLAMA_DEFAULT_MAX_TOKENS = 8192;
|
|
245
|
+
/**
|
|
246
|
+
* Ollama's OpenAI-compatible `reasoning.effort` only accepts
|
|
247
|
+
* `high|medium|low|max|none`; passing OMP's `minimal`/`xhigh` levels verbatim
|
|
248
|
+
* makes the server reject the turn with HTTP 400 `invalid reasoning value`.
|
|
249
|
+
* Map the two unsupported levels onto the closest accepted ones (`low`/`max`).
|
|
250
|
+
*/
|
|
251
|
+
const OLLAMA_REASONING_EFFORT_MAP = { minimal: "low", xhigh: "max" } as const;
|
|
252
|
+
|
|
253
|
+
/** Stamp the Ollama reasoning-effort map onto a reasoning-capable model. */
|
|
254
|
+
function applyOllamaReasoningCompat(model: Model<"openai-responses">): void {
|
|
255
|
+
if (!model.reasoning) return;
|
|
256
|
+
model.compat = {
|
|
257
|
+
...model.compat,
|
|
258
|
+
reasoningEffortMap: { ...OLLAMA_REASONING_EFFORT_MAP, ...model.compat?.reasoningEffortMap },
|
|
259
|
+
};
|
|
260
|
+
}
|
|
245
261
|
|
|
246
262
|
interface OllamaResolvedMetadata {
|
|
247
263
|
contextWindow: number;
|
|
@@ -1357,12 +1373,14 @@ export function ollamaModelManagerOptions(config?: OllamaModelManagerConfig): Mo
|
|
|
1357
1373
|
if (metadata.input) {
|
|
1358
1374
|
model.input = metadata.input;
|
|
1359
1375
|
}
|
|
1376
|
+
applyOllamaReasoningCompat(model);
|
|
1360
1377
|
}),
|
|
1361
1378
|
);
|
|
1362
1379
|
return openAiCompatible;
|
|
1363
1380
|
}
|
|
1364
1381
|
const nativeFallback = await fetchOllamaNativeModels(baseUrl, resolveMetadata);
|
|
1365
1382
|
if (nativeFallback && nativeFallback.length > 0) {
|
|
1383
|
+
for (const model of nativeFallback) applyOllamaReasoningCompat(model);
|
|
1366
1384
|
return nativeFallback;
|
|
1367
1385
|
}
|
|
1368
1386
|
return openAiCompatible;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { $env, $flag, extractHttpStatusFromError, fetchWithRetry } from "@oh-my-pi/pi-utils";
|
|
11
|
-
import type { Effort } from "../
|
|
11
|
+
import type { Effort } from "../effort";
|
|
12
12
|
import { mapEffortToAnthropicAdaptiveEffort, requireSupportedEffort } from "../model-thinking";
|
|
13
13
|
import { calculateCost } from "../models";
|
|
14
14
|
import type {
|
|
@@ -1778,16 +1778,12 @@ type SystemBlockOptions = {
|
|
|
1778
1778
|
cacheControl?: AnthropicCacheControl;
|
|
1779
1779
|
};
|
|
1780
1780
|
|
|
1781
|
-
function withGlobalCacheScope(cacheControl: AnthropicCacheControl): AnthropicCacheControl {
|
|
1782
|
-
return { ...cacheControl, scope: "global" };
|
|
1783
|
-
}
|
|
1784
|
-
|
|
1785
1781
|
function applyClaudeCodeSystemCache(
|
|
1786
1782
|
blocks: AnthropicSystemBlock[],
|
|
1787
1783
|
cacheControl: AnthropicCacheControl | undefined,
|
|
1788
1784
|
): number {
|
|
1789
1785
|
if (!cacheControl || blocks.length <= 2) return 0;
|
|
1790
|
-
blocks[2] = { ...blocks[2], cache_control:
|
|
1786
|
+
blocks[2] = { ...blocks[2], cache_control: cacheControl };
|
|
1791
1787
|
if (blocks.length === 3) return 1;
|
|
1792
1788
|
const lastIndex = blocks.length - 1;
|
|
1793
1789
|
blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cacheControl };
|
|
@@ -2431,22 +2427,31 @@ function isZaiAnthropicEndpoint(model: Model<"anthropic-messages">): boolean {
|
|
|
2431
2427
|
}
|
|
2432
2428
|
|
|
2433
2429
|
/**
|
|
2434
|
-
* Returns true
|
|
2435
|
-
*
|
|
2436
|
-
*
|
|
2437
|
-
*
|
|
2430
|
+
* Returns true when unsigned `thinking` blocks from prior assistant turns should
|
|
2431
|
+
* be replayed as Anthropic-native thinking instead of demoted to text.
|
|
2432
|
+
*
|
|
2433
|
+
* Official Anthropic (matched via `isAnthropicApiBaseUrl`, which intentionally
|
|
2434
|
+
* treats a missing baseUrl as official since `resolveAnthropicBaseUrl` routes
|
|
2435
|
+
* it to `https://api.anthropic.com`) enforces signature-based thinking-chain
|
|
2436
|
+
* integrity, so unsigned blocks must remain text there. Anthropic-compatible
|
|
2437
|
+
* reasoning endpoints commonly emit unsigned thinking blocks while still
|
|
2438
|
+
* expecting them back as `type: "thinking"` on continuation; demoting them
|
|
2439
|
+
* loses the model's reasoning chain and can destabilize the next tool-call
|
|
2440
|
+
* arguments (#2005). Known non-signing hosts are also preserved for
|
|
2441
|
+
* compatibility.
|
|
2438
2442
|
*/
|
|
2439
|
-
function
|
|
2440
|
-
// Known non-signing providers
|
|
2443
|
+
function shouldReplayUnsignedThinking(model: Model<"anthropic-messages">): boolean {
|
|
2441
2444
|
if (model.provider === "zai" || model.provider === "deepseek") return true;
|
|
2442
2445
|
const baseUrl = model.baseUrl;
|
|
2443
|
-
if (
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2446
|
+
if (baseUrl) {
|
|
2447
|
+
try {
|
|
2448
|
+
const hostname = new URL(baseUrl).hostname.toLowerCase();
|
|
2449
|
+
if (hostname === "api.deepseek.com" || hostname.endsWith(".deepseek.com")) return true;
|
|
2450
|
+
} catch {
|
|
2451
|
+
// Fall through to the protocol-level reasoning rule below.
|
|
2452
|
+
}
|
|
2449
2453
|
}
|
|
2454
|
+
return model.reasoning && !isAnthropicApiBaseUrl(baseUrl);
|
|
2450
2455
|
}
|
|
2451
2456
|
|
|
2452
2457
|
function buildToolResultBlock(model: Model<"anthropic-messages">, msg: ToolResultMessage): ContentBlockParam {
|
|
@@ -2537,7 +2542,7 @@ export function convertAnthropicMessages(
|
|
|
2537
2542
|
}
|
|
2538
2543
|
if (block.thinking.trim().length === 0) continue;
|
|
2539
2544
|
if (!block.thinkingSignature || block.thinkingSignature.trim().length === 0) {
|
|
2540
|
-
if (
|
|
2545
|
+
if (shouldReplayUnsignedThinking(model)) {
|
|
2541
2546
|
blocks.push({
|
|
2542
2547
|
type: "thinking",
|
|
2543
2548
|
thinking: block.thinking.toWellFormed(),
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
isOpenAIResponsesProgressEvent,
|
|
41
41
|
normalizeResponsesToolCallIdForTransform,
|
|
42
42
|
processResponsesStream,
|
|
43
|
+
repairOrphanResponsesToolCalls,
|
|
43
44
|
} from "./openai-responses-shared";
|
|
44
45
|
import { transformMessages } from "./transform-messages";
|
|
45
46
|
|
|
@@ -347,7 +348,7 @@ function convertMessages(
|
|
|
347
348
|
msgIndex++;
|
|
348
349
|
}
|
|
349
350
|
|
|
350
|
-
return messages;
|
|
351
|
+
return repairOrphanResponsesToolCalls(messages);
|
|
351
352
|
}
|
|
352
353
|
|
|
353
354
|
function convertTools(tools: Tool[]): OpenAITool[] {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Effort } from "../../
|
|
1
|
+
import type { Effort } from "../../effort";
|
|
2
2
|
import { requireSupportedEffort } from "../../model-thinking";
|
|
3
3
|
import type { Api, Model } from "../../types";
|
|
4
4
|
|
|
@@ -76,6 +76,83 @@ function filterInput(input: InputItem[] | undefined): InputItem[] | undefined {
|
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
const CODEX_ORPHAN_OUTPUT_LIMIT = 16_000;
|
|
80
|
+
/** Placeholder output for a tool call whose result never landed in the input. */
|
|
81
|
+
const CODEX_INTERRUPTED_TOOL_OUTPUT =
|
|
82
|
+
"[No tool output recorded: the tool call was interrupted before it produced a result.]";
|
|
83
|
+
|
|
84
|
+
function orphanFunctionOutputToMessage(item: InputItem, callId: string): InputItem {
|
|
85
|
+
const itemRecord = item as unknown as Record<string, unknown>;
|
|
86
|
+
const toolName = typeof itemRecord.name === "string" ? itemRecord.name : "tool";
|
|
87
|
+
let text = "";
|
|
88
|
+
try {
|
|
89
|
+
const output = itemRecord.output;
|
|
90
|
+
text = typeof output === "string" ? output : JSON.stringify(output);
|
|
91
|
+
} catch {
|
|
92
|
+
text = String(itemRecord.output ?? "");
|
|
93
|
+
}
|
|
94
|
+
if (text.length > CODEX_ORPHAN_OUTPUT_LIMIT) {
|
|
95
|
+
text = `${text.slice(0, CODEX_ORPHAN_OUTPUT_LIMIT)}\n...[truncated]`;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
type: "message",
|
|
99
|
+
role: "assistant",
|
|
100
|
+
content: `[Previous ${toolName} result; call_id=${callId}]: ${text}`,
|
|
101
|
+
} as InputItem;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Repair both halves of unpaired tool exchanges so the Responses input grammar
|
|
106
|
+
* stays valid — the API rejects either orphan with a 400:
|
|
107
|
+
*
|
|
108
|
+
* - `function_call_output` with no matching `function_call` → folded into an
|
|
109
|
+
* assistant message (`400 No tool call found for function call output …`).
|
|
110
|
+
* Regression of #472 / #1351.
|
|
111
|
+
* - `function_call` / `custom_tool_call` with no matching `*_output` → a
|
|
112
|
+
* placeholder output is synthesized immediately after the call
|
|
113
|
+
* (`400 No tool output found for function call …`). Hit when the user
|
|
114
|
+
* branches/navigates the session tree to a node that ends on a tool call (the
|
|
115
|
+
* tool-result child is dropped from the reconstructed history) or when a turn
|
|
116
|
+
* is aborted/crashes after the call streamed but before its result persisted.
|
|
117
|
+
*/
|
|
118
|
+
function repairToolCallPairs(input: InputItem[]): InputItem[] {
|
|
119
|
+
const callIds = new Set<string>();
|
|
120
|
+
const outputCallIds = new Set<string>();
|
|
121
|
+
for (const item of input) {
|
|
122
|
+
const callId = typeof item.call_id === "string" ? item.call_id : undefined;
|
|
123
|
+
if (callId === undefined) continue;
|
|
124
|
+
if (item.type === "function_call" || item.type === "custom_tool_call") callIds.add(callId);
|
|
125
|
+
else if (item.type === "function_call_output" || item.type === "custom_tool_call_output") {
|
|
126
|
+
outputCallIds.add(callId);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const repaired: InputItem[] = [];
|
|
131
|
+
for (const item of input) {
|
|
132
|
+
const callId = typeof item.call_id === "string" ? item.call_id : undefined;
|
|
133
|
+
|
|
134
|
+
if (item.type === "function_call_output" && callId !== undefined && !callIds.has(callId)) {
|
|
135
|
+
repaired.push(orphanFunctionOutputToMessage(item, callId));
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
repaired.push(item);
|
|
140
|
+
|
|
141
|
+
if (
|
|
142
|
+
(item.type === "function_call" || item.type === "custom_tool_call") &&
|
|
143
|
+
callId !== undefined &&
|
|
144
|
+
!outputCallIds.has(callId)
|
|
145
|
+
) {
|
|
146
|
+
repaired.push({
|
|
147
|
+
type: item.type === "custom_tool_call" ? "custom_tool_call_output" : "function_call_output",
|
|
148
|
+
call_id: callId,
|
|
149
|
+
output: CODEX_INTERRUPTED_TOOL_OUTPUT,
|
|
150
|
+
} as InputItem);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return repaired;
|
|
154
|
+
}
|
|
155
|
+
|
|
79
156
|
export async function transformRequestBody(
|
|
80
157
|
body: RequestBody,
|
|
81
158
|
model: Model<Api>,
|
|
@@ -87,39 +164,8 @@ export async function transformRequestBody(
|
|
|
87
164
|
|
|
88
165
|
if (body.input && Array.isArray(body.input)) {
|
|
89
166
|
body.input = filterInput(body.input);
|
|
90
|
-
|
|
91
167
|
if (body.input) {
|
|
92
|
-
|
|
93
|
-
body.input
|
|
94
|
-
.filter(item => item.type === "function_call" && typeof item.call_id === "string")
|
|
95
|
-
.map(item => item.call_id as string),
|
|
96
|
-
);
|
|
97
|
-
|
|
98
|
-
body.input = body.input.map(item => {
|
|
99
|
-
if (item.type === "function_call_output" && typeof item.call_id === "string") {
|
|
100
|
-
const callId = item.call_id as string;
|
|
101
|
-
if (!functionCallIds.has(callId)) {
|
|
102
|
-
const itemRecord = item as unknown as Record<string, unknown>;
|
|
103
|
-
const toolName = typeof itemRecord.name === "string" ? itemRecord.name : "tool";
|
|
104
|
-
let text = "";
|
|
105
|
-
try {
|
|
106
|
-
const output = itemRecord.output;
|
|
107
|
-
text = typeof output === "string" ? output : JSON.stringify(output);
|
|
108
|
-
} catch {
|
|
109
|
-
text = String(itemRecord.output ?? "");
|
|
110
|
-
}
|
|
111
|
-
if (text.length > 16000) {
|
|
112
|
-
text = `${text.slice(0, 16000)}\n...[truncated]`;
|
|
113
|
-
}
|
|
114
|
-
return {
|
|
115
|
-
type: "message",
|
|
116
|
-
role: "assistant",
|
|
117
|
-
content: `[Previous ${toolName} result; call_id=${callId}]: ${text}`,
|
|
118
|
-
} as InputItem;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return item;
|
|
122
|
-
});
|
|
168
|
+
body.input = repairToolCallPairs(body.input);
|
|
123
169
|
}
|
|
124
170
|
}
|
|
125
171
|
|
|
@@ -10,7 +10,8 @@ import type {
|
|
|
10
10
|
ChatCompletionToolMessageParam,
|
|
11
11
|
} from "openai/resources/chat/completions";
|
|
12
12
|
import packageJson from "../../package.json" with { type: "json" };
|
|
13
|
-
import {
|
|
13
|
+
import type { Effort } from "../effort";
|
|
14
|
+
import { getSupportedEfforts } from "../model-thinking";
|
|
14
15
|
import { calculateCost } from "../models";
|
|
15
16
|
import { getEnvApiKey } from "../stream";
|
|
16
17
|
import {
|
|
@@ -212,6 +212,59 @@ export function repairOrphanResponsesToolOutputs(input: ResponseInput): Response
|
|
|
212
212
|
});
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
/** Placeholder output for a tool call whose result is absent from the input. */
|
|
216
|
+
const ORPHAN_TOOL_CALL_PLACEHOLDER =
|
|
217
|
+
"[No tool output recorded: the tool call was interrupted before it produced a result.]";
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Synthesize a placeholder `function_call_output` / `custom_tool_call_output`
|
|
221
|
+
* for every `function_call` / `custom_tool_call` whose `call_id` has no matching
|
|
222
|
+
* output later in the same input. The Responses API rejects an unpaired call
|
|
223
|
+
* with `400 No tool output found for function call …`.
|
|
224
|
+
*
|
|
225
|
+
* Orphan calls surface when the user branches/navigates the session tree to a
|
|
226
|
+
* node that ends on a tool call (the tool-result child is excluded from the
|
|
227
|
+
* reconstructed history) or when a turn is aborted/crashes after the call
|
|
228
|
+
* streamed but before its result persisted. Dropping the call would erase the
|
|
229
|
+
* assistant's action; a placeholder output keeps the call visible so the model
|
|
230
|
+
* can recover (e.g. re-issue the call). Symmetric to
|
|
231
|
+
* {@link repairOrphanResponsesToolOutputs}.
|
|
232
|
+
*/
|
|
233
|
+
export function repairOrphanResponsesToolCalls(input: ResponseInput): ResponseInput {
|
|
234
|
+
const outputCallIds = new Set<string>();
|
|
235
|
+
for (const item of input) {
|
|
236
|
+
const t = (item as { type?: string }).type;
|
|
237
|
+
if (t !== "function_call_output" && t !== "custom_tool_call_output") continue;
|
|
238
|
+
const callId = (item as { call_id?: unknown }).call_id;
|
|
239
|
+
if (typeof callId === "string") outputCallIds.add(callId);
|
|
240
|
+
}
|
|
241
|
+
let hasOrphan = false;
|
|
242
|
+
for (const item of input) {
|
|
243
|
+
const t = (item as { type?: string }).type;
|
|
244
|
+
if (t !== "function_call" && t !== "custom_tool_call") continue;
|
|
245
|
+
const callId = (item as { call_id?: unknown }).call_id;
|
|
246
|
+
if (typeof callId === "string" && !outputCallIds.has(callId)) {
|
|
247
|
+
hasOrphan = true;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (!hasOrphan) return input;
|
|
252
|
+
const repaired: ResponseInput = [];
|
|
253
|
+
for (const item of input) {
|
|
254
|
+
repaired.push(item);
|
|
255
|
+
const t = (item as { type?: string }).type;
|
|
256
|
+
if (t !== "function_call" && t !== "custom_tool_call") continue;
|
|
257
|
+
const callId = (item as { call_id?: unknown }).call_id;
|
|
258
|
+
if (typeof callId !== "string" || outputCallIds.has(callId)) continue;
|
|
259
|
+
repaired.push({
|
|
260
|
+
type: t === "custom_tool_call" ? "custom_tool_call_output" : "function_call_output",
|
|
261
|
+
call_id: callId,
|
|
262
|
+
output: ORPHAN_TOOL_CALL_PLACEHOLDER,
|
|
263
|
+
} as ResponseInput[number]);
|
|
264
|
+
}
|
|
265
|
+
return repaired;
|
|
266
|
+
}
|
|
267
|
+
|
|
215
268
|
export function convertResponsesInputContent(
|
|
216
269
|
content: string | Array<TextContent | ImageContent>,
|
|
217
270
|
supportsImages: boolean,
|
|
@@ -395,7 +448,7 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
395
448
|
model: Model<TApi>,
|
|
396
449
|
options?: ProcessResponsesStreamOptions,
|
|
397
450
|
): Promise<void> {
|
|
398
|
-
type StreamingToolCallBlock = ToolCall & { partialJson: string; lastParseLen?: number };
|
|
451
|
+
type StreamingToolCallBlock = ToolCall & { partialJson: string; lastParseLen?: number; argumentsDone?: boolean };
|
|
399
452
|
interface StreamingItem {
|
|
400
453
|
item: ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | ResponseCustomToolCall;
|
|
401
454
|
block: ThinkingContent | TextContent | StreamingToolCallBlock;
|
|
@@ -409,6 +462,7 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
409
462
|
const openItemsByOutputIndex = new Map<number, StreamingItem>();
|
|
410
463
|
const openItemsByItemId = new Map<string, StreamingItem>();
|
|
411
464
|
let lastOpenItem: StreamingItem | null = null;
|
|
465
|
+
const openItemsInOrder: StreamingItem[] = [];
|
|
412
466
|
|
|
413
467
|
const registerOpenItem = (
|
|
414
468
|
outputIndex: number | undefined,
|
|
@@ -417,6 +471,7 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
417
471
|
): void => {
|
|
418
472
|
if (typeof outputIndex === "number") openItemsByOutputIndex.set(outputIndex, entry);
|
|
419
473
|
if (itemId) openItemsByItemId.set(itemId, entry);
|
|
474
|
+
openItemsInOrder.push(entry);
|
|
420
475
|
lastOpenItem = entry;
|
|
421
476
|
};
|
|
422
477
|
const lookupOpenItem = (event: { output_index?: number; item_id?: string }): StreamingItem | undefined => {
|
|
@@ -431,6 +486,24 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
431
486
|
// Fallback for tests / mock providers that omit identifiers on stream events.
|
|
432
487
|
return lastOpenItem ?? undefined;
|
|
433
488
|
};
|
|
489
|
+
const hasOpenItemKey = (event: { output_index?: number; item_id?: string }): boolean =>
|
|
490
|
+
typeof event.output_index === "number" || event.item_id !== undefined;
|
|
491
|
+
const lookupOpenFunctionCallItem = (event: {
|
|
492
|
+
output_index?: number;
|
|
493
|
+
item_id?: string;
|
|
494
|
+
}): StreamingItem | undefined => {
|
|
495
|
+
if (hasOpenItemKey(event)) return lookupOpenItem(event);
|
|
496
|
+
for (const candidate of openItemsInOrder) {
|
|
497
|
+
if (
|
|
498
|
+
candidate.item.type === "function_call" &&
|
|
499
|
+
candidate.block.type === "toolCall" &&
|
|
500
|
+
!candidate.block.argumentsDone
|
|
501
|
+
) {
|
|
502
|
+
return candidate;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return lastOpenItem?.item.type === "function_call" ? lastOpenItem : undefined;
|
|
506
|
+
};
|
|
434
507
|
const closeOpenItem = (
|
|
435
508
|
outputIndex: number | undefined,
|
|
436
509
|
itemId: string | undefined,
|
|
@@ -438,6 +511,10 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
438
511
|
): void => {
|
|
439
512
|
if (typeof outputIndex === "number") openItemsByOutputIndex.delete(outputIndex);
|
|
440
513
|
if (itemId) openItemsByItemId.delete(itemId);
|
|
514
|
+
if (entry) {
|
|
515
|
+
const index = openItemsInOrder.indexOf(entry);
|
|
516
|
+
if (index >= 0) openItemsInOrder.splice(index, 1);
|
|
517
|
+
}
|
|
441
518
|
if (entry && lastOpenItem === entry) lastOpenItem = null;
|
|
442
519
|
};
|
|
443
520
|
const contentIndexOf = (block: ThinkingContent | TextContent | StreamingToolCallBlock): number =>
|
|
@@ -584,7 +661,7 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
584
661
|
}
|
|
585
662
|
}
|
|
586
663
|
} else if (event.type === "response.function_call_arguments.delta") {
|
|
587
|
-
const entry =
|
|
664
|
+
const entry = lookupOpenFunctionCallItem(event);
|
|
588
665
|
if (entry?.item.type === "function_call" && entry.block.type === "toolCall") {
|
|
589
666
|
const block = entry.block;
|
|
590
667
|
block.partialJson += event.delta;
|
|
@@ -601,11 +678,12 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
601
678
|
});
|
|
602
679
|
}
|
|
603
680
|
} else if (event.type === "response.function_call_arguments.done") {
|
|
604
|
-
const entry =
|
|
681
|
+
const entry = lookupOpenFunctionCallItem(event);
|
|
605
682
|
if (entry?.item.type === "function_call" && entry.block.type === "toolCall") {
|
|
606
683
|
const block = entry.block;
|
|
607
684
|
block.partialJson = event.arguments;
|
|
608
685
|
block.arguments = parseStreamingJson(block.partialJson);
|
|
686
|
+
block.argumentsDone = true;
|
|
609
687
|
delete (block as { partialJson?: string }).partialJson;
|
|
610
688
|
delete (block as { lastParseLen?: number }).lastParseLen;
|
|
611
689
|
}
|
|
@@ -668,9 +746,11 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
668
746
|
closeOpenItem(event.output_index, item.id, entry);
|
|
669
747
|
} else if (item.type === "function_call") {
|
|
670
748
|
const block = entry?.block.type === "toolCall" ? entry.block : undefined;
|
|
671
|
-
const args = block?.
|
|
672
|
-
?
|
|
673
|
-
:
|
|
749
|
+
const args = block?.argumentsDone
|
|
750
|
+
? block.arguments
|
|
751
|
+
: block?.partialJson
|
|
752
|
+
? parseStreamingJson(block.partialJson)
|
|
753
|
+
: parseStreamingJson(item.arguments || "{}");
|
|
674
754
|
const toolCall: ToolCall = {
|
|
675
755
|
type: "toolCall",
|
|
676
756
|
id: encodeResponsesToolCallId(item.call_id, item.id),
|
|
@@ -685,6 +765,7 @@ export async function processResponsesStream<TApi extends Api>(
|
|
|
685
765
|
block.arguments = args;
|
|
686
766
|
delete (block as { partialJson?: string }).partialJson;
|
|
687
767
|
delete (block as { lastParseLen?: number }).lastParseLen;
|
|
768
|
+
delete (block as { argumentsDone?: boolean }).argumentsDone;
|
|
688
769
|
}
|
|
689
770
|
const contentIndex = block ? contentIndexOf(block) : output.content.length - 1;
|
|
690
771
|
closeOpenItem(event.output_index, item.id, entry);
|
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
isOpenAIResponsesProgressEvent,
|
|
63
63
|
normalizeResponsesToolCallIdForTransform,
|
|
64
64
|
processResponsesStream,
|
|
65
|
+
repairOrphanResponsesToolCalls,
|
|
65
66
|
repairOrphanResponsesToolOutputs,
|
|
66
67
|
} from "./openai-responses-shared";
|
|
67
68
|
import { transformMessages } from "./transform-messages";
|
|
@@ -614,7 +615,7 @@ function convertConversationMessages(
|
|
|
614
615
|
msgIndex++;
|
|
615
616
|
}
|
|
616
617
|
|
|
617
|
-
return repairOrphanResponsesToolOutputs(messages);
|
|
618
|
+
return repairOrphanResponsesToolCalls(repairOrphanResponsesToolOutputs(messages));
|
|
618
619
|
}
|
|
619
620
|
|
|
620
621
|
/**
|
package/src/stream.ts
CHANGED
|
@@ -3,7 +3,7 @@ import * as os from "node:os";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { $env, $pickenv, extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
|
|
5
5
|
import { getCustomApi } from "./api-registry";
|
|
6
|
-
import type { Effort } from "./
|
|
6
|
+
import type { Effort } from "./effort";
|
|
7
7
|
import {
|
|
8
8
|
mapEffortToAnthropicAdaptiveEffort,
|
|
9
9
|
mapEffortToGoogleThinkingLevel,
|
package/src/types.ts
CHANGED
|
@@ -151,7 +151,7 @@ export type KnownProvider =
|
|
|
151
151
|
| "lm-studio";
|
|
152
152
|
export type Provider = KnownProvider | string;
|
|
153
153
|
|
|
154
|
-
import type { Effort } from "./
|
|
154
|
+
import type { Effort } from "./effort";
|
|
155
155
|
|
|
156
156
|
/** Token budgets for each thinking level (token-based providers only) */
|
|
157
157
|
export type ThinkingBudgets = { [key in Effort]?: number };
|
|
@@ -148,10 +148,17 @@ async function fetchAntigravityUsage(params: UsageFetchParams, ctx: UsageFetchCo
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
const data = (await response.json()) as AntigravityUsageResponse;
|
|
151
|
-
|
|
151
|
+
|
|
152
|
+
// The API returns per-model quota entries, but quota is shared across
|
|
153
|
+
// models within the same tier. Deduplicate by (tier, windowId) so one
|
|
154
|
+
// account doesn't produce 15 redundant bars.
|
|
155
|
+
const deduped = new Map<
|
|
156
|
+
string,
|
|
157
|
+
{ amount: UsageAmount; window: UsageWindow | undefined; tier: string | undefined }
|
|
158
|
+
>();
|
|
152
159
|
let earliestReset: number | undefined;
|
|
153
160
|
|
|
154
|
-
for (const [
|
|
161
|
+
for (const [_modelId, modelInfo] of Object.entries(data.models ?? {})) {
|
|
155
162
|
const quotaInfos = normalizeQuotaInfos(modelInfo);
|
|
156
163
|
for (const quotaInfo of quotaInfos) {
|
|
157
164
|
const amount = buildAmount(quotaInfo);
|
|
@@ -159,35 +166,81 @@ async function fetchAntigravityUsage(params: UsageFetchParams, ctx: UsageFetchCo
|
|
|
159
166
|
if (window?.resetsAt) {
|
|
160
167
|
earliestReset = earliestReset ? Math.min(earliestReset, window.resetsAt) : window.resetsAt;
|
|
161
168
|
}
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
169
|
+
const tier = (quotaInfo.tier ?? "default").toLowerCase();
|
|
170
|
+
// Use quotaInfo.windowId even when parseWindow returns undefined
|
|
171
|
+
// (no resetTime) — separate windows must not collapse to "default".
|
|
172
|
+
const windowId = quotaInfo.windowId ?? window?.id ?? "default";
|
|
173
|
+
const key = `${tier}|${windowId}`;
|
|
174
|
+
const existing = deduped.get(key);
|
|
175
|
+
if (!existing) {
|
|
176
|
+
deduped.set(key, { amount, window, tier: quotaInfo.tier });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// Merge: keep the entry with fraction data for the bar, but
|
|
180
|
+
// also keep any window with a reset time so "resets in…" survives.
|
|
181
|
+
const eFrac = existing.amount.remainingFraction;
|
|
182
|
+
const cFrac = amount.remainingFraction;
|
|
183
|
+
const eHasFrac = eFrac !== undefined;
|
|
184
|
+
const cHasFrac = cFrac !== undefined;
|
|
185
|
+
|
|
186
|
+
let bestAmount = existing.amount;
|
|
187
|
+
let bestWindow = existing.window?.resetsAt ? existing.window : (window ?? existing.window);
|
|
188
|
+
let bestTier = existing.tier ?? quotaInfo.tier;
|
|
189
|
+
|
|
190
|
+
if (!eHasFrac && cHasFrac) {
|
|
191
|
+
bestAmount = amount;
|
|
192
|
+
bestTier = quotaInfo.tier ?? existing.tier;
|
|
193
|
+
} else if (eHasFrac && cHasFrac && cFrac! < eFrac!) {
|
|
194
|
+
bestAmount = amount;
|
|
195
|
+
bestTier = quotaInfo.tier ?? existing.tier;
|
|
196
|
+
}
|
|
197
|
+
// Always merge in window with reset time if the current
|
|
198
|
+
// best doesn't have one.
|
|
199
|
+
if (!bestWindow?.resetsAt && window?.resetsAt) {
|
|
200
|
+
bestWindow = window;
|
|
201
|
+
}
|
|
202
|
+
deduped.set(key, { amount: bestAmount, window: bestWindow, tier: bestTier });
|
|
180
203
|
}
|
|
181
204
|
}
|
|
182
205
|
|
|
206
|
+
const limits: UsageLimit[] = [];
|
|
207
|
+
for (const [key, entry] of deduped) {
|
|
208
|
+
const [tier, windowId] = key.split("|") as [string, string];
|
|
209
|
+
const label = "Usage";
|
|
210
|
+
limits.push({
|
|
211
|
+
id: `${params.provider}:${tier}:${windowId}`,
|
|
212
|
+
label,
|
|
213
|
+
scope: {
|
|
214
|
+
provider: params.provider,
|
|
215
|
+
accountId: credential.accountId,
|
|
216
|
+
projectId: credential.projectId,
|
|
217
|
+
tier: entry.tier,
|
|
218
|
+
windowId,
|
|
219
|
+
},
|
|
220
|
+
window: entry.window,
|
|
221
|
+
amount: entry.amount,
|
|
222
|
+
status: getUsageStatus(entry.amount.remainingFraction),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
limits.sort((a, b) => {
|
|
227
|
+
const aFraction = a.amount.remainingFraction ?? 1;
|
|
228
|
+
const bFraction = b.amount.remainingFraction ?? 1;
|
|
229
|
+
return aFraction - bFraction;
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const metadata: UsageReport["metadata"] = {
|
|
233
|
+
endpoint: url,
|
|
234
|
+
projectId: credential.projectId,
|
|
235
|
+
};
|
|
236
|
+
if (credential.email) metadata.email = credential.email;
|
|
237
|
+
if (credential.accountId) metadata.accountId = credential.accountId;
|
|
238
|
+
|
|
183
239
|
const report: UsageReport = {
|
|
184
240
|
provider: params.provider,
|
|
185
241
|
fetchedAt: nowMs,
|
|
186
242
|
limits,
|
|
187
|
-
metadata
|
|
188
|
-
endpoint: url,
|
|
189
|
-
projectId: credential.projectId,
|
|
190
|
-
},
|
|
243
|
+
metadata,
|
|
191
244
|
raw: data,
|
|
192
245
|
};
|
|
193
246
|
|
|
@@ -154,6 +154,22 @@ export const CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS: Record<string, Record<string,
|
|
|
154
154
|
null: {},
|
|
155
155
|
};
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Flat set of every type-specific key across all CCA types.
|
|
159
|
+
* Used to identify sibling keys that need filtering during mixed-type collapse.
|
|
160
|
+
*/
|
|
161
|
+
export const ALL_CCA_TYPE_SPECIFIC_KEYS: Record<string, true> = buildAllCcaTypeSpecificKeys();
|
|
162
|
+
|
|
163
|
+
function buildAllCcaTypeSpecificKeys(): Record<string, true> {
|
|
164
|
+
const all: Record<string, true> = {};
|
|
165
|
+
for (const typeKeys of Object.values(CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS)) {
|
|
166
|
+
for (const key in typeKeys) {
|
|
167
|
+
all[key] = true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return all;
|
|
171
|
+
}
|
|
172
|
+
|
|
157
173
|
/**
|
|
158
174
|
* Cloud Code Assist shared schema keys allowed on any type.
|
|
159
175
|
* Used alongside CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS for CCA combiner collapsing.
|
|
@@ -11,6 +11,7 @@ import { dereferenceJsonSchema } from "./dereference";
|
|
|
11
11
|
import { upgradeJsonSchemaTo202012 } from "./draft";
|
|
12
12
|
import { areJsonValuesEqual, mergePropertySchemas } from "./equality";
|
|
13
13
|
import {
|
|
14
|
+
ALL_CCA_TYPE_SPECIFIC_KEYS,
|
|
14
15
|
CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS,
|
|
15
16
|
CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS,
|
|
16
17
|
COMBINATOR_KEYS,
|
|
@@ -501,12 +502,32 @@ function collapseMixedTypeCombinerVariants(schema: JsonObject, combiner: "anyOf"
|
|
|
501
502
|
if (variantTypes.length < 2 || variantTypes.every(type => type === "object")) {
|
|
502
503
|
return schema;
|
|
503
504
|
}
|
|
504
|
-
|
|
505
505
|
const nextSchema = copySchemaWithout(schema, combiner);
|
|
506
506
|
const nonNullTypes = variantTypes.filter(t => t !== "null");
|
|
507
|
-
|
|
507
|
+
const chosenType: string = nonNullTypes[0] ?? variantTypes[0];
|
|
508
|
+
nextSchema.type = chosenType;
|
|
509
|
+
const chosenTypeAllowedKeys = CLOUD_CODE_ASSIST_TYPE_SPECIFIC_KEYS[chosenType] ?? {};
|
|
510
|
+
|
|
511
|
+
// Strip sibling keys that were copied from the parent and belong to a
|
|
512
|
+
// different type (e.g. `items` sibling on a now-string-typed schema).
|
|
513
|
+
for (const key in nextSchema) {
|
|
514
|
+
if (!Object.hasOwn(nextSchema, key)) continue;
|
|
515
|
+
if (key === "type") continue;
|
|
516
|
+
if (
|
|
517
|
+
Object.hasOwn(ALL_CCA_TYPE_SPECIFIC_KEYS, key) &&
|
|
518
|
+
!Object.hasOwn(chosenTypeAllowedKeys, key) &&
|
|
519
|
+
!Object.hasOwn(CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS, key)
|
|
520
|
+
) {
|
|
521
|
+
delete nextSchema[key];
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
508
525
|
for (const key in mergedVariantFields) {
|
|
509
526
|
if (!Object.hasOwn(mergedVariantFields, key)) continue;
|
|
527
|
+
// Drop type-specific keys that don't belong to the chosen type
|
|
528
|
+
if (!Object.hasOwn(chosenTypeAllowedKeys, key) && !Object.hasOwn(CLOUD_CODE_ASSIST_SHARED_SCHEMA_KEYS, key)) {
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
510
531
|
const value = mergedVariantFields[key];
|
|
511
532
|
const existingValue = nextSchema[key];
|
|
512
533
|
if (existingValue !== undefined && !areJsonValuesEqual(existingValue, value)) {
|