@oh-my-pi/pi-ai 15.9.0 → 15.9.3

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/types/auth-broker/index.d.ts +1 -0
  3. package/dist/types/auth-broker/remote-store.d.ts +5 -0
  4. package/dist/types/auth-broker/snapshot-cache.d.ts +17 -0
  5. package/dist/types/auth-broker/types.d.ts +2 -0
  6. package/dist/types/auth-storage.d.ts +23 -0
  7. package/dist/types/provider-models/openai-compat.d.ts +28 -1
  8. package/dist/types/providers/openai-completions-compat.d.ts +3 -1
  9. package/dist/types/providers/openai-responses-shared.d.ts +6 -1
  10. package/dist/types/providers/vision-guard.d.ts +13 -1
  11. package/dist/types/types.d.ts +19 -1
  12. package/dist/types/utils/oauth/types.d.ts +1 -1
  13. package/dist/types/utils/oauth/xiaomi.d.ts +10 -4
  14. package/package.json +2 -2
  15. package/src/auth-broker/index.ts +1 -0
  16. package/src/auth-broker/remote-store.ts +14 -0
  17. package/src/auth-broker/snapshot-cache.ts +174 -0
  18. package/src/auth-broker/types.ts +3 -0
  19. package/src/auth-storage.ts +240 -49
  20. package/src/models.json +96 -13
  21. package/src/provider-models/descriptors.ts +9 -0
  22. package/src/provider-models/openai-compat.ts +69 -17
  23. package/src/providers/anthropic.ts +0 -1
  24. package/src/providers/azure-openai-responses.ts +1 -1
  25. package/src/providers/openai-completions-compat.ts +31 -11
  26. package/src/providers/openai-completions.ts +15 -12
  27. package/src/providers/openai-responses-shared.ts +160 -105
  28. package/src/providers/openai-responses.ts +1 -1
  29. package/src/providers/vision-guard.ts +22 -1
  30. package/src/stream.ts +3 -0
  31. package/src/types.ts +21 -0
  32. package/src/usage/claude.ts +1 -1
  33. package/src/utils/oauth/index.ts +18 -0
  34. package/src/utils/oauth/types.ts +3 -0
  35. package/src/utils/oauth/xiaomi.ts +72 -17
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.9.2] - 2026-06-05
6
+
7
+ ### Added
8
+
9
+ - Added an AES-256-GCM auth-broker snapshot cache module and `RemoteAuthCredentialStoreOptions.onSnapshot` so broker clients can persist broker-sourced full snapshots without blocking startup on every run.
10
+ - Added `Model.omitMaxOutputTokens` so providers (notably Ollama proxies fronting cloud catalogs) can suppress `max_output_tokens` (Responses) and `max_tokens`/`max_completion_tokens` (Completions) on the wire while still using the catalog `maxTokens` for local budgeting. Without it, `applyCommonResponsesSamplingParams` unconditionally sent the catalog cap and HTTP-400'd against upstream APIs whose true output limit was unknown to OMP. ([#1881](https://github.com/can1357/oh-my-pi/issues/1881))
11
+
12
+ ### Changed
13
+
14
+ - Changed usage-ranked OAuth credential selection to pick deterministic session-sticky weighted buckets instead of always choosing the top-ranked account, capping the best account at 2x the baseline session likelihood while keeping equal-priority accounts evenly balanced.
15
+
16
+ ### Fixed
17
+
18
+ - Fixed parallel `function_call` items on the OpenAI Responses API losing arguments on every call except the last when the upstream server interleaves their stream events (observed against llama.cpp and other local Responses-compat hosts). `processResponsesStream` no longer routes `function_call_arguments.{delta,done}`, `output_item.done`, content_part/text/refusal/reasoning events through a singleton `currentItem`/`currentBlock` reference; it now tracks every open item in registries keyed by `output_index` and `item_id` so each event is folded into the matching block and the emitted `toolcall_end` carries the correct `contentIndex`. ([#1880](https://github.com/can1357/oh-my-pi/issues/1880))
19
+
20
+ ## [15.9.1] - 2026-06-04
21
+
22
+ ### Added
23
+
24
+ - Added regional Xiaomi Token Plan login/provider entries (`xiaomi-token-plan-sgp`, `xiaomi-token-plan-ams`, `xiaomi-token-plan-cn`) so `omp login` can store token-plan keys against the selected region. ([#1846](https://github.com/can1357/oh-my-pi/issues/1846))
25
+
26
+ ### Fixed
27
+
28
+ - Removed the `context-1m-2025-08-07` (1M long-context) beta from the Anthropic agent request headers, the OAuth model-discovery header, and the Claude usage-API header. Sending it caused subscription/OAuth requests without long-context credits to fail with `429 Usage credits are required for long context requests`, breaking Sonnet. The remaining betas are unchanged.
29
+ - Fixed Kimi K2.x `maxTokens` on Fireworks and Fire Pass (`fireworks/kimi-k2.5`, `fireworks/kimi-k2.6`, `firepass/kimi-k2.6-turbo`) being inherited from Fireworks `/v1/models` discovery (`max_completion_tokens: 65536`) rather than the published Kimi-on-Fireworks output budget, which let callers (and the openai-completions default-injection safety net) ship a budget the router cannot honor and made runaway reasoning traces more likely. The Fireworks resolver now clamps every Kimi K2.x id (public catalog ids and the canonical `accounts/fireworks/{models,routers}/kimi-k2…` wire form) to 32,768 output tokens, and the generator applies the same cap as a post-processing safety net so the `firepass` static fallback and the bundled `fireworks` entries stay in sync across regens. ([#1849](https://github.com/can1357/oh-my-pi/issues/1849))
30
+ - Fixed Xiaomi Token Plan MiMo OpenAI-compatible tool-call continuations omitting required `reasoning_content` replay. ([#1846](https://github.com/can1357/oh-my-pi/issues/1846))
31
+ - Fixed Anthropic prompt caching for OpenAI-compatible Claude proxies by honoring `compat.cacheControlFormat: "anthropic"` outside OpenRouter. ([#1845](https://github.com/can1357/oh-my-pi/issues/1845))
32
+ - Fixed Moonshot Kimi K2.6 silently pausing for many seconds between tool calls because the server discarded the `reasoning_content` that omp was already sending with every assistant tool-call replay. The K2.6 `thinking` parameter takes an extra `keep` field whose default (`null`) ignores historical reasoning, so K2.6 had to re-derive its full chain-of-thought from the user prompt on every iteration of the agent loop. The Moonshot direct (`api.moonshot.ai`) and Kimi Code (`api.kimi.com`) wire bodies now send `thinking: { type: "enabled", keep: "all" }` for `kimi-k2.6` requests with reasoning enabled, matching Moonshot's documented best practice for multi-step tool-calling agents. The flag is gated on the K2.6 id and the two native hosts because earlier Moonshot models (K2.5 and below) 400 on the unknown field and every Kimi gateway (OpenRouter, OpenCode, Kilo, Fireworks, …) speaks its own thinking shape. ([#1838](https://github.com/can1357/oh-my-pi/issues/1838))
33
+ - Fixed Alibaba DashScope (Bailian) compatible-mode endpoint `400 InternalError.Algo.InvalidParameter: The provided messages input is invalid. The error info is [Unexpected item type in content.]` when a screenshot or other image-producing tool result was folded into a known text-only Qwen turn (e.g. `qwen3.7-max`, `qwen-max`, `qwen3-coder-*`) hosted at `dashscope.aliyuncs.com/compatible-mode/v1`. `convertMessages` in `openai-completions` no longer forwards `image_url` content parts for those text-only id families even when a misconfigured custom provider claims `input: ["text", "image"]`; multimodal compatible-mode ids such as `qwen3.7-plus` and `qwen-vl-max` still rely on the catalog `input` field. The tool-result branch and the user-content branch both fall back to the standard `[image omitted: model does not support vision]` placeholder for text-only ids so the model still sees the attachment intent. ([#1859](https://github.com/can1357/oh-my-pi/issues/1859))
34
+
5
35
  ## [15.9.0] - 2026-06-04
6
36
 
7
37
  ### Fixed
@@ -18,6 +48,7 @@
18
48
  - Fixed MiniMax Coding Plan China login opening the international `platform.minimax.io` subscription page instead of the China `platform.minimaxi.com` page.
19
49
 
20
50
  ## [15.8.0] - 2026-06-02
51
+
21
52
  ### Added
22
53
 
23
54
  - Added `AnthropicMessagesClient` and related Anthropic wire types/errors via `anthropic-client` export so callers can build a standalone Anthropic Messages client without depending on `@anthropic-ai/sdk`
@@ -2,4 +2,5 @@ export * from "./client";
2
2
  export * from "./refresher";
3
3
  export * from "./remote-store";
4
4
  export * from "./server";
5
+ export * from "./snapshot-cache";
5
6
  export * from "./types";
@@ -16,6 +16,11 @@ export interface RemoteAuthCredentialStoreOptions {
16
16
  * to long-poll permanently when the broker returns 404. Default `true`.
17
17
  */
18
18
  streamSnapshots?: boolean;
19
+ /**
20
+ * Called after broker-sourced full snapshots are applied. The constructor's
21
+ * initial snapshot intentionally does not trigger this hook.
22
+ */
23
+ onSnapshot?: (snapshot: SnapshotResponse, generation: number) => void;
19
24
  }
20
25
  export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
21
26
  #private;
@@ -0,0 +1,17 @@
1
+ import type { SnapshotResponse } from "./types";
2
+ export interface ReadAuthBrokerSnapshotCacheOptions {
3
+ path: string;
4
+ token: string;
5
+ url: string;
6
+ ttlMs: number;
7
+ /** Override clock for deterministic tests. */
8
+ now?: () => number;
9
+ }
10
+ export interface WriteAuthBrokerSnapshotCacheOptions {
11
+ path: string;
12
+ token: string;
13
+ url: string;
14
+ snapshot: SnapshotResponse;
15
+ }
16
+ export declare function readAuthBrokerSnapshotCache(opts: ReadAuthBrokerSnapshotCacheOptions): Promise<SnapshotResponse | null>;
17
+ export declare function writeAuthBrokerSnapshotCache(opts: WriteAuthBrokerSnapshotCacheOptions): Promise<void>;
@@ -96,6 +96,8 @@ export declare const DEFAULT_AUTH_BROKER_BIND = "127.0.0.1:8765";
96
96
  export declare const DEFAULT_REFRESH_SKEW_MS: number;
97
97
  /** Default broker refresh-loop cadence. */
98
98
  export declare const DEFAULT_REFRESH_INTERVAL_MS = 60000;
99
+ /** Default freshness window for the encrypted local broker snapshot cache. */
100
+ export declare const DEFAULT_SNAPSHOT_CACHE_TTL_MS: number;
99
101
  /** Keepalive cadence for `GET /v1/snapshot/stream` SSE comments. */
100
102
  export declare const DEFAULT_STREAM_KEEPALIVE_MS = 20000;
101
103
  /**
@@ -377,11 +377,25 @@ type AuthApiKeyOptions = {
377
377
  */
378
378
  export interface OAuthAccess {
379
379
  accessToken: string;
380
+ credentialId?: number;
380
381
  accountId?: string;
381
382
  email?: string;
382
383
  projectId?: string;
383
384
  enterpriseUrl?: string;
384
385
  }
386
+ export interface OAuthAccessFailure {
387
+ credentialId?: number;
388
+ accountId?: string;
389
+ email?: string;
390
+ projectId?: string;
391
+ enterpriseUrl?: string;
392
+ error: string;
393
+ }
394
+ export type OAuthAccessResolution = ({
395
+ ok: true;
396
+ } & OAuthAccess) | ({
397
+ ok: false;
398
+ } & OAuthAccessFailure);
385
399
  export interface InvalidateCredentialMatchingOptions {
386
400
  signal?: AbortSignal;
387
401
  sessionId?: string;
@@ -622,6 +636,15 @@ export declare class AuthStorage {
622
636
  * OAuth with an explicit API key.
623
637
  */
624
638
  getOAuthAccess(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<OAuthAccess | undefined>;
639
+ /**
640
+ * Resolve every stored OAuth credential for `provider` independently.
641
+ *
642
+ * Refreshes credentials through the same broker/local path as
643
+ * {@link AuthStorage.getOAuthAccess}, but does not rank, round-robin, or
644
+ * stop after the first usable account. Intended for diagnostics that must
645
+ * exercise each stored account exactly once.
646
+ */
647
+ getOAuthAccesses(provider: string, options?: AuthApiKeyOptions): Promise<OAuthAccessResolution[]>;
625
648
  invalidateCredentialMatching(provider: string, apiKey: string, options?: InvalidateCredentialMatchingOptions): Promise<boolean>;
626
649
  invalidateCredentialMatching(provider: string, apiKey: string, signal?: AbortSignal): Promise<boolean>;
627
650
  /**
@@ -1,5 +1,5 @@
1
1
  import type { ModelManagerOptions } from "../model-manager";
2
- import type { Api, Model } from "../types";
2
+ import type { Api, Model, Provider } from "../types";
3
3
  export interface ModelsDevModel {
4
4
  id?: string;
5
5
  name?: string;
@@ -110,6 +110,27 @@ export interface ZhipuCodingPlanModelManagerConfig {
110
110
  baseUrl?: string;
111
111
  }
112
112
  export declare function zhipuCodingPlanModelManagerOptions(config?: ZhipuCodingPlanModelManagerConfig): ModelManagerOptions<"openai-completions">;
113
+ /**
114
+ * Fireworks-published cap for the Kimi K2 family. Fireworks' `/v1/models`
115
+ * envelope generically reports `max_completion_tokens: 65536` for every Kimi
116
+ * deployment, but Kimi K2 (instruct / thinking / turbo) on Fireworks is
117
+ * documented to ship long reasoning traces that should be bounded — capping
118
+ * at 32,768 prevents handing callers a budget the router cannot honor.
119
+ * See https://github.com/can1357/oh-my-pi/issues/1849.
120
+ */
121
+ export declare const FIREWORKS_KIMI_MAX_TOKENS = 32768;
122
+ /**
123
+ * Returns true for any Kimi K2.x public model id served by Fireworks-backed
124
+ * providers (`fireworks` direct, `firepass` router). Matches both the public
125
+ * catalog id (`kimi-k2.5`, `kimi-k2.6`, `kimi-k2.6-turbo`) and the canonical
126
+ * Fireworks wire id (`accounts/fireworks/{models,routers}/kimi-k2…`).
127
+ */
128
+ export declare function isFireworksKimiK2ModelId(modelId: string): boolean;
129
+ /**
130
+ * Clamp the Kimi K2 family's `maxTokens` to {@link FIREWORKS_KIMI_MAX_TOKENS}
131
+ * on Fireworks-backed providers, leaving every other model untouched.
132
+ */
133
+ export declare function clampFireworksKimiMaxTokens(modelId: string, candidate: number): number;
113
134
  export interface FireworksModelManagerConfig {
114
135
  apiKey?: string;
115
136
  baseUrl?: string;
@@ -219,10 +240,16 @@ export interface CloudflareAiGatewayModelManagerConfig {
219
240
  baseUrl?: string;
220
241
  }
221
242
  export declare function cloudflareAiGatewayModelManagerOptions(config?: CloudflareAiGatewayModelManagerConfig): ModelManagerOptions<"anthropic-messages">;
243
+ /** Region codes for Xiaomi Token Plan clusters exposed as separate login providers. */
244
+ export type XiaomiTokenPlanRegion = "sgp" | "ams" | "cn";
245
+ /** Configures Xiaomi standard or regional Token Plan OpenAI-compatible model discovery. */
222
246
  export interface XiaomiModelManagerConfig {
223
247
  apiKey?: string;
224
248
  baseUrl?: string;
249
+ providerId?: Provider;
250
+ tokenPlanRegion?: XiaomiTokenPlanRegion;
225
251
  }
252
+ /** Builds a Xiaomi model manager, preserving Token Plan region provider ids during discovery. */
226
253
  export declare function xiaomiModelManagerOptions(config?: XiaomiModelManagerConfig): ModelManagerOptions<"openai-completions">;
227
254
  export interface LiteLLMModelManagerConfig {
228
255
  apiKey?: string;
@@ -1,9 +1,11 @@
1
1
  import type { Model, OpenAICompat } from "../types";
2
2
  type ResolvedToolStrictMode = NonNullable<OpenAICompat["toolStrictMode"]> | "mixed";
3
- export type ResolvedOpenAICompat = Required<Omit<OpenAICompat, "openRouterRouting" | "vercelGatewayRouting" | "extraBody" | "toolStrictMode">> & {
3
+ export type ResolvedOpenAICompat = Required<Omit<OpenAICompat, "openRouterRouting" | "vercelGatewayRouting" | "extraBody" | "toolStrictMode" | "cacheControlFormat" | "thinkingKeep">> & {
4
4
  openRouterRouting?: OpenAICompat["openRouterRouting"];
5
5
  vercelGatewayRouting?: OpenAICompat["vercelGatewayRouting"];
6
6
  extraBody?: OpenAICompat["extraBody"];
7
+ cacheControlFormat?: OpenAICompat["cacheControlFormat"];
8
+ thinkingKeep?: OpenAICompat["thinkingKeep"];
7
9
  toolStrictMode: ResolvedToolStrictMode;
8
10
  };
9
11
  /**
@@ -64,8 +64,13 @@ type CommonSamplingOptions = Pick<StreamOptions, "temperature" | "topP" | "topK"
64
64
  /**
65
65
  * Apply the common `StreamOptions` → Responses sampling-parameter mapping (max output tokens,
66
66
  * temperature, top-p/k, min-p, presence/repetition penalties, service tier). Mutates `params`.
67
+ *
68
+ * `max_output_tokens` is suppressed when {@link Model.omitMaxOutputTokens} is `true`, so
69
+ * proxies (notably Ollama) that forward to upstream APIs with an unknown output-token cap
70
+ * can let the upstream apply its own default instead of 400-ing on `maxTokens` values that
71
+ * reflect the model's context window rather than the upstream output limit.
67
72
  */
68
- export declare function applyCommonResponsesSamplingParams<P extends CommonResponsesParams>(params: P, options: CommonSamplingOptions | undefined, provider: string): void;
73
+ export declare function applyCommonResponsesSamplingParams<P extends CommonResponsesParams>(params: P, options: CommonSamplingOptions | undefined, model: Pick<Model, "provider" | "omitMaxOutputTokens">): void;
69
74
  type ReasoningOptions = {
70
75
  reasoning?: string;
71
76
  reasoningSummary?: "auto" | "detailed" | "concise" | null;
@@ -1,4 +1,4 @@
1
- import type { ImageContent, TextContent } from "../types";
1
+ import type { ImageContent, Model, TextContent } from "../types";
2
2
  export declare const NON_VISION_IMAGE_PLACEHOLDER = "[image omitted: model does not support vision]";
3
3
  export declare function partitionVisionContent(content: ReadonlyArray<TextContent | ImageContent>, supportsImages: boolean): {
4
4
  textBlocks: TextContent[];
@@ -6,3 +6,15 @@ export declare function partitionVisionContent(content: ReadonlyArray<TextConten
6
6
  omittedImages: boolean;
7
7
  };
8
8
  export declare function joinTextWithImagePlaceholder(text: string, omittedImages: boolean): string;
9
+ /**
10
+ * Detect known text-only Qwen models served via Alibaba DashScope's consumer
11
+ * `compatible-mode` endpoint that the upstream chat-completions API rejects
12
+ * multimodal content arrays for. The compatible-mode endpoint also serves
13
+ * multimodal Qwen SKUs without `vl` in the id (e.g. `qwen3.7-plus`), so this
14
+ * guard only covers families verified to be text-only for issue #1859:
15
+ * `qwen*-max` and `qwen*-coder*`.
16
+ *
17
+ * Used as a defensive override in `convertMessages` so a misconfigured custom
18
+ * provider (issue #1859) can't drive the request into an unrecoverable 400.
19
+ */
20
+ export declare function isDashscopeCompatibleModeTextOnlyQwen(model: Model<"openai-completions">): boolean;
@@ -48,7 +48,7 @@ export interface ThinkingConfig {
48
48
  /** Provider-specific transport used to encode the selected effort. */
49
49
  mode: ThinkingControlMode;
50
50
  }
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" | "wafer-pass" | "wafer-serverless" | "zenmux" | "lm-studio";
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
53
  import type { Effort } from "./model-thinking";
54
54
  /** Token budgets for each thinking level (token-based providers only) */
@@ -421,6 +421,8 @@ export interface UserMessage {
421
421
  content: string | (TextContent | ImageContent)[];
422
422
  /** True if the message was injected by the system (e.g., auto-continue). */
423
423
  synthetic?: boolean;
424
+ /** True when injected mid-turn as a steer; consumed by the agent's pre-LLM transform to wrap it for emphasis. Never rendered. */
425
+ steering?: boolean;
424
426
  /** Who initiated this message for billing/attribution semantics. */
425
427
  attribution?: MessageAttribution;
426
428
  /** Provider-specific opaque payload used to reconstruct transport-native history. */
@@ -650,6 +652,8 @@ export interface OpenAICompat {
650
652
  requiresMistralToolIds?: boolean;
651
653
  /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "zai" uses thinking: { type: "enabled" | "disabled" } (also used by Moonshot Kimi), "qwen" uses top-level enable_thinking, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */
652
654
  thinkingFormat?: "openai" | "openrouter" | "zai" | "qwen" | "qwen-chat-template";
655
+ /** Optional `thinking.keep` value for Z.ai/Moonshot-style thinking params. Set false to suppress auto-detected keep. Default: auto-detected. */
656
+ thinkingKeep?: "all" | false;
653
657
  /** Which reasoning content field to emit on assistant messages. Default: auto-detected. */
654
658
  reasoningContentField?: "reasoning_content" | "reasoning" | "reasoning_text";
655
659
  /** Whether assistant tool-call messages must include reasoning content. Default: false. */
@@ -681,6 +685,8 @@ export interface OpenAICompat {
681
685
  vercelGatewayRouting?: VercelGatewayRouting;
682
686
  /** Extra fields to include in request body (e.g. gateway routing hints for OpenClaw-style proxies). */
683
687
  extraBody?: Record<string, unknown>;
688
+ /** Whether chat-completions payloads should include provider-specific prompt-cache markers. */
689
+ cacheControlFormat?: "anthropic" | undefined;
684
690
  /** Whether the provider supports the `strict` field in tool definitions. Default: auto-detected per provider/baseUrl (conservative for unknown providers). */
685
691
  supportsStrictMode?: boolean;
686
692
  /** Whether tool schemas must be sent either all strict or all non-strict. Undefined keeps the existing per-tool mixed behavior. */
@@ -757,6 +763,18 @@ export interface Model<TApi extends Api = any> {
757
763
  premiumMultiplier?: number;
758
764
  contextWindow: number;
759
765
  maxTokens: number;
766
+ /**
767
+ * When `true`, providers MUST omit `max_output_tokens` (Responses) /
768
+ * `max_tokens` / `max_completion_tokens` (Completions) from the outbound
769
+ * request and let the upstream API decide the per-response cap. `maxTokens`
770
+ * is still used locally for budgeting (compaction, context promotion); only
771
+ * the wire field is suppressed.
772
+ *
773
+ * Use this for proxies (notably Ollama) that forward to a backend whose true
774
+ * output limit OMP cannot discover — sending the wrong value triggers 400s
775
+ * from the upstream provider.
776
+ */
777
+ omitMaxOutputTokens?: boolean;
760
778
  headers?: Record<string, string>;
761
779
  /**
762
780
  * Streaming transport override. When `"pi-native"`, `streamSimple` routes
@@ -7,7 +7,7 @@ export type OAuthCredentials = {
7
7
  email?: string;
8
8
  accountId?: string;
9
9
  };
10
- export type OAuthProvider = "alibaba-coding-plan" | "anthropic" | "cerebras" | "cloudflare-ai-gateway" | "cursor" | "deepseek" | "fireworks" | "firepass" | "github-copilot" | "google-gemini-cli" | "google-antigravity" | "gitlab-duo" | "huggingface" | "kimi-code" | "kilo" | "kagi" | "litellm" | "lm-studio" | "minimax-code" | "minimax-code-cn" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "openai-codex" | "openai-codex-device" | "opencode-go" | "openrouter" | "opencode-zen" | "parallel" | "perplexity" | "qianfan" | "qwen-portal" | "synthetic" | "tavily" | "together" | "venice" | "vercel-ai-gateway" | "wafer-pass" | "wafer-serverless" | "vllm" | "xai-oauth" | "xiaomi" | "zenmux" | "zai" | "zhipu-coding-plan";
10
+ export type OAuthProvider = "alibaba-coding-plan" | "anthropic" | "cerebras" | "cloudflare-ai-gateway" | "cursor" | "deepseek" | "fireworks" | "firepass" | "github-copilot" | "google-gemini-cli" | "google-antigravity" | "gitlab-duo" | "huggingface" | "kimi-code" | "kilo" | "kagi" | "litellm" | "lm-studio" | "minimax-code" | "minimax-code-cn" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "openai-codex" | "openai-codex-device" | "opencode-go" | "openrouter" | "opencode-zen" | "parallel" | "perplexity" | "qianfan" | "qwen-portal" | "synthetic" | "tavily" | "together" | "venice" | "vercel-ai-gateway" | "wafer-pass" | "wafer-serverless" | "vllm" | "xai-oauth" | "xiaomi" | "xiaomi-token-plan-sgp" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "zenmux" | "zai" | "zhipu-coding-plan";
11
11
  export type OAuthProviderId = OAuthProvider | (string & {});
12
12
  export type OAuthPrompt = {
13
13
  message: string;
@@ -4,12 +4,12 @@
4
4
  * Xiaomi MiMo provides OpenAI-compatible models via
5
5
  * https://api.xiaomimimo.com/v1.
6
6
  *
7
- * This is not OAuth - it's a simple API key flow:
8
- * 1. Open browser to Xiaomi MiMo API key console
9
- * 2. User copies their API key
10
- * 3. User pastes the API key into the CLI
7
+ * Standard Xiaomi login opens the pay-as-you-go API key console. Token Plan
8
+ * login opens plan management so users copy the regional `tp-...` key.
11
9
  */
12
10
  import type { OAuthController } from "./types";
11
+ /** Region codes accepted by the Xiaomi Token Plan login flow. */
12
+ export type XiaomiTokenPlanRegion = "sgp" | "ams" | "cn";
13
13
  /**
14
14
  * Login to Xiaomi MiMo.
15
15
  *
@@ -17,3 +17,9 @@ import type { OAuthController } from "./types";
17
17
  * Returns the API key directly (not OAuthCredentials - this isn't OAuth).
18
18
  */
19
19
  export declare function loginXiaomi(options: OAuthController): Promise<string>;
20
+ /**
21
+ * Login to a regional Xiaomi Token Plan endpoint.
22
+ *
23
+ * Prompts for a token-plan API key and validates it against the selected region.
24
+ */
25
+ export declare function loginXiaomiTokenPlan(options: OAuthController, region: XiaomiTokenPlanRegion): Promise<string>;
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.0",
4
+ "version": "15.9.3",
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.0",
42
+ "@oh-my-pi/pi-utils": "15.9.3",
43
43
  "openai": "^6.39.0",
44
44
  "partial-json": "^0.1.7",
45
45
  "zod": "4.4.3"
@@ -2,4 +2,5 @@ export * from "./client";
2
2
  export * from "./refresher";
3
3
  export * from "./remote-store";
4
4
  export * from "./server";
5
+ export * from "./snapshot-cache";
5
6
  export * from "./types";
@@ -73,11 +73,17 @@ export interface RemoteAuthCredentialStoreOptions {
73
73
  * to long-poll permanently when the broker returns 404. Default `true`.
74
74
  */
75
75
  streamSnapshots?: boolean;
76
+ /**
77
+ * Called after broker-sourced full snapshots are applied. The constructor's
78
+ * initial snapshot intentionally does not trigger this hook.
79
+ */
80
+ onSnapshot?: (snapshot: SnapshotResponse, generation: number) => void;
76
81
  }
77
82
 
78
83
  export class RemoteAuthCredentialStore implements AuthCredentialStore {
79
84
  readonly #client: AuthBrokerClient;
80
85
  readonly #streamSnapshots: boolean;
86
+ readonly #onSnapshot?: (snapshot: SnapshotResponse, generation: number) => void;
81
87
  #snapshot: SnapshotResponse = emptySnapshot();
82
88
  #snapshotReceivedAt = Date.now();
83
89
  #generation = 0;
@@ -100,6 +106,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
100
106
  this.#client = opts.client;
101
107
  this.#streamSnapshots = opts.streamSnapshots ?? true;
102
108
  this.#applySnapshot(opts.initialSnapshot ?? emptySnapshot(), opts.initialSnapshot?.generation ?? 0);
109
+ this.#onSnapshot = opts.onSnapshot;
103
110
  void this.#runBackground();
104
111
  }
105
112
 
@@ -115,6 +122,13 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
115
122
  this.#snapshot = snapshot;
116
123
  this.#generation = generation;
117
124
  this.#snapshotReceivedAt = Date.now();
125
+ const onSnapshot = this.#onSnapshot;
126
+ if (!onSnapshot) return;
127
+ try {
128
+ onSnapshot(snapshot, generation);
129
+ } catch (error) {
130
+ logger.debug("auth-broker snapshot callback failed", { error: String(error) });
131
+ }
118
132
  }
119
133
 
120
134
  async #runBackground(): Promise<void> {
@@ -0,0 +1,174 @@
1
+ /**
2
+ * AES-GCM encrypted local cache for auth-broker snapshots.
3
+ *
4
+ * The cache is defense-in-depth for at-rest snapshots: a copied cache file is
5
+ * useless without the matching broker bearer token and URL. The token itself is
6
+ * still the trust boundary; a process that can read both the token and this file
7
+ * can decrypt the snapshot.
8
+ */
9
+ import * as fs from "node:fs/promises";
10
+ import * as path from "node:path";
11
+ import { isEnoent, logger } from "@oh-my-pi/pi-utils";
12
+ import type { SnapshotResponse } from "./types";
13
+ import { snapshotResponseSchema } from "./wire-schemas";
14
+
15
+ const MAGIC = new Uint8Array([0x4f, 0x4d, 0x50, 0x53]); // "OMPS"
16
+ const VERSION = 1;
17
+ const VERSION_OFFSET = MAGIC.byteLength;
18
+ const IV_OFFSET = VERSION_OFFSET + 1;
19
+ const IV_LENGTH = 12;
20
+ const HEADER_LENGTH = IV_OFFSET + IV_LENGTH;
21
+ const AES_ALGORITHM = "AES-GCM";
22
+ const TEXT_ENCODER = new TextEncoder();
23
+ const TEXT_DECODER = new TextDecoder();
24
+ const HEX = "0123456789abcdef";
25
+
26
+ export interface ReadAuthBrokerSnapshotCacheOptions {
27
+ path: string;
28
+ token: string;
29
+ url: string;
30
+ ttlMs: number;
31
+ /** Override clock for deterministic tests. */
32
+ now?: () => number;
33
+ }
34
+
35
+ export interface WriteAuthBrokerSnapshotCacheOptions {
36
+ path: string;
37
+ token: string;
38
+ url: string;
39
+ snapshot: SnapshotResponse;
40
+ }
41
+
42
+ export async function readAuthBrokerSnapshotCache(
43
+ opts: ReadAuthBrokerSnapshotCacheOptions,
44
+ ): Promise<SnapshotResponse | null> {
45
+ if (opts.ttlMs <= 0) return null;
46
+ let data: Uint8Array;
47
+ try {
48
+ data = await fs.readFile(opts.path);
49
+ } catch (error) {
50
+ if (isEnoent(error)) return null;
51
+ throw error;
52
+ }
53
+
54
+ try {
55
+ const plaintext = await decryptCachePayload(data, opts.token, opts.url);
56
+ if (!plaintext) return null;
57
+ const parsed: unknown = JSON.parse(TEXT_DECODER.decode(plaintext));
58
+ const result = snapshotResponseSchema.safeParse(parsed);
59
+ if (!result.success) {
60
+ logger.debug("auth-broker snapshot cache schema invalid", { path: opts.path });
61
+ return null;
62
+ }
63
+ const snapshot = result.data;
64
+ const now = opts.now?.() ?? Date.now();
65
+ if (now - snapshot.generatedAt > opts.ttlMs) return null;
66
+ return snapshot;
67
+ } catch (error) {
68
+ logger.debug("auth-broker snapshot cache read failed", { path: opts.path, error: String(error) });
69
+ return null;
70
+ }
71
+ }
72
+
73
+ export async function writeAuthBrokerSnapshotCache(opts: WriteAuthBrokerSnapshotCacheOptions): Promise<void> {
74
+ const payload = await encryptCachePayload(opts.snapshot, opts.token, opts.url);
75
+ await fs.mkdir(path.dirname(opts.path), { recursive: true });
76
+ const tmpPath = `${opts.path}.${process.pid}.${randomHex(8)}.tmp`;
77
+ let removeTemp = false;
78
+ try {
79
+ const handle = await fs.open(tmpPath, "wx", 0o600);
80
+ removeTemp = true;
81
+ try {
82
+ await handle.writeFile(payload);
83
+ } finally {
84
+ await handle.close();
85
+ }
86
+ await fs.chmod(tmpPath, 0o600);
87
+ await fs.rename(tmpPath, opts.path);
88
+ removeTemp = false;
89
+ } finally {
90
+ if (removeTemp) await fs.rm(tmpPath, { force: true }).catch(() => {});
91
+ }
92
+ }
93
+
94
+ async function encryptCachePayload(snapshot: SnapshotResponse, token: string, url: string): Promise<Uint8Array> {
95
+ const key = await deriveAesKey(token, ["encrypt"]);
96
+ const iv = new Uint8Array(IV_LENGTH);
97
+ globalThis.crypto.getRandomValues(iv);
98
+ const plaintext = TEXT_ENCODER.encode(JSON.stringify(snapshot));
99
+ const ciphertext = new Uint8Array(
100
+ await globalThis.crypto.subtle.encrypt(
101
+ {
102
+ name: AES_ALGORITHM,
103
+ iv,
104
+ additionalData: TEXT_ENCODER.encode(url),
105
+ },
106
+ key,
107
+ plaintext,
108
+ ),
109
+ );
110
+ const payload = new Uint8Array(HEADER_LENGTH + ciphertext.byteLength);
111
+ payload.set(MAGIC, 0);
112
+ payload[VERSION_OFFSET] = VERSION;
113
+ payload.set(iv, IV_OFFSET);
114
+ payload.set(ciphertext, HEADER_LENGTH);
115
+ return payload;
116
+ }
117
+
118
+ async function decryptCachePayload(data: Uint8Array, token: string, url: string): Promise<Uint8Array | null> {
119
+ if (data.byteLength <= HEADER_LENGTH) {
120
+ logger.debug("auth-broker snapshot cache file too short");
121
+ return null;
122
+ }
123
+ for (let i = 0; i < MAGIC.byteLength; i++) {
124
+ if (data[i] !== MAGIC[i]) {
125
+ logger.debug("auth-broker snapshot cache magic mismatch");
126
+ return null;
127
+ }
128
+ }
129
+ if (data[VERSION_OFFSET] !== VERSION) {
130
+ logger.debug("auth-broker snapshot cache version mismatch", { version: data[VERSION_OFFSET] });
131
+ return null;
132
+ }
133
+ const key = await deriveAesKey(token, ["decrypt"]);
134
+ const iv = asStrict(data.subarray(IV_OFFSET, HEADER_LENGTH));
135
+ const ciphertext = asStrict(data.subarray(HEADER_LENGTH));
136
+ try {
137
+ return new Uint8Array(
138
+ await globalThis.crypto.subtle.decrypt(
139
+ {
140
+ name: AES_ALGORITHM,
141
+ iv,
142
+ additionalData: TEXT_ENCODER.encode(url),
143
+ },
144
+ key,
145
+ ciphertext,
146
+ ),
147
+ );
148
+ } catch (error) {
149
+ logger.debug("auth-broker snapshot cache decrypt failed", { error: String(error) });
150
+ return null;
151
+ }
152
+ }
153
+
154
+ async function deriveAesKey(token: string, usages: Array<"encrypt" | "decrypt">): Promise<CryptoKey> {
155
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", TEXT_ENCODER.encode(token));
156
+ return globalThis.crypto.subtle.importKey("raw", digest, AES_ALGORITHM, false, usages);
157
+ }
158
+
159
+ function asStrict(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
160
+ if (bytes.buffer instanceof ArrayBuffer && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
161
+ return bytes as Uint8Array<ArrayBuffer>;
162
+ }
163
+ const copy = new Uint8Array(bytes.byteLength);
164
+ copy.set(bytes);
165
+ return copy;
166
+ }
167
+
168
+ function randomHex(byteLength: number): string {
169
+ const bytes = new Uint8Array(byteLength);
170
+ globalThis.crypto.getRandomValues(bytes);
171
+ let out = "";
172
+ for (const byte of bytes) out += HEX[byte >> 4] + HEX[byte & 15];
173
+ return out;
174
+ }
@@ -117,6 +117,9 @@ export const DEFAULT_REFRESH_SKEW_MS = 5 * 60_000;
117
117
  /** Default broker refresh-loop cadence. */
118
118
  export const DEFAULT_REFRESH_INTERVAL_MS = 60_000;
119
119
 
120
+ /** Default freshness window for the encrypted local broker snapshot cache. */
121
+ export const DEFAULT_SNAPSHOT_CACHE_TTL_MS = 60 * 60_000;
122
+
120
123
  /** Keepalive cadence for `GET /v1/snapshot/stream` SSE comments. */
121
124
  export const DEFAULT_STREAM_KEEPALIVE_MS = 20_000;
122
125