@oh-my-pi/pi-catalog 17.3.7 → 17.3.8

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,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.3.8] - 2026-08-19
6
+
7
+ ### Added
8
+
9
+ - Added a Cursor variant-collapse table folding the per-effort Grok siblings (`cursor-grok-4.5` low/medium/high and `cursor-grok-4.6` low/medium/high/xhigh, plus their `-fast` service-tier lanes) into one logical model per lane with effort routing onto the live wire ids, matching Devin's `grok-4-5` collapse ([#8803](https://github.com/can1357/oh-my-pi/issues/8803)).
10
+ - Regenerated the Cursor agent protobufs to model hosted WebFetch permission queries (`interaction_query` / `interaction_response` field 9) and the matching `ToolCall` variant (field 37).
11
+
12
+ ### Fixed
13
+
14
+ - Fixed a physically corrupt `models.db` (`SQLITE_CORRUPT*` / `SQLITE_NOTADB`, "database disk image is malformed") permanently disabling the model cache. The shared read/write paths swallowed unrecoverable SQLite corruption as a best-effort miss and cached the broken handle, so a successful live catalog could never overwrite the corrupt cache and every later process repeated the miss — a runtime provider extension with no bundled catalog was left with only its bootstrap model. Corruption now self-heals: the cache closes the handle, quarantines `models.db`(+`-wal`/`-shm`) aside, recreates a fresh database, and retries the operation once; `SQLITE_BUSY`, permission, and unrelated errors keep their existing best-effort paths ([#8867](https://github.com/can1357/oh-my-pi/issues/8867)).
15
+ - Fixed local Qwen 3.8+ models (llama.cpp, vLLM, loopback custom providers) exposing the generic `minimal..high` thinking ladder instead of the chat template's real `low`/`medium`/`xhigh` `reasoning_effort` tiers. The derived metadata now marks thinking as mandatory (the official 3.8 template raises on `enable_thinking: false`), vLLM-served Qwen routes through the `chat_template_kwargs` dialect (top-level `enable_thinking` is ignored by vLLM), and vLLM discovery lights up the reasoning dial for Qwen 3.8+ ids its `/v1/models` endpoint reports as non-reasoning.
16
+ - Fixed `deepseek-v4-pro-0813` surfacing from Alibaba Token Plan discovery with `contextWindow`/`maxTokens` of `null`. The dated DeepSeek V4 Pro snapshot was missing from `ALIBABA_TOKEN_PLAN_DISCOVERED_MODEL_LIMITS`, so unlike its `deepseek-v4-flash-0731` sibling it fell through to unknown limits ([#8847](https://github.com/can1357/oh-my-pi/issues/8847)).
17
+ - Cloud Code Assist Gemini 3.6/3.7 Flash no longer maps user `minimal` to wire `thinkingLevel: MINIMAL` when that effort is aliased onto the `-low` SKU. The request now sends `LOW`, which those SKUs accept.
18
+ - Fixed SuperGrok (`xai-oauth`) Grok 4.6 hiding the thinking-level picker: the Responses effort-capable allowlist now includes `grok-4.6`, so `/model` can select the documented `low`/`medium`/`high`/`xhigh` ladder (`max` is rejected by api.x.ai).
19
+ - Marked CoreWeave runtime discovery as authoritative so stale bundled model ids that the endpoint no longer serves stop appearing as selectable models.
20
+ - ChatGPT Codex discovery that advertises only worker `-wm` SKUs now also registers the plain model route, so a configured `openai-codex/<model>` keeps resolving instead of fuzzy-falling-back to the `-wm` SKU some accounts reject.
21
+ - Fixed `opencode-go/muse-spark-1.2` (and `muse-spark-1.2-contributor`) failing every tool-call turn with `OpenAI completions stream closed before a finish_reason was received`. The Go gateway serves these ids only at `/zen/go/v1/responses`, but the `/zen/go/v1/models` discovery omits the `provider.npm` hint, so the resolver fell through to `openai-completions`; both ids are now pinned to `openai-responses` like `deepseek-v4-flash` ([#8957](https://github.com/can1357/oh-my-pi/issues/8957)).
22
+ - Fixed GitHub Copilot `grok-4.6` / `grok-4.6-1m` failing with HTTP 400 `unsupported_api_for_model` by routing them through the OpenAI Responses API (`/responses`) instead of `/chat/completions`, matching `grok-4.5`. Stale cached completion routes are invalidated on refresh ([#8807](https://github.com/can1357/oh-my-pi/issues/8807)).
23
+ - Fixed Cursor Grok 4.5/4.6 discovery classifying the versioned ids as non-reasoning: `GetUsableModels` ships no `thinkingDetails` and the bundled references read `reasoning: false`, so the picker hid the effort ladder. Discovery now marks `cursor-grok-<version>` ids as reasoning models (the non-reasoning `grok-code-*` ids stay out) ([#8803](https://github.com/can1357/oh-my-pi/issues/8803)).
24
+ - Fixed GMI Cloud (`gmi-cloud`) models resolved via `/v1/models` discovery surfacing with `null` context windows, zero pricing, and no reasoning/thinking metadata for every model except the bundled `deepseek-ai/DeepSeek-V4-Flash` seed. GMI's endpoint returns only bare `{id}` rows, so the mapper now recovers intrinsic capability metadata (context window, output limit, reasoning, thinking ladder) for resold open-weight models from the cross-provider canonical reference index — matching the SiliconFlow behavior — while never borrowing another provider's pricing ([#8890](https://github.com/can1357/oh-my-pi/issues/8890)).
25
+
5
26
  ## [17.3.6] - 2026-08-17
6
27
 
7
28
  ### Changed
@@ -1218,15 +1218,22 @@ export type ToolCall = Message<"agent.v1.ToolCall"> & {
1218
1218
  */
1219
1219
  value: SearchConversationsToolCall;
1220
1220
  case: "searchConversationsToolCall";
1221
+ } | {
1222
+ /**
1223
+ * @generated from field: agent.v1.FetchToolCall web_fetch_tool_call = 37;
1224
+ */
1225
+ value: FetchToolCall;
1226
+ case: "webFetchToolCall";
1221
1227
  } | {
1222
1228
  case: undefined;
1223
1229
  value?: undefined;
1224
1230
  };
1225
1231
  /**
1226
1232
  * Modern builds carry the call id on the envelope instead of inside each
1227
- * variant's args. Fields 37-53, 55, 56, 58 (further tool variants) and 54
1228
- * (hook_additional_contexts), 59/60 (started_at_ms/completed_at_ms) are not
1229
- * modelled and decode into unknown fields; do not reuse those numbers.
1233
+ * variant's args. Field 37 is the hosted WebFetch tool. Fields 38-53, 55,
1234
+ * 56, 58 (further tool variants) and 54 (hook_additional_contexts),
1235
+ * 59/60 (started_at_ms/completed_at_ms) are not modelled and decode into
1236
+ * unknown fields; do not reuse those numbers.
1230
1237
  *
1231
1238
  * @generated from field: optional string tool_call_id = 57;
1232
1239
  */
@@ -2923,6 +2930,12 @@ export type InteractionQuery = Message<"agent.v1.InteractionQuery"> & {
2923
2930
  */
2924
2931
  value: SetupVmEnvironmentArgs;
2925
2932
  case: "setupVmEnvironmentArgs";
2933
+ } | {
2934
+ /**
2935
+ * @generated from field: agent.v1.WebFetchRequestQuery web_fetch_request_query = 9;
2936
+ */
2937
+ value: WebFetchRequestQuery;
2938
+ case: "webFetchRequestQuery";
2926
2939
  } | {
2927
2940
  case: undefined;
2928
2941
  value?: undefined;
@@ -2986,6 +2999,12 @@ export type InteractionResponse = Message<"agent.v1.InteractionResponse"> & {
2986
2999
  */
2987
3000
  value: SetupVmEnvironmentResult;
2988
3001
  case: "setupVmEnvironmentResult";
3002
+ } | {
3003
+ /**
3004
+ * @generated from field: agent.v1.WebFetchRequestResponse web_fetch_request_response = 9;
3005
+ */
3006
+ value: WebFetchRequestResponse;
3007
+ case: "webFetchRequestResponse";
2989
3008
  } | {
2990
3009
  case: undefined;
2991
3010
  value?: undefined;
@@ -2996,6 +3015,72 @@ export type InteractionResponse = Message<"agent.v1.InteractionResponse"> & {
2996
3015
  * Use `create(InteractionResponseSchema)` to create a new message.
2997
3016
  */
2998
3017
  export declare const InteractionResponseSchema: GenMessage<InteractionResponse>;
3018
+ /**
3019
+ * @generated from message agent.v1.WebFetchRequestQuery
3020
+ */
3021
+ export type WebFetchRequestQuery = Message<"agent.v1.WebFetchRequestQuery"> & {
3022
+ /**
3023
+ * @generated from field: agent.v1.FetchArgs args = 1;
3024
+ */
3025
+ args?: FetchArgs | undefined;
3026
+ };
3027
+ /**
3028
+ * Describes the message agent.v1.WebFetchRequestQuery.
3029
+ * Use `create(WebFetchRequestQuerySchema)` to create a new message.
3030
+ */
3031
+ export declare const WebFetchRequestQuerySchema: GenMessage<WebFetchRequestQuery>;
3032
+ /**
3033
+ * @generated from message agent.v1.WebFetchRequestResponse
3034
+ */
3035
+ export type WebFetchRequestResponse = Message<"agent.v1.WebFetchRequestResponse"> & {
3036
+ /**
3037
+ * @generated from oneof agent.v1.WebFetchRequestResponse.result
3038
+ */
3039
+ result: {
3040
+ /**
3041
+ * @generated from field: agent.v1.WebFetchRequestResponse_Approved approved = 1;
3042
+ */
3043
+ value: WebFetchRequestResponse_Approved;
3044
+ case: "approved";
3045
+ } | {
3046
+ /**
3047
+ * @generated from field: agent.v1.WebFetchRequestResponse_Rejected rejected = 2;
3048
+ */
3049
+ value: WebFetchRequestResponse_Rejected;
3050
+ case: "rejected";
3051
+ } | {
3052
+ case: undefined;
3053
+ value?: undefined;
3054
+ };
3055
+ };
3056
+ /**
3057
+ * Describes the message agent.v1.WebFetchRequestResponse.
3058
+ * Use `create(WebFetchRequestResponseSchema)` to create a new message.
3059
+ */
3060
+ export declare const WebFetchRequestResponseSchema: GenMessage<WebFetchRequestResponse>;
3061
+ /**
3062
+ * @generated from message agent.v1.WebFetchRequestResponse_Approved
3063
+ */
3064
+ export type WebFetchRequestResponse_Approved = Message<"agent.v1.WebFetchRequestResponse_Approved"> & {};
3065
+ /**
3066
+ * Describes the message agent.v1.WebFetchRequestResponse_Approved.
3067
+ * Use `create(WebFetchRequestResponse_ApprovedSchema)` to create a new message.
3068
+ */
3069
+ export declare const WebFetchRequestResponse_ApprovedSchema: GenMessage<WebFetchRequestResponse_Approved>;
3070
+ /**
3071
+ * @generated from message agent.v1.WebFetchRequestResponse_Rejected
3072
+ */
3073
+ export type WebFetchRequestResponse_Rejected = Message<"agent.v1.WebFetchRequestResponse_Rejected"> & {
3074
+ /**
3075
+ * @generated from field: string reason = 1;
3076
+ */
3077
+ reason: string;
3078
+ };
3079
+ /**
3080
+ * Describes the message agent.v1.WebFetchRequestResponse_Rejected.
3081
+ * Use `create(WebFetchRequestResponse_RejectedSchema)` to create a new message.
3082
+ */
3083
+ export declare const WebFetchRequestResponse_RejectedSchema: GenMessage<WebFetchRequestResponse_Rejected>;
2999
3084
  /**
3000
3085
  * @generated from message agent.v1.AskQuestionInteractionQuery
3001
3086
  */
@@ -30,6 +30,17 @@ export declare const isClaudeModelId: (modelId: string) => boolean;
30
30
  export declare const isAnthropicNamespacedModelId: (modelId: string) => boolean;
31
31
  /** Qwen family ids (substring match — Qwen SKUs have no stable prefix shape). */
32
32
  export declare const isQwenModelId: (modelId: string) => boolean;
33
+ /**
34
+ * Open-weight Qwen 3.8+ releases (`qwen3.8-27b`, `qwen3.8-2.4t-a95b`, GGUF
35
+ * names like `Qwen3.8-27B-UD-Q6_K_XL`) whose chat template steers thinking
36
+ * depth through a `reasoning_effort` template kwarg (`low`/`medium`/`xhigh`,
37
+ * template default `xhigh`; thinking itself cannot be disabled). Compared
38
+ * component-wise so `qwen3.10` sorts after `qwen3.8`. API-only `-max` SKUs are
39
+ * excluded — Dashscope drives them through OpenAI-style `reasoning_effort`
40
+ * with curated compat. The trailing guard rejects parameter-count lookalikes
41
+ * (`qwen-3.8b`) without breaking `qwen3.8-27b`.
42
+ */
43
+ export declare const isQwen38PlusTemplateEffortModelId: (modelId: string) => boolean;
33
44
  /** Gemma open-weights family (`gemma-3-27b-it`, `google/gemma-4-E2B-it`, `gemma2-9b`). */
34
45
  export declare const isGemmaModelId: (modelId: string) => boolean;
35
46
  /** DeepSeek family by id or display name (proxies often rename the id but keep the name). */
@@ -52,7 +63,8 @@ export declare const isGrokModelId: (modelId: string) => boolean;
52
63
  /**
53
64
  * Grok SKUs that expose the wire `reasoning.effort` dial. Other Grok reasoners
54
65
  * (e.g. `grok-build`, `grok-4.20-0309-reasoning`) think natively but reject the
55
- * param, so callers must omit reasoning effort for them.
66
+ * param, so callers must omit reasoning effort for them. `grok-4.6` accepts
67
+ * `low`/`medium`/`high`/`xhigh` and 400s on `max`.
56
68
  */
57
69
  export declare const isGrokReasoningEffortCapable: (modelId: string) => boolean;
58
70
  /**
@@ -44,8 +44,12 @@ export declare function getSupportedEfforts<TApi extends Api>(model: ApiModel<TA
44
44
  */
45
45
  export declare function clampThinkingLevelForModel<TApi extends Api>(model: ApiModel<TApi> | undefined, requested: Effort | undefined): Effort | undefined;
46
46
  export declare function requireSupportedEffort<TApi extends Api>(model: ApiModel<TApi>, effort: Effort): Effort;
47
- /** Maps a normalized thinking effort to Google's `thinkingLevel` enum values. */
48
- export declare function mapEffortToGoogleThinkingLevel(effort: Effort): "MINIMAL" | "LOW" | "MEDIUM" | "HIGH";
47
+ /** Maps a normalized thinking effort to Google's `thinkingLevel` enum values.
48
+ * When a collapsed family routes `minimal` onto the same wire id as `low`
49
+ * (Antigravity Gemini 3.6/3.7 Flash), emit `LOW` — Cloud Code Assist rejects
50
+ * `MINIMAL` on those `-low` SKUs.
51
+ */
52
+ export declare function mapEffortToGoogleThinkingLevel<TApi extends Api>(effort: Effort, model?: ApiModel<TApi>): "MINIMAL" | "LOW" | "MEDIUM" | "HIGH";
49
53
  /**
50
54
  * Maps a normalized thinking effort to Anthropic adaptive effort values via
51
55
  * the model's baked `thinking.effortMap` (identity for unmapped efforts).
@@ -428,6 +428,7 @@ export declare const CATALOG_PROVIDERS: readonly [{
428
428
  readonly defaultModel: "openai/gpt-oss-120b";
429
429
  readonly envVars: readonly ["COREWEAVE_API_KEY", "WANDB_API_KEY"];
430
430
  readonly createModelManagerOptions: (config: ModelManagerConfig) => import("../index.js").ModelManagerOptions<"openai-completions", unknown>;
431
+ readonly dynamicModelsAuthoritative: true;
431
432
  readonly catalogDiscovery: {
432
433
  readonly label: "CoreWeave Serverless Inference";
433
434
  };
@@ -220,6 +220,16 @@ export interface OpenAICompat {
220
220
  * Non-Qwen templates ignore the flag, so the auto-detection is safe.
221
221
  */
222
222
  qwenPreserveThinking?: boolean;
223
+ /**
224
+ * Route the requested thinking effort onto the Qwen 3.8+ chat template's
225
+ * `reasoning_effort` kwarg (`low`/`medium`/`xhigh`; template default
226
+ * `xhigh`). Emitted inside `chat_template_kwargs` for both Qwen dialects
227
+ * (plus the top-level field on the `qwen` dialect, which newer llama.cpp
228
+ * builds map natively). Without it the qwen dialects only toggle
229
+ * `enable_thinking` and the template always thinks at its `xhigh` default.
230
+ * Default: auto-detected (Qwen 3.8+ id on a local llama.cpp-style backend).
231
+ */
232
+ qwenTemplateReasoningEffort?: boolean;
223
233
  /** Whether assistant tool-call messages must include non-empty content. Default: false. */
224
234
  requiresAssistantContentForToolCalls?: boolean;
225
235
  /** Whether the provider supports the `tool_choice` parameter. Default: true. */
@@ -558,6 +568,7 @@ export interface ResolvedOpenAISharedCompat {
558
568
  allowsSyntheticReasoningContentForToolCalls: boolean;
559
569
  replayReasoningContent: boolean;
560
570
  qwenPreserveThinking: boolean;
571
+ qwenTemplateReasoningEffort: boolean;
561
572
  requiresThinkingAsText: boolean;
562
573
  requiresMistralToolIds: boolean;
563
574
  requiresToolResultName: boolean;
@@ -595,7 +606,7 @@ export interface ResolvedOpenAISharedCompat {
595
606
  * `buildModel`; request handlers read fields and never detect, resolve, or
596
607
  * allocate.
597
608
  */
598
- export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat & Required<Omit<OpenAICompat, "supportsDeveloperRole" | "supportsReasoningEffort" | "reasoningEffortMap" | "supportsReasoningParams" | "supportsSamplingParams" | "supportsPenaltyAndStopParams" | "thinkingFormat" | "kimiApiFormat" | "reasoningDisableMode" | "omitReasoningEffort" | "includeEncryptedReasoning" | "filterReasoningHistory" | "disableReasoningOnForcedToolChoice" | "disableReasoningOnToolChoice" | "supportsToolChoice" | "supportsForcedToolChoice" | "supportsNamedToolChoice" | "reasoningContentField" | "requiresReasoningContentForToolCalls" | "requiresReasoningContentForAllAssistantTurns" | "allowsSyntheticReasoningContentForToolCalls" | "replayReasoningContent" | "qwenPreserveThinking" | "requiresThinkingAsText" | "requiresMistralToolIds" | "requiresToolResultName" | "requiresAssistantAfterToolResult" | "requiresAssistantContentForToolCalls" | "stripDeepseekSpecialTokens" | "streamMarkupHealingPattern" | "reasoningDeltasMayBeCumulative" | "emptyLengthFinishIsContextError" | "usesOpenAIToolCallIdLimit" | "promptCacheSessionHeader" | "supportsPromptCacheBreakpoints" | "promptCacheBreakpointTtl" | "openRouterRouting" | "isOpenRouterHost" | "supportsStrictMode" | "supportsLongPromptCacheRetention" | "alwaysSendMaxTokens" | "wireModelIdMode" | "vercelGatewayRouting" | "extraBody" | "toolStrictMode" | "toolSchemaFlavor" | "streamFirstEventTimeoutMs" | "streamIdleTimeoutMs" | "cacheControlFormat" | "thinkingKeep" | "strictResponsesPairing" | "supportsImageDetailOriginal" | "whenThinking">> & {
609
+ export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat & Required<Omit<OpenAICompat, "supportsDeveloperRole" | "supportsReasoningEffort" | "reasoningEffortMap" | "supportsReasoningParams" | "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" | "wireModelIdMode" | "vercelGatewayRouting" | "extraBody" | "toolStrictMode" | "toolSchemaFlavor" | "streamFirstEventTimeoutMs" | "streamIdleTimeoutMs" | "cacheControlFormat" | "thinkingKeep" | "strictResponsesPairing" | "supportsImageDetailOriginal" | "whenThinking">> & {
599
610
  vercelGatewayRouting?: OpenAICompat["vercelGatewayRouting"];
600
611
  extraBody?: OpenAICompat["extraBody"];
601
612
  cacheControlFormat?: OpenAICompat["cacheControlFormat"];
@@ -58,6 +58,8 @@ export declare const ANTIGRAVITY_VARIANT_COLLAPSE_TABLE: VariantCollapseTable;
58
58
  /** `google-gemini-cli` Gemini families on the official CLI's level transport. */
59
59
  export declare const GEMINI_CLI_VARIANT_COLLAPSE_TABLE: VariantCollapseTable;
60
60
  export declare const DEVIN_VARIANT_COLLAPSE_TABLE: VariantCollapseTable;
61
+ /** `cursor` Grok families: per-effort siblings collapsed per service-tier lane. */
62
+ export declare const CURSOR_VARIANT_COLLAPSE_TABLE: VariantCollapseTable;
61
63
  /** Provider id → hand collapse table. The CCA providers diverge on thinking transport. */
62
64
  export declare const VARIANT_COLLAPSE_TABLES: Readonly<Record<string, VariantCollapseTable>>;
63
65
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-catalog",
4
- "version": "17.3.7",
4
+ "version": "17.3.8",
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": "Can Boluk",
@@ -35,11 +35,11 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@bufbuild/protobuf": "^2.12.1",
38
- "@oh-my-pi/omptype": "17.3.7",
39
- "@oh-my-pi/pi-utils": "17.3.7"
38
+ "@oh-my-pi/omptype": "17.3.8",
39
+ "@oh-my-pi/pi-utils": "17.3.8"
40
40
  },
41
41
  "devDependencies": {
42
- "@oh-my-pi/pi-ai": "17.3.7",
42
+ "@oh-my-pi/pi-ai": "17.3.8",
43
43
  "@types/bun": "^1.3.14"
44
44
  },
45
45
  "engines": {
@@ -22,6 +22,7 @@ import {
22
22
  isKimiModelId,
23
23
  isMimoModelIdOrName,
24
24
  isOpenAISamplingRestrictedModelId,
25
+ isQwen38PlusTemplateEffortModelId,
25
26
  isQwenModelId,
26
27
  } from "../identity/family";
27
28
  import type {
@@ -464,7 +465,7 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
464
465
  ? "zai"
465
466
  : isOpenRouter
466
467
  ? "openrouter"
467
- : isQwen && isNvidiaNim
468
+ : isQwen && (isNvidiaNim || provider === "vllm")
468
469
  ? "qwen-chat-template"
469
470
  : isQwen && isFireworks
470
471
  ? "openai"
@@ -587,6 +588,18 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
587
588
  // parameter, so the flag stays a no-op outside the Qwen path.
588
589
  qwenPreserveThinking:
589
590
  (thinkingFormat === "qwen" || thinkingFormat === "qwen-chat-template") && isLocalOpenAICompatBackend,
591
+ // Qwen 3.8+ templates steer thinking depth via the `reasoning_effort`
592
+ // template kwarg (low/medium/xhigh, default xhigh); without routing the
593
+ // requested effort there, the enable_thinking toggle alone leaves the
594
+ // model at xhigh no matter what the user selects.
595
+ // Local-only like `qwenPreserveThinking`: first-party Qwen APIs
596
+ // (Dashscope, Qwen Portal) drive effort through their own OpenAI-style
597
+ // dialect, and local Ollama keeps its native effort vocabulary.
598
+ qwenTemplateReasoningEffort:
599
+ (thinkingFormat === "qwen" || thinkingFormat === "qwen-chat-template") &&
600
+ isLocalOpenAICompatBackend &&
601
+ provider !== "ollama" &&
602
+ isQwen38PlusTemplateEffortModelId(spec.id),
590
603
  requiresAssistantContentForToolCalls: isKimiModel || isDirectDeepseekReasoning,
591
604
  cacheControlFormat: isOpenRouter && spec.id.startsWith("anthropic/") ? "anthropic" : undefined,
592
605
  supportsPromptCacheBreakpoints,
@@ -761,6 +774,7 @@ export function buildOpenAIResponsesCompat(spec: OpenAIResponsesSpecLike): Resol
761
774
  // Responses-only; the Qwen `preserve_thinking` template knob lives on
762
775
  // the chat-completions wire shape, never on Responses.
763
776
  qwenPreserveThinking: false,
777
+ qwenTemplateReasoningEffort: false,
764
778
  requiresThinkingAsText: false,
765
779
  requiresMistralToolIds: false,
766
780
  requiresToolResultName: false,
@@ -807,6 +821,18 @@ export function buildOpenAIResponsesCompat(spec: OpenAIResponsesSpecLike): Resol
807
821
  if (spec.compat?.omitReasoningEffort === undefined && !compat.supportsReasoningEffort) {
808
822
  compat.omitReasoningEffort = true;
809
823
  }
824
+ // xai-oauth cache/discovery rows written before a SKU joined the
825
+ // effort-capable allowlist still carry omitReasoningEffort: true. The
826
+ // allowlist is the live wire contract; do not let that stale flag hide
827
+ // the picker or strip reasoning.effort.
828
+ if (
829
+ spec.provider === "xai-oauth" &&
830
+ isGrokReasoningEffortCapable(id) &&
831
+ spec.compat?.supportsReasoningEffort !== false
832
+ ) {
833
+ compat.supportsReasoningEffort = true;
834
+ compat.omitReasoningEffort = false;
835
+ }
810
836
  return compat;
811
837
  }
812
838
 
@@ -1,5 +1,6 @@
1
1
  import { type } from "@oh-my-pi/omptype";
2
2
  import { parseKnownModel, semverEqual } from "../identity/classify";
3
+ import { getBundledModels } from "../models";
3
4
  import { resolveOpenAIDaybreakStandardCost } from "../openai-pricing";
4
5
  import type { FetchImpl, ModelSpec } from "../types";
5
6
  import { discoveryFetch } from "../utils";
@@ -22,6 +23,29 @@ const GPT_5_6_CONTEXT_WINDOW = 372_000;
22
23
  */
23
24
  const GPT_5_6_1M_CONTEXT_WINDOW = 1_000_000;
24
25
  const CODEX_GPT_5_6_1M_SLUGS: ReadonlySet<string> = new Set(["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"]);
26
+ /**
27
+ * Codex advertises worker-mode SKUs under a `-wm` suffix (`gpt-5.6-luna-wm`).
28
+ *
29
+ * Those rows route through the same Codex backend as their plain SKU, but an
30
+ * authoritative discovery list that only advertises the `-wm` slug prunes the
31
+ * bundled plain model, leaving a configured `openai-codex/gpt-5.6-luna`
32
+ * unresolvable except via fuzzy fallback onto the `-wm` row — which this user's
33
+ * ChatGPT account rejects. The compatibility rule, scoped to Codex discovery:
34
+ * a `-wm` slug whose plain counterpart exists in the bundled Codex catalog is
35
+ * ALSO registered under its plain id. Both listings derive their base-model
36
+ * metadata (1M-window floor, daybreak pricing, context fallback) from the
37
+ * canonical plain slug — the suffix is a routing variant, not a different
38
+ * model, so the `-wm` row no longer keeps stale backend-parsed capability
39
+ * values while its plain listing is enriched.
40
+ *
41
+ * Deliberate boundary: the "safe" gate is the bundled Codex catalog. A `-wm`
42
+ * slug whose plain counterpart is only a user-local models.yml entry (not
43
+ * bundled) stays verbatim — authoritative discovery for genuinely distinct
44
+ * `-wm` SKUs is preserved, and a hidden plain backend entry can be re-surfaced
45
+ * through its advertised `-wm` row because the configured plain slug must
46
+ * resolve.
47
+ */
48
+ const CODEX_WORKER_SUFFIX = "-wm";
25
49
  const CODEX_REMOTE_COMPACTION = {
26
50
  enabled: true,
27
51
  api: "openai-codex-responses",
@@ -194,11 +218,30 @@ function normalizeCodexModels(payload: unknown, baseUrl: string): ModelSpec<"ope
194
218
  }
195
219
 
196
220
  const entries = parsedResponse.models ?? parsedResponse.data ?? [];
197
- const normalized: NormalizedCodexModel[] = [];
221
+ const parsedEntries: ParsedCodexModelEntry[] = [];
198
222
  for (const entry of entries) {
199
- const model = normalizeCodexModelEntry(entry, baseUrl);
200
- if (model) {
201
- normalized.push(model);
223
+ const parsed = parseCodexModelEntry(entry);
224
+ if (parsed) {
225
+ parsedEntries.push(parsed);
226
+ }
227
+ }
228
+
229
+ // A worker `-wm` slug gets an extra plain-id route only when the bundled
230
+ // catalog ships the plain SKU (the "safe" precondition); the backend's own
231
+ // plain slug wins over any synthesized clone, and unknown `-wm` SKUs stay
232
+ // verbatim. Both listings of a safe `-wm` model carry the same base-model
233
+ // metadata (context-window floor, daybreak pricing) derived from the
234
+ // canonical plain slug — the suffix is a routing variant, not a different
235
+ // model.
236
+ const advertisedSlugs = new Set(parsedEntries.map(parsed => parsed.slug));
237
+ const bundledCodexModelIds = getBundledCodexModelIds();
238
+ const normalized: NormalizedCodexModel[] = [];
239
+ for (const parsed of parsedEntries) {
240
+ const canonicalSlug = plainCounterpartForWorkerSlug(parsed.slug, bundledCodexModelIds) ?? parsed.slug;
241
+ normalized.push(buildNormalizedCodexModel(parsed, parsed.slug, canonicalSlug, baseUrl));
242
+ const plainSlug = canonicalSlug !== parsed.slug ? canonicalSlug : null;
243
+ if (plainSlug && !advertisedSlugs.has(plainSlug)) {
244
+ normalized.push(buildNormalizedCodexModel(parsed, plainSlug, canonicalSlug, baseUrl));
202
245
  }
203
246
  }
204
247
 
@@ -212,7 +255,37 @@ function normalizeCodexModels(payload: unknown, baseUrl: string): ModelSpec<"ope
212
255
  return normalized.map(item => item.model);
213
256
  }
214
257
 
215
- function normalizeCodexModelEntry(entry: unknown, baseUrl: string): NormalizedCodexModel | null {
258
+ /** Ids of the bundled Codex catalog, consulted once per discovery run. */
259
+ function getBundledCodexModelIds(): ReadonlySet<string> {
260
+ const ids = new Set(getBundledModels("openai-codex").map(model => model.id));
261
+ return ids;
262
+ }
263
+
264
+ /**
265
+ * Map a Codex worker `-wm` slug to its plain counterpart when the bundled
266
+ * catalog registers that plain SKU. Returns `null` for non-worker slugs and
267
+ * for `-wm` slugs without a safe plain counterpart.
268
+ */
269
+ function plainCounterpartForWorkerSlug(slug: string, bundledCodexModelIds: ReadonlySet<string>): string | null {
270
+ if (!slug.endsWith(CODEX_WORKER_SUFFIX)) {
271
+ return null;
272
+ }
273
+ const plain = slug.slice(0, -CODEX_WORKER_SUFFIX.length);
274
+ return plain.length > 0 && bundledCodexModelIds.has(plain) ? plain : null;
275
+ }
276
+
277
+ interface ParsedCodexModelEntry {
278
+ slug: string;
279
+ name: string;
280
+ contextWindow: number | null;
281
+ reasoning: boolean;
282
+ input: ("text" | "image")[];
283
+ preferWebsockets: boolean;
284
+ useResponsesLite: boolean;
285
+ priority: number;
286
+ }
287
+
288
+ function parseCodexModelEntry(entry: unknown): ParsedCodexModelEntry | null {
216
289
  const parsedEntry = codexModelEntrySchema(entry);
217
290
  if (parsedEntry instanceof type.errors) {
218
291
  return null;
@@ -229,44 +302,65 @@ function normalizeCodexModelEntry(entry: unknown, baseUrl: string): NormalizedCo
229
302
  return null;
230
303
  }
231
304
 
232
- const name = toNonEmptyString(payload.display_name) ?? slug;
305
+ return {
306
+ slug,
307
+ name: toNonEmptyString(payload.display_name) ?? slug,
308
+ contextWindow: toPositiveInt(payload.context_window),
309
+ reasoning: supportsReasoning(payload.default_reasoning_level, payload.supported_reasoning_levels),
310
+ input: normalizeInputModalities(payload.input_modalities),
311
+ preferWebsockets: toBoolean(payload.prefer_websockets) === true,
312
+ useResponsesLite: toBoolean(payload.use_responses_lite) === true,
313
+ priority: toFiniteNumber(payload.priority) ?? Number.MAX_SAFE_INTEGER,
314
+ };
315
+ }
316
+
317
+ /**
318
+ * Build a normalized Codex model spec. `slug` is the registered id (either the
319
+ * advertised slug or a synthesized plain counterpart); `canonicalSlug` names
320
+ * the model's bundled SKU (`slug` itself for plain/unknown rows, the plain
321
+ * counterpart for a safe `-wm` row) and owns the base-model metadata derivation
322
+ * so both listings of a model report the same context window and pricing.
323
+ */
324
+ function buildNormalizedCodexModel(
325
+ parsed: ParsedCodexModelEntry,
326
+ slug: string,
327
+ canonicalSlug: string,
328
+ baseUrl: string,
329
+ ): NormalizedCodexModel {
233
330
  // Codex discovery historically omitted `context_window` for GPT-5.6-family
234
331
  // SKUs (#5705); luna/sol/terra additionally floor the reported value because
235
- // the registry still declares the pre-1M 272000 window.
236
- const parsed = parseKnownModel(slug);
332
+ // the registry still declares the pre-1M 272000 window. Keyed on the
333
+ // canonical slug so a safe `gpt-5.6-luna-wm` row gets the same floor as its
334
+ // plain listing.
335
+ const parsedKnown = parseKnownModel(canonicalSlug);
237
336
  const fallbackContextWindow =
238
- parsed.family === "openai" && semverEqual(parsed.version, "5.6")
337
+ parsedKnown.family === "openai" && semverEqual(parsedKnown.version, "5.6")
239
338
  ? GPT_5_6_CONTEXT_WINDOW
240
339
  : DEFAULT_CONTEXT_WINDOW;
241
- const reportedContextWindow = toPositiveInt(payload.context_window) ?? fallbackContextWindow;
242
- const contextWindow = CODEX_GPT_5_6_1M_SLUGS.has(slug)
340
+ const reportedContextWindow = parsed.contextWindow ?? fallbackContextWindow;
341
+ const contextWindow = CODEX_GPT_5_6_1M_SLUGS.has(canonicalSlug)
243
342
  ? Math.max(reportedContextWindow, GPT_5_6_1M_CONTEXT_WINDOW)
244
343
  : reportedContextWindow;
245
344
  const maxTokens = Math.min(DEFAULT_MAX_TOKENS, contextWindow);
246
- const reasoning = supportsReasoning(payload.default_reasoning_level, payload.supported_reasoning_levels);
247
- const input = normalizeInputModalities(payload.input_modalities);
248
- const preferWebsockets = toBoolean(payload.prefer_websockets) === true;
249
- const useResponsesLite = toBoolean(payload.use_responses_lite) === true;
250
- const priority = toFiniteNumber(payload.priority) ?? Number.MAX_SAFE_INTEGER;
251
- const daybreakCost = resolveOpenAIDaybreakStandardCost(slug);
345
+ const daybreakCost = resolveOpenAIDaybreakStandardCost(canonicalSlug);
252
346
 
253
347
  return {
254
- priority,
348
+ priority: parsed.priority,
255
349
  model: {
256
350
  id: slug,
257
- name,
351
+ name: parsed.name,
258
352
  api: "openai-codex-responses",
259
353
  provider: "openai-codex",
260
354
  baseUrl,
261
- reasoning,
262
- input,
355
+ reasoning: parsed.reasoning,
356
+ input: parsed.input,
263
357
  cost: daybreakCost ? { ...daybreakCost } : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
264
358
  remoteCompaction: CODEX_REMOTE_COMPACTION,
265
359
  contextWindow,
266
360
  maxTokens,
267
- ...(preferWebsockets ? { preferWebsockets: true } : {}),
268
- ...(useResponsesLite ? { useResponsesLite: true } : {}),
269
- ...(priority !== Number.MAX_SAFE_INTEGER ? { priority } : {}),
361
+ ...(parsed.preferWebsockets ? { preferWebsockets: true } : {}),
362
+ ...(parsed.useResponsesLite ? { useResponsesLite: true } : {}),
363
+ ...(parsed.priority !== Number.MAX_SAFE_INTEGER ? { priority: parsed.priority } : {}),
270
364
  },
271
365
  };
272
366
  }