@oh-my-pi/pi-catalog 18.2.0 → 18.2.1

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 (41) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/types/compat/behavior.d.ts +11 -0
  3. package/dist/types/compat/types.d.ts +21 -0
  4. package/dist/types/discovery/antigravity.d.ts +10 -1
  5. package/dist/types/model-thinking.d.ts +7 -0
  6. package/dist/types/provider-models/openai-compat.d.ts +2 -0
  7. package/dist/types/types.d.ts +26 -1
  8. package/package.json +4 -4
  9. package/src/compat/axes.ts +14 -0
  10. package/src/compat/behavior.ts +22 -2
  11. package/src/compat/cascade.ts +3 -1
  12. package/src/compat/context-window.ts +11 -1
  13. package/src/compat/resolve.ts +42 -16
  14. package/src/compat/rules/README.md +3 -1
  15. package/src/compat/rules/classes/deepseek.kdl +9 -1
  16. package/src/compat/rules/classes/kimi.kdl +6 -0
  17. package/src/compat/rules/providers/alibaba-token-plan.kdl +16 -8
  18. package/src/compat/rules/providers/amazon-bedrock.kdl +30 -0
  19. package/src/compat/rules/providers/azure.kdl +6 -0
  20. package/src/compat/rules/providers/cerebras.kdl +10 -0
  21. package/src/compat/rules/providers/commandcode.kdl +20 -4
  22. package/src/compat/rules/providers/cursor.kdl +32 -0
  23. package/src/compat/rules/providers/deepseek.kdl +5 -5
  24. package/src/compat/rules/providers/google-vertex.kdl +15 -0
  25. package/src/compat/rules/providers/meta.kdl +3 -0
  26. package/src/compat/rules/providers/muse-code.kdl +3 -0
  27. package/src/compat/rules/providers/openrouter.kdl +6 -0
  28. package/src/compat/rules/runtime/behavior.kdl +17 -0
  29. package/src/compat/rules/taxonomy/deepseek.kdl +5 -0
  30. package/src/compat/rules.json +1 -1
  31. package/src/compat/types.ts +23 -0
  32. package/src/discovery/antigravity.ts +80 -43
  33. package/src/identity/bundled.ts +4 -3
  34. package/src/model-cache.ts +115 -37
  35. package/src/model-thinking.ts +10 -7
  36. package/src/models.json +1 -1
  37. package/src/provider-models/bundled-references.ts +4 -3
  38. package/src/provider-models/cache-provider-id.ts +14 -8
  39. package/src/provider-models/ollama.ts +11 -31
  40. package/src/provider-models/openai-compat.ts +94 -24
  41. package/src/types.ts +28 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.2.1] - 2026-09-15
6
+
7
+ ### Added
8
+
9
+ - DeepSeek V4.1 Flash (`deepseek-flash`) now accepts image inputs and resolves its reasoning metadata, name, and effort ladder from upstream instead of shipping as a text-only row without them.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed Cerebras Qwen 3.8 reasoning control by enabling correct effort tiers and disable behavior
14
+ - Fixed Cerebras Qwen models using incorrect reasoning format, enabling proper support for thinking modes
15
+ - Fixed Command Code's `deepseek/deepseek-v4.1-flash` row exposing its documented low/high/max thinking levels and image input, and made live discovery resolve the reasoning contract its rules declare ([#1666](https://github.com/can1357/oh-my-pi/issues/1666), [#11703](https://github.com/can1357/oh-my-pi/pull/11703) by [@aliefe04](https://github.com/aliefe04)).
16
+ - Fixed Meta Model API and Muse Code requests failing with 400 whenever omp forced a tool choice: `api.meta.ai/v1` accepts only `tool_choice: "auto"`, so `none`, `required`, and named choices (subagent final-retry `yield`, forced tools, structured output, compaction handoff) are now omitted instead of sent ([#11635](https://github.com/can1357/oh-my-pi/pull/11635) by [@quantmind-br](https://github.com/quantmind-br)).
17
+ - Bedrock's Qwen rows no longer ask for more output tokens than the model accepts, which Bedrock rejected with a 400. ([#12117](https://github.com/can1357/oh-my-pi/pull/12117) by [@Huang-404-Q](https://github.com/Huang-404-Q))
18
+ - Gemini 2.5 Flash Lite on Vertex AI no longer requests `maxOutputTokens=65536`, which the endpoint rejects with a 400; the output cap is clamped to 65535 ([#10595](https://github.com/can1357/oh-my-pi/pull/10595) by [@WeMingT](https://github.com/WeMingT)).
19
+ - Fixed Meta muse-spark models on OpenRouter wedging every turn with `400 Referenced reasoning item ... was not found or has expired`: replayed Responses reasoning history is now filtered for the muse-spark family, matching the existing Anthropic-on-OpenRouter treatment ([#10675](https://github.com/can1357/oh-my-pi/pull/10675) by [@Giardi77](https://github.com/Giardi77)).
20
+ - Fixed Ollama Cloud model discovery synthesizing a generic `minimal`/`low`/`medium`/`high` effort ladder for every thinking-capable model, which shadowed the per-model compat rules and made `max` unreachable on the DeepSeek V4 line (including the served `deepseek-v4.1-flash`, `deepseek-v4-flash:0731`, and `deepseek-v4-pro:0813` ids): discovery now leaves the ladder to the rule tree, so those models advertise the wire-exact `low`/`high`/`max` and GLM-5.3 exposes `low`/`high`/`max` ([#8334](https://github.com/can1357/oh-my-pi/issues/8334)).
21
+ - Fixed Meta Model API and Muse Code requests failing with 400 whenever omp forced a tool choice: `api.meta.ai/v1` accepts only `tool_choice: "auto"`, so `none`, `required`, and named choices (subagent final-retry `yield`, forced tools, structured output, compaction handoff) are now omitted instead of sent.
22
+ - Fixed Azure GPT-6 Astra Chat Completions policy to disable reasoning with function tools and encode thinking-off as `reasoning_effort: "none"` ([#11052](https://github.com/can1357/oh-my-pi/issues/11052)).
23
+ - Fixed Z.AI and Zhipu timezone-naive quota reset timestamps resolving eight hours late by declaring their UTC+8 reset timezone ([#11014](https://github.com/can1357/oh-my-pi/issues/11014)).
24
+ - Fixed Amazon Bedrock Claude Fable 5.1 effort metadata to expose `xhigh` and `max` instead of the unsupported `minimal` level. ([#10788](https://github.com/can1357/oh-my-pi/pull/10788) by [@voonfoo](https://github.com/voonfoo))
25
+ - LiteLLM discovery no longer exposes known task-specific models, including embedding, media, moderation, reranking, and search models, as coding models.
26
+ - Fixed custom `anthropic-messages` providers whose adaptive effort ladder exposes `minimal` sending `output_config.effort: "minimal"`, which the Anthropic Messages API rejects with `400 level "minimal" not supported`; the adaptive effort mapper now clamps `minimal` to `low` ([#10994](https://github.com/can1357/oh-my-pi/issues/10994)).
27
+ - Qwen 3.8 models on Alibaba Token Plan now send the selected reasoning effort and replay reasoning history across turns.
28
+ - Fixed DeepInfra model cost reporting so promotional pricing is reflected: the `metadata.discount` fraction is now applied to input, output, and cache-read rates ([#10935](https://github.com/can1357/oh-my-pi/issues/10935)).
29
+ - Fixed LiteLLM model discovery leaking ClinePass display names and pricing into models with colliding ids ([#10932](https://github.com/can1357/oh-my-pi/issues/10932)).
30
+ - Raised Cursor context windows to Cursor's documented sizes (Grok 4.5/4.6 256k, default/Auto 256k, Kimi K2.7 Code 262k, GPT-5.6 272k, Claude Opus 5 and Fable 300k by default) so compaction no longer fires too early, without lowering existing 1M Max-mode windows.
31
+
5
32
  ## [18.2.0] - 2026-09-15
6
33
 
7
34
  ### Breaking Changes
@@ -29,6 +29,8 @@ export declare function cursorModelParameters(model: string): readonly {
29
29
  * preserve quota semantics for newly discovered ids.
30
30
  */
31
31
  export declare function quotaTierFor(provider: string, model: string): string | undefined;
32
+ /** UTC offset for a provider's timezone-naive absolute retry-reset timestamps. */
33
+ export declare function retryResetTimezoneOffsetFor(provider: string): string | undefined;
32
34
  /** Whether a provider has catalog-authored model quota scopes. */
33
35
  export declare function hasQuotaTierPolicy(provider: string): boolean;
34
36
  /** The provider-default wire model for a model-less hosted operation. */
@@ -55,11 +57,20 @@ export declare function modelLimitsFor(provider: string, model: string): {
55
57
  } | undefined;
56
58
  /** Whether a provider roster entry is a declared non-chat/unsupported SKU. */
57
59
  export declare function isExcludedModel(provider: string, model: string): boolean;
60
+ /** Whether an exact upstream discovery mode is excluded from a provider's coding-model roster. */
61
+ export declare function isExcludedDiscoveryMode(provider: string, mode: string): boolean;
58
62
  /**
59
63
  * Whether a provider is retired: its entire roster is excluded from the
60
64
  * generated bundle and must never be resurrected from the previous snapshot.
61
65
  */
62
66
  export declare function isRetiredProvider(provider: string): boolean;
67
+ /**
68
+ * Whether a provider's bundled rows may seed cross-provider bare-id enrichment
69
+ * references. Gateway-namespaced providers (ClinePass) are isolated so their
70
+ * limits, pricing, and reasoning controls never contaminate an unrelated proxy
71
+ * model that happens to advertise the same bare id.
72
+ */
73
+ export declare function isBareIdReferenceProvider(provider: string): boolean;
63
74
  /** The declared subscription tier required to use a provider model id, if any. */
64
75
  export declare function planRequirementFor(provider: string, model: string): string | undefined;
65
76
  /** Cross-provider pricing-peer resolution for one provider model id. */
@@ -272,6 +272,11 @@ export interface CompiledExcludeModels {
272
272
  provider: string;
273
273
  match: CompiledMatchList;
274
274
  }
275
+ /** Exact upstream discovery modes excluded from one provider's coding-model roster. */
276
+ export interface CompiledExcludeDiscoveryModes {
277
+ provider: string;
278
+ modes: string[];
279
+ }
275
280
  /** Provider plan-requirement tiers keyed by matcher token lists. */
276
281
  export interface CompiledPlanRequirement {
277
282
  provider: string;
@@ -289,6 +294,11 @@ export interface CompiledPricingPeer {
289
294
  peerId: string;
290
295
  }[];
291
296
  }
297
+ /** Provider timezone assumption for offset-less absolute retry-reset timestamps. */
298
+ export interface CompiledRetryResetTimezone {
299
+ provider: string;
300
+ offset: string;
301
+ }
292
302
  /** Compiled runtime behavior vocabulary (`runtime/behavior.kdl`). */
293
303
  export interface CompiledBehavior {
294
304
  openaiResponsesHeuristic?: CompiledResponsesHeuristic;
@@ -299,10 +309,13 @@ export interface CompiledBehavior {
299
309
  hostedDefaults: CompiledHostedDefault[];
300
310
  apiRoutes: CompiledApiRoutes[];
301
311
  modelLimits: CompiledModelLimits[];
312
+ excludeDiscoveryModes: CompiledExcludeDiscoveryModes[];
302
313
  excludeModels: CompiledExcludeModels[];
303
314
  planRequirements: CompiledPlanRequirement[];
304
315
  pricingPeers: CompiledPricingPeer[];
316
+ retryResetTimezones: CompiledRetryResetTimezone[];
305
317
  retiredProviders: string[];
318
+ referenceIsolatedProviders: string[];
306
319
  }
307
320
  /**
308
321
  * A string setting from `auth/*.kdl` that may be overridden by environment
@@ -647,4 +660,12 @@ export interface ResolvedAxes {
647
660
  wire: Record<string, unknown>;
648
661
  thinking: Record<string, unknown>;
649
662
  catalog: Record<string, unknown>;
663
+ /**
664
+ * Reasoning capability after the exact-model effort upgrade: `true` when the
665
+ * target reported reasoning or an exact rule declares a ladder for it (the
666
+ * reviewed correction to metadata-less discovery rows). Compat resolvers
667
+ * read this instead of the raw spec flag, or one id resolves two different
668
+ * wire contracts depending on whether it came from discovery or the bake.
669
+ */
670
+ reasoning: boolean;
650
671
  }
@@ -1,3 +1,4 @@
1
+ import type { FetchImpl } from "@oh-my-pi/pi-utils";
1
2
  import { type VariantCollapseTable } from "../compat/collapse.js";
2
3
  import type { ModelSpec } from "../types.js";
3
4
  export declare const ANTIGRAVITY_PRIMARY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
@@ -37,6 +38,7 @@ export interface AntigravityDiscoveryAgentModelSort {
37
38
  export interface AntigravityDiscoveryApiResponse {
38
39
  models?: Record<string, AntigravityDiscoveryApiModel>;
39
40
  agentModelSorts?: AntigravityDiscoveryAgentModelSort[];
41
+ imageGenerationModelIds?: string[];
40
42
  }
41
43
  /**
42
44
  * Options for fetching Antigravity discovery models.
@@ -53,7 +55,7 @@ export interface FetchAntigravityDiscoveryModelsOptions {
53
55
  /** Optional abort signal for request cancellation. */
54
56
  signal?: AbortSignal;
55
57
  /** Optional fetch implementation override for tests. */
56
- fetcher?: typeof fetch;
58
+ fetcher?: FetchImpl;
57
59
  /**
58
60
  * Hand collapse table to apply to the discovered list. Defaults to the
59
61
  * Antigravity (budget-transport) table; `googleGeminiCli` passes the
@@ -68,3 +70,10 @@ export interface FetchAntigravityDiscoveryModelsOptions {
68
70
  * Returns `[]` only when the endpoint responds successfully with no usable models.
69
71
  */
70
72
  export declare function fetchAntigravityDiscoveryModels(options: FetchAntigravityDiscoveryModelsOptions): Promise<ModelSpec<"google-gemini-cli">[] | null>;
73
+ /** Advertised image model and serving endpoint for one Antigravity account. */
74
+ export interface AntigravityImageModel {
75
+ id: string;
76
+ endpoint: string;
77
+ }
78
+ /** Resolves the first image-generation model advertised by an Antigravity account. */
79
+ export declare function fetchAntigravityImageModel(options: FetchAntigravityDiscoveryModelsOptions): Promise<AntigravityImageModel | null>;
@@ -35,6 +35,13 @@ export declare function mapEffortToGoogleThinkingLevel<TApi extends Api>(effort:
35
35
  /**
36
36
  * Maps a normalized thinking effort to Anthropic adaptive effort values via
37
37
  * the model's baked `thinking.effortMap` (identity for unmapped efforts).
38
+ *
39
+ * The Anthropic adaptive wire vocabulary has no `minimal` tier (valid values:
40
+ * `low`, `medium`, `high`, `xhigh`, `max`); a model ladder that exposes
41
+ * `minimal` — custom `anthropic-messages` providers do, unlike the built-in
42
+ * Claude ladders — would otherwise serialize `output_config.effort: "minimal"`
43
+ * and 400 (`level "minimal" not supported`). Clamp it to the lowest real tier,
44
+ * mirroring {@link mapEffortToGoogleThinkingLevel}'s `minimal` handling.
38
45
  */
39
46
  export declare function mapEffortToAnthropicAdaptiveEffort<TApi extends Api>(model: ApiModel<TApi>, effort: Effort): "low" | "medium" | "high" | "xhigh" | "max" | "adaptive";
40
47
  /**
@@ -523,6 +523,8 @@ export interface FetchLiteLLMRichModelsOptions<TApi extends Api> {
523
523
  }
524
524
  export declare const OPENAI_COMPAT_DISCOVERY_DEFAULT_CONTEXT_WINDOW = 128000;
525
525
  export declare const OPENAI_COMPAT_DISCOVERY_DEFAULT_MAX_TOKENS = 32768;
526
+ /** Exclude only known non-conversational modes; unknown and non-string modes remain selectable for aliases. */
527
+ export declare function isSelectableLiteLLMModelMode(mode: unknown): boolean;
526
528
  export declare function normalizeLiteLLMManagementBaseUrl(baseUrl: string): string;
527
529
  export declare function resolveLiteLLMApi(entry: Record<string, unknown> | undefined, id: string, fallbackApi?: Api): Api;
528
530
  export declare function fetchLiteLLMRichModels<TApi extends Api>(options: FetchLiteLLMRichModelsOptions<TApi>): Promise<ModelSpec<TApi>[] | null>;
@@ -278,6 +278,12 @@ export interface OpenAICompat {
278
278
  * Default: auto-detected (DeepSeek reasoning models).
279
279
  */
280
280
  disableReasoningOnToolChoice?: boolean;
281
+ /**
282
+ * Disable reasoning whenever the request advertises function tools.
283
+ * Use for model surfaces that reject every tools-plus-reasoning combination.
284
+ * Default: false.
285
+ */
286
+ disableReasoningWithTools?: boolean;
281
287
  /** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */
282
288
  openRouterRouting?: OpenRouterRouting;
283
289
  /** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */
@@ -372,12 +378,24 @@ export interface OpenAICompat {
372
378
  strictResponsesPairing?: boolean;
373
379
  /** Whether the Responses API accepts the `detail: "original"` image hint. Default: auto-detected (false for GitHub Copilot, which rejects it with a 400). */
374
380
  supportsImageDetailOriginal?: boolean;
381
+ /**
382
+ * Whether the Responses endpoint accepts `configuration_update` input items
383
+ * that change `reasoning.effort` mid-conversation while the request-level
384
+ * effort stays pinned for prompt caching (GPT-6 Astra). Default:
385
+ * rule-detected (`true` for `gpt-6-astra` on any host, `false` otherwise).
386
+ * Set `false` for custom `openai-responses` / `openai-codex-responses`
387
+ * endpoints that reject the item type with HTTP 400; effort changes are then
388
+ * sent as the top-level `reasoning.effort`.
389
+ */
390
+ supportsConfigurationUpdate?: boolean;
375
391
  /** Whether streamed reasoning deltas for the same field may repeat the full cumulative text snapshot. Default: false. */
376
392
  reasoningDeltasMayBeCumulative?: boolean;
377
393
  /** Strip leaked DeepSeek chat-template special tokens from visible content deltas. Default: auto-detected. */
378
394
  stripDeepseekSpecialTokens?: boolean;
379
395
  /** Heal leaked chat-template/tool-call/thinking markup from visible content deltas. Default: auto-detected. */
380
396
  streamMarkupHealingPattern?: OpenAIStreamMarkupHealingPattern;
397
+ /** Whether this wire may revise already-streamed text (`stream-revision` axis). Unassigned: append-only. */
398
+ streamRevision?: "none" | "possible";
381
399
  /** Treat an empty length-finished stream as a context-window error. Default: auto-detected. */
382
400
  emptyLengthFinishIsContextError?: boolean;
383
401
  /** Normalize tool call ids to OpenAI's 40-character limit. Default: auto-detected. */
@@ -548,6 +566,8 @@ export interface AnthropicCompat {
548
566
  export interface BedrockCompat {
549
567
  /** Whether this endpoint accepts no checkpoints, automatic caching, or explicit cachePoint blocks. */
550
568
  promptCacheMode?: "none" | "automatic" | "explicit";
569
+ /** Whether this wire may revise already-streamed text (`stream-revision` axis). Unassigned: append-only. */
570
+ streamRevision?: "none" | "possible";
551
571
  /** Whether explicit cachePoint blocks accept `ttl: "1h"`; omitted TTL means Bedrock's 5-minute default. */
552
572
  supportsLongPromptCacheRetention?: boolean;
553
573
  /**
@@ -571,6 +591,8 @@ export interface BedrockCompat {
571
591
  /** Fully-resolved Bedrock Converse prompt-cache capabilities, materialized once by `buildModel`. */
572
592
  export interface ResolvedBedrockCompat {
573
593
  promptCacheMode: NonNullable<BedrockCompat["promptCacheMode"]>;
594
+ /** See {@link BedrockCompat.streamRevision}. */
595
+ streamRevision?: BedrockCompat["streamRevision"];
574
596
  supportsLongPromptCacheRetention: boolean;
575
597
  promptCacheMinimumTokens: number;
576
598
  promptCacheMaximumCheckpoints: number;
@@ -632,6 +654,7 @@ export interface ResolvedOpenAISharedCompat {
632
654
  filterReasoningHistory: boolean;
633
655
  disableReasoningOnForcedToolChoice: boolean;
634
656
  disableReasoningOnToolChoice: boolean;
657
+ disableReasoningWithTools?: boolean;
635
658
  supportsToolChoice: boolean;
636
659
  supportsForcedToolChoice: boolean;
637
660
  supportsNamedToolChoice: boolean;
@@ -649,6 +672,8 @@ export interface ResolvedOpenAISharedCompat {
649
672
  requiresAssistantContentForToolCalls: boolean;
650
673
  stripDeepseekSpecialTokens: boolean;
651
674
  streamMarkupHealingPattern?: OpenAIStreamMarkupHealingPattern;
675
+ /** See {@link OpenAICompat.streamRevision}. */
676
+ streamRevision?: OpenAICompat["streamRevision"];
652
677
  /** See {@link OpenAICompat.streamFirstEventTimeoutMs}. */
653
678
  streamFirstEventTimeoutMs?: number;
654
679
  reasoningDeltasMayBeCumulative: boolean;
@@ -689,7 +714,7 @@ export interface ResolvedOpenAISharedCompat {
689
714
  * `buildModel`; request handlers read fields and never detect, resolve, or
690
715
  * allocate.
691
716
  */
692
- export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat & Required<Omit<OpenAICompat, "supportsDeveloperRole" | "supportsReasoningEffort" | "reasoningEffortMap" | "supportsReasoningParams" | "supportsReasoningSummary" | "supportsSamplingParams" | "supportsPenaltyAndStopParams" | "thinkingFormat" | "kimiApiFormat" | "reasoningDisableMode" | "omitReasoningEffort" | "includeEncryptedReasoning" | "filterReasoningHistory" | "disableReasoningOnForcedToolChoice" | "disableReasoningOnToolChoice" | "supportsToolChoice" | "supportsForcedToolChoice" | "supportsNamedToolChoice" | "reasoningContentField" | "requiresReasoningContentForToolCalls" | "requiresReasoningContentForAllAssistantTurns" | "allowsSyntheticReasoningContentForToolCalls" | "replayReasoningContent" | "qwenPreserveThinking" | "qwenTemplateReasoningEffort" | "requiresThinkingAsText" | "requiresMistralToolIds" | "requiresToolResultName" | "requiresAssistantAfterToolResult" | "requiresAssistantContentForToolCalls" | "stripDeepseekSpecialTokens" | "streamMarkupHealingPattern" | "reasoningDeltasMayBeCumulative" | "emptyLengthFinishIsContextError" | "usesOpenAIToolCallIdLimit" | "promptCacheSessionHeader" | "supportsPromptCacheBreakpoints" | "promptCacheBreakpointTtl" | "openRouterRouting" | "isOpenRouterHost" | "supportsStrictMode" | "supportsLongPromptCacheRetention" | "alwaysSendMaxTokens" | "clampOutputToModelMax" | "wireModelIdMode" | "vercelGatewayRouting" | "extraBody" | "toolStrictMode" | "toolSchemaFlavor" | "streamFirstEventTimeoutMs" | "streamIdleTimeoutMs" | "cacheControlFormat" | "thinkingKeep" | "strictResponsesPairing" | "supportsImageDetailOriginal" | "stripImageInput" | "thinkingLoopGuard" | "whenThinking">> & {
717
+ export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat & Required<Omit<OpenAICompat, "supportsDeveloperRole" | "supportsReasoningEffort" | "reasoningEffortMap" | "supportsReasoningParams" | "supportsReasoningSummary" | "supportsSamplingParams" | "supportsPenaltyAndStopParams" | "thinkingFormat" | "kimiApiFormat" | "reasoningDisableMode" | "omitReasoningEffort" | "includeEncryptedReasoning" | "filterReasoningHistory" | "disableReasoningOnForcedToolChoice" | "disableReasoningOnToolChoice" | "disableReasoningWithTools" | "supportsToolChoice" | "supportsForcedToolChoice" | "supportsNamedToolChoice" | "reasoningContentField" | "requiresReasoningContentForToolCalls" | "requiresReasoningContentForAllAssistantTurns" | "allowsSyntheticReasoningContentForToolCalls" | "replayReasoningContent" | "qwenPreserveThinking" | "qwenTemplateReasoningEffort" | "requiresThinkingAsText" | "requiresMistralToolIds" | "requiresToolResultName" | "requiresAssistantAfterToolResult" | "requiresAssistantContentForToolCalls" | "stripDeepseekSpecialTokens" | "streamMarkupHealingPattern" | "reasoningDeltasMayBeCumulative" | "emptyLengthFinishIsContextError" | "usesOpenAIToolCallIdLimit" | "promptCacheSessionHeader" | "supportsPromptCacheBreakpoints" | "promptCacheBreakpointTtl" | "openRouterRouting" | "isOpenRouterHost" | "supportsStrictMode" | "supportsLongPromptCacheRetention" | "alwaysSendMaxTokens" | "clampOutputToModelMax" | "wireModelIdMode" | "vercelGatewayRouting" | "extraBody" | "toolStrictMode" | "toolSchemaFlavor" | "streamFirstEventTimeoutMs" | "streamIdleTimeoutMs" | "streamRevision" | "cacheControlFormat" | "thinkingKeep" | "strictResponsesPairing" | "supportsImageDetailOriginal" | "supportsConfigurationUpdate" | "stripImageInput" | "thinkingLoopGuard" | "whenThinking">> & {
693
718
  vercelGatewayRouting?: OpenAICompat["vercelGatewayRouting"];
694
719
  extraBody?: OpenAICompat["extraBody"];
695
720
  cacheControlFormat?: OpenAICompat["cacheControlFormat"];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-catalog",
4
- "version": "18.2.0",
4
+ "version": "18.2.1",
5
5
  "description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -35,12 +35,12 @@
35
35
  "gen:proto": "bun scripts/generate-protocols.ts"
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/omptype": "18.2.0",
39
- "@oh-my-pi/pi-utils": "18.2.0"
38
+ "@oh-my-pi/omptype": "18.2.1",
39
+ "@oh-my-pi/pi-utils": "18.2.1"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@bgotink/kdl": "0.4.0",
43
- "@oh-my-pi/pi-ai": "18.2.0",
43
+ "@oh-my-pi/pi-ai": "18.2.1",
44
44
  "@types/bun": "^1.3.14"
45
45
  },
46
46
  "engines": {
@@ -93,6 +93,7 @@ export const AXES: Readonly<Record<string, AxisDef>> = {
93
93
  "clamp-output-to-model-max": wire("clampOutputToModelMax", OAI),
94
94
  "disable-reasoning-on-forced-tool-choice": wire("disableReasoningOnForcedToolChoice", OAI),
95
95
  "disable-reasoning-on-tool-choice": wire("disableReasoningOnToolChoice", OAI),
96
+ "disable-reasoning-with-tools": wire("disableReasoningWithTools", ["openai"]),
96
97
  "drop-thinking-when-reasoning-effort": wire("dropThinkingWhenReasoningEffort", ["openai"]),
97
98
  "empty-length-finish-is-context-error": wire("emptyLengthFinishIsContextError", OAI),
98
99
  "extra-body": { ...wire("extraBody", ["openai"], "object"), verbatimKeys: true },
@@ -235,6 +236,19 @@ export const AXES: Readonly<Record<string, AxisDef>> = {
235
236
  "supports-function-part-id": wire("supportsFunctionPartId", ["google"]),
236
237
 
237
238
  // ── wire: shared across surfaces ──
239
+ /**
240
+ * Whether this wire may revise text it has already streamed: bytes
241
+ * reclassified out of the visible channel (a leaned-on thinking opener),
242
+ * carved into a tool call, reordered by content-block index, or replaced
243
+ * wholesale by an authoritative final payload. Unassigned means the wire
244
+ * only appends, so the transcript may retire finished lines into native
245
+ * scrollback while the turn is still streaming (see
246
+ * `AssistantMessageComponent`). Declare `possible` only with a citable
247
+ * mechanism: the renderer also verifies published rows every frame and stops
248
+ * retiring the block on the first mismatch, so this axis decides where
249
+ * mid-stream retirement is attempted, not whether it is safe.
250
+ */
251
+ "stream-revision": wire("streamRevision", [...OAI, "bedrock"], "scalar", ["none", "possible"]),
238
252
  "stream-first-event-timeout-ms": wire("streamFirstEventTimeoutMs", [...OAI, "google"]),
239
253
  "stream-idle-timeout-ms": wire("streamIdleTimeoutMs", [...OAI, "anthropic", "bedrock", "google"]),
240
254
  "strip-image-input": wire("stripImageInput", [...OAI, "anthropic", "google"]),
@@ -2,8 +2,8 @@
2
2
  * Typed accessors over the compiled runtime-behavior vocabulary
3
3
  * (`rules/runtime/behavior.kdl`): provider/model heuristics that run before
4
4
  * or outside exact bundled-model lookup — responses routing, API routing,
5
- * quota tiers, plan requirements, model limits, roster exclusions, hosted
6
- * defaults, and pricing peers.
5
+ * quota tiers, plan requirements, model limits, roster and discovery-mode
6
+ * exclusions, hosted defaults, and pricing peers.
7
7
  */
8
8
  import { globMatch } from "./cascade";
9
9
  import rules from "./rules.json";
@@ -103,6 +103,11 @@ export function quotaTierFor(provider: string, model: string): string | undefine
103
103
  return undefined;
104
104
  }
105
105
 
106
+ /** UTC offset for a provider's timezone-naive absolute retry-reset timestamps. */
107
+ export function retryResetTimezoneOffsetFor(provider: string): string | undefined {
108
+ return behavior.retryResetTimezones.find(rule => rule.provider === provider)?.offset;
109
+ }
110
+
106
111
  /** Whether a provider has catalog-authored model quota scopes. */
107
112
  export function hasQuotaTierPolicy(provider: string): boolean {
108
113
  return behavior.quotaTiers.some(rule => rule.provider === provider);
@@ -176,6 +181,11 @@ export function isExcludedModel(provider: string, model: string): boolean {
176
181
  return behavior.excludeModels.some(rule => rule.provider === provider && matchesList(rule.match, lower, lower));
177
182
  }
178
183
 
184
+ /** Whether an exact upstream discovery mode is excluded from a provider's coding-model roster. */
185
+ export function isExcludedDiscoveryMode(provider: string, mode: string): boolean {
186
+ return behavior.excludeDiscoveryModes.some(rule => rule.provider === provider && rule.modes.includes(mode));
187
+ }
188
+
179
189
  /**
180
190
  * Whether a provider is retired: its entire roster is excluded from the
181
191
  * generated bundle and must never be resurrected from the previous snapshot.
@@ -184,6 +194,16 @@ export function isRetiredProvider(provider: string): boolean {
184
194
  return behavior.retiredProviders.includes(provider);
185
195
  }
186
196
 
197
+ /**
198
+ * Whether a provider's bundled rows may seed cross-provider bare-id enrichment
199
+ * references. Gateway-namespaced providers (ClinePass) are isolated so their
200
+ * limits, pricing, and reasoning controls never contaminate an unrelated proxy
201
+ * model that happens to advertise the same bare id.
202
+ */
203
+ export function isBareIdReferenceProvider(provider: string): boolean {
204
+ return !behavior.referenceIsolatedProviders.includes(provider);
205
+ }
206
+
187
207
  /** The declared subscription tier required to use a provider model id, if any. */
188
208
  export function planRequirementFor(provider: string, model: string): string | undefined {
189
209
  const rule = behavior.planRequirements.find(candidate => candidate.provider === provider);
@@ -293,6 +293,7 @@ function cloneAxes(axes: ResolvedAxes): ResolvedAxes {
293
293
  wire: cloneAxisRecord(axes.wire),
294
294
  thinking: cloneAxisRecord(axes.thinking),
295
295
  catalog: cloneAxisRecord(axes.catalog),
296
+ reasoning: axes.reasoning,
296
297
  };
297
298
  }
298
299
 
@@ -326,7 +327,7 @@ export function resolveCascadeRules(cascade: CompiledCascade, target: ResolveTar
326
327
 
327
328
  function resolveOverIndex(index: RuleIndex, target: ResolveTarget): ResolvedAxes {
328
329
  const ranked = rankRelevantRules(index, prepareTarget(target));
329
- let reasoning = target.reasoning;
330
+ let reasoning = target.reasoning === true;
330
331
  if (!reasoning) {
331
332
  for (const { rule, rank } of ranked) {
332
333
  if (rule.hasExactEffortsRule && rank[0] === 2) {
@@ -347,5 +348,6 @@ function resolveOverIndex(index: RuleIndex, target: ResolveTarget): ResolvedAxes
347
348
  wire: collect(wire, rule => rule.wire),
348
349
  thinking: collect(thinking, rule => rule.thinking),
349
350
  catalog: collect(catalog, rule => rule.catalog),
351
+ reasoning,
350
352
  };
351
353
  }
@@ -6,8 +6,11 @@ import { resolveModelPolicy } from "./resolve";
6
6
  * Rule-owned maxima by provider/id/api. Resolve once per process rather than
7
7
  * walking the static policy cascade on every catalog rebuild. Null caches the
8
8
  * absence of a curated maximum; undefined means the key has not been resolved.
9
+ * Bounded: one entry per distinct model; the wire-id set is bounded.
9
10
  */
10
11
  const ruleMaximumCache = new Map<string, number | null>();
12
+ const clampOverrideCache = new Map<string, boolean>();
13
+ const RULE_POLICY_CACHE_MAX = 8192;
11
14
 
12
15
  /**
13
16
  * Extended-context capacity. Curated maxima correct stale lower discovery
@@ -20,6 +23,7 @@ export function resolveMaxContextWindow(model: Model): number | undefined {
20
23
  if (curated === undefined) {
21
24
  const maximum = resolveModelPolicy(toModelSpec(model)).catalog.maxContextWindow;
22
25
  curated = typeof maximum === "number" && Number.isFinite(maximum) && maximum > 0 ? maximum : null;
26
+ if (ruleMaximumCache.size >= RULE_POLICY_CACHE_MAX) ruleMaximumCache.clear();
23
27
  ruleMaximumCache.set(key, curated);
24
28
  }
25
29
 
@@ -49,7 +53,13 @@ export function codexOverrideCeiling(model: Model): number | undefined {
49
53
  * it here keeps provider deployment contracts out of TypeScript.
50
54
  */
51
55
  export function clampsContextOverride(model: Model): boolean {
52
- return resolveModelPolicy(toModelSpec(model)).catalog.clampContextOverride === true;
56
+ const key = `${model.provider} ${model.id} ${model.api}`;
57
+ const cached = clampOverrideCache.get(key);
58
+ if (cached !== undefined) return cached;
59
+ const clamps = resolveModelPolicy(toModelSpec(model)).catalog.clampContextOverride === true;
60
+ if (clampOverrideCache.size >= RULE_POLICY_CACHE_MAX) clampOverrideCache.clear();
61
+ clampOverrideCache.set(key, clamps);
62
+ return clamps;
53
63
  }
54
64
 
55
65
  /**
@@ -305,7 +305,11 @@ interface OpenAIDetection {
305
305
  isOpenRouter: boolean;
306
306
  }
307
307
 
308
- function detectOpenAI(spec: ModelSpec<"openai-completions" | "openrouter">, facts: IdentityFacts): OpenAIDetection {
308
+ function detectOpenAI(
309
+ spec: ModelSpec<"openai-completions" | "openrouter">,
310
+ facts: IdentityFacts,
311
+ reasoningCapable: boolean,
312
+ ): OpenAIDetection {
309
313
  const provider = spec.provider;
310
314
  const baseUrl = spec.baseUrl;
311
315
  const hostModel = { provider, baseUrl };
@@ -315,7 +319,7 @@ function detectOpenAI(spec: ModelSpec<"openai-completions" | "openrouter">, fact
315
319
  const isXiaomiHost = modelMatchesHost(hostModel, "xiaomi");
316
320
  const isDirectDeepseekApi = modelMatchesHost(hostModel, "deepseekDirect");
317
321
  const isDeepseekFamily = modelMatchesHost(hostModel, "deepseekFamily") || facts.is("deepseek");
318
- const isDeepseekReasoning = isDeepseekFamily && Boolean(spec.reasoning);
322
+ const isDeepseekReasoning = isDeepseekFamily && reasoningCapable;
319
323
  const isLocalOpenAICompatBackend =
320
324
  PROXY_OPENAI_COMPAT_PROVIDERS[provider] !== true &&
321
325
  (LOCAL_OPENAI_COMPAT_PROVIDERS[provider] === true || hasLocalLoopbackBaseUrl(baseUrl));
@@ -348,6 +352,7 @@ function detectOpenAI(spec: ModelSpec<"openai-completions" | "openrouter">, fact
348
352
  function detectOpenAICompat(
349
353
  spec: ModelSpec<"openai-completions" | "openrouter">,
350
354
  d: OpenAIDetection,
355
+ reasoningCapable: boolean,
351
356
  ): ResolvedOpenAICompat {
352
357
  const provider = spec.provider;
353
358
  const baseUrl = spec.baseUrl;
@@ -435,12 +440,12 @@ function detectOpenAICompat(
435
440
  ? GLM_CODING_PLAN_STREAM_IDLE_TIMEOUT_MS
436
441
  : facts.is("mimo") && hostMatchesUrl(baseUrl, "xiaomi")
437
442
  ? 300_000
438
- : spec.reasoning &&
443
+ : reasoningCapable &&
439
444
  facts.is("kimi") &&
440
445
  (facts.family("k3") || facts.family("k2.7-code")) &&
441
446
  hostMatchesUrl(baseUrl, "moonshotNative")
442
447
  ? 300_000
443
- : spec.reasoning && facts.is("deepseek") && hostMatchesUrl(baseUrl, "deepseekDirect")
448
+ : reasoningCapable && facts.is("deepseek") && hostMatchesUrl(baseUrl, "deepseekDirect")
444
449
  ? 300_000
445
450
  : d.isLocalServingBackend
446
451
  ? LOCAL_OPENAI_COMPAT_STREAM_IDLE_TIMEOUT_MS
@@ -476,13 +481,14 @@ function detectOpenAICompat(
476
481
  // provider rule without changing Copilot Responses rows.
477
482
  supportsReasoningParams: provider !== "github-copilot",
478
483
  supportsSamplingParams: !(facts.is("openai") && (facts.family("o-series") || facts.revGte("5"))),
479
- supportsPenaltyAndStopParams: !(isGrok && Boolean(spec.reasoning)),
484
+ supportsPenaltyAndStopParams: !(isGrok && reasoningCapable),
480
485
  reasoningEffortMap: {},
481
486
  supportsUsageInStreaming: !isCerebrasHost,
482
487
  alwaysSendMaxTokens: facts.is("kimi"),
483
488
  disableReasoningOnForcedToolChoice:
484
489
  !d.isClinePass && ((facts.is("kimi") && !isMoonshotKimiK3) || isAnthropicModel),
485
- disableReasoningOnToolChoice: !d.isClinePass && isDeepseekFamily && Boolean(spec.reasoning) && !d.isOpenRouter,
490
+ disableReasoningOnToolChoice: !d.isClinePass && isDeepseekFamily && reasoningCapable && !d.isOpenRouter,
491
+ disableReasoningWithTools: false,
486
492
  supportsToolChoice: d.isClinePass || !d.isDirectDeepseekReasoning,
487
493
  supportsForcedToolChoice:
488
494
  !d.requiresEnabledThinking && !(d.isOpenCodeHost && d.isDeepseekReasoning) && !(d.isClinePass && isQwen),
@@ -506,12 +512,12 @@ function detectOpenAICompat(
506
512
  reasoningContentField: d.isClinePass ? "reasoning" : "reasoning_content",
507
513
  requiresReasoningContentForToolCalls:
508
514
  (facts.is("kimi") && !d.isOpenCodeProvider) ||
509
- (isDeepseekFamily && Boolean(spec.reasoning)) ||
515
+ (isDeepseekFamily && reasoningCapable) ||
510
516
  d.isXiaomiMimo ||
511
- (d.isOpenRouter && Boolean(spec.reasoning)),
517
+ (d.isOpenRouter && reasoningCapable),
512
518
  requiresReasoningContentForAllAssistantTurns:
513
- ((isDeepseekFamily && Boolean(spec.reasoning)) || d.isXiaomiMimo) && !d.isOpenRouter,
514
- allowsSyntheticReasoningContentForToolCalls: (!isDeepseekFamily || !spec.reasoning) && !d.isXiaomiMimo,
519
+ ((isDeepseekFamily && reasoningCapable) || d.isXiaomiMimo) && !d.isOpenRouter,
520
+ allowsSyntheticReasoningContentForToolCalls: (!isDeepseekFamily || !reasoningCapable) && !d.isXiaomiMimo,
515
521
  replayReasoningContent: d.isLocalOpenAICompatBackend,
516
522
  qwenPreserveThinking:
517
523
  (thinkingFormat === "qwen" || thinkingFormat === "qwen-chat-template") && d.isLocalOpenAICompatBackend,
@@ -577,6 +583,11 @@ const DSML_HEALING_PROVIDERS: Record<string, true> = {
577
583
  nanogpt: true,
578
584
  "opencode-go": true,
579
585
  openrouter: true,
586
+ // Transparent gateways / user-configured hosts forward the upstream model's
587
+ // native chat template unchanged, so a deepseek-classed model behind them
588
+ // still emits DSML tool-call envelopes and needs the DSML healer.
589
+ litellm: true,
590
+ nous: true,
580
591
  };
581
592
 
582
593
  /**
@@ -635,7 +646,7 @@ function fixupOpenAICompat(
635
646
  compat.omitReasoningEffort = true;
636
647
  }
637
648
 
638
- const axisWhenThinking = spec.reasoning ? objectPayload(axes.wire.whenThinking) : undefined;
649
+ const axisWhenThinking = compatReasoning(spec, axes) ? objectPayload(axes.wire.whenThinking) : undefined;
639
650
  const whenThinkingPolicy =
640
651
  spec.compat?.whenThinking ??
641
652
  axisWhenThinking ??
@@ -662,8 +673,9 @@ function resolveOpenAICompletionsPolicy(
662
673
  facts: IdentityFacts,
663
674
  axes: ResolvedAxes,
664
675
  ): ResolvedOpenAICompat {
665
- const d = detectOpenAI(spec, facts);
666
- const compat = detectOpenAICompat(spec, d);
676
+ const reasoningCapable = compatReasoning(spec, axes);
677
+ const d = detectOpenAI(spec, facts, reasoningCapable);
678
+ const compat = detectOpenAICompat(spec, d, reasoningCapable);
667
679
  applyWireAxes(compat, axes.wire, "openai-completions");
668
680
  applyCompatOverrides(compat, spec.compat);
669
681
  overlayEffortMapAxis(compat, axes, spec.compat);
@@ -689,7 +701,7 @@ function resolveOpenAIResponsesPolicy(
689
701
  const supportsPromptCacheBreakpoints =
690
702
  isOfficialOpenAIEndpoint(provider, baseUrl) && facts.is("openai") && facts.revGte("5.6");
691
703
  const thinkingFormat: ResolvedOpenAISharedCompat["thinkingFormat"] = isOpenRouter ? "openrouter" : "openai";
692
- const reasoningCapable = Boolean(spec.reasoning);
704
+ const reasoningCapable = compatReasoning(spec, axes);
693
705
  const isLocalServingBackend =
694
706
  (PROXY_OPENAI_COMPAT_PROVIDERS[provider] !== true && LOCAL_OPENAI_COMPAT_PROVIDERS[provider] === true) ||
695
707
  hasLocalLoopbackBaseUrl(baseUrl);
@@ -729,6 +741,7 @@ function resolveOpenAIResponsesPolicy(
729
741
  filterReasoningHistory: isOpenRouter && isAnthropicModel,
730
742
  disableReasoningOnForcedToolChoice: facts.is("kimi"),
731
743
  disableReasoningOnToolChoice: isDeepseekFamily && reasoningCapable && !isOpenRouter,
744
+ disableReasoningWithTools: false,
732
745
  supportsToolChoice: true,
733
746
  supportsForcedToolChoice: provider !== "opencode-go" && provider !== "opencode-zen",
734
747
  supportsNamedToolChoice: true,
@@ -860,7 +873,8 @@ function resolveAnthropicPolicy(
860
873
  supportsSamplingParams: !facts.anthropicAdaptiveGenAtLeast("4.7"),
861
874
  requiresToolResultId: false,
862
875
  requiresThinkingEnabled,
863
- replayUnsignedThinking: !signingEndpoint && (Boolean(spec.reasoning) || modelMatchesHost(spec, "deepseekFamily")),
876
+ replayUnsignedThinking:
877
+ !signingEndpoint && (compatReasoning(spec, axes) || modelMatchesHost(spec, "deepseekFamily")),
864
878
  escapeBuiltinToolNames: false,
865
879
  injectClaudeCodeInstruction: true,
866
880
  stripImageInput: false,
@@ -884,7 +898,7 @@ function resolveBedrockPolicy(spec: ModelSpec<"bedrock-converse-stream">, axes:
884
898
  promptCacheMaximumCheckpoints: 0,
885
899
  };
886
900
  // Reasoning capability is a mechanism gate; adaptive-lineage duration is rule-owned.
887
- compat.streamIdleTimeoutMs = spec.reasoning ? BEDROCK_REASONING_STREAM_IDLE_TIMEOUT_MS : undefined;
901
+ compat.streamIdleTimeoutMs = compatReasoning(spec, axes) ? BEDROCK_REASONING_STREAM_IDLE_TIMEOUT_MS : undefined;
888
902
  applyWireAxes(compat, axes.wire, "bedrock-converse-stream");
889
903
  applyCompatOverrides(compat, spec.compat);
890
904
  return compat;
@@ -1047,6 +1061,18 @@ function readRuleThinking(axes: ResolvedAxes): RuleThinking {
1047
1061
  return out;
1048
1062
  }
1049
1063
 
1064
+ /**
1065
+ * Compat-time reasoning capability. `axes.reasoning` also promotes targets on
1066
+ * any exact `thinking-efforts` rule (the cascade's thinking-axis gate), but
1067
+ * compat may only be repaired where the provider contract opted in with
1068
+ * `thinking-upgrade-neutral`; everywhere else a spec that reports no reasoning
1069
+ * stays the authoritative capability surface.
1070
+ */
1071
+ function compatReasoning<TApi extends Api>(spec: ModelSpec<TApi>, axes: ResolvedAxes): boolean {
1072
+ if (spec.reasoning) return true;
1073
+ return axes.reasoning && readRuleThinking(axes).upgradeNeutral === true;
1074
+ }
1075
+
1050
1076
  /** Identity-derived `requiresEffort` default (mandatory-reasoning lineages). */
1051
1077
  function impliesMandatoryReasoning(facts: IdentityFacts, modelId: string): boolean {
1052
1078
  if (facts.identity.thinkingVariant) return true;
@@ -288,15 +288,17 @@ behavior {
288
288
  route "openai-completions" prefix="openai/" strip-prefix=#true
289
289
  }
290
290
  model-limits provider="github-copilot" { limits "gpt-5.6" context=272000 max-tokens=128000 }
291
+ exclude-discovery-modes "embedding" "moderation" provider="litellm"
291
292
  exclude-models provider="nanogpt" substring="embed" substring="tts"
292
293
  plan-requirement provider="openai-codex" { tier "pro" substring="-spark" }
294
+ retry-reset-timezone provider="zai" offset="+08:00"
293
295
  pricing-peer provider="google-antigravity" peers="google" "google-vertex" {
294
296
  alias "gemini-3-pro" peer-id="gemini-3-pro-preview"
295
297
  }
296
298
  }
297
299
  ```
298
300
 
299
- Matcher properties on `route` / `exclude-models` / `tier` nodes are `exact=` / `prefix=` / `substring=` / `glob=`, repeatable. `strip-prefix=#true` on a prefix route strips the matched prefix off the wire id. Values are copied verbatim from the TS constants they replaced; runtime accessors live in `src/compat/behavior.ts`.
301
+ `exclude-discovery-modes` takes one or more exact, case-sensitive upstream mode strings plus `provider=`; discovery mappers preserve missing, malformed, and unknown modes unless the provider policy explicitly lists them. Matcher properties on `route` / `exclude-models` / `tier` nodes are `exact=` / `prefix=` / `substring=` / `glob=`, repeatable. `strip-prefix=#true` on a prefix route strips the matched prefix off the wire id. Values are copied verbatim from the TS constants they replaced; runtime accessors live in `src/compat/behavior.ts`.
300
302
 
301
303
  ## Auth grammar
302
304
 
@@ -84,7 +84,15 @@ class "deepseek" {
84
84
  models "*v4.1-flash*" priority=10 {
85
85
  strip-image-input #false
86
86
  }
87
- on "ollama-cloud" "nvidia" "deepseek" "fireworks" "nanogpt" "opencode-go" "openrouter" {
87
+ // V4.1 Flash is natively multimodal (vision encoder + projector, image-text
88
+ // pre-training) but its release id carries no `vision` token to key on.
89
+ // Same priority as the V4.1 Flash carve-out: the OpenCode Go provider rule
90
+ // declares the same value for its `deepseek-flash` lane and would otherwise
91
+ // tie at equal rank.
92
+ models "deepseek-flash" priority=10 {
93
+ strip-image-input #false
94
+ }
95
+ on "ollama-cloud" "nvidia" "deepseek" "fireworks" "nanogpt" "opencode-go" "openrouter" "litellm" "nous" {
88
96
  stream-markup-healing-pattern "dsml"
89
97
  }
90
98
  thinking-loop-guard "deepseek"