diff --git a/CHANGELOG.md b/CHANGELOG.md index 927bc20d..3800d460 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 56c7740e..7378985c 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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). @@ -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 diff --git a/docs/config.md b/docs/config.md index 7528f562..d6510cc5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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..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..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`. | @@ -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`. | @@ -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 , 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 diff --git a/docs/features.md b/docs/features.md index a3d69862..79bf7771 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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`). diff --git a/src/agent/agent_loop/h7_smoke.rs b/src/agent/agent_loop/h7_smoke.rs index ffa231c3..ba9a81ae 100644 --- a/src/agent/agent_loop/h7_smoke.rs +++ b/src/agent/agent_loop/h7_smoke.rs @@ -106,6 +106,7 @@ fn build_stream_fn() -> Option { 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), }; @@ -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 { + 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>, + 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 = 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, diff --git a/src/agent/agent_loop/rig_stream_factory.rs b/src/agent/agent_loop/rig_stream_factory.rs index a5fd6b6b..6b5d0a3f 100644 --- a/src/agent/agent_loop/rig_stream_factory.rs +++ b/src/agent/agent_loop/rig_stream_factory.rs @@ -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 @@ -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] diff --git a/src/compression.rs b/src/compression.rs index 068d0da5..e81cefbb 100644 --- a/src/compression.rs +++ b/src/compression.rs @@ -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, } } @@ -218,6 +224,7 @@ mod tests { ProviderKind::Requesty, ProviderKind::Opper, ProviderKind::CheaperInference, + ProviderKind::StandardCompute, ProviderKind::Custom, ] { assert!( diff --git a/src/config/mod.rs b/src/config/mod.rs index e202b36e..2383b100 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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`. @@ -338,6 +338,7 @@ fn provider_type_supports_images(provider_type: Option<&str>) -> bool { | Some("requesty") | Some("opper") | Some("cheaperinference") + | Some("standardcompute") ) } @@ -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, ); @@ -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 { diff --git a/src/provider/adapter.rs b/src/provider/adapter.rs index 3871af40..08325898 100644 --- a/src/provider/adapter.rs +++ b/src/provider/adapter.rs @@ -149,6 +149,12 @@ pub fn reasoning_profile(provider: Option<&str>, model: Option<&str>) -> Reasoni effort: EffortWire::TopLevelStandardEffort, disable: DisableWire::None, }, + // Standard Compute is an OpenAI-compatible gateway whose chat/completions + // path reads the top-level `reasoning_effort`, the Requesty shape. + Some("standardcompute") => ReasoningProfile { + effort: EffortWire::TopLevelStandardEffort, + disable: DisableWire::None, + }, Some("openai") => ReasoningProfile { effort: EffortWire::NestedEffort, disable: DisableWire::None, @@ -600,6 +606,11 @@ mod tests { EffortWire::TopLevelStandardEffort, DisableWire::None, ), + ( + "standardcompute", + EffortWire::TopLevelStandardEffort, + DisableWire::None, + ), ("openai", EffortWire::NestedEffort, DisableWire::None), ( "custom", @@ -1242,6 +1253,32 @@ mod max_tokens_tests { assert_eq!(profile.disable_params(), None); } + #[test] + fn standard_compute_uses_standard_top_level_effort_without_disable_knob() { + let profile = reasoning_profile(Some("standardcompute"), None); + for (level, expected) in [ + (ThinkingLevel::Minimal, "low"), + (ThinkingLevel::Low, "low"), + (ThinkingLevel::Medium, "medium"), + (ThinkingLevel::High, "high"), + (ThinkingLevel::Xhigh, "high"), + (ThinkingLevel::Max, "high"), + ] { + let params = profile + .effort_params(level, None) + .expect("enabled Standard Compute reasoning should produce params"); + assert_eq!( + params, + serde_json::json!({ "reasoning_effort": expected }), + "unexpected Standard Compute params for {level:?}", + ); + assert!(params.get("reasoning").is_none()); + } + + assert_eq!(profile.effort_params(ThinkingLevel::Off, None), None); + assert_eq!(profile.disable_params(), None); + } + /// Only the budget-shaped wire needs this. Effort-string providers send /// no budget, so forcing a ceiling on them would override the model's /// own (larger) default for no reason. diff --git a/src/provider/build.rs b/src/provider/build.rs index e167cb12..16b99ab0 100644 --- a/src/provider/build.rs +++ b/src/provider/build.rs @@ -387,6 +387,7 @@ pub async fn build_agent( AnyModel::Requesty(m) => build_inner!(m, Requesty), AnyModel::Opper(m) => build_inner!(m, Opper), AnyModel::CheaperInference(m) => build_inner!(m, CheaperInference), + AnyModel::StandardCompute(m) => build_inner!(m, StandardCompute), AnyModel::Custom(m) => build_inner!(m, Custom), }; @@ -510,7 +511,7 @@ pub async fn build_agent( eprintln!( "error: escalation_provider '{}' is configured but does not match any entry \ in `providers` or any built-in (anthropic/openai/deepseek/glm/cerebras/\ - opencode/gemini/ollama/openrouter/requesty/opper/cheaperinference). Either add it under `providers` or remove \ + opencode/gemini/ollama/openrouter/requesty/opper/cheaperinference/standardcompute). Either add it under `providers` or remove \ the `escalation_provider` setting.", alias ); diff --git a/src/provider/client.rs b/src/provider/client.rs index 9a54f00a..c4269496 100644 --- a/src/provider/client.rs +++ b/src/provider/client.rs @@ -254,6 +254,7 @@ fn resolve_provider_base_url( ProviderKind::Requesty => (None, Some("https://router.requesty.ai/v1")), ProviderKind::Opper => (None, Some("https://api.opper.ai/v3/compat")), ProviderKind::CheaperInference => (None, Some("https://api.cheaperinference.com/v1")), + ProviderKind::StandardCompute => (None, Some("https://api.stdcmpt.com/v1")), ProviderKind::Kimi => ( Some("KIMI_CODE_BASE_URL"), Some(crate::auth::kimi_device::KIMI_CODE_BASE_URL), @@ -319,7 +320,7 @@ where } = request_context; let info = resolve_provider_info(provider_name, providers).ok_or_else(|| { anyhow::anyhow!( - "Unknown provider: {}. Supported providers: openrouter, openai, anthropic, gemini, deepseek, glm, cerebras, opencode, kimi, ollama, requesty, opper, cheaperinference, custom", + "Unknown provider: {}. Supported providers: openrouter, openai, anthropic, gemini, deepseek, glm, cerebras, opencode, kimi, ollama, requesty, opper, cheaperinference, standardcompute, custom", provider_name ) })?; @@ -702,6 +703,17 @@ where .http_headers(headers); Ok(AnyClient::CheaperInference(b.build()?)) } + ProviderKind::StandardCompute => { + let b = openai::CompletionsClient::builder() + .http_client(compressing( + reqwest::Client::new(), + ProviderKind::StandardCompute, + )) + .api_key(&key) + .base_url(require_base_url(info.kind, base_url.as_deref())?) + .http_headers(headers); + Ok(AnyClient::StandardCompute(b.build()?)) + } ProviderKind::Custom => { let base_url = base_url.ok_or_else(|| { anyhow::anyhow!( @@ -1143,6 +1155,7 @@ fn wire_kind(kind: ProviderKind) -> crate::llmtrim::ir::ProviderKind { | ProviderKind::Requesty | ProviderKind::Opper | ProviderKind::CheaperInference + | ProviderKind::StandardCompute | ProviderKind::Custom => Wire::OpenAi, } } @@ -1415,6 +1428,10 @@ mod tests { ProviderKind::CheaperInference, "https://api.cheaperinference.com/v1", ), + ( + ProviderKind::StandardCompute, + "https://api.stdcmpt.com/v1", + ), ( ProviderKind::Kimi, crate::auth::kimi_device::KIMI_CODE_BASE_URL, @@ -2525,6 +2542,11 @@ mod tests { .expect("cheaperinference should resolve through the production parser") } + fn parsed_standard_compute_kind() -> ProviderKind { + crate::provider::parse_provider("standardcompute") + .expect("standardcompute should resolve through the production parser") + } + #[test] fn cheaper_inference_default_base_url_is_api_v1() { let got = resolve_provider_base_url(parsed_cheaper_inference_kind(), None, no_env) @@ -2533,6 +2555,14 @@ mod tests { assert_eq!(got.as_deref(), Some("https://api.cheaperinference.com/v1")); } + #[test] + fn standard_compute_default_base_url_is_api_v1() { + let got = resolve_provider_base_url(parsed_standard_compute_kind(), None, no_env) + .expect("Standard Compute default URL should resolve"); + + assert_eq!(got.as_deref(), Some("https://api.stdcmpt.com/v1")); + } + #[test] fn cheaper_inference_configured_https_base_url_overrides_default() { let got = resolve_provider_base_url( @@ -2545,6 +2575,18 @@ mod tests { assert_eq!(got.as_deref(), Some("https://gateway.example.com/v1")); } + #[test] + fn standard_compute_configured_https_base_url_overrides_default() { + let got = resolve_provider_base_url( + parsed_standard_compute_kind(), + Some("https://gateway.example.com/v1".to_string()), + no_env, + ) + .expect("configured Standard Compute URL should resolve"); + + assert_eq!(got.as_deref(), Some("https://gateway.example.com/v1")); + } + #[test] fn cheaper_inference_client_builds_from_only_cheaper_inference_api_key() { let client = create_client_with( @@ -2563,6 +2605,24 @@ mod tests { ); } + #[test] + fn standard_compute_client_builds_from_only_standard_compute_api_key() { + let client = create_client_with( + "standardcompute", + None, + &HashMap::new(), + |name| (name == "STANDARDCOMPUTE_API_KEY").then(|| "test-sc-key".to_string()), + || Ok(None), + ) + .expect("Standard Compute client should build from its standard environment key"); + let model = client.completion_model("StandardCompute"); + + assert_eq!( + (model.provider_name(), model.name()), + ("standardcompute", "StandardCompute".to_string()), + ); + } + #[test] fn cheaper_inference_missing_key_names_only_cheaper_inference_api_key() { let result = create_client_with( @@ -2584,6 +2644,27 @@ mod tests { assert!(!message.contains("test-openai-key-must-not-leak")); } + #[test] + fn standard_compute_missing_key_names_only_standard_compute_api_key() { + let result = create_client_with( + "standardcompute", + None, + &HashMap::new(), + |name| (name == "OPENAI_API_KEY").then(|| "test-openai-key-must-not-leak".to_string()), + || Ok(None), + ); + let message = match result { + Ok(_) => panic!("Standard Compute must not accept an OpenAI key"), + Err(err) => err.to_string(), + }; + + assert!( + message.contains("STANDARDCOMPUTE_API_KEY"), + "unexpected error: {message}" + ); + assert!(!message.contains("test-openai-key-must-not-leak")); + } + // ── Kimi provider ─────────────────────────────────────────────────── /// Run `body` with DIRGE_DATA_DIR pointed at an isolated (empty or diff --git a/src/provider/dispatch.rs b/src/provider/dispatch.rs index bc8ba060..52f3dd23 100644 --- a/src/provider/dispatch.rs +++ b/src/provider/dispatch.rs @@ -58,6 +58,9 @@ pub enum AnyClient { CheaperInference( openai::CompletionsClient>, ), + StandardCompute( + openai::CompletionsClient>, + ), Custom( openai::CompletionsClient>, ), @@ -96,6 +99,7 @@ impl AnyClient { AnyClient::Requesty(c) => AnyModel::Requesty(c.completion_model(name)), AnyClient::Opper(c) => AnyModel::Opper(c.completion_model(name)), AnyClient::CheaperInference(c) => AnyModel::CheaperInference(c.completion_model(name)), + AnyClient::StandardCompute(c) => AnyModel::StandardCompute(c.completion_model(name)), AnyClient::Custom(c) => AnyModel::Custom(c.completion_model(name)), } } @@ -422,6 +426,11 @@ pub enum AnyModel { super::compressing_http::CompressingHttpClient, >, ), + StandardCompute( + openai::completion::CompletionModel< + super::compressing_http::CompressingHttpClient, + >, + ), Custom( openai::completion::CompletionModel< super::compressing_http::CompressingHttpClient, @@ -487,6 +496,7 @@ impl AnyModel { AnyModel::Requesty(m) => one_shot!(m), AnyModel::Opper(m) => one_shot!(m), AnyModel::CheaperInference(m) => one_shot!(m), + AnyModel::StandardCompute(m) => one_shot!(m), AnyModel::Custom(m) => one_shot!(m), } } @@ -548,6 +558,7 @@ impl AnyModel { AnyModel::Requesty(_) => "requesty", AnyModel::Opper(_) => "opper", AnyModel::CheaperInference(_) => "cheaperinference", + AnyModel::StandardCompute(_) => "standardcompute", AnyModel::Custom(_) => "custom", } } @@ -575,6 +586,7 @@ impl AnyModel { AnyModel::Requesty(m) => m.model.clone(), AnyModel::Opper(m) => m.model.clone(), AnyModel::CheaperInference(m) => m.model.clone(), + AnyModel::StandardCompute(m) => m.model.clone(), AnyModel::Custom(m) => m.model.clone(), } } diff --git a/src/provider/mod.rs b/src/provider/mod.rs index cb4ebb80..f27c363a 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -375,6 +375,11 @@ pub(crate) enum AnyAgentInner { compressing_http::CompressingHttpClient, >, ), + StandardCompute( + openai::completion::CompletionModel< + compressing_http::CompressingHttpClient, + >, + ), Custom( openai::completion::CompletionModel< compressing_http::CompressingHttpClient, @@ -905,6 +910,7 @@ impl AnyAgent { AnyAgentInner::Requesty(_) => "requesty", AnyAgentInner::Opper(_) => "opper", AnyAgentInner::CheaperInference(_) => "cheaperinference", + AnyAgentInner::StandardCompute(_) => "standardcompute", AnyAgentInner::Custom(_) => "custom", } } diff --git a/src/provider/mod_tests.rs b/src/provider/mod_tests.rs index 55bd8fec..51efd80c 100644 --- a/src/provider/mod_tests.rs +++ b/src/provider/mod_tests.rs @@ -247,6 +247,22 @@ fn cheaper_inference_standard_key_lookup_uses_only_cheaper_inference_api_key() { assert!(provider_env_var_fallbacks(kind).is_empty()); } +#[test] +fn standard_compute_standard_key_lookup_uses_only_standard_compute_api_key() { + let kind = + parse_provider("standardcompute").expect("standardcompute should be a built-in provider"); + let key = resolve_api_key_from( + kind, + None, + None, + mock_env(&[("STANDARDCOMPUTE_API_KEY", "test-sc-key")]), + ) + .expect("standard Standard Compute key should resolve"); + + assert_eq!(key, "test-sc-key"); + assert!(provider_env_var_fallbacks(kind).is_empty()); +} + #[test] fn cheaper_inference_builtin_name_is_protected_from_plugin_shadowing() { let err = validate_custom_provider( @@ -263,6 +279,22 @@ fn cheaper_inference_builtin_name_is_protected_from_plugin_shadowing() { ); } +#[test] +fn standard_compute_builtin_name_is_protected_from_plugin_shadowing() { + let err = validate_custom_provider( + "STANDARDCOMPUTE", + "https://interceptor.invalid/v1", + false, + true, + ) + .expect_err("plugins must not shadow the Standard Compute built-in"); + + assert!( + err.contains("collides with built-in"), + "unexpected error: {err}" + ); +} + /// No stored `dirge auth` login → no provider implied, so the /// caller falls through to the openrouter default. #[test] diff --git a/src/provider/resolve.rs b/src/provider/resolve.rs index 5b55da02..a2c7ea62 100644 --- a/src/provider/resolve.rs +++ b/src/provider/resolve.rs @@ -27,6 +27,7 @@ pub enum ProviderKind { Requesty, Opper, CheaperInference, + StandardCompute, Custom, } @@ -50,6 +51,7 @@ pub fn default_model_for(provider_name: &str) -> &'static str { Some(ProviderKind::Requesty) => "openai/gpt-4o-mini", Some(ProviderKind::Opper) => "claude-sonnet-4-6", Some(ProviderKind::CheaperInference) => "gpt-5.4-mini", + Some(ProviderKind::StandardCompute) => "StandardCompute", // OpenRouter + Custom + unknown — keep the historical default // since OpenRouter wants the `vendor/model` form. _ => "deepseek/deepseek-v4-flash", @@ -129,6 +131,7 @@ pub fn parse_provider(name: &str) -> Option { "requesty" => Some(ProviderKind::Requesty), "opper" => Some(ProviderKind::Opper), "cheaperinference" => Some(ProviderKind::CheaperInference), + "standardcompute" => Some(ProviderKind::StandardCompute), "custom" => Some(ProviderKind::Custom), _ => None, } @@ -386,6 +389,7 @@ fn kind_label(kind: ProviderKind) -> &'static str { ProviderKind::Requesty => "requesty", ProviderKind::Opper => "opper", ProviderKind::CheaperInference => "cheaperinference", + ProviderKind::StandardCompute => "standardcompute", ProviderKind::Custom => "custom", } } @@ -549,6 +553,7 @@ const BUILTIN_PROVIDER_NAMES: &[&str] = &[ "requesty", "opper", "cheaperinference", + "standardcompute", "custom", ]; @@ -699,6 +704,7 @@ fn provider_env_var(kind: ProviderKind) -> &'static str { ProviderKind::Requesty => "REQUESTY_API_KEY", ProviderKind::Opper => "OPPER_API_KEY", ProviderKind::CheaperInference => "CHEAPER_INFERENCE_API_KEY", + ProviderKind::StandardCompute => "STANDARDCOMPUTE_API_KEY", ProviderKind::Custom => "CUSTOM_API_KEY", } } @@ -1062,6 +1068,37 @@ mod cheaper_inference_identity_tests { } } +#[cfg(test)] +mod standard_compute_identity_tests { + use super::*; + + #[test] + fn standard_compute_parses_case_insensitively_and_defaults_to_a_bare_model() { + for name in ["standardcompute", "STANDARDCOMPUTE", "StandardCompute"] { + assert_eq!( + parse_provider(name).map(kind_label), + Some("standardcompute"), + "provider name {name:?} should resolve canonically", + ); + } + assert_eq!(default_model_for("standardcompute"), "StandardCompute"); + assert_eq!( + provider_env_var(ProviderKind::StandardCompute), + "STANDARDCOMPUTE_API_KEY" + ); + } + + #[test] + fn standard_compute_is_not_autodetected() { + assert!( + PROVIDER_AUTODETECT_ORDER + .iter() + .all(|(_, provider)| *provider != "standardcompute"), + "Standard Compute must be selected explicitly", + ); + } +} + #[cfg(test)] mod kimi_identity_tests { use super::*; diff --git a/src/provider/stream_dispatch.rs b/src/provider/stream_dispatch.rs index b4bbac06..4c1a312c 100644 --- a/src/provider/stream_dispatch.rs +++ b/src/provider/stream_dispatch.rs @@ -28,8 +28,8 @@ macro_rules! dispatch_stream_fn { use $crate::agent::agent_loop::rig_stream_fn_from_model_with_filter as __stream_fn; // Provider-specific wire adapters key off canonical backend names, not // configured aliases. OpenAI Responses needs canonical `openai` for - // reasoning/tool-call ID conversion; Cerebras, Requesty, Opper and Cheaper - // Inference need their + // reasoning/tool-call ID conversion; Cerebras, Requesty, Opper, Cheaper + // Inference and Standard Compute need their // canonical names for the top-level reasoning_effort shape. These concrete dispatch arms // know the backend even when a role route was configured under an alias. // Other providers retain the passed identity until they need the same @@ -141,6 +141,14 @@ macro_rules! dispatch_stream_fn { $model_name, $filter, ), + $enum::StandardCompute($bind) => __stream_fn( + $model, + $tools, + $timeout, + Some("standardcompute".to_string()), + $model_name, + $filter, + ), $enum::Custom($bind) => { __stream_fn($model, $tools, $timeout, $provider, $model_name, $filter) } diff --git a/src/provider/summarize.rs b/src/provider/summarize.rs index c15afede..2ccb5e0e 100644 --- a/src/provider/summarize.rs +++ b/src/provider/summarize.rs @@ -84,6 +84,9 @@ pub(crate) async fn oneshot_with_model( super::AnyModel::CheaperInference(m) => { run_oneshot(m, label, preamble, prompt, disable).await } + super::AnyModel::StandardCompute(m) => { + run_oneshot(m, label, preamble, prompt, disable).await + } super::AnyModel::Custom(m) => run_oneshot(m, label, preamble, prompt, disable).await, } } @@ -465,6 +468,7 @@ mod tests { assert_eq!(reasoning_disable_for_kind("requesty", None), None); assert_eq!(reasoning_disable_for_kind("opper", None), None); assert_eq!(reasoning_disable_for_kind("cheaperinference", None), None); + assert_eq!(reasoning_disable_for_kind("standardcompute", None), None); } #[test]