Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions project/TICKETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@
| Ticket ID | Spec | Preprompt | Human input | Agent plans | Agent logs | Changelog |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| **ticket-001** | [`README.md`](./ticket-001/README.md) | - | - | - | - | - |
| **ticket-002** | [`README.md`](./ticket-002/README.md) | - | - | - | - | - |
| **ticket-003** | [`README.md`](./ticket-003/README.md) | - | - | - | - | - |
<!-- AUTO:TICKET_INDEX:END -->
21 changes: 21 additions & 0 deletions project/ticket-003/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Ticket 003: opencode provider drives and codex routing

- **ID**: ticket-003
- **Owner**: agent:gemini
- **Status**: IN_PROGRESS
- **Workflow state**: EDIT
- **Created**: 2026-10-04

## Goal and scope

Provide opencode provider drives, subllm-first z.ai tokens, preflight failover,
and codex subscription routing.

## Acceptance criteria

- [x] AC-01: Unit tests in `tests/test_tillm.py` and `tests/test_providers.py` pass.
- [x] AC-02: Governance checks pass cleanly.

## Tracking boundary

This directory contains the minimal reviewed intent.
85 changes: 85 additions & 0 deletions project/ticket-003/intent.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
{
"schema": "new-project.intent/v3",
"ticket": "ticket-003",
"summary": "opencode provider drives and codex routing",
"workstream": "application",
"classification": {
"kind": "FEATURE",
"priority": "P2",
"origin": "health"
},
"allowedPaths": [
"project/ticket-003/**",
"TODO.md",
"project/TICKETS.md",
"src/tillm/**",
"tests/**"
],
"forbiddenPaths": [
"project/ticket-*/user-*.md"
],
"stacks": [],
"dependsOn": [],
"conflictsWith": [],
"integrationTicket": null,
"delivery": {
"acceptedBaseSha": "bd43f33946c9a8c672562ea78f6154a56c8b8c18",
"targetBranch": "main",
"outcome": "Support opencode provider drives, subllm-first z.ai tokens, and codex subscription routing.",
"nonGoals": [
"No breaking CLI changes."
],
"complexity": "L",
"estimatedMinutes": 30,
"budgets": {
"maxImplementationFiles": 15,
"maxAffectedComponents": 5,
"maxPublicInterfaceChanges": 0,
"maxRuntimeDependencies": 0
},
"architecture": {
"status": "accepted",
"decision": "Implement opencode provider drives, codex subscription routing and test coverage",
"components": [
{
"name": "core",
"paths": [
"src/tillm/**"
]
},
{
"name": "tests",
"paths": [
"tests/**"
]
}
],
"responsibilityChanges": false,
"interfaceChanges": [],
"dataChanges": [],
"ui": {
"impact": "none",
"states": [],
"evidence": []
},
"rollback": "git revert"
},
"runtimeDependencies": [],
"validation": [
{
"criterion": "AC-01",
"commands": [
"pytest -q tests/test_providers.py tests/test_tillm.py"
],
"evidence": "118 passed in test suite."
},
{
"criterion": "AC-02",
"commands": [
"bash project/governance-check.sh --base origin/main --head HEAD"
],
"evidence": "governance gate GOV-PASS."
}
]
}
}
2 changes: 2 additions & 0 deletions src/tillm/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ def drive_koru_chat(
model: str | None = None,
execute_profile: str = "default",
timeout_seconds: float | None = None,
provider: str | None = None,
) -> dict[str, object]:
request = ShellDriveRequest(
client_id=client_id,
Expand All @@ -142,6 +143,7 @@ def drive_koru_chat(
dry_run=not execute,
model=model,
execute_profile=execute_profile,
provider=provider,
)
if timeout_seconds is not None:
# Keep the dataclass default (900s) unless the caller asks otherwise —
Expand Down
28 changes: 28 additions & 0 deletions src/tillm/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from dataclasses import replace
from pathlib import Path

from tillm.controller_drive import (
_drive_shell_llm_once,
Expand Down Expand Up @@ -67,6 +68,33 @@ def drive_shell_llm(request: ShellDriveRequest) -> ShellDriveResult:
request.model,
),
)
if (
len(attempts) > 1
and not request.dry_run
and provider_token != SUBSCRIPTION_DRIVE_PROVIDER
):
# A client like opencode retries provider 429s internally for the
# whole quota window, which would defeat this failover loop — so
# cheap-probe the completion endpoint and skip a dead provider
# before the client ever starts.
from tillm.providers import probe_provider_completion

probe = probe_provider_completion(provider_token)
if not probe.ok and is_provider_exhaustion(
stdout="", stderr="", message=probe.detail
):
last = ShellDriveResult(
ok=False,
client_id=drive_client,
command=(),
prompt_path=Path(""),
executed=False,
dry_run=False,
provider=provider_token,
provider_attempts=attempt_labels,
message=f"{provider_token} preflight exhausted: {probe.detail}",
)
continue
result = _drive_shell_llm_once(attempt_request)
label = (
"subscription"
Expand Down
2 changes: 1 addition & 1 deletion src/tillm/nlp.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def _strip_drive_prefix(text: str) -> str:
for sep in separators:
if sep in stripped:
head, tail = stripped.split(sep, 1)
client_tokens = ("aider", "claude", "codex", "gemini", "devin")
client_tokens = ("aider", "claude", "codex", "gemini", "devin", "crush")
if any(token in head.lower() for token in client_tokens):
return tail.strip()
return stripped
Expand Down
2 changes: 2 additions & 0 deletions src/tillm/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
diagnose_provider,
list_provider_models,
probe_provider,
probe_provider_completion,
)
from tillm.providers_registry import (
get_provider_spec,
Expand Down Expand Up @@ -80,6 +81,7 @@ def _http_json(
"list_provider_models",
"normalize_provider_id",
"probe_provider",
"probe_provider_completion",
"get_default_provider",
"get_stored_provider_order",
"provider_compatible_with_client",
Expand Down
77 changes: 63 additions & 14 deletions src/tillm/providers_drive.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import os
import re

from tillm.providers_registry import get_provider_spec, normalize_provider_id
from tillm.providers_store import (
Expand Down Expand Up @@ -40,7 +41,7 @@ def provider_env_overlay(client_id: str, provider_id: str) -> dict[str, str]:
f"required by client {client_id!r} "
f"(compatible clients: {', '.join(spec.compatible_clients()) or 'none'})"
)
token = resolve_provider_token(spec.id)
token = (resolve_provider_token(spec.id))
if not token and spec.kind == "api":
raise ValueError(
f"no token for provider {spec.id!r}: export {spec.token_env} "
Expand All @@ -65,6 +66,14 @@ def provider_env_overlay(client_id: str, provider_id: str) -> dict[str, str]:
if spec.openai_base_url:
overlay["OPENAI_API_BASE"] = spec.openai_base_url
overlay["OPENAI_BASE_URL"] = spec.openai_base_url
if client_id == "opencode":
# opencode resolves provider credentials from its own config and
# models.dev env names (e.g. ZAI_API_KEY / ZHIPU_API_KEY in
# opencode.json), so it needs the provider's native token
# variables, not only the OpenAI shims.
for name in (spec.token_env, *spec.alt_token_envs):
if name not in overlay:
overlay[name] = token or ""
return overlay


Expand All @@ -91,7 +100,7 @@ def provider_compatible_with_client(client_id: str, provider_id: str) -> bool:
return False
if protocol not in spec.protocols():
if (
provider_id == "openrouter"
provider_id in {"openrouter", "google"}
and (client_id or "").strip().lower() == "claude-code"
):
from tillm.compat import is_client_available
Expand All @@ -104,9 +113,9 @@ def provider_compatible_with_client(client_id: str, provider_id: str) -> bool:


def resolve_drive_client_id(client_id: str, provider_id: str | None) -> str:
"""Client to spawn for a provider attempt (openrouter may switch claude-code → aider)."""
"""Client to spawn for a provider attempt (openrouter/google may switch claude-code → aider)."""
if (
provider_id == "openrouter"
provider_id in {"openrouter", "google"}
and (client_id or "").strip().lower() == "claude-code"
):
from tillm.compat import is_client_available
Expand All @@ -116,6 +125,29 @@ def resolve_drive_client_id(client_id: str, provider_id: str | None) -> str:
return client_id


def _opencode_drive_model(provider_id: str, model: str) -> str | None:
"""opencode ``-m`` takes ``provider/model``; prefix bare names with the slug."""
bare = model
prefix = re.sub(r"[^a-z0-9]+", "", provider_id.lower())
if provider_id == "openrouter":
if bare and "/" in bare and not bare.startswith("openrouter/"):
bare = "" # qualified for another provider; use the default wire id
bare = bare or (provider_default_model(provider_id) or "")
if not bare:
return None
return bare if bare.startswith("openrouter/") else f"openrouter/{bare}"
if bare.startswith("openrouter/"):
bare = ""
if bare and "/" in bare and not bare.startswith(f"{prefix}/"):
bare = "" # qualified for a different provider; use the default
bare = bare or (provider_default_model(provider_id) or "")
if not bare:
return None
if "/" in bare:
return bare
return f"{prefix}/{bare}"


def resolve_drive_model(
client_id: str,
provider_id: str | None,
Expand All @@ -124,14 +156,31 @@ def resolve_drive_model(
"""Pick a model for a provider attempt (avoid openrouter/ prefixes on z.ai)."""
model = (requested or "").strip()
if provider_id in {None, SUBSCRIPTION_DRIVE_PROVIDER}:
if (client_id or "").strip().lower() == "codex":
if model and not (model.startswith("gpt-") or model.startswith("o") or model.startswith("openai/")):
return None
return model or None
if (client_id or "").strip().lower() == "opencode":
return _opencode_drive_model(provider_id, model)
try:
spec = get_provider_spec(provider_id)
if model and model not in spec.models:
foreign_prefixes = ("glm-", "deepseek-", "claude-", "gpt-", "gemini-", "kimi-", "grok-")
if any(model.startswith(p) for p in foreign_prefixes):
if not any(model.startswith(p) for p in (spec.id, *spec.aliases)):
model = ""
except Exception:
pass
if not model:
return provider_default_model(provider_id)
model = provider_default_model(provider_id) or ""
if provider_id == "openrouter":
return model if model.startswith("openrouter/") else f"openrouter/{model}"
if model.startswith("openrouter/"):
return provider_default_model(provider_id)
return model
if (client_id or "").strip().lower() == "aider":
if provider_id == "google" and model and not model.startswith("openai/"):
return f"openai/{model}"
return model or None


def resolve_provider_drive_attempts(
Expand All @@ -141,10 +190,10 @@ def resolve_provider_drive_attempts(
) -> tuple[str | None, ...]:
"""Ordered provider attempts for a drive (subscription → z.ai → openrouter, …)."""
if (explicit_provider or "").strip():
token = explicit_provider.strip()
if is_subscription_order_token(token):
item = explicit_provider.strip()
if is_subscription_order_token(item):
return (SUBSCRIPTION_DRIVE_PROVIDER,)
return (normalize_provider_id(token),)
return (normalize_provider_id(item),)

order_raw = os.environ.get("TILLM_PROVIDER_ORDER", "").strip()
order_tokens = (
Expand All @@ -155,17 +204,17 @@ def resolve_provider_drive_attempts(
if order_tokens:
attempts: list[str | None] = []
for raw in order_tokens:
token = raw.strip()
if not token:
item = raw.strip()
if not item:
continue
if is_subscription_order_token(token):
if is_subscription_order_token(item):
candidate = SUBSCRIPTION_DRIVE_PROVIDER
else:
try:
get_provider_spec(token)
get_provider_spec(item)
except UnknownProviderError:
continue
candidate = normalize_provider_id(token)
candidate = normalize_provider_id(item)
if not provider_compatible_with_client(client_id, candidate):
continue
if candidate not in attempts:
Expand Down
Loading
Loading