@oh-my-pi/pi-ai 17.0.2 → 17.0.4

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,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.4] - 2026-07-18
6
+
7
+ ### Fixed
8
+
9
+ - Fixed Kimi Code usage reports dropping the 5h window reset time (`omp usage` showed no "resets in …" for the 5h limit): the API returns `resetTime` on the limit `detail`, not on `window`, so the parsed row-level reset is now carried onto the window when the window itself has none.
10
+ - Made Kimi device-id persistence best-effort: a missing or unwritable `~/.omp/agent` directory no longer throws during Kimi header construction, which silently nulled every `kimi-code` usage probe on fresh installs.
11
+ - Coerced boolean tool-schema subschemas to MFJS object forms for native Moonshot/Kimi endpoints, preventing the task tool's `outputSchema` field from causing HTTP 400 responses ([#5952](https://github.com/can1357/oh-my-pi/issues/5952)).
12
+
13
+ ## [17.0.3] - 2026-07-17
14
+
15
+ ### Fixed
16
+
17
+ - Replaced the opaque `h2 is not supported` failure on the Cursor run transport with an actionable error naming the ALPN-stripping proxy as the cause and pointing at the `providers.cursor.baseUrl` HTTP/2 bridge workaround. The run RPC is HTTP/2-only, so behind a TLS-intercepting proxy that strips ALPN (e.g. Zscaler) bun cannot negotiate `h2` and the completion cannot proceed ([#5828](https://github.com/can1357/oh-my-pi/issues/5828)).
18
+ - Restored the `createAssistantMessageEventStream()` root export used by legacy provider extensions ([#5879](https://github.com/can1357/oh-my-pi/issues/5879)).
19
+ - Fixed parallel Responses tool-result images interleaving synthetic user messages before all pending outputs, preventing strict OpenRouter/Moonshot backends from rejecting follow-up requests. ([#5850](https://github.com/can1357/oh-my-pi/issues/5850))
20
+ - Fixed Kimi Code K3 requests to send native named efforts (`low`, `high`, `max`) and use adaptive effort rather than generic token budgets on explicit Anthropic transport overrides ([#5893](https://github.com/can1357/oh-my-pi/issues/5893)).
21
+ - Automatically invalidate and rotate OAuth credentials when an "invalidated oauth token" error occurs
22
+ - Fixed Anthropic usage reports treating the organization response header as the account identity, which caused the 5h/7d status-line segment to disappear for OAuth credentials without stored organization metadata. ([#5698](https://github.com/can1357/oh-my-pi/issues/5698))
23
+
5
24
  ## [17.0.2] - 2026-07-17
6
25
 
7
26
  ### Fixed
@@ -13,6 +13,20 @@ export interface CursorOptions extends StreamOptions {
13
13
  execHandlers?: CursorExecHandlers;
14
14
  onToolResult?: CursorToolResultHandler;
15
15
  }
16
+ /**
17
+ * Maps an opaque HTTP/2 negotiation failure into an actionable error.
18
+ *
19
+ * bun only opens an HTTP/2 session when TLS-ALPN negotiates `h2`. Behind a
20
+ * TLS-intercepting proxy that strips ALPN (e.g. Zscaler), the handshake yields
21
+ * no `h2` protocol and bun throws `ERR_HTTP2_ERROR: h2 is not supported`. The
22
+ * Cursor run RPC is HTTP/2-only (the ALB rejects HTTP/1.1 with 464), so there
23
+ * is no h1 fallback the way model discovery has one — the run simply cannot
24
+ * proceed. Replace the opaque message with one that names the cause and points
25
+ * at the `providers.cursor.baseUrl` workaround.
26
+ *
27
+ * Non-ALPN errors pass through untouched.
28
+ */
29
+ export declare function mapH2TransportError(error: unknown, baseUrl: string): unknown;
16
30
  export declare const streamCursor: StreamFunction<"cursor-agent">;
17
31
  export type ToolCallState = ToolCall & {
18
32
  [kStreamingBlockIndex]: number;
@@ -5,15 +5,15 @@
5
5
  * - OpenAI: https://api.kimi.com/coding/v1/chat/completions
6
6
  * - Anthropic: https://api.kimi.com/coding/v1/messages
7
7
  *
8
- * The Anthropic API is generally more stable and recommended.
9
- * Note: Kimi calculates TPM rate limits based on max_tokens, not actual output.
8
+ * Each discovered model selects its server-declared protocol; legacy models
9
+ * without protocol metadata retain the Anthropic-compatible default.
10
10
  */
11
11
  import type { Api, Context, Model } from "../types.js";
12
12
  import type { AssistantMessageEventStream } from "../utils/event-stream.js";
13
13
  import { type OpenAIAnthropicApiFormat, type OpenAIAnthropicShimOptions } from "./openai-anthropic-shim.js";
14
14
  export type KimiApiFormat = OpenAIAnthropicApiFormat;
15
15
  export interface KimiOptions extends OpenAIAnthropicShimOptions {
16
- /** API format: "openai" or "anthropic". Default: "anthropic" */
16
+ /** Explicit API format override. Defaults to the model's discovered protocol. */
17
17
  format?: KimiApiFormat;
18
18
  }
19
19
  /**
@@ -7,7 +7,7 @@
7
7
  * format, optional extra headers); the streaming/forwarding plumbing lives
8
8
  * here once.
9
9
  */
10
- import type { Context, Model, SimpleStreamOptions } from "../types.js";
10
+ import type { Context, Model, SimpleStreamOptions, ThinkingControlMode } from "../types.js";
11
11
  import { AssistantMessageEventStream } from "../utils/event-stream.js";
12
12
  export type OpenAIAnthropicApiFormat = "openai" | "anthropic";
13
13
  export interface OpenAIAnthropicShimOptions extends SimpleStreamOptions {
@@ -21,6 +21,8 @@ export interface OpenAIAnthropicShimConfig {
21
21
  openaiBaseUrl?: string;
22
22
  /** Default API format when caller does not specify one. */
23
23
  defaultFormat: OpenAIAnthropicApiFormat;
24
+ /** Thinking transport used when this provider's Anthropic endpoint differs from generic budget semantics. */
25
+ anthropicThinkingMode?: ThinkingControlMode;
24
26
  /** Provider-specific headers (e.g. auth/session) merged ahead of user-supplied headers. */
25
27
  extraHeaders?: () => Record<string, string>;
26
28
  }
@@ -200,6 +200,7 @@ export type OpenAICompletionsParams = Omit<ChatCompletionCreateParamsStreaming,
200
200
  repetition_penalty?: number;
201
201
  thinking?: {
202
202
  type: "enabled" | "disabled";
203
+ effort?: string;
203
204
  keep?: "all";
204
205
  };
205
206
  enable_thinking?: boolean;
@@ -382,6 +383,7 @@ export interface BuildResponsesInputOptions<TApi extends Api> {
382
383
  }
383
384
  export declare function buildResponsesInput<TApi extends Api>(options: BuildResponsesInputOptions<TApi>): ResponseInput;
384
385
  export declare function convertResponsesAssistantMessage<TApi extends Api>(assistantMsg: AssistantMessage, model: Model<TApi>, msgIndex: number, knownCallIds: Set<string>, includeThinkingSignatures?: boolean, customCallIds?: Set<string>, preserveMessageIds?: boolean, supportsCustomToolCalls?: boolean, customToolWireNameMap?: ReadonlyMap<string, string>): ResponseInput;
386
+ /** Appends one tool result while keeping consecutive outputs ahead of its synthetic image messages. */
385
387
  export declare function appendResponsesToolResultMessages<TApi extends Api>(messages: ResponseInput, toolResult: ToolResultMessage, model: Model<TApi>, strictResponsesPairing: boolean, supportsImageDetailOriginal: boolean, knownCallIds: ReadonlySet<string>, customCallIds?: ReadonlySet<string>, supportsCustomToolCalls?: boolean): void;
386
388
  /**
387
389
  * Per-block accumulation helpers shared by the two Responses decode loops —
@@ -376,7 +376,7 @@ export interface SimpleStreamOptions extends Omit<StreamOptions, "apiKey"> {
376
376
  toolChoice?: ToolChoice;
377
377
  /** OpenAI service tier for processing priority/cost control. Ignored by non-OpenAI providers. */
378
378
  serviceTier?: ServiceTier;
379
- /** API format for Kimi Code provider: "openai" or "anthropic" (default: "anthropic") */
379
+ /** Explicit Kimi Code API format override; omitted uses live per-model protocol metadata. */
380
380
  kimiApiFormat?: "openai" | "anthropic";
381
381
  /** API format for Synthetic provider: "openai" or "anthropic" (default: "openai") */
382
382
  syntheticApiFormat?: "openai" | "anthropic";
@@ -35,3 +35,5 @@ export declare class AssistantMessageEventStream extends EventStream<AssistantMe
35
35
  push(event: AssistantMessageEvent): void;
36
36
  end(result?: AssistantMessage): void;
37
37
  }
38
+ /** Create an assistant-message event stream for legacy extension providers. */
39
+ export declare function createAssistantMessageEventStream(): AssistantMessageEventStream;
@@ -2,8 +2,11 @@ import { type DescriptionSpillFormat } from "./spill.js";
2
2
  import { type JsonObject } from "./types.js";
3
3
  export type ResidualSchemaIncompatibility = "type-array" | "type-null" | "nullable" | "combiners" | "not";
4
4
  export interface NormalizeSchemaOptions {
5
- /** Coerce JSON Schema boolean subschemas for providers whose wire cannot encode them. */
6
- coerceBooleanSubschemas?: boolean;
5
+ /**
6
+ * Coerce boolean subschemas to object forms. `standard` preserves `false`
7
+ * with `not`; `permissive` uses `{}` when the provider cannot express it.
8
+ */
9
+ coerceBooleanSubschemas?: "standard" | "permissive";
7
10
  unsupportedFields: (key: string) => boolean;
8
11
  normalizeFieldNames: boolean;
9
12
  collapseNullFields: boolean;
@@ -62,6 +65,9 @@ export declare function normalizeSchemaForMCP(value: unknown): unknown;
62
65
  * `default` and `description` are MFJS Meta Data fields and are preserved.
63
66
  * - `additionalProperties` (boolean or schema) and `type: "null"` (incl.
64
67
  * inside `anyOf`) are kept.
68
+ * - Boolean subschemas are object-coerced; MFJS has no exact `false` schema,
69
+ * so both values become the permissive empty schema while local tool
70
+ * validation remains authoritative.
65
71
  *
66
72
  * Out of scope (absent from the built-in tool surface, spec-ambiguous to
67
73
  * rewrite blindly): `allOf` intersection merging, external/recursive `$ref`,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "17.0.2",
4
+ "version": "17.0.4",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.1",
41
- "@oh-my-pi/pi-catalog": "17.0.2",
42
- "@oh-my-pi/pi-utils": "17.0.2",
43
- "@oh-my-pi/pi-wire": "17.0.2",
41
+ "@oh-my-pi/pi-catalog": "17.0.4",
42
+ "@oh-my-pi/pi-utils": "17.0.4",
43
+ "@oh-my-pi/pi-wire": "17.0.4",
44
44
  "arktype": "2.2.3",
45
45
  "zod": "^4"
46
46
  },
@@ -1,9 +1,13 @@
1
1
  import { afterEach, describe, expect, it, vi } from "bun:test";
2
2
  import { getBundledModel } from "@oh-my-pi/pi-catalog";
3
+ import { buildModel } from "@oh-my-pi/pi-catalog/build";
4
+ import { Effort } from "@oh-my-pi/pi-catalog/effort";
5
+ import type { ModelSpec } from "@oh-my-pi/pi-catalog/types";
3
6
  import * as kimiOauth from "../../registry/oauth/kimi";
4
- import type { Context } from "../../types";
7
+ import { streamSimple } from "../../stream";
8
+ import type { Context, Model } from "../../types";
5
9
  import type { MessageCreateParamsStreaming } from "../anthropic-wire";
6
- import { streamKimi } from "../kimi";
10
+ import { type KimiApiFormat, streamKimi } from "../kimi";
7
11
  import { streamOpenAIAnthropicShim } from "../openai-anthropic-shim";
8
12
  import {
9
13
  applyChatCompletionsCompatPolicy,
@@ -38,10 +42,123 @@ const TITLE_CONTEXT: Context = {
38
42
  ],
39
43
  };
40
44
 
45
+ const K3_MODEL = buildModel({
46
+ id: "k3",
47
+ name: "K3",
48
+ api: "openai-completions",
49
+ provider: "kimi-code",
50
+ baseUrl: "https://api.kimi.com/coding/v1",
51
+ reasoning: true,
52
+ input: ["text"],
53
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
54
+ contextWindow: 1_048_576,
55
+ maxTokens: 32_000,
56
+ thinking: {
57
+ mode: "effort",
58
+ efforts: [Effort.Low, Effort.High, Effort.Max],
59
+ defaultLevel: Effort.Max,
60
+ requiresEffort: true,
61
+ },
62
+ compat: {
63
+ thinkingFormat: "kimi",
64
+ kimiApiFormat: "openai",
65
+ reasoningContentField: "reasoning_content",
66
+ supportsDeveloperRole: false,
67
+ },
68
+ } satisfies ModelSpec<"openai-completions">);
69
+
70
+ async function captureKimiPayload(
71
+ model: Model<"openai-completions">,
72
+ reasoning: Effort,
73
+ format?: KimiApiFormat,
74
+ ): Promise<unknown> {
75
+ let payload: unknown;
76
+ const stream = streamKimi(
77
+ model,
78
+ {
79
+ systemPrompt: [],
80
+ messages: [{ role: "user", content: "Reply OK", timestamp: 0 }],
81
+ tools: [],
82
+ },
83
+ {
84
+ ...(format ? { format } : {}),
85
+ apiKey: "test-key",
86
+ reasoning,
87
+ onPayload: body => {
88
+ payload = body;
89
+ throw new Error("stop after payload capture");
90
+ },
91
+ },
92
+ );
93
+ await stream.result();
94
+ if (payload === undefined) throw new Error("Kimi request payload was not captured");
95
+ return payload;
96
+ }
97
+
41
98
  afterEach(() => {
42
99
  vi.restoreAllMocks();
43
100
  });
44
101
 
102
+ describe("Kimi K3 thinking transport", () => {
103
+ it("sends every live named effort through Kimi's native thinking object by default", async () => {
104
+ vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
105
+
106
+ for (const effort of [Effort.Low, Effort.High, Effort.Max]) {
107
+ const payload = await captureKimiPayload(K3_MODEL, effort);
108
+ expect(payload).toMatchObject({ thinking: { type: "enabled", effort } });
109
+ expect(payload).not.toHaveProperty("reasoning_effort");
110
+ }
111
+ });
112
+
113
+ it("uses adaptive named effort rather than a token budget for an explicit Anthropic override", async () => {
114
+ vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
115
+
116
+ const payload = await captureKimiPayload(K3_MODEL, Effort.Max, "anthropic");
117
+
118
+ expect(payload).toMatchObject({
119
+ thinking: { type: "adaptive" },
120
+ output_config: { effort: Effort.Max },
121
+ });
122
+ expect(payload).not.toHaveProperty("thinking.budget_tokens");
123
+ });
124
+
125
+ it("keeps the legacy K2 default on the Anthropic transport", async () => {
126
+ vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
127
+ const model = getBundledModel<"openai-completions">("kimi-code", "kimi-for-coding");
128
+
129
+ const payload = await captureKimiPayload(model, Effort.High);
130
+
131
+ expect(payload).toMatchObject({ thinking: { type: "enabled" } });
132
+ expect(payload).toHaveProperty("thinking.budget_tokens");
133
+ });
134
+
135
+ it("clamps disabled thinking to the lowest effort for a mandatory-thinking K3", async () => {
136
+ vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
137
+
138
+ let payload: unknown;
139
+ const stream = streamSimple(
140
+ K3_MODEL,
141
+ {
142
+ systemPrompt: [],
143
+ messages: [{ role: "user", content: "Reply OK", timestamp: 0 }],
144
+ tools: [],
145
+ },
146
+ {
147
+ apiKey: "test-key",
148
+ disableReasoning: true,
149
+ onPayload: body => {
150
+ payload = body;
151
+ throw new Error("stop after payload capture");
152
+ },
153
+ },
154
+ );
155
+ await stream.result();
156
+
157
+ expect(payload).toMatchObject({ thinking: { type: "enabled", effort: Effort.Low } });
158
+ expect(payload).not.toMatchObject({ thinking: { type: "disabled" } });
159
+ });
160
+ });
161
+
45
162
  describe("Kimi K2.7 Code thinking policy", () => {
46
163
  it("expresses disabled thinking explicitly for title-generator-style Kimi Code requests", () => {
47
164
  const model = getBundledModel<"openai-completions">("kimi-code", "kimi-for-coding");
@@ -213,6 +213,34 @@ function parseConnectEndStream(data: Uint8Array): Error | null {
213
213
  }
214
214
  }
215
215
 
216
+ /**
217
+ * Maps an opaque HTTP/2 negotiation failure into an actionable error.
218
+ *
219
+ * bun only opens an HTTP/2 session when TLS-ALPN negotiates `h2`. Behind a
220
+ * TLS-intercepting proxy that strips ALPN (e.g. Zscaler), the handshake yields
221
+ * no `h2` protocol and bun throws `ERR_HTTP2_ERROR: h2 is not supported`. The
222
+ * Cursor run RPC is HTTP/2-only (the ALB rejects HTTP/1.1 with 464), so there
223
+ * is no h1 fallback the way model discovery has one — the run simply cannot
224
+ * proceed. Replace the opaque message with one that names the cause and points
225
+ * at the `providers.cursor.baseUrl` workaround.
226
+ *
227
+ * Non-ALPN errors pass through untouched.
228
+ */
229
+ export function mapH2TransportError(error: unknown, baseUrl: string): unknown {
230
+ const code = (error as { code?: unknown } | null)?.code;
231
+ const message = error instanceof Error ? error.message : String(error);
232
+ if (code === "ERR_HTTP2_ERROR" && /h2 is not supported/i.test(message)) {
233
+ return new AIError.ProviderResponseError(
234
+ `Cursor run transport could not negotiate HTTP/2 with ${baseUrl}: "h2 is not supported". ` +
235
+ "This host serves the run RPC over HTTP/2 only, and the TLS handshake did not negotiate " +
236
+ "h2 via ALPN — typically an ALPN-stripping TLS-intercepting proxy (e.g. Zscaler). " +
237
+ "Front the provider with a local HTTP/2 bridge and set providers.cursor.baseUrl to it.",
238
+ { provider: "cursor", kind: "runtime", cause: error },
239
+ );
240
+ }
241
+ return error;
242
+ }
243
+
216
244
  function debugBytes(bytes: Uint8Array, asHex: boolean): string {
217
245
  if (asHex) {
218
246
  return Buffer.from(bytes).toString("hex");
@@ -431,7 +459,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
431
459
  } else {
432
460
  h2Client = http2.connect(baseUrl);
433
461
  }
434
- h2Client.on("error", error => settleH2(error));
462
+ h2Client.on("error", error => settleH2(mapH2TransportError(error, baseUrl)));
435
463
 
436
464
  h2Request = h2Client.request(requestHeaders);
437
465
 
@@ -573,7 +601,8 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
573
601
  });
574
602
 
575
603
  h2Request.on("error", error => {
576
- void closeDebugLog().finally(() => settleH2(error));
604
+ const mapped = mapH2TransportError(error, baseUrl);
605
+ void closeDebugLog().finally(() => settleH2(mapped));
577
606
  });
578
607
 
579
608
  if (options?.signal) {
@@ -5,8 +5,8 @@
5
5
  * - OpenAI: https://api.kimi.com/coding/v1/chat/completions
6
6
  * - Anthropic: https://api.kimi.com/coding/v1/messages
7
7
  *
8
- * The Anthropic API is generally more stable and recommended.
9
- * Note: Kimi calculates TPM rate limits based on max_tokens, not actual output.
8
+ * Each discovered model selects its server-declared protocol; legacy models
9
+ * without protocol metadata retain the Anthropic-compatible default.
10
10
  */
11
11
 
12
12
  import { getKimiCommonHeaders } from "../registry/oauth/kimi";
@@ -21,7 +21,7 @@ import {
21
21
  export type KimiApiFormat = OpenAIAnthropicApiFormat;
22
22
 
23
23
  export interface KimiOptions extends OpenAIAnthropicShimOptions {
24
- /** API format: "openai" or "anthropic". Default: "anthropic" */
24
+ /** Explicit API format override. Defaults to the model's discovered protocol. */
25
25
  format?: KimiApiFormat;
26
26
  }
27
27
 
@@ -36,7 +36,8 @@ export function streamKimi(
36
36
  ): AssistantMessageEventStream {
37
37
  return streamOpenAIAnthropicShim(model, context, options, {
38
38
  anthropicBaseUrl: model.baseUrl.replace(/\/v1\/?$/, ""),
39
- defaultFormat: "anthropic",
39
+ defaultFormat: model.compat.kimiApiFormat ?? "anthropic",
40
+ anthropicThinkingMode: model.compat.thinkingFormat === "kimi" ? "anthropic-adaptive" : undefined,
40
41
  extraHeaders: getKimiCommonHeaders,
41
42
  });
42
43
  }
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { buildModel } from "@oh-my-pi/pi-catalog/build";
12
12
  import { ANTHROPIC_THINKING, mapAnthropicToolChoice } from "../stream";
13
- import type { Context, Model, ModelSpec, SimpleStreamOptions } from "../types";
13
+ import type { Context, Model, ModelSpec, SimpleStreamOptions, ThinkingControlMode } from "../types";
14
14
  import { AssistantMessageEventStream } from "../utils/event-stream";
15
15
  import { createProviderErrorMessage } from "./error-message";
16
16
  import { streamAnthropic, streamOpenAICompletions } from "./register-builtins";
@@ -29,6 +29,8 @@ export interface OpenAIAnthropicShimConfig {
29
29
  openaiBaseUrl?: string;
30
30
  /** Default API format when caller does not specify one. */
31
31
  defaultFormat: OpenAIAnthropicApiFormat;
32
+ /** Thinking transport used when this provider's Anthropic endpoint differs from generic budget semantics. */
33
+ anthropicThinkingMode?: ThinkingControlMode;
32
34
  /** Provider-specific headers (e.g. auth/session) merged ahead of user-supplied headers. */
33
35
  extraHeaders?: () => Record<string, string>;
34
36
  }
@@ -67,6 +69,9 @@ export function streamOpenAIAnthropicShim(
67
69
  contextWindow: model.contextWindow,
68
70
  maxTokens: model.maxTokens,
69
71
  reasoning: model.reasoning,
72
+ ...(config.anthropicThinkingMode && model.thinking
73
+ ? { thinking: { ...model.thinking, mode: config.anthropicThinkingMode } }
74
+ : {}),
70
75
  input: model.input,
71
76
  cost: model.cost,
72
77
  } as ModelSpec<"anthropic-messages">);
@@ -95,6 +100,7 @@ export function streamOpenAIAnthropicShim(
95
100
  fetch: options?.fetch,
96
101
  thinkingEnabled,
97
102
  thinkingBudgetTokens: thinkingBudget,
103
+ reasoning: config.anthropicThinkingMode ? reasoningEffort : undefined,
98
104
  toolChoice: mapAnthropicToolChoice(options?.toolChoice),
99
105
  serviceTier: options?.serviceTier,
100
106
  });
@@ -38,6 +38,7 @@ import {
38
38
  adaptSchemaForStrict,
39
39
  findStrictToolSchemaViolation,
40
40
  NO_STRICT,
41
+ normalizeSchemaForMoonshot,
41
42
  sanitizeSchemaForOpenAIResponses,
42
43
  toolWireSchema,
43
44
  } from "../utils/schema";
@@ -999,7 +1000,15 @@ export function convertTools(
999
1000
  }
1000
1001
  const strict = !NO_STRICT && strictMode && tool.strict !== false;
1001
1002
  const baseParameters = toolWireSchema(tool);
1002
- const responseParameters = sanitizeSchemaForOpenAIResponses(baseParameters);
1003
+ // MFJS must run AFTER the Responses sanitizer: the sanitizer normalizes
1004
+ // `{}` → `true` (issue #1179), and Moonshot's validator rejects boolean
1005
+ // subschemas ("property schema … must be an object"), so the Moonshot
1006
+ // pass re-coerces them last.
1007
+ const sanitized = sanitizeSchemaForOpenAIResponses(baseParameters);
1008
+ const responseParameters =
1009
+ model.compat.toolSchemaFlavor === "moonshot-mfjs"
1010
+ ? (normalizeSchemaForMoonshot(sanitized) as Record<string, unknown>)
1011
+ : sanitized;
1003
1012
  const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(responseParameters, strict);
1004
1013
  // Quarantine a tool whose emitted schema carries a provider-rejecting
1005
1014
  // enum/const-vs-type contradiction: dropping just that tool keeps the rest
@@ -654,7 +654,7 @@ export type OpenAICompletionsParams = Omit<ChatCompletionCreateParamsStreaming,
654
654
  top_k?: number;
655
655
  min_p?: number;
656
656
  repetition_penalty?: number;
657
- thinking?: { type: "enabled" | "disabled"; keep?: "all" };
657
+ thinking?: { type: "enabled" | "disabled"; effort?: string; keep?: "all" };
658
658
  enable_thinking?: boolean;
659
659
  preserve_thinking?: boolean;
660
660
  chat_template_kwargs?: { enable_thinking?: boolean; preserve_thinking?: boolean };
@@ -944,6 +944,11 @@ export function applyChatCompletionsCompatPolicy(params: OpenAICompletionsParams
944
944
  encodeChatCompletionsDisabledReasoning(params, reasoning.disableMode);
945
945
  return;
946
946
  }
947
+ if (reasoning.dialect === "kimi" && reasoning.wireEffort !== undefined) {
948
+ params.thinking = { type: "enabled", effort: reasoning.wireEffort };
949
+ if (policy.compat.thinkingKeep) params.thinking.keep = policy.compat.thinkingKeep;
950
+ break;
951
+ }
947
952
  params.thinking = { type: "enabled" };
948
953
  if (policy.compat.thinkingKeep) params.thinking.keep = policy.compat.thinkingKeep;
949
954
  if (policy.compat.supportsReasoningEffort && reasoning.wireEffort !== undefined) {
@@ -1734,6 +1739,21 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
1734
1739
  return outputItems;
1735
1740
  }
1736
1741
 
1742
+ const syntheticToolImageMessages = new WeakSet<object>();
1743
+
1744
+ function insertResponsesToolOutput(messages: ResponseInput, output: ResponseInput[number]): void {
1745
+ let index = messages.length;
1746
+ while (index > 0) {
1747
+ const previous = messages[index - 1];
1748
+ if (typeof previous !== "object" || previous === null || !syntheticToolImageMessages.has(previous)) {
1749
+ break;
1750
+ }
1751
+ index -= 1;
1752
+ }
1753
+ messages.splice(index, 0, output);
1754
+ }
1755
+
1756
+ /** Appends one tool result while keeping consecutive outputs ahead of its synthetic image messages. */
1737
1757
  export function appendResponsesToolResultMessages<TApi extends Api>(
1738
1758
  messages: ResponseInput,
1739
1759
  toolResult: ToolResultMessage,
@@ -1780,13 +1800,13 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
1780
1800
  return;
1781
1801
  }
1782
1802
  if (supportsCustomToolCalls && customCallIds?.has(normalized.callId)) {
1783
- messages.push({
1803
+ insertResponsesToolOutput(messages, {
1784
1804
  type: "custom_tool_call_output",
1785
1805
  call_id: normalized.callId,
1786
1806
  output,
1787
1807
  } as ResponseInput[number]);
1788
1808
  } else {
1789
- messages.push({
1809
+ insertResponsesToolOutput(messages, {
1790
1810
  type: "function_call_output",
1791
1811
  call_id: normalized.callId,
1792
1812
  output,
@@ -1809,7 +1829,9 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
1809
1829
  } satisfies ResponseInputImage);
1810
1830
  }
1811
1831
  }
1812
- messages.push({ role: "user", content: contentParts });
1832
+ const imageMessage = { role: "user", content: contentParts } satisfies ResponseInput[number];
1833
+ syntheticToolImageMessages.add(imageMessage);
1834
+ messages.push(imageMessage);
1813
1835
  }
1814
1836
 
1815
1837
  /**
@@ -7,7 +7,7 @@ import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
9
  import { scheduler } from "node:timers/promises";
10
- import { $env, getAgentDir, isEnoent } from "@oh-my-pi/pi-utils";
10
+ import { $env, getAgentDir } from "@oh-my-pi/pi-utils";
11
11
  import packageJson from "../../../package.json" with { type: "json" };
12
12
  import * as AIError from "../../error";
13
13
  import type { OAuthController, OAuthCredentials } from "./types";
@@ -57,21 +57,29 @@ function getDeviceModel(): string {
57
57
  return formatDeviceModel(label, release, arch);
58
58
  }
59
59
 
60
+ // Device id identifies this install to Kimi. Persistence is best-effort: a
61
+ // missing/unwritable agent dir must never break header construction (and with
62
+ // it every usage probe / request that spreads getKimiCommonHeaders()) — fall
63
+ // back to a per-process ephemeral id instead.
60
64
  let getDeviceId = (): string => {
61
65
  const deviceIdPath = path.join(getAgentDir(), DEVICE_ID_FILENAME);
62
66
  try {
63
- const existing = fs.readFileSync(deviceIdPath, "utf-8");
64
- const trimmed = existing.trim();
65
- if (trimmed) {
66
- getDeviceId = () => trimmed;
67
- return trimmed;
67
+ const existing = fs.readFileSync(deviceIdPath, "utf-8").trim();
68
+ if (existing) {
69
+ getDeviceId = () => existing;
70
+ return existing;
68
71
  }
69
- } catch (error) {
70
- if (!isEnoent(error)) throw error;
72
+ } catch {
73
+ // Unreadable device-id file: regenerate below.
71
74
  }
72
75
 
73
76
  const deviceId = crypto.randomUUID().replace(/-/g, "");
74
- fs.writeFileSync(deviceIdPath, `${deviceId}\n`, { mode: 0o600 });
77
+ try {
78
+ fs.mkdirSync(path.dirname(deviceIdPath), { recursive: true });
79
+ fs.writeFileSync(deviceIdPath, `${deviceId}\n`, { mode: 0o600 });
80
+ } catch {
81
+ // Persist failure → ephemeral id for this process.
82
+ }
75
83
  getDeviceId = () => deviceId;
76
84
  return deviceId;
77
85
  };
package/src/stream.ts CHANGED
@@ -1178,12 +1178,17 @@ export function streamSimple<TApi extends Api>(
1178
1178
 
1179
1179
  // Kimi Code - route to dedicated handler that wraps OpenAI or Anthropic API
1180
1180
  if (isKimiModel(model)) {
1181
- // Pass raw SimpleStreamOptions - streamKimi handles mapping internally
1182
- return withProviderInFlightLimit(model, requestOptions, () =>
1181
+ // streamKimi handles openai/anthropic format mapping internally, but the
1182
+ // mandatory-reasoning clamp is a request-shaping concern owned here: K3's
1183
+ // `supports_thinking_type: "only"` endpoint rejects disabled/omitted
1184
+ // thinking, so clamp disabled requests to the lowest supported effort
1185
+ // (mirrors the mapOptionsForApi path every other provider takes).
1186
+ const kimiOptions = normalizeMandatoryReasoningOptions(model, requestOptions);
1187
+ return withProviderInFlightLimit(model, kimiOptions, () =>
1183
1188
  streamKimi(model as Model<"openai-completions">, context, {
1184
- ...requestOptions,
1189
+ ...kimiOptions,
1185
1190
  apiKey,
1186
- format: requestOptions?.kimiApiFormat ?? "anthropic",
1191
+ format: kimiOptions?.kimiApiFormat,
1187
1192
  }),
1188
1193
  );
1189
1194
  }
package/src/types.ts CHANGED
@@ -539,7 +539,7 @@ export interface SimpleStreamOptions extends Omit<StreamOptions, "apiKey"> {
539
539
  toolChoice?: ToolChoice;
540
540
  /** OpenAI service tier for processing priority/cost control. Ignored by non-OpenAI providers. */
541
541
  serviceTier?: ServiceTier;
542
- /** API format for Kimi Code provider: "openai" or "anthropic" (default: "anthropic") */
542
+ /** Explicit Kimi Code API format override; omitted uses live per-model protocol metadata. */
543
543
  kimiApiFormat?: "openai" | "anthropic";
544
544
  /** API format for Synthetic provider: "openai" or "anthropic" (default: "openai") */
545
545
  syntheticApiFormat?: "openai" | "anthropic";
@@ -96,11 +96,6 @@ interface ParsedApiLimitEntry {
96
96
  displayName?: string;
97
97
  }
98
98
 
99
- type ClaudeUsagePayload = {
100
- payload: ClaudeUsageResponse;
101
- orgId?: string;
102
- };
103
-
104
99
  function parseIsoTime(value: string | undefined): number | undefined {
105
100
  if (!value) return undefined;
106
101
  const parsed = Date.parse(value);
@@ -178,22 +173,17 @@ function getNestedPayloadString(payload: Record<string, unknown>, key: string, n
178
173
  return isRecord(nested) ? getPayloadString(nested, nestedKey) : undefined;
179
174
  }
180
175
 
181
- function extractUsageIdentity(payload: ClaudeUsageResponse, orgId?: string): { accountId?: string; email?: string } {
182
- if (!isRecord(payload)) return { accountId: orgId };
176
+ function extractUsageIdentity(payload: ClaudeUsageResponse): { accountId?: string; email?: string } {
177
+ if (!isRecord(payload)) return {};
183
178
  const accountId =
184
179
  getPayloadString(payload, "account_id") ??
185
180
  getPayloadString(payload, "accountId") ??
186
181
  getPayloadString(payload, "user_id") ??
187
182
  getPayloadString(payload, "userId") ??
188
- getPayloadString(payload, "org_id") ??
189
- getPayloadString(payload, "orgId") ??
190
183
  getNestedPayloadString(payload, "account", "uuid") ??
191
184
  getNestedPayloadString(payload, "account", "id") ??
192
- getNestedPayloadString(payload, "organization", "uuid") ??
193
- getNestedPayloadString(payload, "organization", "id") ??
194
185
  getNestedPayloadString(payload, "user", "uuid") ??
195
- getNestedPayloadString(payload, "user", "id") ??
196
- orgId;
186
+ getNestedPayloadString(payload, "user", "id");
197
187
  const email =
198
188
  getPayloadString(payload, "email") ??
199
189
  getPayloadString(payload, "user_email") ??
@@ -263,16 +253,13 @@ async function fetchUsagePayload(
263
253
  headers: Record<string, string>,
264
254
  ctx: UsageFetchContext,
265
255
  signal?: AbortSignal,
266
- ): Promise<ClaudeUsagePayload | null> {
256
+ ): Promise<ClaudeUsageResponse | null> {
267
257
  if (signal?.aborted) return null;
268
258
 
269
259
  let lastPayload: ClaudeUsageResponse | null = null;
270
- let lastOrgId: string | undefined;
271
260
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
272
261
  try {
273
262
  const response = await ctx.fetch(url, { headers, signal });
274
- const orgId = response.headers.get("anthropic-organization-id")?.trim() || undefined;
275
- lastOrgId = orgId ?? lastOrgId;
276
263
 
277
264
  if (!response.ok) {
278
265
  const retryable = isRetryableStatus(response.status);
@@ -292,7 +279,7 @@ async function fetchUsagePayload(
292
279
  if (isRecord(parsed)) {
293
280
  const payload = parsed as ClaudeUsageResponse;
294
281
  lastPayload = payload;
295
- if (hasUsageData(payload)) return { payload, orgId };
282
+ if (hasUsageData(payload)) return payload;
296
283
  }
297
284
 
298
285
  ctx.logger?.warn("Claude usage response missing usage data", {
@@ -311,7 +298,7 @@ async function fetchUsagePayload(
311
298
  }
312
299
  }
313
300
 
314
- return lastPayload ? { payload: lastPayload, orgId: lastOrgId } : null;
301
+ return lastPayload;
315
302
  }
316
303
 
317
304
  interface ClaudeProfile {
@@ -507,9 +494,8 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
507
494
  authorization: `Bearer ${credential.accessToken}`,
508
495
  };
509
496
 
510
- const payloadResult = await fetchUsagePayload(url, headers, ctx, params.signal);
511
- if (!payloadResult || !isRecord(payloadResult.payload)) return null;
512
- const { payload, orgId } = payloadResult;
497
+ const payload = await fetchUsagePayload(url, headers, ctx, params.signal);
498
+ if (!payload || !isRecord(payload)) return null;
513
499
 
514
500
  const apiLimitEntries = parseApiLimitEntries(payload.limits);
515
501
  const fiveHour = parseBucket(payload.five_hour) ?? apiLimitEntries.find(entry => entry.kind === "session")?.bucket;
@@ -563,7 +549,7 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
563
549
  ].filter((limit): limit is UsageLimit => limit !== null);
564
550
 
565
551
  if (limits.length === 0) return null;
566
- const identity = extractUsageIdentity(payload, orgId);
552
+ const identity = extractUsageIdentity(payload);
567
553
  let accountId = identity.accountId ?? credential.accountId;
568
554
  let email = identity.email ?? credential.email;
569
555
  if ((!accountId || !email) && !params.signal?.aborted) {
@@ -580,7 +566,7 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
580
566
  endpoint: url,
581
567
  ...(accountId ? { accountId } : {}),
582
568
  ...(email ? { email } : {}),
583
- ...(orgId ? { orgId } : {}),
569
+ ...(credential.orgId ? { orgId: credential.orgId } : {}),
584
570
  },
585
571
  raw: payload,
586
572
  };
package/src/usage/kimi.ts CHANGED
@@ -144,15 +144,21 @@ function buildUsageStatus(amount: UsageAmount): UsageStatus {
144
144
  }
145
145
 
146
146
  function toUsageLimit(row: KimiUsageRow, provider: string, index: number, accountId?: string): UsageLimit {
147
- const window: UsageWindow | undefined =
148
- row.window ??
149
- (row.resetsAt
147
+ // Kimi puts `resetTime` on the limit `detail`, not on `window`, so a
148
+ // window built from `duration`/`timeUnit` alone carries no resetsAt.
149
+ // Fall back to the row-level reset so `omp usage` can render
150
+ // "resets in …" for the 5h window too.
151
+ const window: UsageWindow | undefined = row.window
152
+ ? row.window.resetsAt !== undefined || row.resetsAt === undefined
153
+ ? row.window
154
+ : { ...row.window, resetsAt: row.resetsAt }
155
+ : row.resetsAt
150
156
  ? {
151
157
  id: "default",
152
158
  label: "Usage window",
153
159
  resetsAt: row.resetsAt,
154
160
  }
155
- : undefined);
161
+ : undefined;
156
162
 
157
163
  const amount = buildUsageAmount(row);
158
164
  return {
@@ -195,3 +195,8 @@ export class AssistantMessageEventStream extends EventStream<AssistantMessageEve
195
195
  this.endWaiting();
196
196
  }
197
197
  }
198
+
199
+ /** Create an assistant-message event stream for legacy extension providers. */
200
+ export function createAssistantMessageEventStream(): AssistantMessageEventStream {
201
+ return new AssistantMessageEventStream();
202
+ }
@@ -29,8 +29,11 @@ import { decontaminateZodInstance } from "./zod-decontaminate";
29
29
  export type ResidualSchemaIncompatibility = "type-array" | "type-null" | "nullable" | "combiners" | "not";
30
30
 
31
31
  export interface NormalizeSchemaOptions {
32
- /** Coerce JSON Schema boolean subschemas for providers whose wire cannot encode them. */
33
- coerceBooleanSubschemas?: boolean;
32
+ /**
33
+ * Coerce boolean subschemas to object forms. `standard` preserves `false`
34
+ * with `not`; `permissive` uses `{}` when the provider cannot express it.
35
+ */
36
+ coerceBooleanSubschemas?: "standard" | "permissive";
34
37
  unsupportedFields: (key: string) => boolean;
35
38
  normalizeFieldNames: boolean;
36
39
  collapseNullFields: boolean;
@@ -284,12 +287,12 @@ function normalizeSchemaNode(value: unknown, options: NormalizeSchemaWalkOptions
284
287
  }
285
288
  if (typeof value === "boolean") {
286
289
  // A bare boolean is a JSON Schema subschema only in a subschema slot.
287
- // The Google/CCA protobuf Schema wire has no representation for it
288
- // (issue #5604): `true` accepts anything -> `{}`, `false` accepts nothing
289
- // -> `{ not: {} }`. In a keyword slot (`nullable`, `enum` entry, …) a
290
- // boolean is a plain value and is left untouched.
291
- if (!options.coerceBooleanSubschemas || !options.booleanIsSubschema) return value;
292
- return value ? {} : { not: {} };
290
+ // Some provider wires have no boolean-schema representation: `true`
291
+ // becomes `{}`; `false` uses `not` when supported, or the permissive
292
+ // `{}` fallback when the provider cannot express an impossible schema.
293
+ const mode = options.coerceBooleanSubschemas;
294
+ if (!mode || !options.booleanIsSubschema) return value;
295
+ return value || mode === "permissive" ? {} : { not: {} };
293
296
  }
294
297
  if (!isJsonObject(value)) {
295
298
  return value;
@@ -993,7 +996,7 @@ export function normalizeSchema(value: unknown, options: NormalizeSchemaOptions)
993
996
 
994
997
  export function normalizeSchemaForGoogle(value: unknown): unknown {
995
998
  return normalizeSchema(value, {
996
- coerceBooleanSubschemas: true,
999
+ coerceBooleanSubschemas: "standard",
997
1000
  unsupportedFields: isGoogleUnsupportedSchemaField,
998
1001
  normalizeFieldNames: true,
999
1002
  collapseNullFields: true,
@@ -1015,7 +1018,7 @@ export function normalizeSchemaForGoogle(value: unknown): unknown {
1015
1018
 
1016
1019
  export function normalizeSchemaForCCA(value: unknown): unknown {
1017
1020
  return normalizeSchema(value, {
1018
- coerceBooleanSubschemas: true,
1021
+ coerceBooleanSubschemas: "standard",
1019
1022
  unsupportedFields: isGoogleUnsupportedSchemaField,
1020
1023
  normalizeFieldNames: true,
1021
1024
  collapseNullFields: false,
@@ -1080,6 +1083,9 @@ export function normalizeSchemaForMCP(value: unknown): unknown {
1080
1083
  * `default` and `description` are MFJS Meta Data fields and are preserved.
1081
1084
  * - `additionalProperties` (boolean or schema) and `type: "null"` (incl.
1082
1085
  * inside `anyOf`) are kept.
1086
+ * - Boolean subschemas are object-coerced; MFJS has no exact `false` schema,
1087
+ * so both values become the permissive empty schema while local tool
1088
+ * validation remains authoritative.
1083
1089
  *
1084
1090
  * Out of scope (absent from the built-in tool surface, spec-ambiguous to
1085
1091
  * rewrite blindly): `allOf` intersection merging, external/recursive `$ref`,
@@ -1087,6 +1093,7 @@ export function normalizeSchemaForMCP(value: unknown): unknown {
1087
1093
  */
1088
1094
  export function normalizeSchemaForMoonshot(value: unknown): unknown {
1089
1095
  return normalizeSchema(value, {
1096
+ coerceBooleanSubschemas: "permissive",
1090
1097
  unsupportedFields: isMoonshotUnsupportedSchemaField,
1091
1098
  normalizeFieldNames: false,
1092
1099
  collapseNullFields: false,