Skip to content

Commit 0d8dc78

Browse files
authored
Merge pull request #73 from dmoliveira/my_opencode-e12-routing-trace
feat: add persisted model-routing fallback traces
2 parents 4284c6b + 0e67d09 commit 0d8dc78

File tree

7 files changed

+138
-8
lines changed

7 files changed

+138
-8
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ All notable changes to this project are documented in this file.
3737
- Added `scripts/context_resilience_command.py` with `/resilience status` and `/resilience doctor` stress diagnostics.
3838
- Added `instructions/context_resilience_tuning.md` with practical tuning guidance and operating playbook.
3939
- Added `instructions/model_fallback_explanation_model.md` defining provider/model fallback trace structure, output levels, and redaction rules for Epic 12 Task 12.1.
40+
- Added persistent model-routing trace runtime support with `/model-routing trace --json` for latest requested/attempted/selected fallback diagnostics.
4041

4142
### Changes
4243
- Documented extension evaluation outcomes and when each tool is the better fit.
@@ -71,6 +72,7 @@ All notable changes to this project are documented in this file.
7172
- Expanded selftest coverage for context resilience policy validation and pruning behavior (dedupe, superseded writes, stale error purge, protected evidence retention).
7273
- Expanded selftest coverage for context recovery outcomes, including resume hints and fallback-path diagnostics.
7374
- Expanded doctor summary coverage to include context resilience subsystem health checks.
75+
- Expanded selftest coverage for model-routing trace persistence and runtime fallback-chain reporting.
7476

7577
## v0.2.0 - 2026-02-12
7678

IMPLEMENTATION_ROADMAP.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Use this map to avoid overlapping implementations.
4848
| E9 | Conditional Rules Injector | done | High | E1 | bd-1q8, bd-3rj, bd-fo8, bd-2ik | Enforce project conventions with scoped rules |
4949
| E10 | Auto Slash Command Detector | paused | Medium | E1, E8 | TBD | Resume only if intent precision stays high in prototypes |
5050
| E11 | Context-Window Resilience Toolkit | done | High | E4 | bd-2tj, bd-n9y, bd-2t0, bd-18e | Improve long-session stability and recovery |
51-
| E12 | Provider/Model Fallback Visibility | in_progress | Medium | E5 | bd-1jq | Explain why model routing decisions happen |
51+
| E12 | Provider/Model Fallback Visibility | in_progress | Medium | E5 | bd-1jq, bd-298 | Explain why model routing decisions happen |
5252
| E13 | Browser Automation Profile Switching | planned | Medium | E1 | TBD | Toggle Playwright/agent-browser with checks |
5353
| E14 | Plan-to-Execution Bridge Command | planned | Medium | E2, E3 | TBD | Execute validated plans with progress tracking |
5454
| E15 | Todo Enforcer and Plan Compliance | planned | High | E14 | TBD | Keep execution aligned with approved checklists |
@@ -509,10 +509,11 @@ Every command-oriented epic must ship all of the following:
509509
- [x] Subtask 12.1.2: Define compact vs verbose output levels
510510
- [x] Subtask 12.1.3: Define redaction rules for sensitive provider details
511511
- [x] Notes: Added `instructions/model_fallback_explanation_model.md` defining fallback trace shape, output levels, redaction policy, and deterministic reason-code requirements.
512-
- [ ] Task 12.2: Implement resolution tracing
513-
- [ ] Subtask 12.2.1: Capture fallback chain attempts in runtime
514-
- [ ] Subtask 12.2.2: Store latest trace per command/session
515-
- [ ] Subtask 12.2.3: Expose trace to doctor and debug commands
512+
- [x] Task 12.2: Implement resolution tracing
513+
- [x] Subtask 12.2.1: Capture fallback chain attempts in runtime
514+
- [x] Subtask 12.2.2: Store latest trace per command/session
515+
- [x] Subtask 12.2.3: Expose trace to doctor and debug commands
516+
- [x] Notes: Extended `scripts/model_routing_schema.py` with requested/attempted/selected runtime trace payloads and added persisted latest-trace support plus `/model-routing trace` in `scripts/model_routing_command.py`.
516517
- [ ] Task 12.3: User-facing command surface
517518
- [ ] Subtask 12.3.1: Add `/routing status` and `/routing explain` commands
518519
- [ ] Subtask 12.3.2: Add examples for category-driven routing outcomes

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,8 +455,11 @@ Use:
455455
/model-routing status
456456
/model-routing set-category deep
457457
/model-routing resolve --category deep --override-model openai/gpt-5.3-codex --json
458+
/model-routing trace --json
458459
```
459460

461+
`/model-routing resolve` now emits a structured fallback trace (`requested -> attempted -> selected`) and persists the latest trace for `/model-routing trace` debug introspection.
462+
460463
Model-profile aliases:
461464
```text
462465
/model-profile status

opencode.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@
131131
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/hooks_command.py\" doctor --json`\nShow only the command output."
132132
},
133133
"model-routing": {
134-
"description": "Manage model routing (status|set-category|resolve)",
134+
"description": "Manage model routing (status|set-category|resolve|trace)",
135135
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/model_routing_command.py\" $ARGUMENTS`\nShow only the command output."
136136
},
137137
"model-routing-status": {
@@ -154,6 +154,10 @@
154154
"description": "Resolve effective model settings in JSON",
155155
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/model_routing_command.py\" resolve $ARGUMENTS --json`\nShow only the command output."
156156
},
157+
"model-routing-trace": {
158+
"description": "Show latest routing trace in JSON",
159+
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/model_routing_command.py\" trace --json`\nShow only the command output."
160+
},
157161
"keyword-mode": {
158162
"description": "Detect and apply keyword-triggered execution modes",
159163
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/keyword_mode_command.py\" $ARGUMENTS`\nShow only the command output."

scripts/model_routing_command.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,13 @@
3333
"reasoning": "medium",
3434
"verbosity": "medium",
3535
},
36+
"latest_trace": {},
3637
}
3738

3839

3940
def usage() -> int:
4041
print(
41-
"usage: /model-routing status [--json] | /model-routing set-category <quick|deep|visual|writing> | /model-routing resolve [--category <name>] [--override-model <id>] [--override-temperature <value>] [--override-reasoning <value>] [--override-verbosity <value>] [--available-models <csv>] [--json]"
42+
"usage: /model-routing status [--json] | /model-routing set-category <quick|deep|visual|writing> | /model-routing resolve [--category <name>] [--override-model <id>] [--override-temperature <value>] [--override-reasoning <value>] [--override-verbosity <value>] [--available-models <csv>] [--json] | /model-routing trace [--json]"
4243
)
4344
return 2
4445

@@ -60,6 +61,7 @@ def save_state(config: dict[str, Any], state: dict[str, Any], write_path: Path)
6061
config[SECTION] = {
6162
"active_category": state.get("active_category", "quick"),
6263
"system_defaults": state.get("system_defaults", {}),
64+
"latest_trace": state.get("latest_trace", {}),
6365
}
6466
save_config_file(config, write_path)
6567

@@ -133,13 +135,15 @@ def command_status(argv: list[str]) -> int:
133135
payload = {
134136
"active_category": state.get("active_category"),
135137
"system_defaults": state.get("system_defaults"),
138+
"has_latest_trace": bool(state.get("latest_trace")),
136139
"config": str(write_path),
137140
}
138141
if json_output:
139142
print(json.dumps(payload, indent=2))
140143
else:
141144
print(f"active_category: {payload['active_category']}")
142145
print(f"system_defaults: {json.dumps(payload['system_defaults'])}")
146+
print(f"has_latest_trace: {'yes' if payload['has_latest_trace'] else 'no'}")
143147
print(f"config: {payload['config']}")
144148
return 0
145149

@@ -163,11 +167,17 @@ def command_set_category(argv: list[str]) -> int:
163167
def command_resolve(argv: list[str]) -> int:
164168
json_output = "--json" in argv
165169
filtered = [arg for arg in argv if arg != "--json"]
166-
_, state, _ = load_state()
170+
config, state, write_path = load_state()
167171
report = run_resolve(state, filtered)
168172
if report.get("result") != "PASS":
169173
print(json.dumps(report, indent=2))
170174
return 1
175+
176+
resolution_trace = report.get("resolution_trace")
177+
if isinstance(resolution_trace, dict):
178+
state["latest_trace"] = resolution_trace
179+
save_state(config, state, write_path)
180+
171181
if json_output:
172182
print(json.dumps(report, indent=2))
173183
return 0
@@ -181,13 +191,37 @@ def command_resolve(argv: list[str]) -> int:
181191
return 0
182192

183193

194+
def command_trace(argv: list[str]) -> int:
195+
if any(arg not in ("--json",) for arg in argv):
196+
return usage()
197+
json_output = "--json" in argv
198+
_, state, _ = load_state()
199+
trace = state.get("latest_trace")
200+
payload = {
201+
"result": "PASS",
202+
"has_trace": isinstance(trace, dict) and bool(trace),
203+
"trace": trace if isinstance(trace, dict) else {},
204+
}
205+
if json_output:
206+
print(json.dumps(payload, indent=2))
207+
return 0
208+
print(f"has_trace: {'yes' if payload['has_trace'] else 'no'}")
209+
if payload["has_trace"]:
210+
selected = payload["trace"].get("selected", {})
211+
print(f"selected_model: {selected.get('model')}")
212+
print(f"selected_reason: {selected.get('reason')}")
213+
return 0
214+
215+
184216
def main(argv: list[str]) -> int:
185217
if not argv or argv[0] == "status":
186218
return command_status(argv[1:] if argv else [])
187219
if argv[0] == "set-category":
188220
return command_set_category(argv[1:])
189221
if argv[0] == "resolve":
190222
return command_resolve(argv[1:])
223+
if argv[0] == "trace":
224+
return command_trace(argv[1:])
191225
if argv[0] == "help":
192226
return usage()
193227
return usage()

scripts/model_routing_schema.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@
1414
}
1515

1616

17+
def _provider_from_model(model: Any) -> str:
18+
value = str(model or "").strip()
19+
if "/" in value:
20+
return value.split("/", 1)[0]
21+
return "unknown"
22+
23+
1724
def default_schema() -> dict[str, Any]:
1825
return {
1926
"default_category": DEFAULT_CATEGORY,
@@ -192,8 +199,60 @@ def resolve_model_settings(
192199
}
193200
)
194201

202+
requested_model = overrides.get("model")
203+
if not isinstance(requested_model, str) or not requested_model.strip():
204+
requested_model = category_settings.get("model")
205+
if not isinstance(requested_model, str) or not requested_model.strip():
206+
requested_model = base_system.get("model")
207+
208+
attempted: list[dict[str, Any]] = []
209+
first_model = requested_model
210+
first_available = available_models is None or first_model in available_models
211+
attempted.append(
212+
{
213+
"rank": 1,
214+
"model": first_model,
215+
"provider": _provider_from_model(first_model),
216+
"result": "accepted" if first_available else "unavailable",
217+
"reason": "requested_or_override_candidate",
218+
}
219+
)
220+
221+
final_model = resolved.get("model")
222+
if final_model != first_model:
223+
attempted.append(
224+
{
225+
"rank": 2,
226+
"model": final_model,
227+
"provider": _provider_from_model(final_model),
228+
"result": "accepted",
229+
"reason": trace[-1].get("reason") if trace else "fallback_selected",
230+
}
231+
)
232+
233+
resolution_trace = {
234+
"requested": {
235+
"category": requested_category,
236+
"model": requested_model,
237+
"source": (
238+
"user_override"
239+
if isinstance(overrides.get("model"), str)
240+
and str(overrides.get("model")).strip()
241+
else "category_default"
242+
),
243+
},
244+
"attempted": attempted,
245+
"selected": {
246+
"category": category_result.get("category"),
247+
"model": final_model,
248+
"provider": _provider_from_model(final_model),
249+
"reason": trace[-1].get("reason") if trace else "selected",
250+
},
251+
}
252+
195253
return {
196254
"category": category_result.get("category"),
197255
"settings": resolved,
198256
"trace": trace,
257+
"resolution_trace": resolution_trace,
199258
}

scripts/selftest.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1386,6 +1386,33 @@ def run_bg(*args: str) -> subprocess.CompletedProcess[str]:
13861386
== "openai/gpt-5.3-codex",
13871387
"model-routing resolve should keep active category and apply model fallback",
13881388
)
1389+
expect(
1390+
isinstance(model_routing_report.get("resolution_trace"), dict),
1391+
"model-routing resolve should emit requested/attempted/selected trace payload",
1392+
)
1393+
1394+
model_routing_trace = subprocess.run(
1395+
[sys.executable, str(MODEL_ROUTING_SCRIPT), "trace", "--json"],
1396+
capture_output=True,
1397+
text=True,
1398+
env=refactor_env,
1399+
check=False,
1400+
cwd=REPO_ROOT,
1401+
)
1402+
expect(
1403+
model_routing_trace.returncode == 0,
1404+
"model-routing trace should succeed",
1405+
)
1406+
model_routing_trace_report = parse_json_output(model_routing_trace.stdout)
1407+
expect(
1408+
model_routing_trace_report.get("has_trace") is True,
1409+
"model-routing trace should persist latest resolution trace",
1410+
)
1411+
expect(
1412+
model_routing_trace_report.get("trace", {}).get("selected", {}).get("model")
1413+
== "openai/gpt-5.3-codex",
1414+
"model-routing trace should expose selected model from latest resolve",
1415+
)
13891416

13901417
keyword_report = resolve_prompt_modes(
13911418
"Please safe-apply and deep-analyze this migration; ulw can wait.",

0 commit comments

Comments
 (0)