Skip to content
Open
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- Standard Compute as a built-in provider. Set `STANDARDCOMPUTE_API_KEY` and
select it with `--provider standardcompute` (or a `providers.standardcompute`
entry); defaults to the routed `StandardCompute` model against
`https://api.stdcmpt.com/v1`. Reasoning effort goes out as a top-level
`reasoning_effort` clamped to `low`/`medium`/`high`. It is not part of key
autodetection.

## [0.26.1] - 2026-10-29

### Fixed
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,10 @@ dirge --provider opper # defaults to claude-sonnet-4-6
export CHEAPER_INFERENCE_API_KEY="..."
dirge --provider cheaperinference # defaults to gpt-5.4-mini

# Standard Compute is an OpenAI-compatible gateway; `StandardCompute` routes each request
export STANDARDCOMPUTE_API_KEY="..."
dirge --provider standardcompute # defaults to StandardCompute

# Verbose mode — debug-level dirge logs + warn-level plugin hook errors
dirge --verbose
```
Expand Down Expand Up @@ -375,7 +379,7 @@ thereafter.)
## Supported providers

OpenRouter (default), OpenAI, Anthropic, Gemini, DeepSeek, GLM (ZhipuAI),
Cerebras, OpenCode, Requesty, Opper, Cheaper Inference, Ollama, and any custom OpenAI-compatible endpoint —
Cerebras, OpenCode, Requesty, Opper, Cheaper Inference, Standard Compute, Ollama, and any custom OpenAI-compatible endpoint —
including GitHub Copilot, which needs no dedicated provider type (see
[docs/config.md](docs/config.md#github-copilot) for the recipe).

Expand Down Expand Up @@ -463,7 +467,7 @@ entirely unless you name one. Each value is an **alias** into `providers`.

**Provider aliases.** Each `providers` entry is keyed by an alias you choose.
`provider_type` is the actual backend (`openai`, `anthropic`, `cerebras`,
`deepseek`, `gemini`, `glm`, `ollama`, `opencode`, `openrouter`, `requesty`, `opper`, `cheaperinference`, or `custom`).
`deepseek`, `gemini`, `glm`, `ollama`, `opencode`, `openrouter`, `requesty`, `opper`, `cheaperinference`, `standardcompute`, or `custom`).
It **defaults to the alias** when omitted, which
is why `"deepseek": { "model": "…" }` needs no `provider_type` but the second
DeepSeek route must spell it out as a distinct alias (`deepseek-flash`). Point an
Expand Down
43 changes: 41 additions & 2 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Accepted top-level keys:

| Key | Type | Description |
| ------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `provider` | string | Active provider alias. Built-ins are `openrouter`, `openai`, `anthropic`, `gemini`/`google`, `deepseek`, `glm`/`zhipu`, `cerebras`, `opencode`, `kimi`/`kimi-code`/`moonshot`, `requesty`, `opper`, `cheaperinference`, and `ollama`; any alias declared in `providers` is also accepted. Default: `openrouter`. See [Providers and roles](#providers-and-roles). |
| `provider` | string | Active provider alias. Built-ins are `openrouter`, `openai`, `anthropic`, `gemini`/`google`, `deepseek`, `glm`/`zhipu`, `cerebras`, `opencode`, `kimi`/`kimi-code`/`moonshot`, `requesty`, `opper`, `cheaperinference`, `standardcompute`, and `ollama`; any alias declared in `providers` is also accepted. Default: `openrouter`. See [Providers and roles](#providers-and-roles). |
| `auth` | string | Default authentication source for providers that don't set their own `providers.<name>.auth`: `api-key` (the implicit default), `chatgpt` (Codex/OpenAI login tokens), `anthropic` / `claude-code` (Anthropic Claude Code OAuth), or `kimi` (Kimi Code device OAuth). See [Providers and roles](#providers-and-roles). |
| `providers` | object | Map of provider alias → entry. The active model lives in `providers.<active-provider>.model`. Each role key below points at one of these aliases. See [Providers and roles](#providers-and-roles). |
| `review_provider` | string | Provider alias for the background session-review pass. Falls back to `provider`. |
Expand Down Expand Up @@ -381,7 +381,7 @@ Each `providers` entry accepts:

| Field | Description |
|-------|-------------|
| `provider_type` | Built-in backend to use: `openrouter`, `openai`, `openai-responses`, `anthropic`, `gemini`, `deepseek`, `glm`, `cerebras`, `opencode`, `kimi`, `requesty`, `opper`, `cheaperinference`, `ollama`, or `custom`. Optional — defaults to the entry's alias when that alias matches a built-in name. `openai` speaks the Chat Completions API (`/v1/chat/completions`); `openai-responses` speaks the Responses API (`/v1/responses`) — see below. |
| `provider_type` | Built-in backend to use: `openrouter`, `openai`, `openai-responses`, `anthropic`, `gemini`, `deepseek`, `glm`, `cerebras`, `opencode`, `kimi`, `requesty`, `opper`, `cheaperinference`, `standardcompute`, `ollama`, or `custom`. Optional — defaults to the entry's alias when that alias matches a built-in name. `openai` speaks the Chat Completions API (`/v1/chat/completions`); `openai-responses` speaks the Responses API (`/v1/responses`) — see below. |
| `base_url` | Endpoint base URL (for custom / self-hosted endpoints). |
| `model` | Model name for this provider. |
| `api_key` | Literal key or `${ENV_VAR}` interpolation. Takes precedence over `api_key_env`. |
Expand Down Expand Up @@ -638,6 +638,45 @@ Reasoning effort is sent as a top-level `reasoning_effort` (`low`, `medium`, or
`high`), with lower and higher Dirge levels clamped to that set and the field
omitted when reasoning is off.

### Standard Compute

[Standard Compute](https://standardcompute.com) is an OpenAI-compatible
gateway. One API key reaches open and frontier models. The default model,
`StandardCompute`, is routed: each request goes to the cheapest enabled model
that can handle it, with automatic fallback. Pinned model ids use the
`vendor/model` form listed by `GET https://api.stdcmpt.com/v1/models`. It needs
no `providers` entry. Create a key at <https://standardcompute.com>, export it
and select the built-in:

```bash
export STANDARDCOMPUTE_API_KEY="..."
dirge --provider standardcompute # defaults to StandardCompute
```

Standard Compute is never picked by key autodetection; select it with
`--provider standardcompute` or `"provider": "standardcompute"`. To pin a
model, add only the model override and keep the secret in
`STANDARDCOMPUTE_API_KEY`:

```json
{
"provider": "standardcompute",
"providers": {
"standardcompute": {
"model": "deepseek/deepseek-v4-pro"
}
}
}
```

Requests go to `https://api.stdcmpt.com/v1`. Set
`providers.standardcompute.base_url` to point somewhere else; Dirge does not
read a separate `STANDARDCOMPUTE_BASE_URL` variable.

Reasoning effort is sent as a top-level `reasoning_effort` (`low`, `medium`, or
`high`), with lower and higher Dirge levels clamped to that set and the field
omitted when reasoning is off.

### OpenAI browser / device-code auth

Run `dirge auth openai` to authorize OpenAI through the browser OAuth flow and
Expand Down
2 changes: 1 addition & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ headline differentiators, see the top-level [README](../README.md).

## Core

- **Multi-provider**: OpenRouter, OpenAI, Anthropic, Gemini, DeepSeek, GLM, Cerebras, OpenCode, Kimi Code (Moonshot), Requesty, Opper, Cheaper Inference, Ollama, plus custom OpenAI-compatible endpoints. A single `providers` map declares aliases; top-level role keys (`provider`, `review_provider`, `escalation_provider`, `summarization_provider`, `subagent_provider`) point each role at one of those aliases. See [config.md](config.md).
- **Multi-provider**: OpenRouter, OpenAI, Anthropic, Gemini, DeepSeek, GLM, Cerebras, OpenCode, Kimi Code (Moonshot), Requesty, Opper, Cheaper Inference, Standard Compute, Ollama, plus custom OpenAI-compatible endpoints. A single `providers` map declares aliases; top-level role keys (`provider`, `review_provider`, `escalation_provider`, `summarization_provider`, `subagent_provider`) point each role at one of those aliases. See [config.md](config.md).
- **Standard tools**: read, write, edit, edit_lines, bash, grep, find_files, glob, list_dir, write_todo_list, issue, apply_patch, repo_overview, session_search, webfetch, websearch, question, memory, skill, spec, task, task_status, tool_search.
- **Hash-anchored editing** (`edit_lines` + `read(line_hashes=true)`): `read` can prefix each line with a 3-char content hash (`42 a3f: …`); `edit_lines` then replaces a line *range* by number (`start_line`/`end_line` + the `expected_hashes` for that range + `new_text`) instead of reproducing the old text. The tool recomputes the hashes from disk and rejects the edit with a per-line diff if any line drifted since the read, so it can't clobber changed content. The hash is a staleness guard, not a locator. On cheaper models (DeepSeek) this cuts retries and output tokens — the model emits line numbers + tiny hashes, not the block it's replacing. Reuses the read-before-edit gate, tree-sitter pre-write validation, and atomic write; the exact-string `edit` is unchanged.
- **Line-numbered read output**: `read` prefixes each line with right-aligned line numbers (`123: content`).
Expand Down
123 changes: 123 additions & 0 deletions src/agent/agent_loop/h7_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ fn build_stream_fn() -> Option<crate::agent::agent_loop::StreamFn> {
AnyModel::Requesty(m) => rig_stream_fn_from_model(m, vec![], chunk_timeout),
AnyModel::Opper(m) => rig_stream_fn_from_model(m, vec![], chunk_timeout),
AnyModel::CheaperInference(m) => rig_stream_fn_from_model(m, vec![], chunk_timeout),
AnyModel::StandardCompute(m) => rig_stream_fn_from_model(m, vec![], chunk_timeout),
AnyModel::Custom(m) => rig_stream_fn_from_model(m, vec![], chunk_timeout),
};

Expand Down Expand Up @@ -1722,6 +1723,128 @@ async fn h7_cheaper_inference_tool_dispatch_completes_round_trip() {
);
}

// =====================================================================
// Standard Compute scenarios
// =====================================================================

fn standard_compute_model(model_name: &str) -> Option<crate::provider::AnyModel> {
use rig::client::CompletionClient;

let key = match std::env::var("STANDARDCOMPUTE_API_KEY") {
Ok(key) if !key.trim().is_empty() => key,
_ => {
eprintln!("[skipped] STANDARDCOMPUTE_API_KEY is unset");
return None;
}
};
let client = crate::provider::create_client(
"standardcompute",
Some(key.as_str()),
&std::collections::HashMap::new(),
)
.expect("Standard Compute client should build from STANDARDCOMPUTE_API_KEY");
Some(client.completion_model(model_name))
}

fn standard_compute_spawn_config(
stream_fn: crate::agent::agent_loop::StreamFn,
system_prompt: &str,
initial_prompt: &str,
tools: Vec<Arc<dyn crate::agent::agent_loop::LoopTool>>,
model_name: &str,
) -> LoopSpawnConfig {
LoopSpawnConfig {
provider_name: Some("standardcompute".to_string()),
..cerebras_spawn_config(stream_fn, system_prompt, initial_prompt, tools, model_name)
}
}

#[tokio::test]
async fn h7_standard_compute_streaming_returns_non_empty_assistant_text() {
let model_name = crate::provider::default_model_for("standardcompute");
let Some(model) = standard_compute_model(model_name) else {
return;
};
assert_eq!(model.provider_name(), "standardcompute");
let inner = model.build_stream_fn(
Vec::new(),
std::time::Duration::from_secs(60),
Some("standardcompute".to_string()),
);
let stream_fn = retrying_stream_fn(inner, RecoveryPolicy::default());
let runner = spawn_loop_runner(standard_compute_spawn_config(
stream_fn,
"You are a concise assistant.",
"Reply with a short greeting.",
Vec::new(),
model_name,
))
.into_agent_runner();
let (events, response) = drain_to_done(runner).await;
dump_events(&events);
if skip_if_provider_unavailable(&events) {
return;
}

assert!(
!events
.iter()
.any(|event| matches!(event, AgentEvent::Error(_))),
"Standard Compute streaming produced an error: {events:?}",
);
assert!(
!response.unwrap_or_default().trim().is_empty(),
"Standard Compute should return non-empty assistant text",
);
}

#[tokio::test]
async fn h7_standard_compute_tool_dispatch_completes_round_trip() {
use crate::agent::agent_loop::loop_tool_to_rig_definition;

let model_name = crate::provider::default_model_for("standardcompute");
let Some(model) = standard_compute_model(model_name) else {
return;
};
assert_eq!(model.provider_name(), "standardcompute");
let tool: Arc<dyn crate::agent::agent_loop::LoopTool> = Arc::new(CerebrasEchoTool);
let tool_definition = loop_tool_to_rig_definition(tool.as_ref());
let inner = model.build_stream_fn(
vec![tool_definition],
std::time::Duration::from_secs(60),
Some("standardcompute".to_string()),
);
let stream_fn = retrying_stream_fn(inner, RecoveryPolicy::default());
let runner = spawn_loop_runner(standard_compute_spawn_config(
stream_fn,
"You have an echo_tool. When asked to echo text, you must call the tool, then confirm its result.",
"Use echo_tool to echo the word pineapple.",
vec![tool],
model_name,
))
.into_agent_runner();
let (events, response) = drain_to_done(runner).await;
dump_events(&events);
if skip_if_provider_unavailable(&events) {
return;
}

let tool_calls = events
.iter()
.filter(|event| matches!(event, AgentEvent::ToolCall { .. }))
.count();
let tool_results = events
.iter()
.filter(|event| matches!(event, AgentEvent::ToolResult { .. }))
.count();
assert!(tool_calls >= 1, "expected a Standard Compute tool call");
assert_eq!(tool_calls, tool_results, "every tool call must complete");
assert!(
!response.unwrap_or_default().trim().is_empty(),
"expected a final assistant turn after the tool round trip"
);
}

// Scenarios 4 (mid-run interjection), 6 (context overflow),
// and 7 (plugin hook) are covered in the manual runbook
// (docs/H7_AGENT_LOOP_TEST.md). They require interactive UI,
Expand Down
25 changes: 25 additions & 0 deletions src/agent/agent_loop/rig_stream_factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,7 @@ pub fn loop_tool_to_rig_definition(tool: &dyn LoopTool) -> ToolDefinition {
/// ignores the nested `reasoning` object.
/// - "opper": same top-level shape and clamping as cerebras.
/// - "cheaperinference": same top-level shape and clamping as cerebras.
/// - "standardcompute": same top-level shape and clamping as cerebras.
/// - "openai" / "custom" (openai-shaped): `{ "reasoning":
/// { "effort": "low" | "medium" | "high" | "xhigh" | "max" } }`
/// per OpenAI Responses spec — Xhigh and Max pass through
Expand Down Expand Up @@ -3106,6 +3107,30 @@ mod tests {
);
}

#[test]
fn standard_compute_reasoning_maps_to_top_level_effort() {
for (level, expected) in [
(ThinkingLevel::Minimal, "low"),
(ThinkingLevel::Medium, "medium"),
(ThinkingLevel::Max, "high"),
] {
let opts = opts_with_reasoning(level);
let params = build_provider_additional_params(Some("standardcompute"), None, &opts)
.expect("Standard Compute reasoning should produce request params");

assert_eq!(
params,
serde_json::json!({ "reasoning_effort": expected }),
"unexpected Standard Compute request params for {level:?}",
);
}
let opts = opts_with_reasoning(ThinkingLevel::Off);
assert_eq!(
build_provider_additional_params(Some("standardcompute"), None, &opts),
None,
);
}

/// Custom, OpenRouter share OpenAI's nested effort-based reasoning
/// shape (deepseek is separate; GLM uses top-level — see below).
#[test]
Expand Down
9 changes: 8 additions & 1 deletion src/compression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,13 @@ fn accepts_prompt_cache_key(kind: crate::provider::ProviderKind) -> bool {
K::OpenRouter => true,
K::Anthropic | K::Gemini => false,
K::DeepSeek | K::Glm | K::Cerebras | K::Kimi => false,
K::Ollama | K::OpenCode | K::Requesty | K::Opper | K::CheaperInference | K::Custom => false,
K::Ollama
| K::OpenCode
| K::Requesty
| K::Opper
| K::CheaperInference
| K::StandardCompute
| K::Custom => false,
}
}

Expand Down Expand Up @@ -218,6 +224,7 @@ mod tests {
ProviderKind::Requesty,
ProviderKind::Opper,
ProviderKind::CheaperInference,
ProviderKind::StandardCompute,
ProviderKind::Custom,
] {
assert!(
Expand Down
17 changes: 15 additions & 2 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use crate::extras::acp::config::AcpServerConfig;
///
/// `provider_type` is optional: when the alias matches a built-in
/// (anthropic, cerebras, deepseek, gemini, glm, kimi, ollama, openai, opencode,
/// openrouter, requesty, opper, cheaperinference, or custom), it is inferred from the key. Set it only when aliasing
/// openrouter, requesty, opper, cheaperinference, standardcompute, or custom), it is inferred from the key. Set it only when aliasing
/// a built-in backend under a different name — e.g.
/// `"ollama": { "provider_type": "openai", "base_url": "..." }`
/// aliases the OpenAI-compatible backend under the alias `ollama`.
Expand Down Expand Up @@ -338,6 +338,7 @@ fn provider_type_supports_images(provider_type: Option<&str>) -> bool {
| Some("requesty")
| Some("opper")
| Some("cheaperinference")
| Some("standardcompute")
)
}

Expand Down Expand Up @@ -2523,7 +2524,7 @@ pub fn load() -> Config {
"error: provider {:?} has invalid provider_type {:?}.\n\
Either the alias must match a built-in (openrouter, openai,\n\
openai-responses, anthropic, gemini, deepseek, glm, cerebras,\n\
opencode, ollama, requesty, opper, cheaperinference, custom) or set `provider_type` explicitly\n\
opencode, ollama, requesty, opper, cheaperinference, standardcompute, custom) or set `provider_type` explicitly\n\
to one of those.",
name, ptype,
);
Expand Down Expand Up @@ -3819,6 +3820,18 @@ mod provider_role_tests {
assert!(entry.model.is_none());
}

#[test]
fn standard_compute_bare_builtin_alias_resolves_without_provider_entry() {
let cfg = cfg_with_providers(r#"{ "provider": "standardcompute" }"#);
let (name, entry) = cfg
.resolve_role(ConfigRole::Default)
.expect("bare Standard Compute built-in should resolve");

assert_eq!(name, "standardcompute");
assert!(entry.provider_type.is_none());
assert!(entry.model.is_none());
}

#[test]
fn provider_type_of_returns_explicit_value_when_set() {
let entry = ProviderEntry {
Expand Down
Loading