@oh-my-pi/pi-ai 15.9.67 → 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 CHANGED
@@ -2,6 +2,22 @@
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
+
5
21
  ## [15.9.67] - 2026-06-06
6
22
 
7
23
  ### Fixed
@@ -1,4 +1,4 @@
1
- import type { Effort } from "../model-thinking";
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.
@@ -0,0 +1,9 @@
1
+ /** User-facing thinking levels, ordered least to most intensive. */
2
+ export declare const enum Effort {
3
+ Minimal = "minimal",
4
+ Low = "low",
5
+ Medium = "medium",
6
+ High = "high",
7
+ XHigh = "xhigh"
8
+ }
9
+ export declare const THINKING_EFFORTS: readonly Effort[];
@@ -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 "../model-thinking";
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;
@@ -1,4 +1,4 @@
1
- import type { Effort } from "./model-thinking";
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";
@@ -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 "./model-thinking";
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.9.67",
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.9.67",
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 "../model-thinking";
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";
@@ -1,4 +1,4 @@
1
- import type { Effort } from "../model-thinking";
1
+ import type { Effort } from "../effort";
2
2
  import type {
3
3
  AssistantMessage,
4
4
  AssistantMessageEventStream,
@@ -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
- if (left.secondaryDrainRate !== right.secondaryDrainRate) {
2785
- return left.secondaryDrainRate - right.secondaryDrainRate;
2786
- }
2787
- if (left.secondaryUsed !== right.secondaryUsed) return left.secondaryUsed - right.secondaryUsed;
2788
- if (left.primaryDrainRate !== right.primaryDrainRate) return left.primaryDrainRate - right.primaryDrainRate;
2789
- if (left.primaryUsed !== right.primaryUsed) return left.primaryUsed - right.primaryUsed;
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";
@@ -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";
@@ -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 "../model-thinking";
11
+ import type { Effort } from "../effort";
12
12
  import { mapEffortToAnthropicAdaptiveEffort, requireSupportedEffort } from "../model-thinking";
13
13
  import { calculateCost } from "../models";
14
14
  import type {
@@ -2427,22 +2427,31 @@ function isZaiAnthropicEndpoint(model: Model<"anthropic-messages">): boolean {
2427
2427
  }
2428
2428
 
2429
2429
  /**
2430
- * Returns true for providers whose Anthropic-compatible endpoints do NOT
2431
- * implement signature-based thinking-chain integrity (DeepSeek, Z.AI, etc.).
2432
- * For these providers, unsigned thinking blocks must be preserved as
2433
- * `type: "thinking"` instead of being degraded to text.
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.
2434
2442
  */
2435
- function isNonSigningAnthropicEndpoint(model: Model<"anthropic-messages">): boolean {
2436
- // Known non-signing providers
2443
+ function shouldReplayUnsignedThinking(model: Model<"anthropic-messages">): boolean {
2437
2444
  if (model.provider === "zai" || model.provider === "deepseek") return true;
2438
2445
  const baseUrl = model.baseUrl;
2439
- if (!baseUrl) return false;
2440
- try {
2441
- const hostname = new URL(baseUrl).hostname.toLowerCase();
2442
- return hostname === "api.deepseek.com" || hostname.endsWith(".deepseek.com");
2443
- } catch {
2444
- return false;
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
+ }
2445
2453
  }
2454
+ return model.reasoning && !isAnthropicApiBaseUrl(baseUrl);
2446
2455
  }
2447
2456
 
2448
2457
  function buildToolResultBlock(model: Model<"anthropic-messages">, msg: ToolResultMessage): ContentBlockParam {
@@ -2533,7 +2542,7 @@ export function convertAnthropicMessages(
2533
2542
  }
2534
2543
  if (block.thinking.trim().length === 0) continue;
2535
2544
  if (!block.thinkingSignature || block.thinkingSignature.trim().length === 0) {
2536
- if (isNonSigningAnthropicEndpoint(model)) {
2545
+ if (shouldReplayUnsignedThinking(model)) {
2537
2546
  blocks.push({
2538
2547
  type: "thinking",
2539
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 "../../model-thinking";
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
- const functionCallIds = new Set(
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 { type Effort, getSupportedEfforts } from "../model-thinking";
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,
@@ -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 "./model-thinking";
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 "./model-thinking";
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
- const limits: UsageLimit[] = [];
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 [modelId, modelInfo] of Object.entries(data.models ?? {})) {
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 labelBase = modelInfo.displayName || modelId;
163
- const label = quotaInfo.tier ? `${labelBase} (${quotaInfo.tier})` : labelBase;
164
- const windowId = window?.id ?? "default";
165
- limits.push({
166
- id: `${modelId}:${quotaInfo.tier ?? "default"}:${windowId}`,
167
- label,
168
- scope: {
169
- provider: params.provider,
170
- accountId: credential.accountId,
171
- projectId: credential.projectId,
172
- modelId,
173
- tier: quotaInfo.tier,
174
- windowId,
175
- },
176
- window,
177
- amount,
178
- status: getUsageStatus(amount.remainingFraction),
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
- nextSchema.type = nonNullTypes[0] ?? variantTypes[0];
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)) {