Repository: gsd-build/gsd-2 (2026-03-26T09:40:44Z)

Issue:
Dynamic routing ignores explicit phase model configuration for unknown models
## Problem
When a user explicitly configures a phase-specific model (e.g., `planning: gpt-5.4`) and enables dynamic routing, the system ignores the configured model and uses tier_models instead. This happens because unknown models (not in the hardcoded capability tier map) are assumed to be "heavy" tier, triggering automatic downgrades for all standard/light complexity units.

## Root Cause
**File:** `extensions/gsd/model-router.js`  
**Lines:** 104-113 (function `getModelTier()`)

```javascript
function getModelTier(modelId) {
    const bareId = modelId.includes("/") ? modelId.split("/").pop() : modelId;
    if (MODEL_CAPABILITY_TIER[bareId])
        return MODEL_CAPABILITY_TIER[bareId];
    for (const [knownId, tier] of Object.entries(MODEL_CAPABILITY_TIER)) {
        if (bareId.includes(knownId) || knownId.includes(bareId))
            return tier;
    }
    // Unknown models are assumed heavy (safest assumption)  ← BUG
    return "heavy";
}
```

When `gpt-5.4` is not found in `MODEL_CAPABILITY_TIER`, it defaults to "heavy". Then in `resolveModelForComplexity()` (lines 44-53), any unit classified as "standard" or "light" gets downgraded to the corresponding `tier_models` value.

## Expected Behavior
1. **Option A (conservative):** Unknown models should preserve the user's explicit phase configuration. If `planning: gpt-5.4` is set, use it for all planning units regardless of complexity classification, unless the user explicitly configured `tier_models` for that phase.

2. **Option B (aggressive):** Add new models to the capability tier map automatically via provider API introspection, or allow users to configure model tiers in preferences:
   ```yaml
   dynamic_routing:
     tier_models:
       light: qwen3.5-plus
       standard: gpt-5.4  # User explicitly says gpt-5.4 is standard-tier
       heavy: o3
   ```

3. **Option C (hybrid):** When an unknown model is configured for a phase, treat it as the "ceiling" tier for that phase. If user sets `planning: gpt-5.4`, treat gpt-5.4 as the baseline tier for planning, and only downgrade if tier_models explicitly defines a lower tier.

## Environment
- GSD version: 2.41.0
- Model: qwen3.5-plus (instead of configured gpt-5.4)
- Unit: plan-slice/M001/S02, research-slice/M001/S02, execute-task/M001/S01/T02, etc.

## Reproduction Context
1. Set `planning: gpt-5.4` in `~/.gsd/preferences.md`
2. Enable dynamic routing with `tier_models.standard: qwen3.5-plus`
3. Run auto-mode on any project
4. Observe all planning/research/execution units use qwen3.5-plus instead of gpt-5.4

## Forensic Evidence
- All 9 recent units show `model: qwen3.5-plus` despite `planning: gpt-5.4` configuration
- Metrics show `tier: "standard"` and `modelDowngraded: true` for all units
- User preferences:
  ```yaml
  models:
    research: qwen3.5-plus
    execution: qwen3.5-plus
    completion: qwen3.5-plus
    planning: gpt-5.4  # ← Ignored
  dynamic_routing:
    enabled: true
    tier_models:
      light: qwen3.5-plus
      standard: qwen3.5-plus
      heavy: gpt-5.4
  ```
- Complexity classifier correctly identifies plan-slice as "standard" tier
- Dynamic routing downgrades from "heavy" (gpt-5.4 assumed) to "standard" (qwen3.5-plus)

---
*Auto-generated by `/gsd forensics`*


Discussion:
Additional context on the scale of this issue: the hardcoded `MODEL_CAPABILITY_TIER` map only covers ~15 models (Claude, GPT, Gemini). But GSD supports custom providers, OpenRouter (hundreds of models), Alibaba Coding Plan, and any user-configured provider. Every model not in the map defaults to "heavy" and gets downgraded.

Three additional fix approaches:

**1. Infer tier from model cost data.** Models in `models.generated.ts` have `cost.input` fields. Cheap → light, mid-range → standard, expensive → heavy. However this won't work for subscription-based providers like Alibaba Coding Plan where cost is reported as $0.

**2. Let users declare model tier in preferences.** The `tier_models` config already exists — make it the authoritative tier source. If a model appears in `tier_models`, use that tier. Only fall back to the hardcoded map for models not in `tier_models`. Currently the map overrides user config, which is backwards.

**3. Default unknown models to "standard" instead of "heavy".** One-line fix (`return "standard"` instead of `return "heavy"` at line 201). This prevents the downgrade for most units since standard is the most common classification. Models explicitly needing heavy treatment are already in the hardcoded map.
We're currently rewriting a section of the state engine to address the systemic root cause behind this and many related issues. We'd appreciate your patience while this work is underway — an upcoming development release will validate this approach. We believe the current patch-per-symptom strategy isn't sustainable, and the rewrite will resolve this class of bugs structurally. Marking as blocked pending the state machine update.



Patch:
diff --git a/src/resources/extensions/gsd/model-router.ts b/src/resources/extensions/gsd/model-router.ts
index fd76d53cae..fe8bdf0a59 100644
--- a/src/resources/extensions/gsd/model-router.ts
+++ b/src/resources/extensions/gsd/model-router.ts
@@ -114,6 +114,21 @@ export function resolveModelForComplexity(
   const configuredTier = getModelTier(configuredPrimary);
   const requestedTier = classification.tier;
 
+  // If the configured model is unknown (not in MODEL_CAPABILITY_TIER),
+  // honor the user's explicit choice — don't downgrade based on a guess.
+  // Unknown models default to "heavy" in getModelTier, which makes every
+  // standard/light unit get downgraded to tier_models, silently ignoring
+  // the user's configuration. (#2192)
+  if (!isKnownModel(configuredPrimary)) {
+    return {
+      modelId: configuredPrimary,
+      fallbacks: phaseConfig.fallbacks,
+      tier: requestedTier,
+      wasDowngraded: false,
+      reason: `configured model "${configuredPrimary}" is not in the known tier map — honoring explicit config`,
+    };
+  }
+
   // Downgrade-only: if requested tier >= configured tier, no change
   if (tierOrdinal(requestedTier) >= tierOrdinal(configuredTier)) {
     return {
@@ -202,6 +217,16 @@ function getModelTier(modelId: string): ComplexityTier {
   return "heavy";
 }
 
+/** Check if a model ID has a known capability tier mapping. (#2192) */
+function isKnownModel(modelId: string): boolean {
+  const bareId = modelId.includes("/") ? modelId.split("/").pop()! : modelId;
+  if (MODEL_CAPABILITY_TIER[bareId]) return true;
+  for (const knownId of Object.keys(MODEL_CAPABILITY_TIER)) {
+    if (bareId.includes(knownId) || knownId.includes(bareId)) return true;
+  }
+  return false;
+}
+
 function findModelForTier(
   tier: ComplexityTier,
   config: DynamicRoutingConfig,


Test patch:
diff --git a/src/resources/extensions/gsd/tests/model-router.test.ts b/src/resources/extensions/gsd/tests/model-router.test.ts
index c7af7fcca9..b22fce7fd9 100644
--- a/src/resources/extensions/gsd/tests/model-router.test.ts
+++ b/src/resources/extensions/gsd/tests/model-router.test.ts
@@ -165,3 +165,43 @@ test("falls back to configured model when no light-tier model available", () =>
   assert.equal(result.modelId, "claude-opus-4-6");
   assert.equal(result.wasDowngraded, false);
 });
+
+// ─── #2192: Unknown models honor explicit config ─────────────────────────────
+
+test("#2192: unknown model is not downgraded — respects user config", () => {
+  const config = { ...defaultRoutingConfig(), enabled: true };
+  const result = resolveModelForComplexity(
+    makeClassification("light"),
+    { primary: "gpt-5.4", fallbacks: [] },
+    config,
+    ["gpt-5.4", ...AVAILABLE_MODELS],
+  );
+  assert.equal(result.modelId, "gpt-5.4", "unknown model should be used as-is");
+  assert.equal(result.wasDowngraded, false, "should not be downgraded");
+  assert.ok(result.reason.includes("not in the known tier map"), "reason should explain why");
+});
+
+test("#2192: unknown model with provider prefix is not downgraded", () => {
+  const config = { ...defaultRoutingConfig(), enabled: true };
+  const result = resolveModelForComplexity(
+    makeClassification("standard"),
+    { primary: "custom-provider/my-model-v3", fallbacks: [] },
+    config,
+    ["custom-provider/my-model-v3", ...AVAILABLE_MODELS],
+  );
+  assert.equal(result.modelId, "custom-provider/my-model-v3");
+  assert.equal(result.wasDowngraded, false);
+});
+
+test("#2192: known model is still downgraded normally", () => {
+  const config = { ...defaultRoutingConfig(), enabled: true };
+  // claude-opus-4-6 is known as "heavy" — a light request should downgrade
+  const result = resolveModelForComplexity(
+    makeClassification("light"),
+    { primary: "claude-opus-4-6", fallbacks: [] },
+    config,
+    AVAILABLE_MODELS,
+  );
+  assert.equal(result.wasDowngraded, true, "known heavy model should still be downgraded for light tasks");
+  assert.notEqual(result.modelId, "claude-opus-4-6");
+});