mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-10 17:10:18 -10:00
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:
commit
fdf0b97ee6
42 files changed
+3588
-368
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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**
|
||||
|
||||
Executable
+89
@@ -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
|
||||
Reference in new issue
Block a user