Merge origin/main into tideline/components-20260828

Union-resolve the two CHANGELOG.md anchors (Route Contract Phase 1 entry
from main + this branch's Tideline components entry). No code conflicts:
this branch adds standalone render modules + goldens; main's delta since
the last merge (#5702 route contract, R1 turn budgets, contributor docs)
touches no file this branch edits outside the changelogs.

Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale Bot committed 2026-08-29 02:26:13 -07:00
commit fdf0b97ee6
42 files changed
+3588 -368

No files matched your search

+5
View File
@@ -225,3 +225,8 @@ redstar = Kai Nacke <[email protected]>
[email protected] = Kai Nacke <[email protected]>
Shizuku = Sh1Zuku <[email protected]>
[email protected] = Sh1Zuku <[email protected]>
musichen = Alex Musichen <[email protected]>
[email protected] = Alex Musichen <[email protected]>
M-Maciej = M-Maciej <[email protected]>
serephus = Serephus <[email protected]>
# [email protected] = UNRESOLVED — 21 commits on main via #704; GitHub login not yet identified
+26 -1
View File
@@ -47,7 +47,26 @@ jobs:
workflow: ${{ steps.detect.outputs.workflow }}
mobile: ${{ steps.detect.outputs.mobile }}
actions: ${{ steps.detect.outputs.actions }}
trusted: ${{ steps.trust.outputs.trusted }}
steps:
- name: Classify event trust
id: trust
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
THIS_REPO: ${{ github.repository }}
run: |
set -euo pipefail
# "trusted" means the code came from this repository, not a fork.
# Only trusted events may run on the self-hosted macOS runner: this
# repo is public with thousands of forks, and a fork PR on a
# self-hosted runner is arbitrary code execution on that machine.
if [ "${EVENT_NAME}" != "pull_request" ] || [ "${HEAD_REPO}" = "${THIS_REPO}" ]; then
echo "trusted=true" >> "$GITHUB_OUTPUT"
else
echo "trusted=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -499,7 +518,13 @@ jobs:
# The ternary is safe: matrix.os is always a non-empty literal, so
# runs-on can never evaluate to empty.
timeout-minutes: 90
runs-on: ${{ needs.changes.outputs.heavy == 'true' && matrix.os || 'ubuntu-latest' }}
# macOS legs go to the self-hosted Mac ONLY when all three hold: the
# change is heavy, the event is trusted (not a fork PR), and the
# CW_SELF_HOSTED_MAC repo variable is 'true'. That variable is the kill
# switch: unset it and every leg falls back to GitHub-hosted runners
# immediately, with no commit — important because an offline
# self-hosted runner queues jobs forever, which is worse than a slow one.
runs-on: ${{ needs.changes.outputs.heavy != 'true' && 'ubuntu-latest' || (matrix.os == 'macos-latest' && needs.changes.outputs.trusted == 'true' && vars.CW_SELF_HOSTED_MAC == 'true' && fromJSON('["self-hosted","macOS","ARM64","codewhale-mac"]')) || matrix.os }}
strategy:
# A failure on one desktop platform must not erase evidence from the
# other one. We need both conclusions to diagnose and release safely.
+6
View File
@@ -26,6 +26,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
and the left rail — each a standalone render module pinned by 28 new
byte-exact golden buffers. Frame wiring follows the Tideline acceptance
gate; `NO_COLOR` is now honored by palette depth detection.
- Route Contract Phase 1: `RouteResolver` is the runtime path for
`resolve_runtime_options`; `codewhale providers export --json` ships the
owned descriptor catalog; CLI `--provider` accepts any catalog route id
(the closed `ProviderArg` enum is deleted). Catalog layers are
bundled → models.dev → provider `/v1/models` → config.toml → user, with
policy DENY last and never overridden.
- Add provider-native web search for documented Xiaomi MiMo 2.5 Pro and 2.5
chat routes while keeping neighboring models and custom gateways fail-closed.
- Add structured provider-native web search for exact Z.AI global and Zhipu
+9
View File
@@ -1051,6 +1051,15 @@ alternate_screen = "auto" # auto/always use the TUI screen; never uses termina
mouse_capture = true # true copies only transcript user/assistant text; false uses raw terminal selection/copy
terminal_probe_timeout_ms = 500 # optional startup terminal-mode timeout (100-5000ms)
stream_chunk_timeout_secs = 900 # optional SSE idle timeout per chunk (0 = default, 1-3600)
# R1 turn budgets. An agent loop with no finite bound can spend real money
# forever, so every one of these is finite by default and finite at its
# maximum. `0` is an invalid value that falls back to the default — it is
# never a "0 means unlimited" sentinel.
max_model_steps = 200 # model steps one turn may take (0 = default, 1-100000)
turn_wall_clock_secs = 3600 # cumulative wall clock for one turn, excluding time
# blocked on a human approval (0 = default, 30-86400)
stream_max_content_mb = 10 # per-step cap on streamed content (0 = default, 1-512)
stream_max_duration_secs = 1800 # per-step cap on one stream's duration (0 = default, 10-86400)
osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTerm2/Ghostty/Kitty/WezTerm/Terminal.app 13+); set false for terminals that misrender
# Ordered footer chips shown in the TUI status line. Omit the key to use the
# built-in default; set [] to hide all configurable chips. You can also edit
+5 -5
View File
@@ -323,7 +323,7 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
// immediately and sleep afterwards. It has no slow_down.
let bundle = codewhale_config::device_code::DeviceCodePoll::new(
timeout.min(server_lifetime),
"Codewhale account login timed out; run `codewhale account login` to try again",
"Codewhale account login timed out; run `codewhale login` to try again",
)
.interval_seconds(Some(device.interval))
.max_interval_seconds(10)
@@ -436,7 +436,7 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
body: Option<Vec<u8>>,
) -> Result<CloudResponse> {
let Some(mut stored) = self.load_auth()? else {
bail!("Not signed in. Run `codewhale account login` first");
bail!("Not signed in. Run `codewhale login` first");
};
let first = self.transport.execute(CloudRequest {
method,
@@ -460,7 +460,7 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
200 => {}
401 => {
self.clear_auth()?;
bail!("The Codewhale account session expired. Run `codewhale account login` again");
bail!("The Codewhale account session expired. Run `codewhale login` again");
}
_ => return Err(response_error(&refresh)),
}
@@ -479,7 +479,7 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
})?;
if retried.status == 401 {
self.clear_auth()?;
bail!("The Codewhale account session expired. Run `codewhale account login` again");
bail!("The Codewhale account session expired. Run `codewhale login` again");
}
Ok(retried)
}
@@ -617,7 +617,7 @@ fn run_with<T: CloudTransport, W: Write>(
writeln!(out, "Not signed in to Codewhale.")?;
writeln!(out, "Profile: {}", printable(profile))?;
writeln!(out, "API: {api_base}")?;
writeln!(out, "Run `codewhale account login` to sign in.")?;
writeln!(out, "Run `codewhale login` to sign in.")?;
Ok(())
}
},
+2 -2
View File
@@ -3,7 +3,7 @@ use std::io::{self, Write};
use clap::Parser;
use super::*;
use crate::{AuthArgs, AuthCommand, Cli, Commands, ProviderArg, no_keyring_secrets};
use crate::{AuthArgs, AuthCommand, Cli, Commands, no_keyring_secrets};
const SENTINEL: &str = "cw-test-secret-never-log-7b30";
const AMBIENT_SENTINEL: &str = "cw-test-ambient-openrouter-never-log-94cd";
@@ -48,7 +48,7 @@ fn command_parses_and_documents_the_pipe_only_boundary() {
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::PrintApiKey {
provider: ProviderArg::Openrouter
provider: ProviderKind::Openrouter
}
}))
));
+144 -237
View File
@@ -20,6 +20,7 @@ use codewhale_agent::ModelRegistry;
use codewhale_app_server::{
AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,
};
use codewhale_config::route::{ProvidersExport, parse_route_kind};
use codewhale_config::{
CliRuntimeOverrides, ConfigApiKeyValueKind, ConfigStore, ConfigToml, ProviderKind,
ProviderSource, ResolvedRuntimeOptions, RuntimeApiKeySource, SetupState,
@@ -34,141 +35,17 @@ use codewhale_telemetry::{
TelemetryDecision, TurnWall,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum ProviderArg {
Deepseek,
NvidiaNim,
Openai,
Atlascloud,
WanjieArk,
Volcengine,
Openrouter,
Orcarouter,
XiaomiMimo,
Novita,
Fireworks,
Siliconflow,
#[value(
alias = "silicon-flow-cn",
alias = "siliconflow-CN",
alias = "silicon_flow_cn",
alias = "siliconflow_cn",
alias = "siliconflow-china",
alias = "siliconflow_china"
)]
SiliconflowCn,
Arcee,
Moonshot,
Sglang,
Vllm,
Ollama,
#[value(alias = "ollama_cloud")]
OllamaCloud,
Huggingface,
Together,
OpenaiCodex,
Anthropic,
#[value(alias = "open-model", alias = "open_model")]
Openmodel,
Zai,
Stepfun,
Minimax,
#[value(
alias = "minimax_anthropic",
alias = "mini-max-anthropic",
alias = "mini_max_anthropic"
)]
MinimaxAnthropic,
#[value(alias = "deep-infra", alias = "deep_infra")]
Deepinfra,
#[value(alias = "fugu", alias = "sakana-ai", alias = "sakana_ai")]
Sakana,
#[value(alias = "long-cat", alias = "meituan-longcat", alias = "meituan")]
LongCat,
#[value(alias = "opencode_go", alias = "opencodego")]
OpencodeGo,
#[value(
alias = "opencode_zen",
alias = "opencodezen",
alias = "zen",
alias = "opencode"
)]
OpencodeZen,
#[value(
alias = "meta-ai",
alias = "meta_ai",
alias = "meta-model-api",
alias = "muse",
alias = "muse-spark"
)]
Meta,
#[value(alias = "x-ai", alias = "x_ai", alias = "grok")]
Xai,
#[value(
alias = "mistral-ai",
alias = "mistral_ai",
alias = "mistralai",
alias = "la-plateforme",
alias = "la_plateforme"
)]
Mistral,
/// Google Gemini (official OpenAI-compatible endpoint).
Google,
/// Google Antigravity (`agy`) — consent-gated OAuth import.
#[value(alias = "agy")]
Antigravity,
#[value(alias = "eden-ai", alias = "eden_ai")]
Edenai,
/// Catalog-backed `--provider` parser. Replaces the closed 47-arm `ProviderArg` enum.
fn parse_catalog_route(value: &str) -> std::result::Result<ProviderKind, String> {
parse_route_kind(value).ok_or_else(|| {
format!(
"unknown route '{value}'; expected a catalog route id (see `codewhale providers export --json`)"
)
})
}
impl From<ProviderArg> for ProviderKind {
fn from(value: ProviderArg) -> Self {
match value {
ProviderArg::Deepseek => ProviderKind::Deepseek,
ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,
ProviderArg::Openai => ProviderKind::Openai,
ProviderArg::Atlascloud => ProviderKind::Atlascloud,
ProviderArg::WanjieArk => ProviderKind::WanjieArk,
ProviderArg::Volcengine => ProviderKind::Volcengine,
ProviderArg::Openrouter => ProviderKind::Openrouter,
ProviderArg::Orcarouter => ProviderKind::Orcarouter,
ProviderArg::XiaomiMimo => ProviderKind::XiaomiMimo,
ProviderArg::Novita => ProviderKind::Novita,
ProviderArg::Fireworks => ProviderKind::Fireworks,
ProviderArg::Siliconflow => ProviderKind::Siliconflow,
ProviderArg::SiliconflowCn => ProviderKind::SiliconflowCN,
ProviderArg::Arcee => ProviderKind::Arcee,
ProviderArg::Moonshot => ProviderKind::Moonshot,
ProviderArg::Sglang => ProviderKind::Sglang,
ProviderArg::Vllm => ProviderKind::Vllm,
ProviderArg::Ollama => ProviderKind::Ollama,
ProviderArg::OllamaCloud => ProviderKind::OllamaCloud,
ProviderArg::Huggingface => ProviderKind::Huggingface,
ProviderArg::Together => ProviderKind::Together,
ProviderArg::OpenaiCodex => ProviderKind::OpenaiCodex,
ProviderArg::Anthropic => ProviderKind::Anthropic,
ProviderArg::Openmodel => ProviderKind::Openmodel,
ProviderArg::Zai => ProviderKind::Zai,
ProviderArg::Stepfun => ProviderKind::Stepfun,
ProviderArg::Minimax => ProviderKind::Minimax,
ProviderArg::MinimaxAnthropic => ProviderKind::MinimaxAnthropic,
ProviderArg::Deepinfra => ProviderKind::Deepinfra,
ProviderArg::Sakana => ProviderKind::Sakana,
ProviderArg::LongCat => ProviderKind::LongCat,
ProviderArg::OpencodeGo => ProviderKind::OpencodeGo,
ProviderArg::OpencodeZen => ProviderKind::OpencodeZen,
ProviderArg::Meta => ProviderKind::Meta,
ProviderArg::Xai => ProviderKind::Xai,
ProviderArg::Mistral => ProviderKind::Mistral,
ProviderArg::Google => ProviderKind::Google,
ProviderArg::Antigravity => ProviderKind::Antigravity,
ProviderArg::Edenai => ProviderKind::Edenai,
}
}
}
fn builtin_provider_arg(value: &str) -> Option<ProviderArg> {
ProviderArg::from_str(value, false).ok()
fn builtin_provider_arg(value: &str) -> Option<ProviderKind> {
parse_route_kind(value)
}
fn parse_provider_identifier(value: &str) -> std::result::Result<String, String> {
@@ -468,6 +345,24 @@ The command prints the completion script to stdout; redirect it to a path your s
Metrics(MetricsArgs),
/// Check for and apply updates to the `codewhale` binary.
Update(UpdateArgs),
/// Export the route catalog (`providers export --json`).
Providers(ProvidersArgs),
}
#[derive(Debug, Args)]
struct ProvidersArgs {
#[command(subcommand)]
command: ProvidersCommand,
}
#[derive(Debug, Subcommand)]
enum ProvidersCommand {
/// Write the owned route catalog as JSON (cwc contract).
Export {
/// Required. The export is the generated cwc catalog source of truth.
#[arg(long)]
json: bool,
},
}
/// The name of this crate's `[[bin]]` target, and the command users actually
@@ -557,18 +452,13 @@ fn top_level_provider_override(
return Ok(None);
};
if let Some(provider) = builtin_provider_arg(provider) {
return Ok(Some(provider.into()));
return Ok(Some(provider));
}
if command_accepts_raw_provider(command) {
return Ok(None);
}
let expected = ProviderArg::value_variants()
.iter()
.filter_map(ValueEnum::to_possible_value)
.map(|value| value.get_name().to_string())
.collect::<Vec<_>>()
.join(", ");
let expected = ProviderKind::names_hint();
bail!(
"invalid value '{provider}' for '--provider <PROVIDER>': expected one of {expected}; configured custom providers are accepted only by exec and fleet"
)
@@ -1536,8 +1426,8 @@ struct LoginArgs {
#[arg(long, hide = true)]
api_key: Option<String>,
/// Legacy provider flag: rejected with a redirect to `auth set`.
#[arg(long, value_enum, hide = true)]
provider: Option<ProviderArg>,
#[arg(long, value_parser = parse_catalog_route, hide = true)]
provider: Option<ProviderKind>,
}
#[derive(Debug, Args)]
@@ -1555,8 +1445,8 @@ enum AuthCommand {
/// another CLI. Managed mutation is currently unsupported and fails closed.
#[command(name = "external-consent")]
ExternalConsent {
#[arg(long, value_enum)]
provider: ProviderArg,
#[arg(long, value_parser = parse_catalog_route)]
provider: ProviderKind,
#[arg(long, value_enum)]
mode: ExternalCredentialModeArg,
/// Exact credential file path. Defaults to the selected CLI's resolved
@@ -1571,16 +1461,16 @@ enum AuthCommand {
/// Revoke access to another CLI's credential file for one provider.
#[command(name = "external-revoke")]
ExternalRevoke {
#[arg(long, value_enum)]
provider: ProviderArg,
#[arg(long, value_parser = parse_catalog_route)]
provider: ProviderKind,
},
/// Show current provider and runtime-effective credential route state.
/// Without `--provider`, shows all known providers.
/// With `--provider`, shows detailed status for that provider.
Status {
/// Show status for a specific provider only.
#[arg(long, value_enum)]
provider: Option<ProviderArg>,
#[arg(long, value_parser = parse_catalog_route)]
provider: Option<ProviderKind>,
/// Report resolved home/config/settings/backend paths and structural
/// credential-source presence without printing credential values or
/// probing provider credential stores.
@@ -1591,8 +1481,8 @@ enum AuthCommand {
/// `--api-key`, `--api-key-stdin`, or prompts on stdin when
/// neither is given. Does not echo the key.
Set {
#[arg(long, value_enum)]
provider: ProviderArg,
#[arg(long, value_parser = parse_catalog_route)]
provider: ProviderKind,
/// Inline value (discouraged — appears in shell history).
#[arg(long)]
api_key: Option<String>,
@@ -1603,18 +1493,18 @@ enum AuthCommand {
/// Report the effective credential route for a provider. Never prints a
/// credential; reports the source layer or structural OAuth/repair state.
Get {
#[arg(long, value_enum)]
provider: ProviderArg,
#[arg(long, value_parser = parse_catalog_route)]
provider: ProviderKind,
},
/// Pipe the runtime-effective API key to a local client; refuses terminals.
PrintApiKey {
#[arg(long, value_enum)]
provider: ProviderArg,
#[arg(long, value_parser = parse_catalog_route)]
provider: ProviderKind,
},
/// Delete a provider's key from config and secret-store storage.
Clear {
#[arg(long, value_enum)]
provider: ProviderArg,
#[arg(long, value_parser = parse_catalog_route)]
provider: ProviderKind,
},
/// List all known providers with their runtime-effective auth state,
/// without revealing credentials.
@@ -1669,13 +1559,13 @@ struct ModelArgs {
#[derive(Debug, Subcommand)]
enum ModelCommand {
List {
#[arg(long, value_enum)]
provider: Option<ProviderArg>,
#[arg(long, value_parser = parse_catalog_route)]
provider: Option<ProviderKind>,
},
Resolve {
model: Option<String>,
#[arg(long, value_enum)]
provider: Option<ProviderArg>,
#[arg(long, value_parser = parse_catalog_route)]
provider: Option<ProviderKind>,
},
/// Set the default model (e.g. "pro", "flash", "deepseek-v4-pro").
Set { model: String },
@@ -2172,6 +2062,7 @@ fn run() -> Result<()> {
finish_cli_telemetry(session, &outcome);
outcome
}
Some(Commands::Providers(args)) => run_providers_command(args),
None => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
let forwarded = root_tui_passthrough(&cli)?;
@@ -3514,8 +3405,8 @@ fn auth_diagnostic_lines(store: &ConfigStore, provider: Option<ProviderKind>) ->
lines
}
fn run_auth_diagnostic(store: &ConfigStore, provider: Option<ProviderArg>) -> Result<()> {
for line in auth_diagnostic_lines(store, provider.map(ProviderKind::from)) {
fn run_auth_diagnostic(store: &ConfigStore, provider: Option<ProviderKind>) -> Result<()> {
for line in auth_diagnostic_lines(store, provider) {
println!("{line}");
}
Ok(())
@@ -3952,7 +3843,6 @@ fn run_auth_command_with_secrets_and_runtime(
path,
yes,
} => {
let provider: ProviderKind = provider.into();
let (source, path) = external_credential_target(provider, path)?;
let preview = external_consent_preview_lines(provider, source, &path);
for line in &preview {
@@ -4021,7 +3911,6 @@ fn run_auth_command_with_secrets_and_runtime(
Ok(())
}
AuthCommand::ExternalRevoke { provider } => {
let provider: ProviderKind = provider.into();
let provider_key = provider.provider().provider_config_key();
codewhale_config::mutate_config_document(store.path(), |document| {
codewhale_config::unset_config_document_value(
@@ -4047,8 +3936,7 @@ fn run_auth_command_with_secrets_and_runtime(
return run_auth_diagnostic(store, provider);
}
match provider {
Some(p) => {
let provider: ProviderKind = p.into();
Some(provider) => {
for line in auth_status_lines_for_provider_with_runtime(
store,
secrets,
@@ -4073,7 +3961,6 @@ fn run_auth_command_with_secrets_and_runtime(
api_key,
api_key_stdin,
} => {
let provider: ProviderKind = provider.into();
let slot = provider_slot(provider);
if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {
let provider_cfg = store.config.providers.for_provider_mut(provider);
@@ -4107,7 +3994,6 @@ fn run_auth_command_with_secrets_and_runtime(
Ok(())
}
AuthCommand::Get { provider } => {
let provider: ProviderKind = provider.into();
println!(
"{}",
auth_get_line_with_runtime(store, secrets, provider, runtime_overrides)
@@ -4115,14 +4001,12 @@ fn run_auth_command_with_secrets_and_runtime(
Ok(())
}
AuthCommand::PrintApiKey { provider } => {
let provider: ProviderKind = provider.into();
let mut stdout = io::stdout().lock();
credential_handoff::handoff_secret_line(&mut stdout, io::stdout().is_terminal(), || {
credential_handoff::resolve_api_key(store, secrets, provider, runtime_overrides)
})
}
AuthCommand::Clear { provider } => {
let provider: ProviderKind = provider.into();
if provider == ProviderKind::Xai {
codewhale_config::with_xai_oauth_revocation_transaction(|| {
clear_auth_provider(store, secrets, provider)
@@ -4391,12 +4275,10 @@ fn clear_recorded_telemetry_opt_out_if_reenabled(key: &str, value: &str) -> Resu
}
fn model_command_provider_hint(
command_provider: Option<ProviderArg>,
command_provider: Option<ProviderKind>,
top_level_provider: Option<ProviderKind>,
) -> Option<ProviderKind> {
command_provider
.map(ProviderKind::from)
.or(top_level_provider)
command_provider.or(top_level_provider)
}
fn provider_source_label(source: ProviderSource) -> String {
@@ -4443,7 +4325,7 @@ fn run_model_command(
// kimi-k3 model resolve` re-derive a registry default and report
// `kimi-k2.7-code` while the runtime used `kimi-k3` (v0.9.1 kimi-k3 dogfood report). The
// top-level `--model` was not consulted at all on that path.
let subcommand_provider = provider.map(ProviderKind::from);
let subcommand_provider = provider;
let queried = model.as_deref().map(str::trim).filter(|m| !m.is_empty());
// With no explicit query, this reports the route the runtime would
@@ -5003,12 +4885,10 @@ fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrou
let keyring_bridge_api_key = resolved_runtime.api_key.as_ref();
let keyring_bridge_source = resolved_runtime.api_key_source;
if let Some(provider) = cli.provider.as_deref() {
let provider = builtin_provider_arg(provider)
.map(ProviderKind::from)
.map_or_else(
|| provider.to_string(),
|provider| provider.as_str().to_string(),
);
let provider = builtin_provider_arg(provider).map_or_else(
|| provider.to_string(),
|provider| provider.as_str().to_string(),
);
unsafe {
std::env::set_var("CODEWHALE_PROVIDER", &provider);
std::env::set_var("DEEPSEEK_PROVIDER", provider);
@@ -5120,6 +5000,21 @@ fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrou
// delegation is now in-process, and
// `only_one_function_may_locate_and_spawn_the_tui` pins that.
fn run_providers_command(args: ProvidersArgs) -> Result<()> {
match args.command {
ProvidersCommand::Export { json } => {
if !json {
bail!("`codewhale providers export` requires `--json`");
}
let export = ProvidersExport::from_registry(env!("CODEWHALE_BUILD_VERSION"));
serde_json::to_writer_pretty(io::stdout(), &export)
.context("failed to write providers export")?;
println!();
Ok(())
}
}
}
fn run_metrics_command(args: MetricsArgs) -> Result<()> {
let since = match args.since.as_deref() {
Some(s) => {
@@ -5300,6 +5195,7 @@ mod tests {
yolo: None,
verbosity: None,
http_headers: std::collections::BTreeMap::new(),
route: None,
}
}
@@ -5767,7 +5663,7 @@ verbosity = "project-imported"
cli.command,
Some(Commands::Model(ModelArgs {
command: ModelCommand::List {
provider: Some(ProviderArg::Openai)
provider: Some(ProviderKind::Openai)
}
}))
));
@@ -5796,7 +5692,7 @@ verbosity = "project-imported"
Some(Commands::Model(ModelArgs {
command: ModelCommand::Resolve {
model: Some(ref model),
provider: Some(ProviderArg::Deepseek)
provider: Some(ProviderKind::Deepseek)
}
})) if model == "deepseek-v4-pro"
));
@@ -5817,7 +5713,7 @@ verbosity = "project-imported"
Some(ProviderKind::Zai)
);
assert_eq!(
model_command_provider_hint(Some(ProviderArg::Minimax), Some(ProviderKind::Zai)),
model_command_provider_hint(Some(ProviderKind::Minimax), Some(ProviderKind::Zai)),
Some(ProviderKind::Minimax)
);
assert_eq!(model_command_provider_hint(None, None), None);
@@ -6360,8 +6256,18 @@ verbosity = "project-imported"
Some(ProviderKind::Openrouter)
);
assert_eq!(
top_level_provider_override(
Some("qianfan"),
Some(&Commands::Exec(TuiPassthroughArgs {
args: vec!["Reply OK".into()]
}))
)
.expect("qianfan is a catalog route"),
Some(ProviderKind::Qianfan)
);
for (provider, command) in [
("qianfan", vec!["exec", "Reply OK"]),
("lm-studio", vec!["exec", "Reply OK"]),
("lm-studio", vec!["fleet", "status"]),
] {
@@ -6383,21 +6289,21 @@ verbosity = "project-imported"
#[test]
fn opencode_go_provider_aliases_parse_as_builtin() {
for alias in ["opencode-go", "opencode_go", "opencodego"] {
assert_eq!(builtin_provider_arg(alias), Some(ProviderArg::OpencodeGo));
assert_eq!(builtin_provider_arg(alias), Some(ProviderKind::OpencodeGo));
}
}
#[test]
fn ollama_cloud_provider_aliases_parse_as_builtin() {
for alias in ["ollama-cloud", "ollama_cloud"] {
assert_eq!(builtin_provider_arg(alias), Some(ProviderArg::OllamaCloud));
assert_eq!(builtin_provider_arg(alias), Some(ProviderKind::OllamaCloud));
}
}
#[test]
fn antigravity_provider_aliases_parse_as_builtin() {
for alias in ["antigravity", "agy"] {
assert_eq!(builtin_provider_arg(alias), Some(ProviderArg::Antigravity));
assert_eq!(builtin_provider_arg(alias), Some(ProviderKind::Antigravity));
}
}
@@ -6414,7 +6320,7 @@ verbosity = "project-imported"
] {
assert_eq!(
builtin_provider_arg(alias),
Some(ProviderArg::MinimaxAnthropic),
Some(ProviderKind::MinimaxAnthropic),
"{alias}"
);
}
@@ -6441,7 +6347,7 @@ verbosity = "project-imported"
"zen",
"opencode",
] {
assert_eq!(builtin_provider_arg(alias), Some(ProviderArg::OpencodeZen));
assert_eq!(builtin_provider_arg(alias), Some(ProviderKind::OpencodeZen));
}
}
@@ -6457,7 +6363,7 @@ verbosity = "project-imported"
let err = Cli::try_parse_from(["codewhale", "auth", "set", "--provider", "lm-studio"])
.expect_err("auth keeps enum-only provider validation");
assert_eq!(err.kind(), ErrorKind::InvalidValue);
assert_eq!(err.kind(), ErrorKind::ValueValidation);
let err = Cli::try_parse_from([
"codewhale",
@@ -6820,7 +6726,7 @@ verbosity = "project-imported"
run_auth_command_with_secrets(
&mut store,
AuthCommand::Set {
provider: ProviderArg::Deepseek,
provider: ProviderKind::Deepseek,
api_key: Some("sk-test".to_string()),
api_key_stdin: false,
},
@@ -6890,7 +6796,7 @@ verbosity = "project-imported"
no_open: false,
timeout_seconds: 600,
api_key: None,
provider: Some(ProviderArg::Deepseek),
provider: Some(ProviderKind::Deepseek),
})
.expect_err("legacy --provider must be rejected");
assert!(
@@ -6969,7 +6875,7 @@ verbosity = "project-imported"
run_auth_command_with_secrets(
&mut store,
AuthCommand::Set {
provider: ProviderArg::Openrouter,
provider: ProviderKind::Openrouter,
api_key: Some("sk-or-repo-scoped".to_string()),
api_key_stdin: false,
},
@@ -7025,7 +6931,7 @@ verbosity = "project-imported"
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::ExternalConsent {
provider: ProviderArg::OpenaiCodex,
provider: ProviderKind::OpenaiCodex,
mode: ExternalCredentialModeArg::ReadOnly,
path: Some(_),
yes: true,
@@ -7038,7 +6944,7 @@ verbosity = "project-imported"
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::ExternalRevoke {
provider: ProviderArg::Xai,
provider: ProviderKind::Xai,
}
}))
));
@@ -7048,7 +6954,7 @@ verbosity = "project-imported"
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Set {
provider: ProviderArg::Deepseek,
provider: ProviderKind::Deepseek,
api_key: None,
api_key_stdin: false,
}
@@ -7067,7 +6973,7 @@ verbosity = "project-imported"
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Set {
provider: ProviderArg::Openrouter,
provider: ProviderKind::Openrouter,
api_key: None,
api_key_stdin: true,
}
@@ -7079,7 +6985,7 @@ verbosity = "project-imported"
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Get {
provider: ProviderArg::Novita
provider: ProviderKind::Novita
}
}))
));
@@ -7089,7 +6995,7 @@ verbosity = "project-imported"
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Clear {
provider: ProviderArg::NvidiaNim
provider: ProviderKind::NvidiaNim
}
}))
));
@@ -7099,7 +7005,7 @@ verbosity = "project-imported"
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Set {
provider: ProviderArg::Fireworks,
provider: ProviderKind::Fireworks,
api_key: None,
api_key_stdin: false,
}
@@ -7111,7 +7017,7 @@ verbosity = "project-imported"
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Set {
provider: ProviderArg::Siliconflow,
provider: ProviderKind::Siliconflow,
api_key: None,
api_key_stdin: false,
}
@@ -7123,7 +7029,7 @@ verbosity = "project-imported"
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Set {
provider: ProviderArg::Arcee,
provider: ProviderKind::Arcee,
api_key: None,
api_key_stdin: false,
}
@@ -7135,7 +7041,7 @@ verbosity = "project-imported"
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Set {
provider: ProviderArg::Moonshot,
provider: ProviderKind::Moonshot,
api_key: None,
api_key_stdin: false,
}
@@ -7147,7 +7053,7 @@ verbosity = "project-imported"
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Set {
provider: ProviderArg::WanjieArk,
provider: ProviderKind::WanjieArk,
api_key: None,
api_key_stdin: false,
}
@@ -7159,7 +7065,7 @@ verbosity = "project-imported"
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Get {
provider: ProviderArg::Sglang
provider: ProviderKind::Sglang
}
}))
));
@@ -7169,7 +7075,7 @@ verbosity = "project-imported"
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Get {
provider: ProviderArg::Vllm
provider: ProviderKind::Vllm
}
}))
));
@@ -7179,7 +7085,7 @@ verbosity = "project-imported"
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Set {
provider: ProviderArg::Ollama,
provider: ProviderKind::Ollama,
api_key: None,
api_key_stdin: false,
}
@@ -7191,7 +7097,7 @@ verbosity = "project-imported"
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Status {
provider: Some(ProviderArg::OpenaiCodex),
provider: Some(ProviderKind::OpenaiCodex),
diagnostic: false,
}
}))
@@ -7209,26 +7115,26 @@ verbosity = "project-imported"
cli.command,
Some(Commands::Auth(AuthArgs {
command: AuthCommand::Status {
provider: Some(ProviderArg::Deepseek),
provider: Some(ProviderKind::Deepseek),
diagnostic: true,
}
}))
));
for (provider, expected) in [
("anthropic", ProviderArg::Anthropic),
("openmodel", ProviderArg::Openmodel),
("open-model", ProviderArg::Openmodel),
("zai", ProviderArg::Zai),
("stepfun", ProviderArg::Stepfun),
("minimax", ProviderArg::Minimax),
("minimax-anthropic", ProviderArg::MinimaxAnthropic),
("minimax_anthropic", ProviderArg::MinimaxAnthropic),
("deepinfra", ProviderArg::Deepinfra),
("deep-infra", ProviderArg::Deepinfra),
("siliconflow-cn", ProviderArg::SiliconflowCn),
("siliconflow-CN", ProviderArg::SiliconflowCn),
("siliconflow_china", ProviderArg::SiliconflowCn),
("anthropic", ProviderKind::Anthropic),
("openmodel", ProviderKind::Openmodel),
("open-model", ProviderKind::Openmodel),
("zai", ProviderKind::Zai),
("stepfun", ProviderKind::Stepfun),
("minimax", ProviderKind::Minimax),
("minimax-anthropic", ProviderKind::MinimaxAnthropic),
("minimax_anthropic", ProviderKind::MinimaxAnthropic),
("deepinfra", ProviderKind::Deepinfra),
("deep-infra", ProviderKind::Deepinfra),
("siliconflow-cn", ProviderKind::SiliconflowCN),
("siliconflow-CN", ProviderKind::SiliconflowCN),
("siliconflow_china", ProviderKind::SiliconflowCN),
] {
let cli = parse_ok(&[
"deepseek",
@@ -7308,7 +7214,7 @@ verbosity = "project-imported"
run_auth_command_with_secrets(
&mut store,
AuthCommand::Set {
provider: ProviderArg::Deepseek,
provider: ProviderKind::Deepseek,
api_key: Some("sk-keyring".to_string()),
api_key_stdin: false,
},
@@ -7366,7 +7272,7 @@ verbosity = "project-imported"
let error = run_auth_command_with_secrets(
&mut store,
AuthCommand::Set {
provider: ProviderArg::Openrouter,
provider: ProviderKind::Openrouter,
api_key: Some("fallback-test-credential".to_string()),
api_key_stdin: false,
},
@@ -7399,7 +7305,7 @@ verbosity = "project-imported"
run_auth_command_with_secrets(
&mut store,
AuthCommand::Set {
provider: ProviderArg::Arcee,
provider: ProviderKind::Arcee,
api_key: Some("arcee-key".to_string()),
api_key_stdin: false,
},
@@ -7439,7 +7345,7 @@ verbosity = "project-imported"
run_auth_command_with_secrets(
&mut store,
AuthCommand::Set {
provider: ProviderArg::Ollama,
provider: ProviderKind::Ollama,
api_key: None,
api_key_stdin: false,
},
@@ -7479,7 +7385,7 @@ verbosity = "project-imported"
run_auth_command_with_secrets(
&mut store,
AuthCommand::Clear {
provider: ProviderArg::Deepseek,
provider: ProviderKind::Deepseek,
},
&secrets,
)
@@ -7534,7 +7440,7 @@ verbosity = "project-imported"
run_auth_command_with_secrets(
&mut store,
AuthCommand::Status {
provider: Some(ProviderArg::Deepseek),
provider: Some(ProviderKind::Deepseek),
diagnostic: false,
},
&secrets,
@@ -8392,7 +8298,7 @@ verbosity = "project-imported"
let unconfirmed = run_auth_command_with_secrets(
&mut store,
AuthCommand::ExternalConsent {
provider: ProviderArg::Xai,
provider: ProviderKind::Xai,
mode: ExternalCredentialModeArg::ReadOnly,
path: Some(external_path.clone()),
yes: false,
@@ -8410,7 +8316,7 @@ verbosity = "project-imported"
run_auth_command_with_secrets(
&mut store,
AuthCommand::ExternalConsent {
provider: ProviderArg::Xai,
provider: ProviderKind::Xai,
mode: ExternalCredentialModeArg::ReadOnly,
path: Some(external_path.clone()),
yes: true,
@@ -8471,7 +8377,7 @@ verbosity = "project-imported"
run_auth_command_with_secrets(
&mut store,
AuthCommand::Set {
provider: ProviderArg::Xai,
provider: ProviderKind::Xai,
api_key: Some("xai-codewhale-owned-key".to_string()),
api_key_stdin: false,
},
@@ -8487,7 +8393,7 @@ verbosity = "project-imported"
run_auth_command_with_secrets(
&mut store,
AuthCommand::ExternalConsent {
provider: ProviderArg::Xai,
provider: ProviderKind::Xai,
mode: ExternalCredentialModeArg::ReadOnly,
path: Some(external_path.clone()),
yes: true,
@@ -8498,7 +8404,7 @@ verbosity = "project-imported"
run_auth_command_with_secrets(
&mut store,
AuthCommand::ExternalRevoke {
provider: ProviderArg::Xai,
provider: ProviderKind::Xai,
},
&secrets,
)
@@ -8522,7 +8428,7 @@ verbosity = "project-imported"
let managed = run_auth_command_with_secrets(
&mut store,
AuthCommand::ExternalConsent {
provider: ProviderArg::OpenaiCodex,
provider: ProviderKind::OpenaiCodex,
mode: ExternalCredentialModeArg::Managed,
path: Some(external_path.clone()),
yes: true,
@@ -8539,7 +8445,7 @@ verbosity = "project-imported"
let kimi = run_auth_command_with_secrets(
&mut store,
AuthCommand::ExternalConsent {
provider: ProviderArg::Moonshot,
provider: ProviderKind::Moonshot,
mode: ExternalCredentialModeArg::ReadOnly,
path: Some(external_path.clone()),
yes: true,
@@ -8607,7 +8513,7 @@ verbosity = "project-imported"
let error = run_auth_command_with_secrets(
&mut store,
AuthCommand::Set {
provider: ProviderArg::Xai,
provider: ProviderKind::Xai,
api_key: Some("new-xai-key".to_string()),
api_key_stdin: false,
},
@@ -9293,6 +9199,7 @@ verbosity = "project-imported"
"mcp-server",
"config",
"model",
"providers",
"thread",
"sandbox",
"app-server",
+68 -9
View File
@@ -57,6 +57,10 @@ pub enum CatalogSource {
},
/// A user / custom override (custom endpoint, pinned model, explicit facts).
UserOverride,
/// Live models.dev refresh (layer 10). Distinct from provider `/v1/models`.
ModelsDevLive { fetched_at: u64 },
/// `config.toml` `[providers.*]` override (layer 30).
ConfigOverride,
}
/// One catalog-layer offering row.
@@ -615,14 +619,32 @@ impl CatalogSnapshot {
}
}
/// Builds a [`CatalogSnapshot`] by merging layers in precedence order:
/// bundled < live < user overrides. Later layers override earlier rows that
/// share a (provider, wire id) identity.
/// Builds a [`CatalogSnapshot`] by merging layers in precedence order.
///
/// Last writer wins per `(provider, wire id)` field. Policy DENY is applied
/// after every layer and is never overridden:
///
/// ```text
/// 0 bundled committed models.dev-shaped snapshot
/// 10 live models.dev models.dev refresh
/// 20 provider per-provider /v1/models refresh
/// 30 config config.toml [providers.*] overrides
/// 40 user user approved set (Phase 3 hook; empty in Phase 1)
/// policy DENY last, never overridden
/// ```
///
/// [`Self::with_live`] remains the combined live bucket so existing callers
/// keep working; prefer [`Self::with_models_dev_live`] / [`Self::with_provider_live`]
/// for the split.
#[derive(Debug, Clone, Default)]
pub struct CatalogCompiler {
bundled: Vec<CatalogOffering>,
models_dev_live: Vec<CatalogOffering>,
live: Vec<CatalogOffering>,
provider_live: Vec<CatalogOffering>,
config: Vec<CatalogOffering>,
overrides: Vec<CatalogOffering>,
policy: crate::route::CatalogPolicy,
}
impl CatalogCompiler {
@@ -647,35 +669,72 @@ impl CatalogCompiler {
self
}
/// Add live (middle-precedence) rows.
/// Add live models.dev refresh rows (layer 10).
#[must_use]
pub fn with_models_dev_live(mut self, rows: Vec<CatalogOffering>) -> Self {
self.models_dev_live.extend(rows);
self
}
/// Add live (combined models.dev + provider) rows.
///
/// Prefer [`Self::with_models_dev_live`] / [`Self::with_provider_live`].
/// Kept so existing callers still compile; these rows sit between
/// models.dev live and provider live.
#[must_use]
pub fn with_live(mut self, rows: Vec<CatalogOffering>) -> Self {
self.live.extend(rows);
self
}
/// Add user/custom override (highest-precedence) rows.
/// Add per-provider `/v1/models` refresh rows (layer 20).
#[must_use]
pub fn with_provider_live(mut self, rows: Vec<CatalogOffering>) -> Self {
self.provider_live.extend(rows);
self
}
/// Add `config.toml` `[providers.*]` override rows (layer 30).
#[must_use]
pub fn with_config(mut self, rows: Vec<CatalogOffering>) -> Self {
self.config.extend(rows);
self
}
/// Add user/custom override (highest catalog-layer precedence) rows.
#[must_use]
pub fn with_overrides(mut self, rows: Vec<CatalogOffering>) -> Self {
self.overrides.extend(rows);
self
}
/// Merge all layers into a deterministic snapshot.
/// Attach policy evaluated after every layer. DENY is never overridden.
#[must_use]
pub fn with_policy(mut self, policy: crate::route::CatalogPolicy) -> Self {
self.policy = policy;
self
}
/// Merge all layers into a deterministic snapshot, then apply policy DENY.
#[must_use]
pub fn compile(self) -> CatalogSnapshot {
let mut merged: BTreeMap<(String, String), CatalogOffering> = BTreeMap::new();
for row in self
.bundled
.into_iter()
.chain(self.models_dev_live)
.chain(self.live)
.chain(self.provider_live)
.chain(self.config)
.chain(self.overrides)
{
merged.insert(row.merge_key(), row);
}
CatalogSnapshot {
offerings: merged.into_values().collect(),
}
let offerings = merged
.into_values()
.filter(|row| self.policy.allows(&row.provider, &row.wire_model_id))
.collect();
CatalogSnapshot { offerings }
}
}
+51
View File
@@ -204,6 +204,57 @@ fn compiler_merges_layers_with_override_precedence() {
assert!(matches!(two.source, CatalogSource::Live { .. }));
}
#[test]
fn compiler_layer_order_and_policy_deny_never_overridden() {
let row = |source: CatalogSource, model: &str, family: &str| CatalogOffering {
provider: "zai-coding-cn".into(),
wire_model_id: model.into(),
endpoint_key: "chat".into(),
family: Some(family.into()),
source,
..Default::default()
};
let policy = crate::route::CatalogPolicy {
rules: vec![crate::route::PolicyRule {
effect: crate::route::PolicyEffect::Deny,
action: crate::route::PolicyAction::ModelUse,
resource: "*-cn/*".to_string(),
}],
};
let snapshot = CatalogCompiler::new()
.with_bundled(vec![row(CatalogSource::Bundled, "glm-5", "bundled")])
.with_models_dev_live(vec![row(
CatalogSource::ModelsDevLive { fetched_at: 1 },
"glm-5",
"models-dev",
)])
.with_provider_live(vec![row(
CatalogSource::Live {
base_url_fingerprint: "fp".into(),
fetched_at: 2,
},
"glm-5",
"provider",
)])
.with_config(vec![row(CatalogSource::ConfigOverride, "glm-5", "config")])
.with_overrides(vec![row(CatalogSource::UserOverride, "glm-5", "user")])
.with_policy(policy)
.compile();
assert!(
snapshot.offerings.is_empty(),
"policy DENY after every layer must drop the row; layers cannot override it"
);
let allowed = CatalogCompiler::new()
.with_bundled(vec![row(CatalogSource::Bundled, "glm-5", "bundled")])
.with_overrides(vec![row(CatalogSource::UserOverride, "glm-5", "user")])
.compile();
let kept = find(&allowed.offerings, "zai-coding-cn", "glm-5");
assert_eq!(kept.source, CatalogSource::UserOverride);
assert_eq!(kept.family.as_deref(), Some("user"));
}
#[test]
fn cache_scopes_by_provider_and_base_url_fingerprint() {
let fp_a = base_url_fingerprint("https://api.example.com/v1");
+36 -63
View File
@@ -3241,69 +3241,8 @@ impl ConfigToml {
) {
resolve_deepseek_base_url(configured_base_url, provider, provider_wire)
} else {
configured_base_url.unwrap_or_else(|| match provider {
ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL.to_string(),
ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(),
ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
ProviderKind::Orcarouter => DEFAULT_ORCAROUTER_BASE_URL.to_string(),
ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(),
ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL.to_string(),
ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL.to_string(),
ProviderKind::Moonshot => {
if auth_mode
.as_deref()
.is_some_and(auth_mode_uses_kimi_imported_token)
{
DEFAULT_KIMI_CODE_BASE_URL.to_string()
} else {
DEFAULT_MOONSHOT_BASE_URL.to_string()
}
}
ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(),
ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(),
ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(),
ProviderKind::OllamaCloud => DEFAULT_OLLAMA_CLOUD_BASE_URL.to_string(),
ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL.to_string(),
ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL.to_string(),
ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL.to_string(),
ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL.to_string(),
ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL.to_string(),
ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL.to_string(),
ProviderKind::Zai => DEFAULT_ZAI_BASE_URL.to_string(),
ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL.to_string(),
ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL.to_string(),
ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL.to_string(),
ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL.to_string(),
ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL.to_string(),
ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL.to_string(),
ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL.to_string(),
ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL.to_string(),
ProviderKind::Meta => DEFAULT_META_BASE_URL.to_string(),
ProviderKind::Xai => DEFAULT_XAI_BASE_URL.to_string(),
ProviderKind::Mistral => DEFAULT_MISTRAL_BASE_URL.to_string(),
ProviderKind::Google => DEFAULT_GOOGLE_BASE_URL.to_string(),
ProviderKind::Antigravity => DEFAULT_ANTIGRAVITY_BASE_URL.to_string(),
ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL.to_string(),
ProviderKind::Edenai => DEFAULT_EDENAI_BASE_URL.to_string(),
ProviderKind::ModelstudioTokenPlan
| ProviderKind::ModelstudioTokenPlanAnthropic
| ProviderKind::ModelstudioCodingPlan
| ProviderKind::ModelstudioCodingPlanAnthropic => {
DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL.to_string()
}
// The custom provider has no built-in endpoint; fall back to its
// descriptor placeholder so the lookup is total. Real custom
// routes always supply a configured base_url before this point.
ProviderKind::Custom => provider.provider().default_base_url().to_string(),
})
configured_base_url
.unwrap_or_else(|| descriptor_fallback_base_url(provider, auth_mode.as_deref()))
};
// Released builds represented Ollama Cloud as the local `ollama`
// identity plus one exact hosted base URL. Upgrade only that tuple in
@@ -3432,6 +3371,20 @@ impl ConfigToml {
normalize_model_for_provider(provider, &model)
};
// RouteResolver is the runtime path: the executable wire model,
// protocol, and endpoint come from a ReadyRouteCandidate. Auth/key
// resolution above is unchanged. A resolver error keeps the existing
// model string so this method stays total.
let route = crate::route::RouteResolver::new()
.resolve(&crate::route::RouteRequest {
explicit_provider: Some(provider),
model_selector: Some(crate::route::LogicalModelRef::from(model.as_str())),
saved_provider_model: None,
base_url_override: Some(base_url.clone()),
limit_overrides: Vec::new(),
})
.ok();
let mut http_headers = self.http_headers.clone();
http_headers.extend(provider_cfg.http_headers.clone());
if let Some(env_headers) = env.http_headers {
@@ -3544,10 +3497,24 @@ impl ConfigToml {
yolo,
verbosity,
http_headers,
route,
}
}
}
/// Default base URL from the route descriptor, plus the one Moonshot/Kimi
/// imported-token exception that is an auth-mode fact rather than a kind.
fn descriptor_fallback_base_url(provider: ProviderKind, auth_mode: Option<&str>) -> String {
if provider == ProviderKind::Moonshot
&& auth_mode.is_some_and(auth_mode_uses_kimi_imported_token)
{
return DEFAULT_KIMI_CODE_BASE_URL.to_string();
}
crate::route::ProviderDescriptor::for_kind(provider)
.default_base_url()
.to_string()
}
fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
if source.model.is_some() {
target.model = source.model.clone();
@@ -5165,6 +5132,12 @@ pub struct ResolvedRuntimeOptions {
pub yolo: Option<bool>,
pub verbosity: Option<String>,
pub http_headers: BTreeMap<String, String>,
/// Executable route minted by [`crate::route::RouteResolver`].
///
/// `None` only when the resolver rejected the selector (foreign model on a
/// strict direct provider, empty model). Auth/key fields above are
/// independent: the resolver never inspects credentials.
pub route: Option<crate::route::ReadyRouteCandidate>,
}
#[derive(Debug, Clone)]
+16 -5
View File
@@ -498,16 +498,24 @@ pub fn catalog_cost_is_valid(cost: &ModelsDevCost) -> bool {
fn provenance_from_source(source: &CatalogSource) -> PricingProvenance {
match source {
CatalogSource::Bundled => PricingProvenance::ModelsDevBundled,
CatalogSource::Bundled | CatalogSource::ModelsDevLive { .. } => {
PricingProvenance::ModelsDevBundled
}
CatalogSource::Live { .. } => PricingProvenance::ProviderLive,
CatalogSource::UserOverride => PricingProvenance::UserOverride,
CatalogSource::ConfigOverride | CatalogSource::UserOverride => {
PricingProvenance::UserOverride
}
}
}
fn effective_at_from_source(source: &CatalogSource) -> Option<u64> {
match source {
CatalogSource::Live { fetched_at, .. } => Some(*fetched_at),
CatalogSource::Bundled | CatalogSource::UserOverride => None,
CatalogSource::Live { fetched_at, .. } | CatalogSource::ModelsDevLive { fetched_at } => {
Some(*fetched_at)
}
CatalogSource::Bundled | CatalogSource::ConfigOverride | CatalogSource::UserOverride => {
None
}
}
}
@@ -517,7 +525,10 @@ fn endpoint_fingerprint_from_source(source: &CatalogSource) -> Option<String> {
base_url_fingerprint,
..
} => Some(base_url_fingerprint.clone()),
CatalogSource::Bundled | CatalogSource::UserOverride => None,
CatalogSource::Bundled
| CatalogSource::ModelsDevLive { .. }
| CatalogSource::ConfigOverride
| CatalogSource::UserOverride => None,
}
}
+2 -1
View File
@@ -2,7 +2,8 @@
//!
//! This module is a metadata foundation for collapsing provider drift over
//! time. It deliberately does not mutate request bodies or choose fallback
//! providers; runtime routing remains in `ConfigToml::resolve_runtime_options`.
//! providers; `ConfigToml::resolve_runtime_options` now mints the executable
//! route through `RouteResolver` (Phase 1). Auth/key resolution stays here.
use super::{
DEFAULT_ANTIGRAVITY_BASE_URL, DEFAULT_ANTIGRAVITY_MODEL, DEFAULT_ARCEE_BASE_URL,
+310
View File
@@ -0,0 +1,310 @@
//! Declarative auth types for the Route Contract (Phase 1).
//!
//! These types describe how a route asks for credentials. They do **not**
//! implement OAuth adapters. `AuthKind::OAuth` is a declared method only;
//! `UNIFIED_PROVIDER_LOGIN.md` remains gated.
use serde::{Deserialize, Serialize};
/// How a route obtains credentials.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AuthKind {
/// A provider-issued API key. Semantics are unchanged from today's first-class key path.
ApiKey,
/// An OAuth adapter *type*. Do not implement the Anthropic OAuth flow here.
#[serde(rename = "oauth")]
OAuth,
/// No credential required (local / keyless by default).
Keyless,
/// Read-only consent to an external CLI's credential file.
ExternalConsent,
}
/// A single declared auth method on a route.
///
/// Phase 2 walks [`Self::prompts`] as one loop. Phase 1 only ships the types
/// and the export projection so the descriptor is complete.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthMethod {
/// Acquisition kind.
pub kind: AuthKind,
/// Human label for the picker / export.
pub label: &'static str,
/// Ordered prompts. Usually empty for a plain API-key route.
pub prompts: &'static [Prompt],
}
/// One question in a declarative auth prompt loop.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Prompt {
/// Free-text (or secret) answer stored under `key`.
Text {
/// Answer map key.
key: &'static str,
/// Prompt shown to the user.
message: &'static str,
/// Optional placeholder.
placeholder: Option<&'static str>,
/// When true, the answer is a secret and must not be echoed.
secret: bool,
/// Optional visibility predicate.
when: Option<When>,
},
/// Forced choice; `value` of the selected option is stored under `key`.
Select {
/// Answer map key.
key: &'static str,
/// Prompt shown to the user.
message: &'static str,
/// Options. A family-disambiguation `value` is a route id.
options: &'static [Choice],
/// Optional visibility predicate.
when: Option<When>,
},
}
/// Predicate over previously collected answers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct When {
/// Answer key to compare.
pub key: &'static str,
/// Comparison operator.
pub op: Op,
/// Comparison value.
pub value: &'static str,
}
/// Comparison used by [`When`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Op {
/// Answer equals `value`.
Eq,
/// Answer does not equal `value`.
Neq,
}
/// One option in a [`Prompt::Select`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Choice {
/// Display label.
pub label: &'static str,
/// Stored value. For family disambiguation this **is** a route id.
pub value: &'static str,
/// Optional hint.
pub hint: Option<&'static str>,
}
impl AuthMethod {
/// Plain API-key method with no extra prompts.
pub const API_KEY: Self = Self {
kind: AuthKind::ApiKey,
label: "API key",
prompts: &[],
};
/// Declared OAuth method. Adapter implementation is out of Phase 1 scope.
pub const OAUTH: Self = Self {
kind: AuthKind::OAuth,
label: "OAuth",
prompts: &[],
};
/// Keyless / local-optional method.
pub const KEYLESS: Self = Self {
kind: AuthKind::Keyless,
label: "Keyless",
prompts: &[],
};
/// External-consent method (read-only grant to another CLI's file).
pub const EXTERNAL_CONSENT: Self = Self {
kind: AuthKind::ExternalConsent,
label: "External consent",
prompts: &[],
};
/// Whether `answers` satisfy this prompt's `when` clause.
#[must_use]
pub fn prompt_visible(
prompt: &Prompt,
answers: &std::collections::BTreeMap<String, String>,
) -> bool {
let when = match prompt {
Prompt::Text { when, .. } | Prompt::Select { when, .. } => *when,
};
match when {
None => true,
Some(When { key, op, value }) => {
let actual = answers.get(key).map(String::as_str).unwrap_or("");
match op {
Op::Eq => actual == value,
Op::Neq => actual != value,
}
}
}
}
}
/// Owned, serializable projection of [`AuthMethod`] for `providers export`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthMethodExport {
/// Acquisition kind.
pub kind: AuthKind,
/// Human label.
pub label: String,
/// Owned prompts.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prompts: Vec<PromptExport>,
}
/// Owned, serializable projection of [`Prompt`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PromptExport {
/// Free-text prompt.
Text {
/// Answer map key.
key: String,
/// Prompt shown to the user.
message: String,
/// Optional placeholder.
#[serde(default, skip_serializing_if = "Option::is_none")]
placeholder: Option<String>,
/// Whether the answer is a secret.
secret: bool,
/// Optional visibility predicate.
#[serde(default, skip_serializing_if = "Option::is_none")]
when: Option<WhenExport>,
},
/// Forced-choice prompt.
Select {
/// Answer map key.
key: String,
/// Prompt shown to the user.
message: String,
/// Options.
options: Vec<ChoiceExport>,
/// Optional visibility predicate.
#[serde(default, skip_serializing_if = "Option::is_none")]
when: Option<WhenExport>,
},
}
/// Owned [`When`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WhenExport {
/// Answer key to compare.
pub key: String,
/// Comparison operator.
pub op: Op,
/// Comparison value.
pub value: String,
}
/// Owned [`Choice`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChoiceExport {
/// Display label.
pub label: String,
/// Stored value.
pub value: String,
/// Optional hint.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
}
impl From<AuthMethod> for AuthMethodExport {
fn from(value: AuthMethod) -> Self {
Self {
kind: value.kind,
label: value.label.to_string(),
prompts: value
.prompts
.iter()
.copied()
.map(PromptExport::from)
.collect(),
}
}
}
impl From<Prompt> for PromptExport {
fn from(value: Prompt) -> Self {
match value {
Prompt::Text {
key,
message,
placeholder,
secret,
when,
} => Self::Text {
key: key.to_string(),
message: message.to_string(),
placeholder: placeholder.map(str::to_string),
secret,
when: when.map(WhenExport::from),
},
Prompt::Select {
key,
message,
options,
when,
} => Self::Select {
key: key.to_string(),
message: message.to_string(),
options: options.iter().copied().map(ChoiceExport::from).collect(),
when: when.map(WhenExport::from),
},
}
}
}
impl From<When> for WhenExport {
fn from(value: When) -> Self {
Self {
key: value.key.to_string(),
op: value.op,
value: value.value.to_string(),
}
}
}
impl From<Choice> for ChoiceExport {
fn from(value: Choice) -> Self {
Self {
label: value.label.to_string(),
value: value.value.to_string(),
hint: value.hint.map(str::to_string),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn when_eq_hides_unmatched_prompt() {
let prompt = Prompt::Text {
key: "key",
message: "Key",
placeholder: None,
secret: true,
when: Some(When {
key: "plan",
op: Op::Eq,
value: "coding",
}),
};
let mut answers = std::collections::BTreeMap::new();
answers.insert("plan".into(), "token".into());
assert!(!AuthMethod::prompt_visible(&prompt, &answers));
answers.insert("plan".into(), "coding".into());
assert!(AuthMethod::prompt_visible(&prompt, &answers));
}
}
@@ -38,6 +38,15 @@ fn every_provider_kind_has_a_wellformed_descriptor() {
ProviderId::from_kind(kind),
"{kind:?}: descriptor id disagrees with ProviderId::from_kind"
);
assert!(
!descriptor.auth_methods().is_empty(),
"{kind:?}: every route declares at least one auth method"
);
assert_eq!(
descriptor.route_id().as_str(),
descriptor.id().as_str(),
"{kind:?}: route id must be the descriptor id"
);
// Transport facts route resolution depends on must be present.
assert!(
+112 -2
View File
@@ -10,10 +10,12 @@
//! inside a `Serialize` struct; serialize the resolved facts instead.
use crate::ProviderKind;
use crate::provider::{self, Provider, WirePolicy};
use crate::provider::{self, CredentialAcquisition, Provider, WirePolicy};
use serde::{Deserialize, Serialize};
use super::RequestProtocol;
use super::ids::{ProviderId, WireModelId};
use super::auth::AuthMethod;
use super::ids::{ProviderId, RouteId, WireModelId};
/// Route-facing view of a built-in provider's transport facts.
///
@@ -42,6 +44,30 @@ impl ProviderDescriptor {
ProviderId::from(self.inner.id())
}
/// Flat kebab route id for this descriptor.
#[must_use]
pub fn route_id(&self) -> RouteId {
RouteId::from_kind(self.kind)
}
/// Display-grouping family. Not a second identity: stored identity is [`Self::route_id`].
#[must_use]
pub fn family(&self) -> &'static str {
family_for(self.kind)
}
/// Bespoke-transport classification. OpenAI-compatible catalog rows share one kind.
#[must_use]
pub fn transport(&self) -> TransportKind {
TransportKind::for_kind(self.kind)
}
/// Declared auth methods. OAuth is a type only; no adapter is implemented here.
#[must_use]
pub fn auth_methods(&self) -> &'static [AuthMethod] {
auth_methods_for(self.kind)
}
/// Default base URL when no override is present.
#[must_use]
pub fn default_base_url(&self) -> &'static str {
@@ -98,3 +124,87 @@ pub struct EndpointDescriptor {
/// Whether streaming is supported.
pub streaming: bool,
}
/// Bespoke-transport classification. Catalog rows that speak OpenAI Chat
/// Completions share [`TransportKind::ChatCompletions`]; only genuinely
/// different wires get their own kind. This is the direction `ProviderKind`
/// shrinks toward.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TransportKind {
/// OpenAI-compatible `/v1/chat/completions`.
ChatCompletions,
/// Native Anthropic Messages (`/v1/messages`).
AnthropicMessages,
/// OpenAI Responses (`/responses`).
OpenAiResponses,
/// Closed, model-aware protocol roster (OpenCode Zen, DeepSeek dual-wire).
ModelAware,
/// ChatGPT Codex OAuth route.
Codex,
/// Google Antigravity consent-gated OAuth.
Antigravity,
/// Local runtime (Ollama / vLLM / SGLang).
LocalRuntime,
/// User-defined OpenAI-compatible endpoint.
Custom,
}
impl TransportKind {
/// Map a known kind onto the ~8 bespoke transports. Everything else is a
/// catalog row on [`Self::ChatCompletions`].
#[must_use]
pub fn for_kind(kind: ProviderKind) -> Self {
match kind {
ProviderKind::Anthropic
| ProviderKind::DeepseekAnthropic
| ProviderKind::MinimaxAnthropic
| ProviderKind::ModelstudioTokenPlanAnthropic
| ProviderKind::ModelstudioCodingPlanAnthropic => Self::AnthropicMessages,
ProviderKind::OpenaiCodex => Self::Codex,
ProviderKind::Antigravity => Self::Antigravity,
ProviderKind::Ollama | ProviderKind::Vllm | ProviderKind::Sglang => Self::LocalRuntime,
ProviderKind::Custom => Self::Custom,
ProviderKind::Deepseek | ProviderKind::OpencodeZen => Self::ModelAware,
_ => match kind.provider().wire_policy() {
WirePolicy::ModelAware => Self::ModelAware,
WirePolicy::Fixed(crate::provider::WireFormat::Responses) => Self::OpenAiResponses,
WirePolicy::Fixed(crate::provider::WireFormat::AnthropicMessages) => {
Self::AnthropicMessages
}
WirePolicy::Fixed(crate::provider::WireFormat::ChatCompletions) => {
Self::ChatCompletions
}
},
}
}
}
/// Display-grouping family. Selecting a family with multiple routes asks a
/// `select` whose option value **is a route id**.
#[must_use]
pub fn family_for(kind: ProviderKind) -> &'static str {
match kind {
ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic => "deepseek",
ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => "minimax",
ProviderKind::ModelstudioTokenPlan
| ProviderKind::ModelstudioTokenPlanAnthropic
| ProviderKind::ModelstudioCodingPlan
| ProviderKind::ModelstudioCodingPlanAnthropic => "alibaba-modelstudio",
ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => "siliconflow",
ProviderKind::Ollama | ProviderKind::OllamaCloud => "ollama",
other => other.as_str(),
}
}
/// Auth methods declared for a provider kind. OAuth is a type, not an adapter.
#[must_use]
pub fn auth_methods_for(kind: ProviderKind) -> &'static [AuthMethod] {
match kind.provider().credential_help().acquisition {
CredentialAcquisition::ApiKey => &[AuthMethod::API_KEY],
CredentialAcquisition::ApiKeyOrOAuth => &[AuthMethod::API_KEY, AuthMethod::OAUTH],
CredentialAcquisition::LocalOptional => &[AuthMethod::KEYLESS],
CredentialAcquisition::OAuth => &[AuthMethod::OAUTH],
CredentialAcquisition::Configuration => &[AuthMethod::EXTERNAL_CONSENT],
}
}
+306
View File
@@ -0,0 +1,306 @@
//! Owned, serializable sibling of [`super::ProviderDescriptor`].
//!
//! `ProviderDescriptor` holds `&'static dyn Provider` and is deliberately not
//! `Serialize`. This module is the export seam: `codewhale providers export
//! --json` and the cwc generated catalog both consume [`ProvidersExport`].
use serde::{Deserialize, Serialize};
use crate::provider::{self, WireFormat, WirePolicy};
use crate::{ProviderKind, catalog::bundled_catalog_offerings};
use super::auth::AuthMethodExport;
use super::descriptor::{ProviderDescriptor, TransportKind};
use super::ids::RouteId;
/// Schema version of the providers export document.
pub const PROVIDERS_EXPORT_SCHEMA_VERSION: u32 = 1;
/// Top-level `codewhale providers export --json` document.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProvidersExport {
/// Document schema.
pub schema_version: u32,
/// Runtime version that produced this export.
pub runtime_version: String,
/// One row per addressable route id.
pub routes: Vec<RouteExport>,
}
/// One exported route. This is the owned sibling of [`ProviderDescriptor`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RouteExport {
/// Flat kebab route id (open string).
pub id: String,
/// Display-grouping family (not a second identity).
pub family: String,
/// Human label.
pub label: String,
/// Default endpoint URL.
pub endpoint: String,
/// Wire format spoken at the default endpoint.
pub wire: String,
/// Default wire model id.
pub default_model: String,
/// Environment variable candidates for the API key.
pub env_vars: Vec<String>,
/// Declared auth methods. OAuth is a type only.
pub auth: Vec<AuthMethodExport>,
/// Bundled catalog models for this route, when any exist.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<RouteModelExport>,
/// Bespoke-transport classification. Catalog rows share `chat-completions`.
pub transport: TransportKind,
}
/// One bundled model advertised on a route.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RouteModelExport {
/// Provider-owned wire id.
pub id: String,
/// Canonical model id when a join exists.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canonical: Option<String>,
/// Whether this is the route default.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub default: bool,
}
impl ProvidersExport {
/// Build the export from the live descriptor registry.
#[must_use]
pub fn from_registry(runtime_version: impl Into<String>) -> Self {
let offerings = bundled_catalog_offerings();
let mut routes: Vec<RouteExport> = provider::all_providers()
.iter()
.map(|entry| {
let descriptor = ProviderDescriptor::for_kind(entry.kind());
route_export(&descriptor, &offerings)
})
.collect();
routes.sort_by(|a, b| a.id.cmp(&b.id));
Self {
schema_version: PROVIDERS_EXPORT_SCHEMA_VERSION,
runtime_version: runtime_version.into(),
routes,
}
}
/// Stable committed route-id list. Removing or respelling an id is a CI failure.
#[must_use]
pub fn route_ids(&self) -> Vec<&str> {
self.routes.iter().map(|route| route.id.as_str()).collect()
}
}
fn route_export(
descriptor: &ProviderDescriptor,
offerings: &[crate::catalog::CatalogOffering],
) -> RouteExport {
let id = descriptor.route_id();
let models = offerings
.iter()
.filter(|row| row.provider == id.as_str())
.map(|row| RouteModelExport {
id: row.wire_model_id.clone(),
canonical: row.canonical_model.clone(),
default: row.default_for_provider,
})
.collect();
RouteExport {
id: id.as_str().to_string(),
family: descriptor.family().to_string(),
label: descriptor.inner.display_name().to_string(),
endpoint: descriptor.default_base_url().to_string(),
wire: wire_label(descriptor.wire_policy()),
default_model: descriptor.default_wire_model().as_str().to_string(),
env_vars: descriptor
.env_vars()
.iter()
.map(|value| (*value).to_string())
.collect(),
auth: descriptor
.auth_methods()
.iter()
.copied()
.map(AuthMethodExport::from)
.collect(),
models,
transport: descriptor.transport(),
}
}
fn wire_label(policy: WirePolicy) -> String {
match policy {
WirePolicy::Fixed(WireFormat::ChatCompletions) => "chat-completions".to_string(),
WirePolicy::Fixed(WireFormat::Responses) => "responses".to_string(),
WirePolicy::Fixed(WireFormat::AnthropicMessages) => "anthropic-messages".to_string(),
WirePolicy::ModelAware => "model-aware".to_string(),
}
}
/// Parse a CLI `--provider` / route-id string against the catalog.
///
/// Replaces the closed `ProviderArg` enum. Any catalog route id or documented
/// alias resolves; unknown ids fail.
#[must_use]
pub fn parse_route_kind(value: &str) -> Option<ProviderKind> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
if let Some(kind) = exact_config_identity(trimmed) {
return Some(kind);
}
let folded = trimmed.replace('_', "-");
if let Some(kind) = exact_config_identity(&folded) {
return Some(kind);
}
if let Some(kind) = clap_compat_alias(trimmed) {
return Some(kind);
}
ProviderKind::parse(trimmed).or_else(|| ProviderKind::parse(&folded.to_ascii_lowercase()))
}
/// Exact id / config-table key only. Does not collapse dialect aliases onto
/// the vendor primary — that is what orphaned `[providers.minimax_anthropic]`
/// tables. Call [`ProviderKind::parse`] only after this and clap-compat aliases.
fn exact_config_identity(value: &str) -> Option<ProviderKind> {
crate::provider::all_providers()
.iter()
.find(|entry| {
value.eq_ignore_ascii_case(entry.id())
|| value.eq_ignore_ascii_case(entry.provider_config_key())
})
.map(|entry| entry.kind())
}
fn clap_compat_alias(value: &str) -> Option<ProviderKind> {
let key = value.replace('_', "-").to_ascii_lowercase();
Some(match key.as_str() {
"agy" => ProviderKind::Antigravity,
"opencodego" | "opencode-go" => ProviderKind::OpencodeGo,
"ollama-cloud" => ProviderKind::OllamaCloud,
"mini-max-anthropic" => ProviderKind::MinimaxAnthropic,
"siliconflow-china" | "silicon-flow-cn" => ProviderKind::SiliconflowCN,
"deep-infra" => ProviderKind::Deepinfra,
"fugu" | "sakana-ai" => ProviderKind::Sakana,
"long-cat" | "meituan-longcat" | "meituan" => ProviderKind::LongCat,
"meta-ai" | "meta-model-api" | "muse" | "muse-spark" => ProviderKind::Meta,
"x-ai" | "grok" => ProviderKind::Xai,
"mistral-ai" | "mistralai" | "la-plateforme" => ProviderKind::Mistral,
"eden-ai" => ProviderKind::Edenai,
_ => return None,
})
}
/// [`RouteId`] for a known kind.
#[must_use]
pub fn route_id_for(kind: ProviderKind) -> RouteId {
RouteId::from(kind.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn export_covers_every_registry_route_exactly_once() {
let export = ProvidersExport::from_registry("0.9.12");
let ids = export.route_ids();
let unique: std::collections::BTreeSet<_> = ids.iter().copied().collect();
assert_eq!(unique.len(), ids.len(), "route ids must be unique");
assert_eq!(ids.len(), provider::all_providers().len());
assert!(ids.contains(&"deepseek"));
assert!(ids.contains(&"custom"));
}
#[test]
fn export_has_no_artificial_analysis_fields() {
let json = serde_json::to_string(&ProvidersExport::from_registry("0.9.12")).unwrap();
let lowered = json.to_ascii_lowercase();
assert!(
!lowered.contains("artificialanalysis")
&& !lowered.contains("artificial_analysis")
&& !lowered.contains("artificial-analysis"),
"export must never carry Artificial Analysis fields"
);
}
#[test]
fn parse_route_kind_accepts_aliases() {
assert_eq!(
parse_route_kind("deepseek-anthropic"),
Some(ProviderKind::DeepseekAnthropic)
);
assert_eq!(
parse_route_kind("siliconflow-CN"),
Some(ProviderKind::SiliconflowCN)
);
assert_eq!(
parse_route_kind("minimax_anthropic"),
Some(ProviderKind::MinimaxAnthropic)
);
assert_eq!(
parse_route_kind("mini-max-anthropic"),
Some(ProviderKind::MinimaxAnthropic)
);
assert_eq!(parse_route_kind(""), None);
assert_eq!(parse_route_kind("not-a-provider"), None);
}
#[test]
fn golden_route_ids_are_stable() {
let export = ProvidersExport::from_registry("0.9.12");
let actual: Vec<&str> = export.route_ids();
let expected: Vec<&str> = include_str!("golden_route_ids.txt")
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.collect();
assert_eq!(
actual, expected,
"route ids are a committed contract; do not remove or respell an id"
);
}
#[test]
#[ignore = "set WRITE_GOLDEN=1 to regenerate providers-export.golden.json"]
fn write_golden_providers_export_when_requested() {
if std::env::var("WRITE_GOLDEN").ok().as_deref() != Some("1") {
return;
}
let export = ProvidersExport::from_registry("0.9.12");
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/src/route/providers-export.golden.json"
);
std::fs::write(
path,
format!("{}\n", serde_json::to_string_pretty(&export).unwrap()),
)
.expect("write providers-export.golden.json");
}
#[test]
fn golden_providers_export_matches_registry() {
let export = ProvidersExport::from_registry("0.9.12");
let golden: ProvidersExport =
serde_json::from_str(include_str!("providers-export.golden.json"))
.expect("providers-export.golden.json must parse");
assert_eq!(
export.routes, golden.routes,
"update providers-export.golden.json from ProvidersExport::from_registry"
);
assert!(
!serde_json::to_string(&export)
.unwrap()
.to_ascii_lowercase()
.contains("artificialanalysis"),
"export must never carry Artificial Analysis fields"
);
}
}
@@ -0,0 +1,50 @@
# Committed Route Contract route ids. Removing or respelling an id fails CI.
# Source: ProviderDescriptor registry via ProvidersExport::from_registry.
# New ids may be appended (sorted); never recycle a retired spelling.
anthropic
antigravity
arcee
atlascloud
custom
deepinfra
deepseek
deepseek-anthropic
edenai
fireworks
google
huggingface
longcat
meta
minimax
minimax-anthropic
mistral
modelstudio-coding-plan
modelstudio-coding-plan-anthropic
modelstudio-token-plan
modelstudio-token-plan-anthropic
moonshot
novita
nvidia-nim
ollama
ollama-cloud
openai
openai-codex
opencode-go
opencode-zen
openmodel
openrouter
orcarouter
qianfan
sakana
sglang
siliconflow
siliconflow-CN
stepfun
telecomjs
together
vllm
volcengine
wanjie-ark
xai
xiaomi-mimo
zai
+29
View File
@@ -79,6 +79,14 @@ string_newtype!(
ProviderId
);
string_newtype!(
/// A flat kebab route id (e.g. `"deepseek"`, `"alibaba-modelstudio-coding-plan"`).
///
/// Route ids are open strings, stable across releases, and are **not** a
/// closed enum. Unknown catalog rows are still valid ids.
RouteId
);
string_newtype!(
/// A canonical, provider-agnostic logical model identity.
///
@@ -112,6 +120,27 @@ impl ProviderId {
}
}
impl RouteId {
/// Whether `raw` is a well-formed kebab route id (lowercase ASCII, digits, `-`).
#[must_use]
pub fn is_well_formed(raw: &str) -> bool {
let trimmed = raw.trim();
!trimmed.is_empty()
&& trimmed
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
&& !trimmed.starts_with('-')
&& !trimmed.ends_with('-')
&& !trimmed.contains("--")
}
/// The route id for a known kind. Catalog rows that are not kinds use [`RouteId::from`].
#[must_use]
pub fn from_kind(kind: ProviderKind) -> Self {
Self(kind.as_str().to_string())
}
}
/// A leading namespace/organization prefix carried by a [`LogicalModelRef`].
///
/// A namespace hint is a *catalog* hint only. It is NEVER convertible to a
+21 -7
View File
@@ -1,9 +1,9 @@
//! Route foundation: additive, runtime-unwired types for EPIC #2608.
//! Route Contract: the runtime path for provider/model identity (Phase 1).
//!
//! This module tree introduces the canonical identity newtypes (#3084) and the
//! `ReadyRouteCandidate` / `RouteResolver` contract (#3384). The TUI, client,
//! and engine consume these types; they remain a self-contained seam so later
//! tracks can keep wiring through here.
//! `RouteResolver` is the sole producer of [`ReadyRouteCandidate`]. Config and
//! CLI resolution now go through it. Catalog rows are keyed by open kebab
//! [`ids::RouteId`] strings; `ProviderKind` remains the bespoke-transport
//! classification plus serde aliases for retired spellings.
//!
//! Layering:
//! - [`ids`] — provider/model/wire string newtypes + namespace hints.
@@ -24,26 +24,40 @@
/// avoid introducing yet another protocol synonym.
pub use crate::provider::WireFormat as RequestProtocol;
pub mod auth;
pub mod candidate;
pub mod capabilities;
pub mod descriptor;
pub mod errors;
pub mod export;
pub mod ids;
pub mod offering;
pub mod policy;
pub mod resolver;
pub use auth::{
AuthKind, AuthMethod, AuthMethodExport, Choice, ChoiceExport, Op, Prompt, PromptExport, When,
WhenExport,
};
pub use candidate::{
LimitField, OverrideSource, PricingSku, ReadyRouteCandidate, ResolvedAuthSource,
ResolvedEndpoint, SourcedLimitOverride, ValidationReport,
};
pub(crate) use capabilities::documented_server_side_web_search;
pub use capabilities::{CapabilityState, RouteCapabilities};
pub use descriptor::{EndpointDescriptor, ProviderDescriptor};
pub use descriptor::{
EndpointDescriptor, ProviderDescriptor, TransportKind, auth_methods_for, family_for,
};
pub use errors::RouteError;
pub use ids::{LogicalModelRef, ModelId, NamespaceHint, ProviderId, WireModelId};
pub use export::{
PROVIDERS_EXPORT_SCHEMA_VERSION, ProvidersExport, RouteExport, RouteModelExport,
parse_route_kind, route_id_for,
};
pub use ids::{LogicalModelRef, ModelId, NamespaceHint, ProviderId, RouteId, WireModelId};
pub use offering::{
ProviderModelOffering, RouteLimits, bundled_offerings, opencode_zen_picker_models,
};
pub use policy::{CatalogPolicy, PolicyAction, PolicyEffect, PolicyRule};
pub use resolver::{RouteRequest, RouteResolver};
#[cfg(test)]
+160
View File
@@ -0,0 +1,160 @@
//! Catalog policy evaluated after every catalog layer (Phase 1 hook).
//!
//! Policy decides whether an allowed offering may be used. It never configures
//! endpoints or credentials, and it never makes an unusable route usable.
//! `DENY` is applied last and is never overridden by a catalog layer.
use serde::{Deserialize, Serialize};
/// Effect of one policy rule. Last match wins.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PolicyEffect {
/// Permit the matched resource.
Allow,
/// Forbid the matched resource. Never overridden by a later catalog layer.
Deny,
}
/// Action a policy rule addresses.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PolicyAction {
/// Using a model on a route (`<route-id>/<model-id>`).
ModelUse,
/// Using a route at all (`<route-id>`).
ProviderUse,
}
/// One wildcard-matched policy rule.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicyRule {
/// Allow or deny.
pub effect: PolicyEffect,
/// Action being gated.
pub action: PolicyAction,
/// Resource glob: `"<route-id>/<model-id>"` or `"<route-id>"`.
pub resource: String,
}
/// Ordered policy document. Empty means allow-all.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CatalogPolicy {
/// Rules in file order; last match wins.
#[serde(default)]
pub rules: Vec<PolicyRule>,
}
impl CatalogPolicy {
/// Empty policy (allow everything).
#[must_use]
pub fn allow_all() -> Self {
Self::default()
}
/// Whether `route_id` / `model_id` survives policy.
///
/// Default is allow. Each matching rule overwrites the decision; the last
/// match wins. A final DENY cannot be undone by a catalog layer because
/// callers apply this *after* merge.
#[must_use]
pub fn allows(&self, route_id: &str, model_id: &str) -> bool {
let mut allowed = true;
for rule in &self.rules {
if rule.matches(route_id, model_id) {
allowed = rule.effect == PolicyEffect::Allow;
}
}
allowed
}
}
impl PolicyRule {
fn matches(&self, route_id: &str, model_id: &str) -> bool {
let resource = match self.action {
PolicyAction::ProviderUse => route_id.to_string(),
PolicyAction::ModelUse => format!("{route_id}/{model_id}"),
};
wildcard_match(&self.resource, &resource)
}
}
/// Single-`*` wildcard match. `*` does not cross `/`.
fn wildcard_match(pattern: &str, value: &str) -> bool {
wildcard_match_parts(pattern.as_bytes(), value.as_bytes())
}
fn wildcard_match_parts(pattern: &[u8], value: &[u8]) -> bool {
let mut p = 0;
let mut v = 0;
let mut star = None;
while v < value.len() {
if p < pattern.len() && pattern[p] == b'*' {
star = Some((p, v));
p += 1;
continue;
}
if p < pattern.len() && pattern[p] == value[v] {
p += 1;
v += 1;
continue;
}
if let Some((star_p, star_v)) = star {
if value[star_v] == b'/' {
return false;
}
v = star_v + 1;
p = star_p + 1;
star = Some((star_p, v));
continue;
}
return false;
}
while p < pattern.len() && pattern[p] == b'*' {
p += 1;
}
p == pattern.len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deny_survives_every_layer_order() {
let policy = CatalogPolicy {
rules: vec![PolicyRule {
effect: PolicyEffect::Deny,
action: PolicyAction::ModelUse,
resource: "*-cn/*".to_string(),
}],
};
assert!(!policy.allows("zai-coding-cn", "glm-5"));
assert!(policy.allows("zai", "glm-5"));
assert!(policy.allows("deepseek", "deepseek-v4-pro"));
}
#[test]
fn last_match_wins() {
let policy = CatalogPolicy {
rules: vec![
PolicyRule {
effect: PolicyEffect::Deny,
action: PolicyAction::ProviderUse,
resource: "deepseek".to_string(),
},
PolicyRule {
effect: PolicyEffect::Allow,
action: PolicyAction::ProviderUse,
resource: "deepseek".to_string(),
},
],
};
assert!(policy.allows("deepseek", "any"));
}
#[test]
fn empty_policy_allows() {
assert!(CatalogPolicy::allow_all().allows("anything", "model"));
}
}
File diff suppressed because it is too large. Load diff
+11
View File
@@ -8776,6 +8776,17 @@ fn telemetry_consent_names_its_source() {
assert_eq!(source, TelemetrySource::Env);
}
#[test]
fn resolved_runtime_options_mints_a_route_candidate() {
let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
let route = resolved
.route
.as_ref()
.expect("RouteResolver is the runtime path");
assert_eq!(route.provider_kind(), resolved.provider);
assert_eq!(route.endpoint().base_url, resolved.base_url);
}
/// #5441: the runtime receipt carries the same source the surfaces print.
#[test]
fn resolved_runtime_options_reports_telemetry_source() {
+6
View File
@@ -26,6 +26,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
and the left rail — each a standalone render module pinned by 28 new
byte-exact golden buffers. Frame wiring follows the Tideline acceptance
gate; `NO_COLOR` is now honored by palette depth detection.
- Route Contract Phase 1: `RouteResolver` is the runtime path for
`resolve_runtime_options`; `codewhale providers export --json` ships the
owned descriptor catalog; CLI `--provider` accepts any catalog route id
(the closed `ProviderArg` enum is deleted). Catalog layers are
bundled → models.dev → provider `/v1/models` → config.toml → user, with
policy DENY last and never overridden.
- Add provider-native web search for documented Xiaomi MiMo 2.5 Pro and 2.5
chat routes while keeping neighboring models and custom gateways fail-closed.
- Add structured provider-native web search for exact Z.AI global and Zhipu
+4
View File
@@ -7479,6 +7479,10 @@ mod tests {
api_key: Some("sk-test".to_string()),
tui: Some(crate::config::TuiConfig {
stream_chunk_timeout_secs: Some(777),
max_model_steps: None,
turn_wall_clock_secs: None,
stream_max_content_mb: None,
stream_max_duration_secs: None,
..crate::config::TuiConfig::default()
}),
..Config::default()
+77
View File
@@ -1697,6 +1697,24 @@ pub struct TuiConfig {
/// Per-SSE-chunk idle timeout in seconds. Defaults to 900 seconds when
/// omitted. `0` maps to the default; values clamp to `1..=3600`.
pub stream_chunk_timeout_secs: Option<u64>,
/// R1: ceiling on model steps in a single turn. Omitted or `0` resolve
/// to the finite default (200); explicit values clamp to
/// `1..=100_000`. There is deliberately no "unlimited" value — `0` is
/// an invalid setting, not a sentinel that disables the cap.
pub max_model_steps: Option<u32>,
/// R1: cumulative wall-clock budget for a single turn, in seconds.
/// Omitted or `0` resolve to the finite default (3600); explicit values
/// clamp to `30..=86_400`. Time blocked on a human approval decision is
/// excluded from the measurement.
pub turn_wall_clock_secs: Option<u64>,
/// R1: per-step cap on accumulated streamed content, in megabytes.
/// Omitted or `0` resolve to the default (10 MB); explicit values clamp
/// to `64 KiB..=512 MiB`.
pub stream_max_content_mb: Option<u64>,
/// R1: per-step cap on a single stream's wall-clock duration, in
/// seconds. Omitted or `0` resolve to the default (1800); explicit
/// values clamp to `10..=86_400`.
pub stream_max_duration_secs: Option<u64>,
/// Ordered list of footer items the user wants visible. `None` (the field
/// missing from `config.toml`) means "use the built-in default order"; an
/// empty `Some(vec![])` means "show nothing in the footer".
@@ -7357,6 +7375,65 @@ impl Config {
raw.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS)
}
/// R1: resolved ceiling on model steps in a single turn.
///
/// Reads `[tui].max_model_steps`, falling back to the
/// `CODEWHALE_MAX_MODEL_STEPS` env var, then to the finite default.
/// `0` — from either source — is treated as an invalid value and
/// resolves to the default; it never means "unlimited".
#[must_use]
pub fn max_model_steps(&self) -> u32 {
let raw = self
.tui
.as_ref()
.and_then(|cfg| cfg.max_model_steps)
.or_else(|| {
std::env::var(MAX_MODEL_STEPS_ENV)
.ok()
.and_then(|value| value.trim().parse::<u32>().ok())
});
crate::core::engine::turn_budget::resolve_max_model_steps(raw)
}
/// R1: resolved cumulative per-turn wall-clock budget.
///
/// Reads `[tui].turn_wall_clock_secs`, falling back to the
/// `CODEWHALE_TURN_WALL_CLOCK_SECS` env var, then to the finite
/// default. `0` resolves to the default; it never means "unlimited".
#[must_use]
pub fn turn_wall_clock(&self) -> std::time::Duration {
let raw = self
.tui
.as_ref()
.and_then(|cfg| cfg.turn_wall_clock_secs)
.or_else(|| {
std::env::var(TURN_WALL_CLOCK_ENV)
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
});
crate::core::engine::turn_budget::resolve_turn_wall_clock(raw)
}
/// R1: resolved per-step cap on accumulated streamed content, in bytes.
#[must_use]
pub fn stream_max_content_bytes(&self) -> usize {
crate::core::engine::turn_budget::resolve_stream_max_content_bytes(
self.tui.as_ref().and_then(|cfg| cfg.stream_max_content_mb),
)
}
/// R1: resolved per-step cap on a single stream's wall-clock duration.
#[must_use]
pub fn stream_max_duration(&self) -> std::time::Duration {
std::time::Duration::from_secs(
crate::core::engine::turn_budget::resolve_stream_max_duration_secs(
self.tui
.as_ref()
.and_then(|cfg| cfg.stream_max_duration_secs),
),
)
}
/// Raw sub-agent model override map. Values are validated at spawn time
/// so an invalid role/type model fails before any partial agent spawn.
#[must_use]
+4
View File
@@ -56,6 +56,10 @@ pub const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600;
pub(crate) const STREAM_CHUNK_TIMEOUT_ENV: &str = "CODEWHALE_STREAM_IDLE_TIMEOUT_SECS";
/// Legacy alias for [`STREAM_CHUNK_TIMEOUT_ENV`].
pub(crate) const LEGACY_STREAM_CHUNK_TIMEOUT_ENV: &str = "DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS";
/// R1: env override for `[tui].max_model_steps`.
pub(crate) const MAX_MODEL_STEPS_ENV: &str = "CODEWHALE_MAX_MODEL_STEPS";
/// R1: env override for `[tui].turn_wall_clock_secs`.
pub(crate) const TURN_WALL_CLOCK_ENV: &str = "CODEWHALE_TURN_WALL_CLOCK_SECS";
pub(crate) fn resolve_subagent_api_timeout_secs(raw: Option<u64>) -> u64 {
let raw = raw.unwrap_or(DEFAULT_SUBAGENT_API_TIMEOUT_SECS);
+12
View File
@@ -2825,6 +2825,10 @@ fn tui_stream_chunk_timeout_defaults_env_and_clamps() {
let zero = Config {
tui: Some(TuiConfig {
stream_chunk_timeout_secs: Some(0),
max_model_steps: None,
turn_wall_clock_secs: None,
stream_max_content_mb: None,
stream_max_duration_secs: None,
..TuiConfig::default()
}),
..Config::default()
@@ -2837,6 +2841,10 @@ fn tui_stream_chunk_timeout_defaults_env_and_clamps() {
let explicit_min = Config {
tui: Some(TuiConfig {
stream_chunk_timeout_secs: Some(MIN_STREAM_CHUNK_TIMEOUT_SECS),
max_model_steps: None,
turn_wall_clock_secs: None,
stream_max_content_mb: None,
stream_max_duration_secs: None,
..TuiConfig::default()
}),
..Config::default()
@@ -2849,6 +2857,10 @@ fn tui_stream_chunk_timeout_defaults_env_and_clamps() {
let high = Config {
tui: Some(TuiConfig {
stream_chunk_timeout_secs: Some(MAX_STREAM_CHUNK_TIMEOUT_SECS + 1),
max_model_steps: None,
turn_wall_clock_secs: None,
stream_max_content_mb: None,
stream_max_duration_secs: None,
..TuiConfig::default()
}),
..Config::default()
+46 -11
View File
@@ -422,6 +422,20 @@ pub struct EngineConfig {
/// Resolved from `[tui].stream_chunk_timeout_secs` (or the legacy
/// `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS`) and updated live by `/config`.
pub stream_chunk_timeout: Duration,
/// Cumulative wall-clock budget for one turn (R1). Counted across every
/// model step of the turn, excluding time blocked on a human approval
/// decision. Resolved from `[tui].turn_wall_clock_secs`; always finite —
/// see [`turn_budget::resolve_turn_wall_clock`].
pub turn_wall_clock: Duration,
/// Per-step cap on accumulated streamed content, in bytes (R1). Resolved
/// from `[tui].stream_max_content_mb`. Pre-R1 this was the hard-coded
/// `STREAM_MAX_CONTENT_BYTES`; it is still finite by default and now
/// overridable.
pub stream_max_content_bytes: usize,
/// Per-step cap on a single stream's wall-clock duration (R1). Resolved
/// from `[tui].stream_max_duration_secs`. Pre-R1 this was the hard-coded
/// `STREAM_MAX_DURATION_SECS`.
pub stream_max_duration: Duration,
/// No-progress heartbeat timeout for live sub-agents. Used by the manager
/// and parent wait loop to auto-cancel stuck children before they exhaust
/// the sub-agent slot pool indefinitely (#2614).
@@ -460,9 +474,14 @@ pub struct EngineConfig {
pub advisor_config: crate::tools::subagent::AdvisorConfig,
}
/// Sentinel used by ordinary interactive hosts: model work has no hidden
/// step-budget ceiling. Progress/stationarity controls live at the tool loop.
pub(crate) const UNBOUNDED_MODEL_STEPS: u32 = u32::MAX;
/// Default model-step ceiling for hosts that do not resolve one from
/// configuration (R1). Formerly `UNBOUNDED_MODEL_STEPS = u32::MAX`, which
/// made an unbounded agent loop the default everywhere. It is finite now:
/// progress/stationarity controls still live at the tool loop, but they are
/// no longer the *only* thing standing between a stuck loop and unbounded
/// spend. Overridable via `[tui].max_model_steps`; see
/// [`turn_budget::resolve_max_model_steps`].
pub(crate) const DEFAULT_MODEL_STEPS: u32 = turn_budget::DEFAULT_MAX_MODEL_STEPS;
impl Default for EngineConfig {
fn default() -> Self {
@@ -481,10 +500,11 @@ impl Default for EngineConfig {
instructions: Vec::new(),
project_context_pack_enabled: false,
translation_enabled: false,
// Ordinary interactive turns have no hidden model-step budget.
// Callers that need a finite safety boundary set one explicitly;
// progress-based stationarity belongs at the tool-loop layer.
max_steps: UNBOUNDED_MODEL_STEPS,
// R1: every turn carries a finite model-step budget. Callers
// that need a different boundary set one explicitly; progress-
// based stationarity still belongs at the tool-loop layer, but
// it is no longer the only bound on spend.
max_steps: DEFAULT_MODEL_STEPS,
max_subagents: DEFAULT_MAX_SUBAGENTS,
max_admitted_subagents: DEFAULT_MAX_SUBAGENTS,
launch_concurrency: DEFAULT_MAX_SUBAGENTS,
@@ -530,6 +550,9 @@ impl Default for EngineConfig {
stream_chunk_timeout: Duration::from_secs(
crate::config::DEFAULT_STREAM_CHUNK_TIMEOUT_SECS,
),
turn_wall_clock: turn_budget::resolve_turn_wall_clock(None),
stream_max_content_bytes: turn_budget::DEFAULT_STREAM_MAX_CONTENT_BYTES,
stream_max_duration: Duration::from_secs(turn_budget::DEFAULT_STREAM_MAX_DURATION_SECS),
subagent_heartbeat_timeout: Duration::from_secs(
crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
),
@@ -832,6 +855,13 @@ pub struct Engine {
pending_lsp_blocks: Vec<crate::lsp::DiagnosticBlock>,
/// Current operating mode. Updated on `ChangeMode` and `SendMessage`.
current_mode: AppMode,
/// R1: cumulative wall-clock budget for the turn currently running.
/// Restarted at the top of every `run_turn`, checked at the
/// provider-request boundary, and paused while the turn is blocked on a
/// human approval decision. It lives on the engine rather than in
/// `TurnContext` so `request_tool_approval` — which never sees the turn
/// context — can pause it.
turn_wall_clock: turn_budget::TurnWallClock,
/// The most recent authority narrowing, if any (#3947). Kept on the engine
/// so doctor and debug surfaces can answer "why is this tool unavailable"
/// with the same record the user and the model already saw.
@@ -1562,6 +1592,10 @@ impl Engine {
let approval_receipt_store = Ok(ApprovalReceiptStore::new(
std::env::temp_dir().join(format!("codewhale-approval-tests-{}", uuid::Uuid::new_v4())),
));
// R1: seed the wall clock from the config the engine is built with.
// `run_turn` restarts it per turn; this initial value only matters
// for hosts that inspect the engine before the first turn.
let turn_wall_clock_budget = config.turn_wall_clock;
let engine = Engine {
config,
api_config: api_config.clone(),
@@ -1615,6 +1649,7 @@ impl Engine {
sandbox_backend,
sandbox_enforcement,
current_mode: AppMode::Agent,
turn_wall_clock: turn_budget::TurnWallClock::start(turn_wall_clock_budget),
last_policy_narrowing: None,
last_turn_meta_git_snapshot: StdMutex::new(None),
token_estimate_cache: TokenEstimateCache::new(),
@@ -7541,6 +7576,7 @@ pub(crate) mod tool_catalog;
mod tool_execution;
mod tool_preparation;
mod tool_setup;
pub(crate) mod turn_budget;
pub(crate) mod turn_loop;
pub(crate) use token_estimate_cache::TokenEstimateCache;
@@ -7569,10 +7605,9 @@ use self::streaming::TOOL_CALL_START_MARKERS;
use self::streaming::filter_tool_call_delta;
use self::streaming::{
ContentBlockKind, FAKE_WRAPPER_NOTICE, MAX_STREAM_ERRORS_BEFORE_FAIL, MAX_STREAM_RETRIES,
MAX_TRANSPARENT_STREAM_RETRIES, STREAM_MAX_CONTENT_BYTES, STREAM_MAX_DURATION_SECS,
StreamResume, StreamRetryBudget, ToolCallDeltaFilterState, ToolUseState,
contains_fake_tool_wrapper, filter_tool_call_delta_with_state, flush_tool_call_delta_state,
should_resume_after_network_drop, should_resume_after_sleep,
MAX_TRANSPARENT_STREAM_RETRIES, StreamResume, StreamRetryBudget, ToolCallDeltaFilterState,
ToolUseState, contains_fake_tool_wrapper, filter_tool_call_delta_with_state,
flush_tool_call_delta_state, should_resume_after_network_drop, should_resume_after_sleep,
should_resume_interactive_after_network_drop, should_transparently_retry_stream,
sleep_gap_detected, stream_read_error_user_message,
};
+11 -1
View File
@@ -116,7 +116,17 @@ impl Engine {
.to_string(),
));
}
self.await_tool_approval(tool_id).await
// R1: the per-turn wall-clock budget bounds what the agent spends on
// its own, not how long a person takes to answer. Pause it across the
// human decision — otherwise an approval prompt left open would fail
// the turn (and discard the work just approved) the moment the user
// came back. Every non-unwinding exit of `await_tool_approval` runs
// through the resume below; a panic unwinds out of `run_turn`, which
// restarts the clock on its next turn anyway.
self.turn_wall_clock.begin_human_wait();
let decision = self.await_tool_approval(tool_id).await;
self.turn_wall_clock.end_human_wait();
decision
}
/// Format a cancellation suffix when the engine knows the cause.
+189 -3
View File
@@ -384,10 +384,17 @@ async fn emergency_compaction_cancellation_drops_provider_and_never_mutates_cont
}
const REPRESENTATIVE_HANDOFF_RELAY: &str = "REPRESENTATIVE_HANDOFF_RELAY";
/// R1 inverted this: the ordinary engine default used to be
/// `UNBOUNDED_MODEL_STEPS = u32::MAX`, i.e. an agent loop with no finite
/// bound. The default is now finite and every host resolves from it.
#[test]
fn ordinary_engine_default_has_no_hidden_step_budget() {
assert_eq!(UNBOUNDED_MODEL_STEPS, u32::MAX);
assert_eq!(EngineConfig::default().max_steps, UNBOUNDED_MODEL_STEPS);
fn ordinary_engine_default_has_a_finite_step_budget() {
assert_eq!(
DEFAULT_MODEL_STEPS,
crate::core::engine::turn_budget::DEFAULT_MAX_MODEL_STEPS
);
const { assert!(DEFAULT_MODEL_STEPS < u32::MAX) };
assert_eq!(EngineConfig::default().max_steps, DEFAULT_MODEL_STEPS);
}
#[test]
@@ -21212,3 +21219,182 @@ async fn idle_engine_routes_child_approval_decisions_to_the_waiting_child() {
handle.send(Op::Shutdown).await.expect("shutdown engine");
run.await.expect("engine task");
}
// ---------------------------------------------------------------------------
// R1: finite turn budgets. Each limit must fire, and each must be overridable.
// ---------------------------------------------------------------------------
#[test]
fn engine_config_defaults_carry_finite_turn_budgets() {
use crate::core::engine::turn_budget;
let config = EngineConfig::default();
assert_eq!(config.max_steps, turn_budget::DEFAULT_MAX_MODEL_STEPS);
assert!(
config.max_steps < u32::MAX,
"the default model-step ceiling must be finite"
);
assert_eq!(
config.turn_wall_clock,
std::time::Duration::from_secs(turn_budget::DEFAULT_TURN_WALL_CLOCK_SECS),
);
assert!(config.turn_wall_clock > std::time::Duration::ZERO);
assert_eq!(
config.stream_max_content_bytes,
turn_budget::DEFAULT_STREAM_MAX_CONTENT_BYTES
);
assert_eq!(
config.stream_max_duration,
std::time::Duration::from_secs(turn_budget::DEFAULT_STREAM_MAX_DURATION_SECS),
);
}
/// R1: a spent wall-clock budget stops the turn *before* another billable
/// request, and reports the stop truthfully rather than as a clean success.
#[tokio::test]
async fn turn_wall_clock_budget_stops_the_turn_before_another_model_request() {
use crate::llm_client::mock::{MockLlmClient, canned};
let workspace = tempdir().expect("tempdir");
let mock = std::sync::Arc::new(MockLlmClient::new(vec![canned::simple_text_turn(
"this response must never be requested",
)]));
let client: crate::core::model_client::SharedModelClient = mock.clone();
let engine_config = EngineConfig {
// Only tests construct a zero budget; `resolve_turn_wall_clock`
// rejects `0` from configuration.
turn_wall_clock: std::time::Duration::ZERO,
..deterministic_engine_config(workspace.path())
};
let (mut engine, _handle) =
Engine::new_with_model_client(engine_config, &Config::default(), client);
let context = crate::tools::ToolContext::new(workspace.path().to_path_buf());
let registry = crate::tools::ToolRegistry::new(context);
let surface = test_tool_surface(&engine, registry, None, AppMode::Agent);
let mut turn = crate::core::turn::TurnContext::new(engine.config.max_steps);
let (status, error) = engine.run_turn(&mut turn, surface, None, None).await;
assert_eq!(
status,
TurnOutcomeStatus::Failed,
"a budget stop is never a clean success"
);
let error = error.expect("a budget stop must carry a reason");
assert!(
error.contains("wall-clock budget exhausted"),
"the stop must name the budget: {error}"
);
assert_eq!(
mock.call_count(),
0,
"no billable request may be authorized once the budget is spent"
);
}
/// R1: the wall-clock budget is overridable — a generous budget lets the same
/// turn run to a normal completion.
#[tokio::test]
async fn turn_wall_clock_budget_is_overridable() {
use crate::llm_client::mock::{MockLlmClient, canned};
let workspace = tempdir().expect("tempdir");
let mock = std::sync::Arc::new(MockLlmClient::new(vec![canned::simple_text_turn(
"The requested work is complete.",
)]));
let client: crate::core::model_client::SharedModelClient = mock.clone();
let engine_config = EngineConfig {
turn_wall_clock: std::time::Duration::from_secs(600),
..deterministic_engine_config(workspace.path())
};
let (mut engine, _handle) =
Engine::new_with_model_client(engine_config, &Config::default(), client);
let context = crate::tools::ToolContext::new(workspace.path().to_path_buf());
let registry = crate::tools::ToolRegistry::new(context);
let surface = test_tool_surface(&engine, registry, None, AppMode::Agent);
let mut turn = crate::core::turn::TurnContext::new(engine.config.max_steps);
let (status, error) = engine.run_turn(&mut turn, surface, None, None).await;
assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
assert_eq!(mock.call_count(), 1);
}
/// R1: a turn that keeps calling tools past its model-step ceiling ends as a
/// reported failure naming the limit — never as a silent completion.
#[tokio::test]
async fn model_step_ceiling_fires_and_reports_the_limit() {
use crate::llm_client::mock::{MockLlmClient, canned};
let workspace = tempdir().expect("tempdir");
let mock = std::sync::Arc::new(MockLlmClient::new(
(0..8)
.map(|index| {
canned::tool_call_turn(&format!("call_{index}"), "definitely_not_a_real_tool", "{}")
})
.collect(),
));
let client: crate::core::model_client::SharedModelClient = mock.clone();
let engine_config = EngineConfig {
max_steps: 2,
..deterministic_engine_config(workspace.path())
};
let (mut engine, _handle) =
Engine::new_with_model_client(engine_config, &Config::default(), client);
let context = crate::tools::ToolContext::new(workspace.path().to_path_buf());
let registry = crate::tools::ToolRegistry::new(context);
let surface = test_tool_surface(&engine, registry, None, AppMode::Agent);
let mut turn = crate::core::turn::TurnContext::new(engine.config.max_steps);
let (status, error) = engine.run_turn(&mut turn, surface, None, None).await;
assert_eq!(
status,
TurnOutcomeStatus::Failed,
"a model that never stops must not report success: {error:?}"
);
let error = error.expect("the step ceiling must carry a reason");
assert!(
error.contains("Maximum model steps reached"),
"the stop must name the limit: {error}"
);
assert!(
mock.call_count() <= 4,
"the ceiling must bound requests, saw {}",
mock.call_count()
);
}
/// R1: the per-step stream cap is overridable, and a tiny cap actually cuts
/// the stream off instead of accumulating without bound.
#[tokio::test]
async fn per_step_stream_content_cap_is_overridable_and_fires() {
use crate::llm_client::mock::{MockLlmClient, canned};
let workspace = tempdir().expect("tempdir");
let long_answer = "x".repeat(4096);
let mock = std::sync::Arc::new(MockLlmClient::new(vec![canned::simple_text_turn(
&long_answer,
)]));
let client: crate::core::model_client::SharedModelClient = mock.clone();
let engine_config = EngineConfig {
// Only tests set a cap below the configurable minimum; the resolver
// clamps configured values into a sane finite range.
stream_max_content_bytes: 64,
..deterministic_engine_config(workspace.path())
};
let (mut engine, _handle) =
Engine::new_with_model_client(engine_config, &Config::default(), client);
let context = crate::tools::ToolContext::new(workspace.path().to_path_buf());
let registry = crate::tools::ToolRegistry::new(context);
let surface = test_tool_surface(&engine, registry, None, AppMode::Agent);
let mut turn = crate::core::turn::TurnContext::new(engine.config.max_steps);
let (status, _error) = engine.run_turn(&mut turn, surface, None, None).await;
assert_ne!(
status,
TurnOutcomeStatus::Completed,
"a stream cut off by the content cap must not report a clean completion"
);
}
+350
View File
@@ -0,0 +1,350 @@
//! Finite turn budgets (R1, v0.9.12 Phase 1).
//!
//! An agent loop with no finite bound can spend real money forever. Before
//! R1 the parent turn had exactly one hard ceiling — the per-step stream
//! caps in [`super::streaming`] — while the model-step ceiling defaulted to
//! `u32::MAX` at every production call site and no cumulative per-turn
//! wall-clock budget existed at all. This module is the single home for the
//! four bounds R1 makes finite:
//!
//! 1. `max_steps` — model steps in one turn.
//! 2. The cumulative per-turn wall clock.
//! 3. `exec --max-turns` — the same step ceiling for headless runs.
//! 4. The per-step stream caps (content bytes, stream duration).
//!
//! ## No `0`-means-unlimited sentinel
//!
//! Every resolver here rejects `0` and falls back to the finite default
//! rather than reading it as "unlimited". That sentinel is exactly the bug
//! class R1 exists to close: a `0` that skips the cap check turns a
//! misconfiguration (or a typo) into an unbounded spend. There is also no
//! "unlimited" value at all — a caller who genuinely wants a turn to run
//! practically forever raises the knob to its documented maximum, which is
//! large but still finite and still terminates.
//!
//! ## Honesty at the limit
//!
//! Hitting a budget is never a clean success. The step ceiling already ends
//! the turn as `TurnOutcomeStatus::Failed` with the limit named (or, when
//! the model still owes work, grants exactly one bounded final-report turn
//! first). [`TurnWallClock`] follows the same contract in `run_turn`.
use std::time::{Duration, Instant};
/// Default ceiling on model steps within a single turn.
///
/// A "step" is one accepted provider response, so this bounds how many
/// billable requests one user message can trigger. 200 is far above what an
/// ordinary interactive turn spends and still finite: a runaway tool loop
/// stops here instead of spending until the operator notices.
pub const DEFAULT_MAX_MODEL_STEPS: u32 = 200;
/// Smallest accepted model-step ceiling. One step still lets the model
/// answer once.
pub const MIN_MAX_MODEL_STEPS: u32 = 1;
/// Largest accepted model-step ceiling. Deliberately large enough to serve
/// as the "effectively unlimited" escape hatch while remaining finite, so
/// no configuration path can produce an unbounded loop.
pub const MAX_MAX_MODEL_STEPS: u32 = 100_000;
/// Default headless `exec --max-turns`. Same ceiling as the interactive
/// engine: a non-interactive run has nobody watching it, so it must not be
/// looser than the one a human is sitting in front of.
pub const DEFAULT_EXEC_MAX_TURNS: u32 = DEFAULT_MAX_MODEL_STEPS;
/// Default cumulative per-turn wall-clock budget, in seconds.
///
/// Measured across every model step of one turn, not per request. Time the
/// turn spends blocked on a human approval decision is excluded (see
/// [`TurnWallClock::begin_human_wait`]) so an unanswered prompt cannot
/// consume the budget.
pub const DEFAULT_TURN_WALL_CLOCK_SECS: u64 = 3_600;
/// Smallest accepted per-turn wall-clock budget. Below this a single slow
/// reasoning request would trip the budget before it could finish.
pub const MIN_TURN_WALL_CLOCK_SECS: u64 = 30;
/// Largest accepted per-turn wall-clock budget (24 hours).
pub const MAX_TURN_WALL_CLOCK_SECS: u64 = 86_400;
/// Default per-step cap on accumulated streamed content, in bytes.
/// Preserves the pre-R1 hard-coded value; R1 only makes it overridable.
pub const DEFAULT_STREAM_MAX_CONTENT_BYTES: usize = super::streaming::STREAM_MAX_CONTENT_BYTES;
/// Smallest accepted per-step stream content cap (64 KiB).
pub const MIN_STREAM_MAX_CONTENT_BYTES: usize = 64 * 1024;
/// Largest accepted per-step stream content cap (512 MiB).
pub const MAX_STREAM_MAX_CONTENT_BYTES: usize = 512 * 1024 * 1024;
/// Default per-step cap on a single stream's wall-clock duration, in
/// seconds. Preserves the pre-R1 hard-coded value.
pub const DEFAULT_STREAM_MAX_DURATION_SECS: u64 = super::streaming::STREAM_MAX_DURATION_SECS;
/// Smallest accepted per-step stream duration cap.
pub const MIN_STREAM_MAX_DURATION_SECS: u64 = 10;
/// Largest accepted per-step stream duration cap (24 hours).
pub const MAX_STREAM_MAX_DURATION_SECS: u64 = 86_400;
/// Resolve a configured model-step ceiling.
///
/// `None` and `0` both resolve to [`DEFAULT_MAX_MODEL_STEPS`]: `0` is
/// treated as an invalid value, never as "unlimited". Positive values clamp
/// into `MIN_MAX_MODEL_STEPS..=MAX_MAX_MODEL_STEPS`, so even the largest
/// configurable turn terminates.
#[must_use]
pub fn resolve_max_model_steps(raw: Option<u32>) -> u32 {
match raw {
None | Some(0) => DEFAULT_MAX_MODEL_STEPS,
Some(value) => value.clamp(MIN_MAX_MODEL_STEPS, MAX_MAX_MODEL_STEPS),
}
}
/// Resolve a configured per-turn wall-clock budget, in seconds.
///
/// `None` and `0` both resolve to [`DEFAULT_TURN_WALL_CLOCK_SECS`]; `0` is
/// invalid, not "unlimited".
#[must_use]
pub fn resolve_turn_wall_clock_secs(raw: Option<u64>) -> u64 {
match raw {
None | Some(0) => DEFAULT_TURN_WALL_CLOCK_SECS,
Some(value) => value.clamp(MIN_TURN_WALL_CLOCK_SECS, MAX_TURN_WALL_CLOCK_SECS),
}
}
/// Resolve a configured per-turn wall-clock budget as a [`Duration`].
#[must_use]
pub fn resolve_turn_wall_clock(raw: Option<u64>) -> Duration {
Duration::from_secs(resolve_turn_wall_clock_secs(raw))
}
/// Resolve a configured per-step stream content cap, given megabytes.
///
/// `None` and `0` both resolve to [`DEFAULT_STREAM_MAX_CONTENT_BYTES`].
#[must_use]
pub fn resolve_stream_max_content_bytes(raw_mb: Option<u64>) -> usize {
match raw_mb {
None | Some(0) => DEFAULT_STREAM_MAX_CONTENT_BYTES,
Some(mb) => usize::try_from(mb.saturating_mul(1024 * 1024))
.unwrap_or(MAX_STREAM_MAX_CONTENT_BYTES)
.clamp(MIN_STREAM_MAX_CONTENT_BYTES, MAX_STREAM_MAX_CONTENT_BYTES),
}
}
/// Resolve a configured per-step stream duration cap, in seconds.
///
/// `None` and `0` both resolve to [`DEFAULT_STREAM_MAX_DURATION_SECS`].
#[must_use]
pub fn resolve_stream_max_duration_secs(raw: Option<u64>) -> u64 {
match raw {
None | Some(0) => DEFAULT_STREAM_MAX_DURATION_SECS,
Some(value) => value.clamp(MIN_STREAM_MAX_DURATION_SECS, MAX_STREAM_MAX_DURATION_SECS),
}
}
/// Cumulative wall-clock budget for one turn.
///
/// Started once at the top of `Engine::run_turn` and checked at the
/// provider-request boundary, so a turn that trips the budget stops before
/// authorizing another billable request and keeps every tool result already
/// in the transcript.
///
/// Time blocked on a human approval decision is excluded: the budget bounds
/// what the agent spends on its own, not how long a person takes to answer.
/// Without that exclusion an approval prompt left open overnight would fail
/// the turn — and discard the work the user just approved — the moment they
/// came back.
#[derive(Debug)]
pub(crate) struct TurnWallClock {
budget: Duration,
started_at: Instant,
/// Total time already excluded because the turn was blocked on a human.
excluded: Duration,
/// Set while currently blocked on a human decision.
blocked_since: Option<Instant>,
}
impl TurnWallClock {
/// Start a fresh budget. A zero budget is legal here (and only here):
/// it is how tests assert the stop path without sleeping. Configuration
/// never produces one — [`resolve_turn_wall_clock`] rejects `0`.
pub(crate) fn start(budget: Duration) -> Self {
Self {
budget,
started_at: Instant::now(),
excluded: Duration::ZERO,
blocked_since: None,
}
}
/// The budget this clock was started with.
pub(crate) fn budget(&self) -> Duration {
self.budget
}
/// Wall-clock time this turn has spent on its own work, excluding time
/// blocked on a human decision.
pub(crate) fn spent(&self) -> Duration {
let blocked_now = self
.blocked_since
.map_or(Duration::ZERO, |since| since.elapsed());
self.started_at
.elapsed()
.saturating_sub(self.excluded)
.saturating_sub(blocked_now)
}
/// Whether the cumulative budget is spent.
pub(crate) fn exhausted(&self) -> bool {
self.spent() >= self.budget
}
/// Stop counting: the turn is now waiting on a human decision.
/// Idempotent — a second call while already blocked does nothing, so a
/// nested or re-entered approval cannot double-exclude.
pub(crate) fn begin_human_wait(&mut self) {
if self.blocked_since.is_none() {
self.blocked_since = Some(Instant::now());
}
}
/// Resume counting after a human decision, banking the blocked time.
pub(crate) fn end_human_wait(&mut self) {
if let Some(since) = self.blocked_since.take() {
self.excluded = self.excluded.saturating_add(since.elapsed());
}
}
/// Test-only: pretend `elapsed` more wall-clock time has passed, so the
/// exhaustion path can be exercised without sleeping. An in-progress
/// human wait is rewound too — otherwise the simulated time would count
/// as agent-owned work that never actually happened.
#[cfg(test)]
pub(crate) fn rewind_for_test(&mut self, elapsed: Duration) {
self.started_at = self
.started_at
.checked_sub(elapsed)
.unwrap_or(self.started_at);
if let Some(since) = self.blocked_since {
self.blocked_since = Some(since.checked_sub(elapsed).unwrap_or(since));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn model_step_defaults_are_finite_and_reject_the_zero_sentinel() {
assert_eq!(resolve_max_model_steps(None), DEFAULT_MAX_MODEL_STEPS);
// `0` is invalid, not "unlimited" — the trap R1 exists to close.
assert_eq!(resolve_max_model_steps(Some(0)), DEFAULT_MAX_MODEL_STEPS);
const { assert!(DEFAULT_MAX_MODEL_STEPS < u32::MAX) };
const { assert!(MAX_MAX_MODEL_STEPS < u32::MAX) };
}
#[test]
fn model_step_ceiling_is_overridable_and_clamped() {
assert_eq!(resolve_max_model_steps(Some(7)), 7);
assert_eq!(resolve_max_model_steps(Some(1)), 1);
assert_eq!(
resolve_max_model_steps(Some(u32::MAX)),
MAX_MAX_MODEL_STEPS,
"the escape hatch is large but still finite"
);
}
#[test]
fn turn_wall_clock_defaults_are_finite_and_reject_the_zero_sentinel() {
assert_eq!(
resolve_turn_wall_clock_secs(None),
DEFAULT_TURN_WALL_CLOCK_SECS
);
assert_eq!(
resolve_turn_wall_clock_secs(Some(0)),
DEFAULT_TURN_WALL_CLOCK_SECS
);
assert_eq!(
resolve_turn_wall_clock(None),
Duration::from_secs(DEFAULT_TURN_WALL_CLOCK_SECS)
);
}
#[test]
fn turn_wall_clock_is_overridable_and_clamped() {
assert_eq!(resolve_turn_wall_clock_secs(Some(120)), 120);
assert_eq!(
resolve_turn_wall_clock_secs(Some(1)),
MIN_TURN_WALL_CLOCK_SECS
);
assert_eq!(
resolve_turn_wall_clock_secs(Some(u64::MAX)),
MAX_TURN_WALL_CLOCK_SECS
);
}
#[test]
fn stream_caps_default_reject_zero_and_are_overridable() {
assert_eq!(
resolve_stream_max_content_bytes(None),
DEFAULT_STREAM_MAX_CONTENT_BYTES
);
assert_eq!(
resolve_stream_max_content_bytes(Some(0)),
DEFAULT_STREAM_MAX_CONTENT_BYTES
);
assert_eq!(resolve_stream_max_content_bytes(Some(1)), 1024 * 1024);
assert_eq!(
resolve_stream_max_content_bytes(Some(u64::MAX)),
MAX_STREAM_MAX_CONTENT_BYTES
);
assert_eq!(
resolve_stream_max_duration_secs(None),
DEFAULT_STREAM_MAX_DURATION_SECS
);
assert_eq!(
resolve_stream_max_duration_secs(Some(0)),
DEFAULT_STREAM_MAX_DURATION_SECS
);
assert_eq!(resolve_stream_max_duration_secs(Some(60)), 60);
assert_eq!(
resolve_stream_max_duration_secs(Some(u64::MAX)),
MAX_STREAM_MAX_DURATION_SECS
);
}
#[test]
fn wall_clock_exhausts_once_the_budget_is_spent() {
let mut clock = TurnWallClock::start(Duration::from_secs(60));
assert!(!clock.exhausted());
clock.rewind_for_test(Duration::from_secs(61));
assert!(clock.exhausted());
assert!(clock.spent() >= clock.budget());
}
#[test]
fn wall_clock_excludes_time_blocked_on_a_human_decision() {
let mut clock = TurnWallClock::start(Duration::from_secs(60));
clock.begin_human_wait();
// The human took two minutes; the agent spent none of its budget.
clock.rewind_for_test(Duration::from_secs(120));
assert!(
!clock.exhausted(),
"an unanswered approval prompt must not burn the turn budget"
);
clock.end_human_wait();
assert!(!clock.exhausted());
// Agent-owned time after the decision still counts.
clock.rewind_for_test(Duration::from_secs(61));
assert!(clock.exhausted());
}
#[test]
fn nested_human_waits_cannot_double_exclude() {
let mut clock = TurnWallClock::start(Duration::from_secs(60));
clock.begin_human_wait();
clock.begin_human_wait();
clock.rewind_for_test(Duration::from_secs(30));
clock.end_human_wait();
// A second unmatched end is a no-op, not another exclusion.
clock.end_human_wait();
clock.rewind_for_test(Duration::from_secs(61));
assert!(clock.exhausted());
}
}
+31 -4
View File
@@ -588,6 +588,11 @@ impl Engine {
// registry-derived field as unknown rather than guessing.
inspection_surface: Option<crate::tool_inspection::ToolSurfaceContext>,
) -> (TurnOutcomeStatus, Option<String>) {
// R1: restart the cumulative per-turn wall-clock budget. This is the
// only place it is started, so exactly one turn owns it at a time.
self.turn_wall_clock =
crate::core::engine::turn_budget::TurnWallClock::start(self.config.turn_wall_clock);
// Only interactive TUI hosts own terminal chrome. Headless exec,
// app-server, and stream-json stdout must remain byte-clean.
if self.config.terminal_chrome_enabled {
@@ -674,6 +679,22 @@ impl Engine {
return (TurnOutcomeStatus::Interrupted, None);
}
// R1: the cumulative per-turn wall-clock budget. Checked at the
// provider-request boundary so a turn that runs out of time stops
// before authorizing another billable request, and every tool
// result already produced stays in the transcript. Hitting it is
// never a clean success — the turn ends `Failed` with the limit
// named, matching how the step ceiling below reports.
if self.turn_wall_clock.exhausted() {
let error = format!(
"Per-turn wall-clock budget exhausted after {}s (limit: {}s). The turn was stopped before another model request; work already done is in the transcript. Send another message to continue, or raise `[tui].turn_wall_clock_secs`.",
self.turn_wall_clock.spent().as_secs(),
self.turn_wall_clock.budget().as_secs(),
);
let _ = self.tx_event.send(Event::status(error.clone())).await;
return (TurnOutcomeStatus::Failed, Some(error));
}
if self.apply_pending_runtime_authority().await {
mode = self.current_mode;
questions_allowed = crate::core::authority::permission_posture_allows_questions(
@@ -4131,7 +4152,13 @@ impl Engine {
let mut pending_resume: Option<StreamResume> = None;
let mut stream_content_bytes: usize = 0;
let (chunk_timeout_secs, chunk_timeout) = stream_chunk_timeout_budget(&self.config);
let max_duration = Duration::from_secs(STREAM_MAX_DURATION_SECS);
// R1: the per-step stream caps are resolved from config rather than
// read from the module constants, so both are overridable. Both stay
// finite: `resolve_stream_*` rejects `0` instead of reading it as
// "unlimited".
let max_duration = self.config.stream_max_duration;
let max_duration_secs = max_duration.as_secs();
let max_content_bytes = self.config.stream_max_content_bytes;
// Process stream events
loop {
@@ -4186,7 +4213,7 @@ impl Engine {
// Guard: max wall-clock duration
if stream_start.elapsed() > max_duration {
let envelope = StreamError::DurationLimit {
limit_secs: STREAM_MAX_DURATION_SECS,
limit_secs: max_duration_secs,
}
.into_envelope();
crate::logging::warn(&envelope.message);
@@ -4196,9 +4223,9 @@ impl Engine {
}
// Guard: max accumulated content bytes
if stream_content_bytes > STREAM_MAX_CONTENT_BYTES {
if stream_content_bytes > max_content_bytes {
let envelope = StreamError::Overflow {
limit_bytes: STREAM_MAX_CONTENT_BYTES,
limit_bytes: max_content_bytes,
}
.into_envelope();
crate::logging::warn(&envelope.message);
+13 -1
View File
@@ -9,8 +9,17 @@
use super::*;
/// Resolve the headless `exec` model-step ceiling.
///
/// R1: omitting `--max-turns` no longer means `u32::MAX`. A non-interactive
/// run has nobody watching it, so its default bound is the same finite
/// ceiling the interactive engine uses. Clap already rejects `--max-turns
/// 0`, so no "0 means unlimited" sentinel can reach here; an explicit value
/// is still clamped to the documented finite range.
pub(crate) fn exec_max_steps(max_turns: Option<u32>) -> u32 {
max_turns.unwrap_or(u32::MAX)
crate::core::engine::turn_budget::resolve_max_model_steps(max_turns.or(Some(
crate::core::engine::turn_budget::DEFAULT_EXEC_MAX_TURNS,
)))
}
#[allow(clippy::too_many_arguments)]
@@ -261,6 +270,9 @@ pub(crate) async fn run_exec_agent(
stream_chunk_timeout: std::time::Duration::from_secs(
execution_config.stream_chunk_timeout_secs(),
),
turn_wall_clock: execution_config.turn_wall_clock(),
stream_max_content_bytes: execution_config.stream_max_content_bytes(),
stream_max_duration: execution_config.stream_max_duration(),
subagent_heartbeat_timeout: std::time::Duration::from_secs(
execution_config.subagent_heartbeat_timeout_secs_for_provider(effective_provider),
),
+2 -2
View File
@@ -96,8 +96,8 @@ fn exact_builtin_provider(provider_id: &str) -> Option<ApiProvider> {
const fn catalog_provenance(source: &CatalogSource) -> &'static str {
match source {
CatalogSource::Bundled => "bundled catalog",
CatalogSource::Live { .. } => "live catalog",
CatalogSource::UserOverride => "override",
CatalogSource::Live { .. } | CatalogSource::ModelsDevLive { .. } => "live catalog",
CatalogSource::ConfigOverride | CatalogSource::UserOverride => "override",
}
}
+31 -2
View File
@@ -15297,16 +15297,29 @@ reasoning = "high"
assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
}
/// R1: omitting `--max-turns` used to resolve to `u32::MAX`, i.e. a
/// headless run with no bound at all. It now resolves to the finite
/// default, and an explicit value still wins.
#[test]
fn exec_omits_the_headless_turn_cap_by_default() {
fn exec_defaults_to_a_finite_headless_turn_cap() {
let cli = parse_cli(&["codewhale", "exec", "--auto", "benchmark this"]);
let Some(Commands::Exec(args)) = cli.command else {
panic!("expected exec command");
};
assert_eq!(args.max_turns, None);
assert_eq!(exec_max_steps(args.max_turns), u32::MAX);
let defaulted = exec_max_steps(args.max_turns);
assert_eq!(
defaulted,
crate::core::engine::turn_budget::DEFAULT_EXEC_MAX_TURNS
);
assert!(defaulted < u32::MAX, "the headless default must be finite");
assert_eq!(exec_max_steps(Some(7)), 7);
assert_eq!(
exec_max_steps(Some(u32::MAX)),
crate::core::engine::turn_budget::MAX_MAX_MODEL_STEPS,
"even the largest override stays finite"
);
}
#[test]
@@ -16104,6 +16117,10 @@ reasoning = "high"
mouse_capture: None,
terminal_probe_timeout_ms: None,
stream_chunk_timeout_secs: None,
max_model_steps: None,
turn_wall_clock_secs: None,
stream_max_content_mb: None,
stream_max_duration_secs: None,
status_items: None,
osc8_links: None,
composer_arrows_scroll: None,
@@ -16199,6 +16216,10 @@ reasoning = "high"
mouse_capture: Some(false),
terminal_probe_timeout_ms: None,
stream_chunk_timeout_secs: None,
max_model_steps: None,
turn_wall_clock_secs: None,
stream_max_content_mb: None,
stream_max_duration_secs: None,
status_items: None,
osc8_links: None,
composer_arrows_scroll: None,
@@ -16232,6 +16253,10 @@ reasoning = "high"
mouse_capture: Some(true),
terminal_probe_timeout_ms: None,
stream_chunk_timeout_secs: None,
max_model_steps: None,
turn_wall_clock_secs: None,
stream_max_content_mb: None,
stream_max_duration_secs: None,
status_items: None,
osc8_links: None,
composer_arrows_scroll: None,
@@ -16319,6 +16344,10 @@ reasoning = "high"
mouse_capture: Some(true),
terminal_probe_timeout_ms: None,
stream_chunk_timeout_secs: None,
max_model_steps: None,
turn_wall_clock_secs: None,
stream_max_content_mb: None,
stream_max_duration_secs: None,
status_items: None,
osc8_links: None,
composer_arrows_scroll: None,
+6 -3
View File
@@ -32,7 +32,7 @@ use crate::compaction::CompactionConfig;
use crate::config::DEFAULT_TEXT_MODEL;
use crate::config::{ApiProvider, Config, MAX_SUBAGENTS, ProviderIdentity};
use crate::core::engine::{
EngineConfig, EngineHandle, UNBOUNDED_MODEL_STEPS, spawn_engine_with_authoritative_route_config,
EngineConfig, EngineHandle, spawn_engine_with_authoritative_route_config,
};
use crate::core::events::{Event as EngineEvent, TurnOutcomeStatus};
use crate::core::ops::Op;
@@ -7641,9 +7641,9 @@ impl RuntimeThreadManager {
},
project_context_pack_enabled: !isolated_chat && cfg.project_context_pack_enabled(),
translation_enabled: false,
// Runtime/API turns follow the same no-hidden-step-budget
// R1: runtime/API turns follow the same finite-budget
// contract as the ordinary interactive engine.
max_steps: UNBOUNDED_MODEL_STEPS,
max_steps: cfg.max_model_steps(),
max_subagents,
max_admitted_subagents: cfg
.max_admitted_subagents_for_provider(provider)
@@ -7703,6 +7703,9 @@ impl RuntimeThreadManager {
stream_chunk_timeout: std::time::Duration::from_secs(
cfg.stream_chunk_timeout_secs(),
),
turn_wall_clock: cfg.turn_wall_clock(),
stream_max_content_bytes: cfg.stream_max_content_bytes(),
stream_max_duration: cfg.stream_max_duration(),
subagent_heartbeat_timeout: std::time::Duration::from_secs(
cfg.subagent_heartbeat_timeout_secs_for_provider(provider),
),
+6 -2
View File
@@ -2316,9 +2316,13 @@ fn render_picker_model_hint(
PickerPricing::Unknown => parts.push("price unknown".to_string()),
}
match metadata.source.as_ref() {
Some(CatalogSource::Live { .. }) => parts.push("live".to_string()),
Some(CatalogSource::Live { .. } | CatalogSource::ModelsDevLive { .. }) => {
parts.push("live".to_string())
}
Some(CatalogSource::Bundled) => parts.push("bundled".to_string()),
Some(CatalogSource::UserOverride) => parts.push("override".to_string()),
Some(CatalogSource::ConfigOverride | CatalogSource::UserOverride) => {
parts.push("override".to_string())
}
None => {}
}
if provider == Some(ApiProvider::OpenaiCodex) {
+10 -7
View File
@@ -213,13 +213,13 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
project_context_pack_enabled: config.project_context_pack_enabled(),
translation_enabled: app.translation_enabled,
verbosity: app.verbosity.clone(),
// Effectively unlimited: the previous cap of 100 hit the ceiling on
// long multi-step plans (wide refactors, sub-agent orchestration) and
// presented as the agent "giving up mid-task". `u32::MAX` is the type
// ceiling; users can still interrupt with Ctrl+C / Esc, and a turn
// naturally ends when the model stops emitting tool calls. A real
// runaway is rare and human-noticeable; we trust the operator.
max_steps: u32::MAX,
// R1: finite, not `u32::MAX`. The old comment argued a runaway is
// "human-noticeable", but an interactive session left running is
// exactly where an unbounded loop spends real money unattended.
// The default (200) is far above what a long multi-step plan needs;
// operators who want more raise `[tui].max_model_steps`, and the
// clamp keeps even the maximum finite.
max_steps: config.max_model_steps(),
max_subagents,
max_admitted_subagents: config
.max_admitted_subagents_for_provider(provider)
@@ -267,6 +267,9 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
config.subagent_api_timeout_secs_for_provider(provider),
),
stream_chunk_timeout: Duration::from_secs(app.stream_chunk_timeout_secs),
turn_wall_clock: config.turn_wall_clock(),
stream_max_content_bytes: config.stream_max_content_bytes(),
stream_max_duration: config.stream_max_duration(),
subagent_heartbeat_timeout: Duration::from_secs(
config.subagent_heartbeat_timeout_secs_for_provider(provider),
),
+4
View File
@@ -7171,6 +7171,10 @@ fn terminal_probe_timeout_uses_tui_config_and_clamps() {
mouse_capture: None,
terminal_probe_timeout_ms: Some(750),
stream_chunk_timeout_secs: None,
max_model_steps: None,
turn_wall_clock_secs: None,
stream_max_content_mb: None,
stream_max_duration_secs: None,
status_items: None,
header_items: None,
osc8_links: None,
+55
View File
@@ -0,0 +1,55 @@
# Codewhale Agent — one identity, many surfaces
"Codewhale Agent" is one product identity with many transport surfaces.
This page is the identity map: what each surface is, where each lives, and
the rules that keep them one product instead of several.
## The one identity
| Surface | Identity object | Where it lives | State |
|---|---|---|---|
| GitHub PR review | GitHub App bot `codewhale-agent[bot]` | GitHub org settings; repo variable `CODEWHALE_APP_ID` + secret `CODEWHALE_APP_PRIVATE_KEY` | `codewhale review --pr N --post` ships with the `codewhale-review.yml` workflow; it no-ops green until the App is configured (founder-gated), then posts as the App instead of the workflow token. Posting is always opt-in via `--post`. |
| Commit co-author | `.github/AUTHOR_MAP` | repo | existing; harvested co-author credit lands in the contributor graph |
| Chat channels (Telegram today; Slack/Feishu/Lark next; Discord/WeCom to come) | per-user bot registration bound to a Codewhale membership | CWC control-plane `services/control-plane/src` BotGateway + `integrations/chat/`; contracts in `packages/contracts` | Telegram pairing end-to-end (token → vault `credentialRef` → one-time code → `/start` binds chat↔membership). Default read-only command allowlist; opt-in write verbs; approve/deny keyboards route as permission decisions. |
## Rules that make it one identity
- Every surface is I/O around the single `Engine::run_turn`
(`crates/tui/src/core/engine/turn_loop.rs`). No surface gets its own
engine or turn loop.
- Every surface authenticates to the same Codewhale membership
(`codewhale login` account session). Membership gates cloud agents;
provider brands stay internal and invisible.
- Approvals from any surface route back as permission decisions into the
same engine. A bare message never executes a gated action.
- Every command and approval is audited, on every channel.
- All surfaces are FREE. Rate limits are anti-abuse only. There is no
per-channel fee, message metering, team gating, or trial timer — and a
billing hook on a channel feature is forbidden by the money model.
## Naming
- Product: Codewhale. Agent surface: "Codewhale Agent".
- Bot handle wherever it can be chosen: `codewhale-agent`.
- The agent speaks as "Codewhale cloud agent" — never as a provider brand.
## Channel contract
Per-channel command allowlists (default read-only: `status`, `jobs`,
`receipts`, `help`; opt-in: `new task`, `approve`, `deny`), inline
approve/deny keyboards routed as permission decisions, `assistant_changes`
reply mode (message only when something changed), per-channel message
format adapters (Telegram markdown today; Slack blocks, Feishu/Lark cards,
WeCom markdown planned), quiet hours + digest, automation output routable
to any paired channel, one pairing code bound inside the authenticated
app, unbind revokes instantly.
WeChat note: personal-WeChat automation is **not supported** —
reverse-engineered protocols get users banned. Official 公众号/服务号 APIs
are the only acceptable path and are still under assessment.
## Pointer
Channel contracts live in `packages/contracts` (CWC repo); the gateway and
adapters live in `services/control-plane/src` (CWC repo). The GitHub
surface lives in `.github/workflows/codewhale-review.yml` here.
+35
View File
@@ -28,6 +28,41 @@ notes, and relevant issue/PR comments.
## Contributors by time
<details open>
<summary><strong>v0.9.12 — native search, live token truth, and runtime surfaces</strong></summary>
**Merged or adapted contributions**
- **[hexin / h3c-hexin](https://github.com/h3c-hexin)** — provider-native web
search across four routes: the DeepSeek adapter (#5683), Z.AI and BigModel
(#5685), and Xiaomi MiMo (#5687), each fail-closed for undocumented models,
plus the constraint enforcement that runs before any fallback (#5682); also
authoritative edit-last-turn boundaries (#5621), Kimi Code k3-256k support
(#5622), post-compaction input-token reporting (#5623), and preserving the
scheduled model selection in automations (#5650)
- **[Isabel Wu / wuisabel-gif](https://github.com/wuisabel-gif)** — live
session token totals (#5624), persisted context-pressure warnings (#5629),
discoverable Fleet roster editing (#5604), the capability-gated cursor
accent (#5599), and `/copy` for the latest completed response (#5692)
- **[Paulo Aboim Pinto / aboimpinto](https://github.com/aboimpinto)** —
preserved Windows verbatim-path operands through POSIX word splitting
(#5610) and moved the plugins group onto the command shapes (#5657)
- **[Alex Musichen / musichen](https://github.com/musichen)** — fixed the
configured-view DeepSeek model picker so it shows a stable DeepSeek heading
and keeps every official catalog model for the active provider visible
(#5689)
- **[gaord](https://github.com/gaord)** — added the `GET /v1/fleet/profiles`
runtime API endpoint, reusing the FleetManager validation path (#5688)
- **[Sh1Zuku / SparkofSpike](https://github.com/SparkofSpike)** — corrected
English documentation inaccuracies and contributed the first zh_hans
translations for the Tier-2 docs (#5613)
- **[M-Maciej](https://github.com/M-Maciej)** — goal continuation cadence fix
(#5591)
- **[Serephus / serephus](https://github.com/serephus)** — nixpkgs update
(#5669)
</details>
<details>
<summary><strong>v0.9.11 — portable command surfaces and turn-loop structure</strong></summary>
**Merged or adapted contributions**
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Register this Mac as a self-hosted GitHub Actions runner for the macOS legs.
#
# WHY: GitHub's hosted macOS queue routinely takes 50+ minutes, which is the
# single slowest gate on every PR. This machine finishes the same work in a
# fraction of that.
#
# SAFETY — read this before running it.
#
# Hmbown/CodeWhale is a PUBLIC repository with thousands of forks. A
# self-hosted runner that accepts pull requests from forks lets anyone who
# opens a PR execute arbitrary code on this machine, as this user. That is the
# one CI configuration GitHub explicitly warns against, and on this machine it
# would expose ~/.codewhale/secrets.json, the gh token, and the whole CW
# workspace.
#
# The companion change in .github/workflows/ci.yml therefore routes jobs to
# this runner ONLY for same-repository events (pushes and PRs whose head repo
# is Hmbown/CodeWhale). Fork PRs keep running on GitHub-hosted runners. Do not
# remove that guard; without it this script is a remote-code-execution hole.
#
# Each job gets a freshly registered --ephemeral runner: the registration is
# consumed by one job and then discarded, so a job cannot persist a runner
# registration for later use. Note this is NOT filesystem isolation — an
# ephemeral runner still runs as this user on this disk. For real isolation,
# run this inside a throwaway VM (Tart, UTM, or a dedicated macOS user with no
# access to the secrets above).
#
# Usage: scripts/ci/setup-mac-runner.sh [--once]
# Requires: gh, authenticated with admin rights on the repo.
set -euo pipefail
REPO="${RUNNER_REPO:-Hmbown/CodeWhale}"
RUNNER_VERSION="${RUNNER_VERSION:-2.337.0}"
RUNNER_HOME="${RUNNER_HOME:-$HOME/actions-runner}"
LABELS="${RUNNER_LABELS:-self-hosted,macOS,ARM64,codewhale-mac}"
RUNNER_NAME="${RUNNER_NAME:-$(scutil --get LocalHostName 2>/dev/null || hostname -s)-cw}"
case "$(uname -m)" in
arm64) ARCH="osx-arm64" ;;
x86_64) ARCH="osx-x64" ;;
*) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;;
esac
command -v gh >/dev/null || { echo "gh is required" >&2; exit 1; }
gh auth status >/dev/null 2>&1 || { echo "run: gh auth login" >&2; exit 1; }
if [ ! -x "$RUNNER_HOME/run.sh" ]; then
echo "==> installing actions-runner $RUNNER_VERSION ($ARCH) into $RUNNER_HOME"
mkdir -p "$RUNNER_HOME"
TARBALL="actions-runner-${ARCH}-${RUNNER_VERSION}.tar.gz"
curl -fsSL -o "$RUNNER_HOME/$TARBALL" \
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/${TARBALL}"
tar -xzf "$RUNNER_HOME/$TARBALL" -C "$RUNNER_HOME"
rm -f "$RUNNER_HOME/$TARBALL"
fi
register_and_run() {
# Registration tokens are single-use and expire in ~1 hour, so mint a fresh
# one per job rather than caching it.
local token
token="$(gh api -X POST "repos/${REPO}/actions/runners/registration-token" --jq .token)"
[ -n "$token" ] || { echo "failed to mint a registration token" >&2; return 1; }
( cd "$RUNNER_HOME" && ./config.sh \
--url "https://github.com/${REPO}" \
--token "$token" \
--name "$RUNNER_NAME" \
--labels "$LABELS" \
--work _work \
--ephemeral \
--unattended \
--replace >/dev/null )
# --ephemeral: run.sh exits after exactly one job, leaving no registration.
( cd "$RUNNER_HOME" && ./run.sh )
}
if [ "${1:-}" = "--once" ]; then
register_and_run
exit 0
fi
echo "==> serving jobs for ${REPO} as '${RUNNER_NAME}' [${LABELS}]"
echo " one ephemeral registration per job; Ctrl-C to stop"
while true; do
register_and_run || { echo "runner exited abnormally; retrying in 15s" >&2; sleep 15; }
done