Merge pull request #23 from Dark-Alex-17/feat/dynamic-model-catalog

feat: dynamic model catalog — generated models.yaml, quirks overlay, runtime refresh
This commit is contained in:
Alex Clarke authored and GitHub committed 2026-09-03 14:17:34 -06:00
commit b04270950a
25 files changed
+14482 -3111

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
name: Update models
on:
schedule:
- cron: '0 */12 * * *'
workflow_dispatch:
permissions:
contents: write
concurrency:
group: update-models
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
update:
name: Update models
runs-on: ubuntu-latest
env:
RUSTFLAGS: --deny warnings
steps:
- uses: actions/checkout@v4
with:
persist-credentials: true
- name: Install Rust Toolchain Components
uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Cache DuckDB Extensions
id: duckdb-extensions
uses: actions/cache@v4
with:
path: ~/.duckdb/extensions
key: duckdb-ext-ubuntu-latest-${{ hashFiles('Cargo.lock') }}
- name: Install DuckDB Extensions
if: steps.duckdb-extensions.outputs.cache-hit != 'true'
run: cargo test --all duckdb
# Coyote loads its config before handling --generate-models/--list-models, and a
# bare non-TTY runner has no config file (and no way to answer the first-run
# prompt), so point COYOTE_CONFIG_DIR at a throwaway dir with a minimal stub.
- name: Write stub config
run: |
COYOTE_CONFIG_DIR="$(mktemp -d)"
printf 'clients:\n - type: openai\n - type: claude\n' > "$COYOTE_CONFIG_DIR/config.yaml"
echo "COYOTE_CONFIG_DIR=$COYOTE_CONFIG_DIR" >> "$GITHUB_ENV"
- name: Regenerate model catalog
run: cargo run --release -- --generate-models
- name: Detect catalog changes
id: diff
run: echo "changed=$(git diff --quiet models.yaml && echo false || echo true)" >> "$GITHUB_OUTPUT"
# The binary built by the regeneration step embedded the OLD models.yaml (the file
# was rewritten after that build), so rebuild before smoke-testing: cargo tracks
# include_str! inputs, making this a cheap incremental rebuild of the final crate.
- name: Rebuild with the refreshed catalog
if: steps.diff.outputs.changed == 'true'
run: cargo build --release
- name: Test
if: steps.diff.outputs.changed == 'true'
run: cargo test --all
- name: Smoke test the refreshed catalog
if: steps.diff.outputs.changed == 'true'
run: |
COUNT=$(./target/release/coyote --list-models | wc -l)
echo "Listed $COUNT chat models"
test "$COUNT" -ge 40
# Pushes made with the default GITHUB_TOKEN do not trigger other workflows,
# so this push cannot start a workflow loop. The pull --rebase covers a human
# push landing mid-run; if the race is lost anyway, the next cron run retries.
- name: Commit and push
if: steps.diff.outputs.changed == 'true'
run: |
git add models.yaml
git checkout -- .
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git commit -m 'chore: refresh generated model catalog'
git pull --rebase origin main
git push origin main
+1
View File
@@ -61,6 +61,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
* [Client Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Clients): Configuration instructions for various LLM providers. * [Client Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Clients): Configuration instructions for various LLM providers.
* [Authentication (API Key & OAuth)](https://github.com/Dark-Alex-17/coyote/wiki/Clients#authentication): Authenticate with API keys or OAuth for subscription-based access. * [Authentication (API Key & OAuth)](https://github.com/Dark-Alex-17/coyote/wiki/Clients#authentication): Authenticate with API keys or OAuth for subscription-based access.
* [Patching API Requests](https://github.com/Dark-Alex-17/coyote/wiki/Patches): Learn how to patch API requests for advanced customization. * [Patching API Requests](https://github.com/Dark-Alex-17/coyote/wiki/Patches): Learn how to patch API requests for advanced customization.
* [Dynamic Model Catalog](https://github.com/Dark-Alex-17/coyote/wiki/Model-Catalog): Coyote's model catalog (context windows, pricing, capabilities) is generated from live API data, refreshed automatically in the background, and mirrorable for air-gapped setups. New models show up without waiting for a release.
* [Custom Themes](https://github.com/Dark-Alex-17/coyote/wiki/Themes): Change the look and feel of Coyote to your preferences with custom themes. * [Custom Themes](https://github.com/Dark-Alex-17/coyote/wiki/Themes): Change the look and feel of Coyote to your preferences with custom themes.
* [History](#history): A history of how Coyote came to be. * [History](#history): A history of how Coyote came to be.
+6 -1
View File
@@ -244,8 +244,13 @@ right_prompt: null # If null, uses the built-in default:
# ---- Miscellaneous ---- # ---- Miscellaneous ----
user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version> user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version>
save_shell_history: true # Whether to save shell execution command to the history file save_shell_history: true # Whether to save shell execution command to the history file
sync_models_url: null # URL to sync model changes from. If null, uses the built-in default: sync_models_url: null # Where `--sync-models` and the automatic refresh get model data from.
# If null, fetches fresh model data from models.dev + OpenRouter.
# Set a URL to mirror a full models.yaml instead, e.g.:
# https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml # https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml
sync_models_auto: true # Whether to refresh the model catalog in the background on REPL startup
# once it goes stale
sync_models_ttl_hours: 24 # Hours before the synced model catalog is considered stale
# ---- Clients ---- # ---- Clients ----
# See the [Clients documentation](https://github.com/Dark-Alex-17/coyote/wiki/Clients) for more details # See the [Clients documentation](https://github.com/Dark-Alex-17/coyote/wiki/Clients) for more details
+1
View File
@@ -67,6 +67,7 @@ permissions:
- 'api.cohere.ai' - 'api.cohere.ai'
- 'api.groq.com' - 'api.groq.com'
- 'openrouter.ai' - 'openrouter.ai'
- 'models.dev'
- 'api.ai21.com' - 'api.ai21.com'
- 'api.cloudflare.com' - 'api.cloudflare.com'
- 'api.deepinfra.com' - 'api.deepinfra.com'
+7 -2
View File
@@ -26,8 +26,13 @@ light_theme: false # Activates a light color theme when true. env:
# ---- Miscellaneous ---- # ---- Miscellaneous ----
user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version> user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version>
save_shell_history: true # Whether to save shell execution command to the history file save_shell_history: true # Whether to save shell execution command to the history file
sync_models_url: > # URL to sync model changes from sync_models_url: null # Where `--sync-models` and the automatic refresh get model data from.
https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml # If null, fetches fresh model data from models.dev + OpenRouter.
# Set a URL to mirror a full models.yaml instead, e.g.:
# https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml
sync_models_auto: true # Whether to refresh the model catalog in the background on REPL startup
# once it goes stale
sync_models_ttl_hours: 24 # Hours before the synced model catalog is considered stale
# ---- REPL Prompt ---- # ---- REPL Prompt ----
# Custom REPL left/right prompts; see the [REPL Prompt Documentation](https://github.com/Dark-Alex-17/coyote/wiki/REPL-Prompt) for more information # Custom REPL left/right prompts; see the [REPL Prompt Documentation](https://github.com/Dark-Alex-17/coyote/wiki/REPL-Prompt) for more information
+9534 -2793
View File
File diff suppressed because it is too large. Load diff
+1472
View File
File diff suppressed because it is too large. Load diff
-2
View File
@@ -1,2 +0,0 @@
requests
ruamel.yaml
-255
View File
@@ -1,255 +0,0 @@
import requests
import sys
import re
import json
# Provider mapping from models.yaml to OpenRouter prefixes
PROVIDER_MAPPING = {
"openai": "openai",
"claude": "anthropic",
"gemini": "google",
"mistral": "mistralai",
"cohere": "cohere",
"perplexity": "perplexity",
"xai": "x-ai",
"openrouter": "openrouter",
"ai21": "ai21",
"deepseek": "deepseek",
"moonshot": "moonshotai",
"qianwen": "qwen",
"zhipuai": "zhipuai",
"minimax": "minimax",
"vertexai": "google",
"groq": "groq",
"bedrock": "amazon",
"hunyuan": "tencent",
"ernie": "baidu",
"github": "github",
}
def fetch_openrouter_models():
print("Fetching models from OpenRouter...")
try:
response = requests.get("https://openrouter.ai/api/v1/models")
response.raise_for_status()
data = response.json()["data"]
print(f"Fetched {len(data)} models.")
return data
except Exception as e:
print(f"Error fetching models: {e}")
sys.exit(1)
def get_openrouter_model(models_data, provider_prefix, model_name, is_openrouter_provider=False):
if is_openrouter_provider:
# For openrouter provider, the model_name in yaml is usually the full ID
for model in models_data:
if model["id"] == model_name:
return model
return None
expected_id = f"{provider_prefix}/{model_name}"
# 1. Try exact match on ID
for model in models_data:
if model["id"] == expected_id:
return model
# 2. Try match by suffix
for model in models_data:
if model["id"].split("/")[-1] == model_name:
if model["id"].startswith(f"{provider_prefix}/"):
return model
return None
def format_price(price_per_token):
if price_per_token is None:
return None
try:
price_per_1m = float(price_per_token) * 1_000_000
if price_per_1m.is_integer():
return str(int(price_per_1m))
else:
return str(round(price_per_1m, 4))
except:
return None
def get_indentation(line):
return len(line) - len(line.lstrip())
def process_model_block(block_lines, current_provider, or_models):
if not block_lines:
return []
# 1. Identify model name and indentation
name_line = block_lines[0]
name_match = re.match(r"^(\s*)-\s*name:\s*(.+)$", name_line)
if not name_match:
return block_lines
name_indent_str = name_match.group(1)
model_name = name_match.group(2).strip()
# 2. Find OpenRouter model
or_prefix = PROVIDER_MAPPING.get(current_provider)
is_openrouter_provider = (current_provider == "openrouter")
if not or_prefix and not is_openrouter_provider:
return block_lines
or_model = get_openrouter_model(or_models, or_prefix, model_name, is_openrouter_provider)
if not or_model:
return block_lines
print(f" Updating {model_name}...")
# 3. Prepare updates
updates = {}
# Pricing
pricing = or_model.get("pricing", {})
p_in = format_price(pricing.get("prompt"))
p_out = format_price(pricing.get("completion"))
if p_in: updates["input_price"] = p_in
if p_out: updates["output_price"] = p_out
# Context
ctx = or_model.get("context_length")
if ctx: updates["max_input_tokens"] = str(ctx)
max_out = None
if "top_provider" in or_model and or_model["top_provider"]:
max_out = or_model["top_provider"].get("max_completion_tokens")
if max_out: updates["max_output_tokens"] = str(max_out)
# Capabilities
arch = or_model.get("architecture", {})
modality = arch.get("modality", "")
if "image" in modality:
updates["supports_vision"] = "true"
# 4. Detect field indentation
field_indent_str = None
existing_fields = {} # key -> line_index
for i, line in enumerate(block_lines):
if i == 0: continue # Skip name line
# Skip comments
if line.strip().startswith("#"):
continue
# Look for "key: value"
m = re.match(r"^(\s*)([\w_-]+):", line)
if m:
indent = m.group(1)
key = m.group(2)
# Must be deeper than name line
if len(indent) > len(name_indent_str):
if field_indent_str is None:
field_indent_str = indent
existing_fields[key] = i
if field_indent_str is None:
field_indent_str = name_indent_str + " "
# 5. Apply updates
new_block = list(block_lines)
# Update existing fields
for key, value in updates.items():
if key in existing_fields:
idx = existing_fields[key]
# Preserve original key indentation exactly
original_line = new_block[idx]
m = re.match(r"^(\s*)([\w_-]+):", original_line)
if m:
current_indent = m.group(1)
new_block[idx] = f"{current_indent}{key}: {value}\n"
# Insert missing fields
# Insert after the name line
insertion_idx = 1
for key, value in updates.items():
if key not in existing_fields:
new_line = f"{field_indent_str}{key}: {value}\n"
new_block.insert(insertion_idx, new_line)
insertion_idx += 1
return new_block
def main():
or_models = fetch_openrouter_models()
print("Reading models.yaml...")
with open("models.yaml", "r") as f:
lines = f.readlines()
new_lines = []
current_provider = None
i = 0
while i < len(lines):
line = lines[i]
# Check for provider
# - provider: name
p_match = re.match(r"^\s*-?\s*provider:\s*(.+)$", line)
if p_match:
current_provider = p_match.group(1).strip()
new_lines.append(line)
i += 1
continue
# Check for model start
# - name: ...
m_match = re.match(r"^(\s*)-\s*name:\s*.+$", line)
if m_match:
# Start of a model block
start_indent = len(m_match.group(1))
# Collect block lines
block_lines = [line]
j = i + 1
while j < len(lines):
next_line = lines[j]
stripped = next_line.strip()
# If empty or comment, include it
if not stripped or stripped.startswith("#"):
block_lines.append(next_line)
j += 1
continue
# Check indentation
next_indent = get_indentation(next_line)
# If indentation is greater, it's part of the block (property)
if next_indent > start_indent:
block_lines.append(next_line)
j += 1
continue
# If indentation is equal or less, it's the end of the block
break
# Process the block
processed_block = process_model_block(block_lines, current_provider, or_models)
new_lines.extend(processed_block)
# Advance i
i = j
continue
# Otherwise, just a regular line
new_lines.append(line)
i += 1
print("Saving models.yaml...")
with open("models.yaml", "w") as f:
f.writelines(new_lines)
print("Done.")
if __name__ == "__main__":
main()
+24 -1
View File
@@ -12,6 +12,7 @@ use clap_complete::ArgValueCompleter;
use is_terminal::IsTerminal; use is_terminal::IsTerminal;
use std::collections::HashSet; use std::collections::HashSet;
use std::io::{Read, stdin}; use std::io::{Read, stdin};
use std::path::PathBuf;
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] #[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpTransportArg { pub enum McpTransportArg {
@@ -53,7 +54,7 @@ pub enum McpScopeArg {
"install_builtins", "sync_models", "list_models", "list_roles", "install_builtins", "sync_models", "list_models", "list_roles",
"list_sessions", "list_agents", "list_rags", "list_macros", "list_sessions", "list_agents", "list_rags", "list_macros",
"list_skills", "list_bundles", "skill", "tail_logs", "completions", "list_skills", "list_bundles", "skill", "tail_logs", "completions",
"update", "update_bundle", "uninstall", "update", "update_bundle", "uninstall", "generate_models",
]) ])
), ),
group( group(
@@ -253,6 +254,15 @@ pub struct Cli {
/// Sync models updates /// Sync models updates
#[arg(long, help_heading = "Installation & Updates")] #[arg(long, help_heading = "Installation & Updates")]
pub sync_models: bool, pub sync_models: bool,
/// Regenerate the models catalog file from live API data
#[arg(
long,
hide = true,
value_name = "PATH",
num_args = 0..=1,
default_missing_value = "models.yaml"
)]
pub generate_models: Option<PathBuf>,
/// Update Coyote to the latest release, or to a specific version /// Update Coyote to the latest release, or to a specific version
#[arg(long, value_name = "VERSION", help_heading = "Installation & Updates")] #[arg(long, value_name = "VERSION", help_heading = "Installation & Updates")]
pub update: Option<Option<String>>, pub update: Option<Option<String>>,
@@ -810,6 +820,19 @@ mod tests {
assert!(cli.sync_models); assert!(cli.sync_models);
} }
#[test]
fn parse_generate_models_flag() {
assert_eq!(parse(&[]).generate_models, None);
assert_eq!(
parse(&["--generate-models"]).generate_models,
Some(PathBuf::from("models.yaml"))
);
assert_eq!(
parse(&["--generate-models", "out.yaml"]).generate_models,
Some(PathBuf::from("out.yaml"))
);
}
#[test] #[test]
fn parse_model_with_role() { fn parse_model_with_role() {
let cli = parse(&["-m", "gpt-4o", "-r", "coder"]); let cli = parse(&["-m", "gpt-4o", "-r", "coder"]);
File diff suppressed because it is too large. Load diff
+1
View File
@@ -1,4 +1,5 @@
mod access_token; mod access_token;
pub mod catalog;
mod claude_oauth; mod claude_oauth;
mod common; mod common;
mod gemini_oauth; mod gemini_oauth;
+93 -11
View File
@@ -1,5 +1,7 @@
use super::{ use super::{
ApiPatch, MessageContentToolCalls, RequestPatch, list_all_models, list_client_names, ApiPatch, MessageContentToolCalls, RequestPatch,
catalog::{ALL_QUIRKS, apply_rules, quirks_for},
list_all_models, list_client_names,
message::{Message, MessageContent, MessageContentPart}, message::{Message, MessageContent, MessageContentPart},
}; };
@@ -63,12 +65,16 @@ impl Model {
}; };
match model_name { match model_name {
Some(model_name) => { Some(model_name) => {
if let Some(model) = models.iter().find(|v| v.id() == model_id) { // Ids are not unique across types (e.g. a model that is both
if model.model_type() == model_type { // an embedder and a reranker), so match on id AND type.
return Ok((*model).clone()); if let Some(model) = models
} else { .iter()
bail!("Model '{model_id}' is not a {model_type} model") .find(|v| v.id() == model_id && v.model_type() == model_type)
} {
return Ok((*model).clone());
}
if models.iter().any(|v| v.id() == model_id) {
bail!("Model '{model_id}' is not a {model_type} model")
} }
if list_client_names(config) if list_client_names(config)
.into_iter() .into_iter()
@@ -77,6 +83,9 @@ impl Model {
{ {
let mut new_model = Self::new(client_name, model_name); let mut new_model = Self::new(client_name, model_name);
new_model.data.model_type = model_type.to_string(); new_model.data.model_type = model_type.to_string();
if let Some(quirks) = quirks_for(&ALL_QUIRKS, client_name) {
apply_rules(quirks, &mut new_model.data);
}
return Ok(new_model); return Ok(new_model);
} }
} }
@@ -132,6 +141,7 @@ impl Model {
output_price, output_price,
supports_vision, supports_vision,
supports_function_calling, supports_function_calling,
reasoning_levels,
.. ..
} = &self.data; } = &self.data;
let max_input_tokens = stringify_option_value(max_input_tokens); let max_input_tokens = stringify_option_value(max_input_tokens);
@@ -145,6 +155,9 @@ impl Model {
if *supports_function_calling { if *supports_function_calling {
capabilities.push('⚒'); capabilities.push('⚒');
}; };
if !reasoning_levels.is_empty() {
capabilities.push('🧠');
};
let capabilities: String = capabilities let capabilities: String = capabilities
.into_iter() .into_iter()
.map(|v| format!("{v} ")) .map(|v| format!("{v} "))
@@ -330,11 +343,11 @@ pub struct ModelData {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub default_reasoning_effort: Option<String>, pub default_reasoning_effort: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")] #[serde(default, skip_serializing_if = "std::ops::Not::not")]
no_stream: bool, pub(crate) no_stream: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")] #[serde(default, skip_serializing_if = "std::ops::Not::not")]
no_system_message: bool, pub(crate) no_system_message: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
system_prompt_prefix: Option<String>, pub(crate) system_prompt_prefix: Option<String>,
// embedding-only properties // embedding-only properties
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -358,7 +371,7 @@ impl ModelData {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderModels { pub struct ProviderModels {
pub provider: String, pub provider: String,
#[serde(default)] #[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth: Option<OAuthConfig>, pub oauth: Option<OAuthConfig>,
pub models: Vec<ModelData>, pub models: Vec<ModelData>,
} }
@@ -419,3 +432,72 @@ where
None => "-".to_string(), None => "-".to_string(),
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::client::ClientConfig;
#[test]
fn created_from_name_inherits_family_rules() {
// The model registries are process-wide caches seeded by a ctor (see
// the request_context tests) that registers a "claude" client; the
// config below documents the shape a real lookup would use.
let claude: ClientConfig = serde_yaml::from_str(
"type: claude\nmodels:\n - name: test-claude-embedder\n type: embedding\n",
)
.unwrap();
let config = AppConfig {
clients: vec![claude],
..AppConfig::default()
};
let model = Model::retrieve_model(
&config,
"claude:claude-definitely-not-a-real-model",
ModelType::Chat,
)
.unwrap();
assert_eq!(model.name(), "claude-definitely-not-a-real-model");
assert_eq!(model.model_type(), ModelType::Chat);
assert!(model.data.require_max_tokens);
}
#[test]
fn duplicate_id_across_types_resolves_by_requested_type() {
// Mirrors jina:jina-colbert-v2, which is both an embedder and a
// reranker under the same id.
let claude: ClientConfig = serde_yaml::from_str(
"type: claude\nmodels:\n - name: test-dual-model\n type: embedding\n - name: test-dual-model\n type: reranker\n",
)
.unwrap();
let config = AppConfig {
clients: vec![claude],
..AppConfig::default()
};
let embedder =
Model::retrieve_model(&config, "claude:test-dual-model", ModelType::Embedding).unwrap();
assert_eq!(embedder.model_type(), ModelType::Embedding);
let reranker =
Model::retrieve_model(&config, "claude:test-dual-model", ModelType::Reranker).unwrap();
assert_eq!(reranker.model_type(), ModelType::Reranker);
// An id that exists only under other types still teaches instead of
// silently creating a chat model from the name.
let err = Model::retrieve_model(&config, "claude:test-dual-model", ModelType::Chat)
.unwrap_err()
.to_string();
assert!(err.contains("is not a chat model"), "got: {err}");
}
#[test]
fn description_shows_brain_glyph() {
let mut model = Model::new("claude", "some-model");
model.data.reasoning_levels = vec!["low".to_string(), "high".to_string()];
assert!(model.description().contains('🧠'));
model.data.reasoning_levels.clear();
assert!(!model.description().contains('🧠'));
}
}
+457
View File
@@ -0,0 +1,457 @@
{
"openai": {
"id": "openai",
"env": [
"OPENAI_API_KEY"
],
"npm": "@ai-sdk/openai",
"name": "OpenAI",
"doc": "https://platform.openai.com/docs/models",
"models": {
"gpt-5.6": {
"id": "gpt-5.6",
"name": "GPT-5.6",
"description": "Frontier GPT-5.6 model for complex professional work, coding, and agentic workflows",
"family": "gpt-sol",
"attachment": true,
"reasoning": true,
"reasoning_options": [
{
"type": "effort",
"values": [
"none",
"low",
"medium",
"high",
"xhigh",
"max"
]
}
],
"tool_call": true,
"structured_output": true,
"temperature": false,
"knowledge": "2026-02-16",
"release_date": "2026-07-09",
"last_updated": "2026-07-09",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 1050000,
"input": 922000,
"output": 128000
},
"experimental": {
"modes": {
"fast": {
"cost": {
"input": 8,
"output": 40,
"cache_read": 0.8,
"cache_write": 10
},
"provider": {
"body": {
"service_tier": "priority"
}
}
},
"pro": {
"provider": {
"body": {
"reasoning": {
"mode": "pro"
}
}
}
}
}
},
"cost": {
"input": 4,
"output": 20,
"cache_read": 0.4,
"cache_write": 5,
"tiers": [
{
"input": 8,
"output": 30,
"cache_read": 0.8,
"cache_write": 10,
"tier": {
"type": "context",
"size": 272000
}
}
],
"context_over_200k": {
"input": 8,
"output": 30,
"cache_read": 0.8,
"cache_write": 10
}
}
},
"gpt-4.1": {
"id": "gpt-4.1",
"name": "GPT-4.1",
"description": "Long-lived GPT workhorse for coding, instruction following, and production apps",
"family": "gpt",
"attachment": true,
"reasoning": false,
"tool_call": true,
"structured_output": true,
"temperature": true,
"knowledge": "2024-04",
"release_date": "2025-04-14",
"last_updated": "2025-04-14",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 1047576,
"output": 32768
},
"cost": {
"input": 2,
"output": 8,
"cache_read": 0.5
}
},
"gpt-4o": {
"id": "gpt-4o",
"name": "GPT-4o",
"description": "Omni-era GPT for multimodal chat, practical coding, and general assistants",
"family": "gpt",
"attachment": true,
"reasoning": false,
"tool_call": true,
"structured_output": true,
"temperature": true,
"knowledge": "2023-09",
"release_date": "2024-05-13",
"last_updated": "2024-08-06",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 128000,
"output": 16384
},
"cost": {
"input": 2.5,
"output": 10,
"cache_read": 1.25
}
}
}
},
"anthropic": {
"id": "anthropic",
"env": [
"ANTHROPIC_API_KEY"
],
"npm": "@ai-sdk/anthropic",
"name": "Anthropic",
"doc": "https://docs.anthropic.com/en/docs/about-claude/models",
"models": {
"claude-sonnet-4-6": {
"id": "claude-sonnet-4-6",
"name": "Claude Sonnet 4.6",
"description": "Claude workhorse for coding agents, careful analysis, and production cost control",
"family": "claude-sonnet",
"attachment": true,
"reasoning": true,
"reasoning_options": [
{
"type": "effort",
"values": [
"low",
"medium",
"high",
"max"
]
},
{
"type": "budget_tokens",
"min": 1024
}
],
"tool_call": true,
"structured_output": true,
"temperature": true,
"knowledge": "2025-08-31",
"release_date": "2026-02-17",
"last_updated": "2026-03-13",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 1000000,
"output": 128000
},
"cost": {
"input": 3,
"output": 15,
"cache_read": 0.3,
"cache_write": 3.75
}
},
"claude-opus-4-6": {
"id": "claude-opus-4-6",
"name": "Claude Opus 4.6",
"description": "High-end Claude for difficult coding, planning, and slower expert reasoning",
"family": "claude-opus",
"attachment": true,
"reasoning": true,
"reasoning_options": [
{
"type": "effort",
"values": [
"low",
"medium",
"high",
"max"
]
},
{
"type": "budget_tokens",
"min": 1024
}
],
"tool_call": true,
"structured_output": true,
"temperature": true,
"knowledge": "2025-05-31",
"release_date": "2026-02-04",
"last_updated": "2026-03-13",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 1000000,
"output": 128000
},
"cost": {
"input": 5,
"output": 25,
"cache_read": 0.5,
"cache_write": 6.25
}
}
}
},
"xai": {
"id": "xai",
"env": [
"XAI_API_KEY"
],
"npm": "@ai-sdk/xai",
"name": "xAI",
"doc": "https://docs.x.ai/docs/models",
"models": {
"grok-4.6": {
"id": "grok-4.6",
"name": "Grok 4.6",
"description": "xAI's frontier model for long-running agents, coding, knowledge work, and visual projects",
"family": "grok",
"attachment": true,
"reasoning": true,
"reasoning_options": [
{
"type": "effort",
"values": [
"low",
"medium",
"high",
"xhigh"
]
}
],
"tool_call": true,
"structured_output": true,
"temperature": true,
"knowledge": "2026-02-01",
"release_date": "2026-08-12",
"last_updated": "2026-08-12",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 500000,
"output": 500000
},
"cost": {
"input": 2,
"output": 6,
"cache_read": 0.5,
"tiers": [
{
"input": 4,
"output": 12,
"cache_read": 1,
"tier": {
"type": "context",
"size": 200000
}
}
],
"context_over_200k": {
"input": 4,
"output": 12,
"cache_read": 1
}
}
},
"grok-4.5": {
"id": "grok-4.5",
"name": "Grok 4.5",
"description": "xAI's Grok model for chat, coding, agentic tools, and lower hallucination risk",
"family": "grok",
"attachment": true,
"reasoning": true,
"reasoning_options": [
{
"type": "effort",
"values": [
"low",
"medium",
"high"
]
}
],
"tool_call": true,
"structured_output": true,
"temperature": true,
"release_date": "2026-07-08",
"last_updated": "2026-07-08",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 500000,
"output": 500000
},
"cost": {
"input": 2,
"output": 6,
"cache_read": 0.3,
"tiers": [
{
"input": 4,
"output": 12,
"cache_read": 0.6,
"tier": {
"type": "context",
"size": 200000
}
}
],
"context_over_200k": {
"input": 4,
"output": 12,
"cache_read": 0.6
}
}
}
}
},
"alibaba": {
"id": "alibaba",
"env": [
"DASHSCOPE_API_KEY"
],
"npm": "@ai-sdk/openai-compatible",
"name": "Alibaba",
"doc": "https://www.alibabacloud.com/help/en/model-studio/models",
"models": {
"qwen-max": {
"id": "qwen-max",
"name": "Qwen Max",
"description": "Flagship Qwen model for complex reasoning, coding, and agentic workflows",
"family": "qwen",
"attachment": false,
"reasoning": false,
"tool_call": true,
"temperature": true,
"knowledge": "2024-04",
"release_date": "2024-04-03",
"last_updated": "2025-01-25",
"modalities": {
"input": [
"text"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 32768,
"output": 8192
},
"cost": {
"input": 1.6,
"output": 6.4
}
}
}
}
}
+538
View File
@@ -0,0 +1,538 @@
{
"data": [
{
"id": "x-ai/grok-4.6",
"canonical_slug": "x-ai/grok-4.6-20260810",
"hugging_face_id": null,
"name": "SpaceXAI: Grok 4.6",
"created": 1786548957,
"description": "Grok 4.6 is SpaceXAI's smartest model with frontier performance on coding, knowledge work, and STEM.",
"context_length": 500000,
"architecture": {
"modality": "text+image+file->text",
"input_modalities": [
"text",
"image",
"file"
],
"output_modalities": [
"text"
],
"tokenizer": "Grok",
"instruct_type": null
},
"pricing": {
"prompt": "0.000002",
"completion": "0.000006",
"web_search": "0.005",
"input_cache_read": "0.0000005",
"overrides": [
{
"min_prompt_tokens": 200000,
"prompt": "0.000004",
"completion": "0.000012",
"input_cache_read": "0.000001"
}
]
},
"top_provider": {
"context_length": 500000,
"max_completion_tokens": 450000,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"include_reasoning",
"logprobs",
"max_tokens",
"reasoning",
"reasoning_effort",
"response_format",
"seed",
"stop",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_k",
"top_logprobs",
"top_p"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": null,
"expiration_date": null,
"links": {
"details": "/api/v1/models/x-ai/grok-4.6-20260810/endpoints"
},
"reasoning": {
"mandatory": true,
"default_enabled": true,
"supported_efforts": [
"xhigh",
"high",
"medium",
"low"
],
"default_effort": "high"
}
},
{
"id": "qwen/qwen3.8-max",
"canonical_slug": "qwen/qwen3.8-max-20260803",
"hugging_face_id": null,
"name": "Qwen: Qwen3.8 Max",
"created": 1785731612,
"description": "Qwen3.8 Max is the flagship model in Alibaba's Qwen3.8 series, the general-availability successor to the Qwen3.8 Max Preview. It is a multimodal reasoning model intended for complex reasoning, visual understanding,...",
"context_length": 1000000,
"architecture": {
"modality": "text+image+video->text",
"input_modalities": [
"text",
"image",
"video"
],
"output_modalities": [
"text"
],
"tokenizer": "Qwen",
"instruct_type": null
},
"pricing": {
"prompt": "0.000002",
"completion": "0.000006",
"input_cache_read": "0.00000025",
"input_cache_write": "0.0000025"
},
"top_provider": {
"context_length": 1000000,
"max_completion_tokens": 131072,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"frequency_penalty",
"include_reasoning",
"logprobs",
"max_tokens",
"presence_penalty",
"reasoning",
"reasoning_effort",
"response_format",
"seed",
"stop",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_k",
"top_logprobs",
"top_p"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": null,
"expiration_date": null,
"links": {
"details": "/api/v1/models/qwen/qwen3.8-max-20260803/endpoints"
},
"reasoning": {
"mandatory": true,
"default_enabled": true,
"supported_efforts": [
"xhigh",
"high",
"medium",
"low",
"minimal"
],
"default_effort": "xhigh"
}
},
{
"id": "x-ai/grok-4.5",
"canonical_slug": "x-ai/grok-4.5-20260708",
"hugging_face_id": null,
"name": "SpaceXAI: Grok 4.5",
"created": 1783523154,
"description": "Grok 4.5 is a model from SpaceXAI with frontier performance on coding, knowledge work, and STEM.",
"context_length": 500000,
"architecture": {
"modality": "text+image+file->text",
"input_modalities": [
"text",
"image",
"file"
],
"output_modalities": [
"text"
],
"tokenizer": "Grok",
"instruct_type": null
},
"pricing": {
"prompt": "0.000002",
"completion": "0.000006",
"web_search": "0.005",
"input_cache_read": "0.0000003",
"overrides": [
{
"min_prompt_tokens": 200000,
"prompt": "0.000004",
"completion": "0.000012",
"input_cache_read": "0.0000006"
}
]
},
"top_provider": {
"context_length": 500000,
"max_completion_tokens": 450000,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"include_reasoning",
"logprobs",
"max_tokens",
"reasoning",
"reasoning_effort",
"response_format",
"seed",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_logprobs",
"top_p"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": null,
"expiration_date": null,
"links": {
"details": "/api/v1/models/x-ai/grok-4.5-20260708/endpoints"
},
"reasoning": {
"mandatory": true,
"default_enabled": true,
"supported_efforts": [
"high",
"medium",
"low"
],
"default_effort": "high"
}
},
{
"id": "anthropic/claude-sonnet-4.6",
"canonical_slug": "anthropic/claude-4.6-sonnet-20260217",
"hugging_face_id": "",
"name": "Anthropic: Claude Sonnet 4.6",
"created": 1771342990,
"description": "Sonnet 4.6 is Anthropic's most capable Sonnet-class model yet, with frontier performance across coding, agents, and professional work. It excels at iterative development, complex codebase navigation, end-to-end project management with...",
"context_length": 1000000,
"architecture": {
"modality": "text+image+file->text",
"input_modalities": [
"text",
"image",
"file"
],
"output_modalities": [
"text"
],
"tokenizer": "Claude",
"instruct_type": null
},
"pricing": {
"prompt": "0.000003",
"completion": "0.000015",
"web_search": "0.01",
"input_cache_read": "0.0000003",
"input_cache_write": "0.00000375",
"input_cache_write_1h": "0.000006"
},
"top_provider": {
"context_length": 1000000,
"max_completion_tokens": 128000,
"is_moderated": true
},
"per_request_limits": null,
"supported_parameters": [
"include_reasoning",
"max_completion_tokens",
"max_tokens",
"reasoning",
"reasoning_effort",
"response_format",
"stop",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_k",
"top_p",
"verbosity"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": null,
"expiration_date": null,
"links": {
"details": "/api/v1/models/anthropic/claude-4.6-sonnet-20260217/endpoints"
},
"reasoning": {
"mandatory": false,
"supported_efforts": [
"max",
"high",
"medium",
"low"
],
"default_effort": "medium"
}
},
{
"id": "openrouter/free",
"canonical_slug": "openrouter/free",
"hugging_face_id": "",
"name": "Free Models Router",
"created": 1769917427,
"description": "The simplest way to get free inference. openrouter/free is a router that selects free models at random from the models available on OpenRouter. The router smartly filters for models that...",
"context_length": 200000,
"architecture": {
"modality": "text+image->text",
"input_modalities": [
"text",
"image"
],
"output_modalities": [
"text"
],
"tokenizer": "Router",
"instruct_type": null
},
"pricing": {
"prompt": "0",
"completion": "0"
},
"top_provider": {
"context_length": null,
"max_completion_tokens": null,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"frequency_penalty",
"include_reasoning",
"logprobs",
"max_completion_tokens",
"max_tokens",
"min_p",
"presence_penalty",
"reasoning",
"reasoning_effort",
"repetition_penalty",
"response_format",
"seed",
"stop",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_k",
"top_logprobs",
"top_p"
],
"default_parameters": {
"temperature": null,
"top_p": null,
"frequency_penalty": null
},
"supported_voices": null,
"knowledge_cutoff": null,
"expiration_date": null,
"links": {
"details": "/api/v1/models/openrouter/free/endpoints"
}
},
{
"id": "openai/gpt-4.1",
"canonical_slug": "openai/gpt-4.1-2025-04-14",
"hugging_face_id": "",
"name": "OpenAI: GPT-4.1",
"created": 1744651385,
"description": "GPT-4.1 is a flagship large language model optimized for advanced instruction following, real-world software engineering, and long-context reasoning. It supports a 1 million token context window and outperforms GPT-4o and...",
"context_length": 1047576,
"architecture": {
"modality": "text+image+file->text",
"input_modalities": [
"image",
"text",
"file"
],
"output_modalities": [
"text"
],
"tokenizer": "GPT",
"instruct_type": null
},
"pricing": {
"prompt": "0.000002",
"completion": "0.000008",
"web_search": "0.01",
"input_cache_read": "0.0000005"
},
"top_provider": {
"context_length": 1047576,
"max_completion_tokens": 32768,
"is_moderated": true
},
"per_request_limits": null,
"supported_parameters": [
"max_completion_tokens",
"max_tokens",
"response_format",
"seed",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_p"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": "2024-06-30",
"expiration_date": null,
"links": {
"details": "/api/v1/models/openai/gpt-4.1-2025-04-14/endpoints"
}
},
{
"id": "openai/gpt-4o",
"canonical_slug": "openai/gpt-4o",
"hugging_face_id": null,
"name": "OpenAI: GPT-4o",
"created": 1715558400,
"description": "GPT-4o (\"o\" for \"omni\") is OpenAI's latest AI model, supporting both text and image inputs with text outputs. It maintains the intelligence level of [GPT-4 Turbo](/models/openai/gpt-4-turbo) while being twice as...",
"context_length": 128000,
"architecture": {
"modality": "text+image+file->text",
"input_modalities": [
"text",
"image",
"file"
],
"output_modalities": [
"text"
],
"tokenizer": "GPT",
"instruct_type": null
},
"pricing": {
"prompt": "0.0000025",
"completion": "0.00001",
"input_cache_read": "0.00000125"
},
"top_provider": {
"context_length": 128000,
"max_completion_tokens": 16384,
"is_moderated": true
},
"per_request_limits": null,
"supported_parameters": [
"frequency_penalty",
"logit_bias",
"logprobs",
"max_completion_tokens",
"max_tokens",
"prediction",
"presence_penalty",
"response_format",
"seed",
"stop",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_logprobs",
"top_p",
"web_search_options"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": "2023-10-31",
"expiration_date": null,
"links": {
"details": "/api/v1/models/openai/gpt-4o/endpoints"
}
},
{
"id": "openrouter/auto",
"canonical_slug": "openrouter/auto",
"hugging_face_id": null,
"name": "Auto Router",
"created": 1699401600,
"description": "The Auto Router automatically selects the best model for your prompt, powered by the wisdom of the market. It routes you based on what the OpenRouter community collectively spends on...",
"context_length": 2000000,
"architecture": {
"modality": "text+image+file+audio+video->text+image",
"input_modalities": [
"text",
"image",
"audio",
"file",
"video"
],
"output_modalities": [
"text",
"image"
],
"tokenizer": "Router",
"instruct_type": null
},
"pricing": {
"prompt": "-1",
"completion": "-1"
},
"top_provider": {
"context_length": null,
"max_completion_tokens": null,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"frequency_penalty",
"include_reasoning",
"logit_bias",
"logprobs",
"max_tokens",
"min_p",
"prediction",
"presence_penalty",
"reasoning",
"reasoning_effort",
"repetition_penalty",
"response_format",
"seed",
"stop",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_a",
"top_k",
"top_logprobs",
"top_p",
"web_search_options"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": null,
"expiration_date": null,
"links": {
"details": "/api/v1/models/openrouter/auto/endpoints"
}
}
]
}
+82 -8
View File
@@ -102,6 +102,8 @@ pub struct AppConfig {
pub no_workspace_mcp: bool, pub no_workspace_mcp: bool,
pub no_workspace_macros: bool, pub no_workspace_macros: bool,
pub sync_models_url: Option<String>, pub sync_models_url: Option<String>,
pub sync_models_auto: bool,
pub sync_models_ttl_hours: u64,
pub clients: Vec<ClientConfig>, pub clients: Vec<ClientConfig>,
} }
@@ -188,6 +190,8 @@ impl Default for AppConfig {
no_workspace_mcp: false, no_workspace_mcp: false,
no_workspace_macros: false, no_workspace_macros: false,
sync_models_url: None, sync_models_url: None,
sync_models_auto: true,
sync_models_ttl_hours: 24,
clients: vec![], clients: vec![],
} }
@@ -276,6 +280,8 @@ impl AppConfig {
no_workspace_mcp: false, no_workspace_mcp: false,
no_workspace_macros: false, no_workspace_macros: false,
sync_models_url: config.sync_models_url, sync_models_url: config.sync_models_url,
sync_models_auto: config.sync_models_auto,
sync_models_ttl_hours: config.sync_models_ttl_hours,
clients: config.clients, clients: config.clients,
}; };
@@ -388,10 +394,8 @@ impl AppConfig {
.ok_or_else(|| anyhow!("Editor not found. Please add the `editor` configuration or set the $EDITOR or $VISUAL environment variable.")) .ok_or_else(|| anyhow!("Editor not found. Please add the `editor` configuration or set the $EDITOR or $VISUAL environment variable."))
} }
pub fn sync_models_url(&self) -> String { pub fn sync_models_url(&self) -> Option<String> {
self.sync_models_url self.sync_models_url.clone()
.clone()
.unwrap_or_else(|| super::SYNC_MODELS_URL.into())
} }
pub fn light_theme(&self) -> bool { pub fn light_theme(&self) -> bool {
@@ -636,7 +640,10 @@ impl AppConfig {
if self.highlight && self.theme.is_none() { if self.highlight && self.theme.is_none() {
if let Some(v) = super::read_env_value::<String>(&get_env_name("theme")) { if let Some(v) = super::read_env_value::<String>(&get_env_name("theme")) {
self.theme = v; self.theme = v;
} else if *IS_STDOUT_TERMINAL { } else if !cfg!(test) && *IS_STDOUT_TERMINAL {
// Never query the terminal from test builds: the test harness
// shares the process TTY, so parallel raw-mode color queries
// garble the runner's output and stall on stolen replies.
if let Ok(color_scheme) = color_scheme(QueryOptions::default()) { if let Ok(color_scheme) = color_scheme(QueryOptions::default()) {
let theme = match color_scheme { let theme = match color_scheme {
ColorScheme::Dark => "dark", ColorScheme::Dark => "dark",
@@ -665,6 +672,13 @@ impl AppConfig {
if let Some(v) = super::read_env_value::<String>(&get_env_name("sync_models_url")) { if let Some(v) = super::read_env_value::<String>(&get_env_name("sync_models_url")) {
self.sync_models_url = v; self.sync_models_url = v;
} }
if let Some(Some(v)) = super::read_env_bool(&get_env_name("sync_models_auto")) {
self.sync_models_auto = v;
}
if let Some(Some(v)) = super::read_env_value::<u64>(&get_env_name("sync_models_ttl_hours"))
{
self.sync_models_ttl_hours = v;
}
} }
} }
@@ -951,10 +965,70 @@ mod tests {
} }
#[test] #[test]
fn sync_models_url_has_default() { fn sync_models_url_defaults_to_none() {
let app = AppConfig::default(); let app = AppConfig::default();
let url = app.sync_models_url(); assert!(app.sync_models_url().is_none());
assert!(!url.is_empty()); }
#[test]
fn sync_models_auto_and_ttl_have_defaults() {
let app = AppConfig::default();
assert!(app.sync_models_auto);
assert_eq!(app.sync_models_ttl_hours, 24);
}
#[test]
#[serial_test::serial]
fn load_envs_overrides_sync_models_auto() {
let env_name = get_env_name("sync_models_auto");
let prev = env::var_os(&env_name);
let mut app = AppConfig::default();
unsafe { env::set_var(&env_name, "false") };
app.load_envs();
assert!(!app.sync_models_auto);
unsafe { env::set_var(&env_name, "true") };
app.load_envs();
assert!(app.sync_models_auto);
unsafe { env::remove_var(&env_name) };
app.sync_models_auto = false;
app.load_envs();
assert!(!app.sync_models_auto);
unsafe {
match prev {
Some(v) => env::set_var(&env_name, v),
None => env::remove_var(&env_name),
}
}
}
#[test]
#[serial_test::serial]
fn load_envs_overrides_sync_models_ttl_hours() {
let env_name = get_env_name("sync_models_ttl_hours");
let prev = env::var_os(&env_name);
let mut app = AppConfig::default();
unsafe { env::set_var(&env_name, "6") };
app.load_envs();
assert_eq!(app.sync_models_ttl_hours, 6);
unsafe { env::remove_var(&env_name) };
app.sync_models_ttl_hours = 48;
app.load_envs();
assert_eq!(app.sync_models_ttl_hours, 48);
unsafe {
match prev {
Some(v) => env::set_var(&env_name, v),
None => env::remove_var(&env_name),
}
}
} }
#[test] #[test]
+260 -22
View File
@@ -69,8 +69,9 @@ pub use self::tool_scope::{
}; };
pub use self::update::run_self_update; pub use self::update::run_self_update;
use crate::client::{ use crate::client::{
self, ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS, self, ClientConfig, MODELS_YAML, MessageContentToolCalls, Model, ModelType,
ProviderModels, create_client_config, list_client_types, oauth, set_client_models_config, OPENAI_COMPATIBLE_PROVIDERS, ProviderModels, catalog, create_client_config, list_client_types,
oauth, set_client_models_config,
}; };
use crate::function::{FunctionDeclaration, Functions}; use crate::function::{FunctionDeclaration, Functions};
use crate::rag::Rag; use crate::rag::Rag;
@@ -93,12 +94,13 @@ use serde_json::json;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::LazyLock; use std::sync::LazyLock;
use std::{ use std::{
env, env, fs,
fs::{File, create_dir_all, read_dir, read_to_string}, fs::{File, create_dir_all, read_dir, read_to_string},
io::Write, io::Write,
path::{Path, PathBuf}, path::{Path, PathBuf},
process, process,
sync::{Arc, OnceLock}, sync::{Arc, OnceLock},
time::SystemTime,
}; };
pub const TEMP_ROLE_NAME: &str = "temp"; pub const TEMP_ROLE_NAME: &str = "temp";
@@ -187,9 +189,6 @@ const GITIGNORE_FILE_NAME: &str = ".gitignore";
const CLIENTS_FIELD: &str = "clients"; const CLIENTS_FIELD: &str = "clients";
const SYNC_MODELS_URL: &str =
"https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml";
const SUMMARIZATION_PROMPT: &str = const SUMMARIZATION_PROMPT: &str =
"Summarize the discussion briefly in 200 words or less to use as a prompt for future context."; "Summarize the discussion briefly in 200 words or less to use as a prompt for future context.";
const SUMMARY_CONTEXT_PROMPT: &str = "This is a summary of the chat history as a recap: "; const SUMMARY_CONTEXT_PROMPT: &str = "This is a summary of the chat history as a recap: ";
@@ -287,6 +286,8 @@ pub struct Config {
pub user_agent: Option<String>, pub user_agent: Option<String>,
pub save_shell_history: bool, pub save_shell_history: bool,
pub sync_models_url: Option<String>, pub sync_models_url: Option<String>,
pub sync_models_auto: bool,
pub sync_models_ttl_hours: u64,
pub clients: Vec<ClientConfig>, pub clients: Vec<ClientConfig>,
} }
@@ -370,6 +371,8 @@ impl Default for Config {
user_agent: None, user_agent: None,
save_shell_history: true, save_shell_history: true,
sync_models_url: None, sync_models_url: None,
sync_models_auto: true,
sync_models_ttl_hours: 24,
clients: vec![], clients: vec![],
} }
@@ -513,26 +516,163 @@ pub fn list_sessions() -> Vec<String> {
list_file_names(default_sessions_dir(), ".yaml") list_file_names(default_sessions_dir(), ".yaml")
} }
pub async fn sync_models(url: &str, abort_signal: AbortSignal) -> Result<()> { /// Refreshes the models override. With a URL, mirrors a full models.yaml from
let content = abortable_run_with_spinner(fetch(url), "Fetching models.yaml", abort_signal) /// it; without one, rebuilds the catalog from the live models.dev + OpenRouter
.await /// APIs merged with the embedded quirks.
.with_context(|| format!("Failed to fetch '{url}'"))?; pub async fn sync_models(url: Option<&str>, abort_signal: AbortSignal) -> Result<()> {
println!("✓ Fetched '{url}'"); let list = match url {
let list = serde_yaml::from_str::<Vec<ProviderModels>>(&content) Some(url) => {
.with_context(|| "Failed to parse models.yaml")?; let content =
abortable_run_with_spinner(fetch(url), "Fetching models.yaml", abort_signal)
.await
.with_context(|| format!("Failed to fetch '{url}'"))?;
println!("✓ Fetched '{url}'");
parse_mirrored_models(&content)?
}
None => {
let sources = abortable_run_with_spinner(
async { Ok(catalog::fetch_sources().await) },
"Fetching model data",
abort_signal,
)
.await?;
let (list, warnings) = build_refreshed_catalog(&sources)?;
for warning in &warnings {
println!("{} {warning}", warning_text("WARNING:"));
}
list
}
};
let model_override_path = write_models_override(list)?;
println!("✓ Updated '{}'", model_override_path.display());
Ok(())
}
pub async fn sync_models_quiet(url: Option<&str>) -> Result<()> {
let list = match url {
Some(url) => {
let content = fetch(url)
.await
.with_context(|| format!("Failed to fetch '{url}'"))?;
parse_mirrored_models(&content)?
}
None => {
let sources = catalog::fetch_sources().await;
let (list, warnings) = build_refreshed_catalog(&sources)?;
for warning in warnings {
debug!("{warning}");
}
list
}
};
write_models_override(list)?;
Ok(())
}
fn parse_mirrored_models(content: &str) -> Result<Vec<ProviderModels>> {
serde_yaml::from_str::<Vec<ProviderModels>>(content)
.with_context(|| "Failed to parse models.yaml")
}
fn build_refreshed_catalog(
sources: &catalog::FetchedSources,
) -> Result<(Vec<ProviderModels>, Vec<String>)> {
if sources.modelsdev.is_none() && sources.openrouter.is_none() {
bail!("Failed to fetch model data from both models.dev and OpenRouter");
}
let mut warnings = Vec::new();
if sources.modelsdev.is_none() {
warnings.push("Failed to fetch models.dev; continuing with OpenRouter data only".into());
}
if sources.openrouter.is_none() {
warnings.push("Failed to fetch OpenRouter; continuing with models.dev data only".into());
}
let mut build = catalog::build_catalog(sources, &catalog::ALL_QUIRKS);
if !build.degraded.is_empty() {
warnings.push(format!(
"No live API data for: {}; keeping their embedded models",
build.degraded.join(", ")
));
}
if !build.failed.is_empty() {
warnings.push(format!(
"No models resolved for: {}; keeping their embedded models",
build.failed.join(", ")
));
}
let embedded = serde_yaml::from_str::<Vec<ProviderModels>>(MODELS_YAML)
.with_context(|| "Failed to parse the embedded models.yaml")?;
catalog::backfill_embedded_floor(&mut build, &embedded);
if build.providers.is_empty() {
bail!("Model sync produced no providers");
}
Ok((build.providers, warnings))
}
fn write_models_override(list: Vec<ProviderModels>) -> Result<PathBuf> {
let models_override = ModelsOverride { let models_override = ModelsOverride {
version: env!("CARGO_PKG_VERSION").to_string(), version: env!("CARGO_PKG_VERSION").to_string(),
list, list,
}; };
let models_override_data = let models_override_data = serde_yaml::to_string(&models_override)
serde_yaml::to_string(&models_override).with_context(|| "Failed to serde {}")?; .with_context(|| "Failed to serialize the models override")?;
let model_override_path = paths::models_override_file(); let model_override_path = paths::models_override_file();
ensure_parent_exists(&model_override_path)?; ensure_parent_exists(&model_override_path)?;
std::fs::write(&model_override_path, models_override_data) // Write through a sibling temp file and rename it over the target so an
.with_context(|| format!("Failed to write to '{}'", model_override_path.display()))?; // interrupted write can never leave a truncated override behind. The temp
println!("✓ Updated '{}'", model_override_path.display()); // name embeds the pid so concurrent coyote processes (auto-refresh racing
Ok(()) // a manual --sync-models) never interleave writes into the same file.
let mut tmp_name = model_override_path.as_os_str().to_os_string();
tmp_name.push(format!(".{}.tmp", process::id()));
let tmp_path = PathBuf::from(tmp_name);
fs::write(&tmp_path, models_override_data)
.with_context(|| format!("Failed to write to '{}'", tmp_path.display()))?;
if let Err(error) = fs::rename(&tmp_path, &model_override_path) {
let _ = fs::remove_file(&tmp_path);
return Err(error)
.with_context(|| format!("Failed to write to '{}'", model_override_path.display()));
}
Ok(model_override_path)
}
fn models_override_is_stale(loadable: bool, mtime: Option<SystemTime>, ttl_hours: u64) -> bool {
if !loadable {
return true;
}
let Some(mtime) = mtime else {
return true;
};
match SystemTime::now().duration_since(mtime) {
Ok(age) => age.as_secs() > ttl_hours.saturating_mul(3600),
Err(_) => false,
}
}
pub fn maybe_spawn_models_refresh(app: &AppConfig) {
if !app.sync_models_auto || app.dry_run {
return;
}
let ttl_hours = app.sync_models_ttl_hours;
let url = app.sync_models_url.clone();
tokio::spawn(async move {
// The loadable probe deserializes the whole override catalog; keep it
// off the startup path.
let loadable = paths::local_models_override().is_ok();
let mtime = fs::metadata(paths::models_override_file())
.and_then(|metadata| metadata.modified())
.ok();
if !models_override_is_stale(loadable, mtime, ttl_hours) {
return;
}
if let Err(err) = sync_models_quiet(url.as_deref()).await {
debug!("Background models refresh failed: {err}");
}
});
} }
impl Config { impl Config {
@@ -890,13 +1030,13 @@ fn render_config_template_from(
fn write_config_file(config_path: &Path, config_data: &str) -> Result<()> { fn write_config_file(config_path: &Path, config_data: &str) -> Result<()> {
ensure_parent_exists(config_path)?; ensure_parent_exists(config_path)?;
std::fs::write(config_path, config_data) fs::write(config_path, config_data)
.with_context(|| format!("Failed to write to '{}'", config_path.display()))?; .with_context(|| format!("Failed to write to '{}'", config_path.display()))?;
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::prelude::PermissionsExt; use std::os::unix::prelude::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600); let perms = fs::Permissions::from_mode(0o600);
std::fs::set_permissions(config_path, perms)?; fs::set_permissions(config_path, perms)?;
} }
println!("✓ Saved the config file to '{}'.\n", config_path.display()); println!("✓ Saved the config file to '{}'.\n", config_path.display());
@@ -1381,4 +1521,102 @@ clients:
"placeholder should be preserved as a literal string in sandbox mode" "placeholder should be preserved as a literal string in sandbox mode"
); );
} }
#[test]
fn unloadable_override_is_stale() {
assert!(models_override_is_stale(false, Some(SystemTime::now()), 24));
assert!(models_override_is_stale(false, None, 24));
}
#[test]
fn unreadable_mtime_is_stale() {
assert!(models_override_is_stale(true, None, 24));
}
#[test]
fn fresh_loadable_override_is_not_stale() {
assert!(!models_override_is_stale(true, Some(SystemTime::now()), 24));
}
#[test]
fn ttl_boundary_controls_staleness() {
let ttl_hours = 24;
let ttl = std::time::Duration::from_secs(ttl_hours * 3600);
let margin = std::time::Duration::from_secs(300);
let just_fresh = SystemTime::now() - (ttl - margin);
let just_stale = SystemTime::now() - (ttl + margin);
assert!(!models_override_is_stale(true, Some(just_fresh), ttl_hours));
assert!(models_override_is_stale(true, Some(just_stale), ttl_hours));
}
#[test]
fn future_mtime_is_not_stale() {
let future = SystemTime::now() + std::time::Duration::from_secs(3600);
assert!(!models_override_is_stale(true, Some(future), 24));
}
#[test]
#[serial_test::serial]
fn models_override_loading_and_atomic_write() {
use std::fs;
let unique = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let tmp_dir = env::temp_dir().join(format!("coyote-models-override-{unique}"));
create_dir_all(&tmp_dir).unwrap();
let env_name = get_env_name("config_dir");
let prev = env::var_os(&env_name);
unsafe { env::set_var(&env_name, &tmp_dir) };
let result = std::panic::catch_unwind(|| {
let path = paths::models_override_file();
let entry = || ProviderModels {
provider: "openai".into(),
oauth: None,
models: vec![client::ModelData::new("gpt-test")],
};
// Missing override is unloadable.
assert!(paths::local_models_override().is_err());
// Corrupt override is unloadable.
fs::write(&path, "not: [valid").unwrap();
assert!(paths::local_models_override().is_err());
// Version-mismatched override is unloadable.
let mismatched = ModelsOverride {
version: "0.0.0".into(),
list: vec![entry()],
};
fs::write(&path, serde_yaml::to_string(&mismatched).unwrap()).unwrap();
assert!(paths::local_models_override().is_err());
// write_models_override goes through the sibling temp file and
// leaves a loadable override behind.
let written = write_models_override(vec![entry()]).unwrap();
assert_eq!(written, path);
let mut tmp_name = path.as_os_str().to_os_string();
tmp_name.push(format!(".{}.tmp", std::process::id()));
assert!(!PathBuf::from(tmp_name).exists());
let loaded = paths::local_models_override().unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].provider, "openai");
});
unsafe {
match prev {
Some(v) => env::set_var(&env_name, v),
None => env::remove_var(&env_name),
}
}
let _ = fs::remove_dir_all(&tmp_dir);
if let Err(panic) = result {
std::panic::resume_unwind(panic);
}
}
} }
+19 -2
View File
@@ -338,6 +338,8 @@ pub struct RequestContext {
pub last_continuation_response: Option<String>, pub last_continuation_response: Option<String>,
pub pending_prefill: Option<String>, pub pending_prefill: Option<String>,
pub session_abort: Option<AbortSignal>,
pub render_mode: RenderMode, pub render_mode: RenderMode,
} }
@@ -375,6 +377,7 @@ impl RequestContext {
skill_registry: SkillRegistry::default(), skill_registry: SkillRegistry::default(),
last_continuation_response: None, last_continuation_response: None,
pending_prefill: None, pending_prefill: None,
session_abort: None,
render_mode: RenderMode::default(), render_mode: RenderMode::default(),
} }
} }
@@ -439,6 +442,7 @@ impl RequestContext {
skill_registry: SkillRegistry::default(), skill_registry: SkillRegistry::default(),
last_continuation_response: None, last_continuation_response: None,
pending_prefill: None, pending_prefill: None,
session_abort: None,
render_mode: RenderMode::default(), render_mode: RenderMode::default(),
}; };
ctx.refresh_mcp_tool_filters(); ctx.refresh_mcp_tool_filters();
@@ -492,6 +496,7 @@ impl RequestContext {
skill_registry: self.skill_registry.clone(), skill_registry: self.skill_registry.clone(),
last_continuation_response: None, last_continuation_response: None,
pending_prefill: None, pending_prefill: None,
session_abort: self.session_abort.clone(),
render_mode: self.render_mode, render_mode: self.render_mode,
} }
} }
@@ -541,6 +546,7 @@ impl RequestContext {
skill_registry: SkillRegistry::default(), skill_registry: SkillRegistry::default(),
last_continuation_response: None, last_continuation_response: None,
pending_prefill: None, pending_prefill: None,
session_abort: None,
render_mode: parent.render_mode, render_mode: parent.render_mode,
} }
} }
@@ -5232,7 +5238,7 @@ mod tests {
// with an empty client list, which would permanently pin every later // with an empty client list, which would permanently pin every later
// model lookup in this process to "unknown model" and make any test that // model lookup in this process to "unknown model" and make any test that
// needs a resolvable model dependent on test ordering. Seed the caches // needs a resolvable model dependent on test ordering. Seed the caches
// before any test runs with one client, "test-seeded", exposing a single // before any test runs with a client, "test-seeded", exposing a single
// embedding model. Deliberately NO chat model: some tests assert that no // embedding model. Deliberately NO chat model: some tests assert that no
// chat model is available, and chat lookups don't need one — a // chat model is available, and chat lookups don't need one — a
// "test-seeded:<anything>" chat id resolves through the create-from-name // "test-seeded:<anything>" chat id resolves through the create-from-name
@@ -5252,8 +5258,19 @@ mod tests {
embedder.model_type = "embedding".to_string(); embedder.model_type = "embedding".to_string();
config.models = vec![embedder]; config.models = vec![embedder];
} }
// A claude client is seeded alongside it so tests can exercise
// provider-quirk inheritance through the create-from-name fallback.
// It deliberately carries one embedding model: an empty `models` list
// would fall back to the full embedded claude catalog and register
// real chat models, breaking the no-chat-model invariant above.
// The test-dual-model pair shares one id across two types, mirroring
// jina:jina-colbert-v2, for the type-aware lookup tests.
let claude: ClientConfig = serde_yaml::from_str(
"type: claude\nmodels:\n - name: test-claude-embedder\n type: embedding\n - name: test-dual-model\n type: embedding\n - name: test-dual-model\n type: reranker\n",
)
.unwrap();
let config = AppConfig { let config = AppConfig {
clients: vec![client], clients: vec![client, claude],
..AppConfig::default() ..AppConfig::default()
}; };
let _ = list_client_names(&config); let _ = list_client_names(&config);
+88 -2
View File
@@ -7,7 +7,7 @@ use crate::config::{
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox}; use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox};
use crate::supervisor::notification::agent_notification; use crate::supervisor::notification::agent_notification;
use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor, TaskKind}; use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor, TaskKind};
use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal}; use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal, wait_user_interrupt};
use crate::graph; use crate::graph;
use anyhow::{Context, Result, anyhow, bail}; use anyhow::{Context, Result, anyhow, bail};
@@ -1031,6 +1031,14 @@ async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result<Value>
sup.abort_signal_for(id) sup.abort_signal_for(id)
}; };
let interrupted = || {
json!({
"status": "interrupted",
"id": id,
"message": format!("agent__collect was interrupted by the user; agent '{id}' is still running in the background. Collect it again later, cancel it with agent__cancel, or continue with other work."),
})
};
loop { loop {
let is_finished = { let is_finished = {
let sup = supervisor.read(); let sup = supervisor.read();
@@ -1041,6 +1049,10 @@ async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result<Value>
break; break;
} }
if ctx.session_abort.as_ref().is_some_and(|s| s.aborted()) {
return Ok(interrupted());
}
if let Some(queue) = ctx.root_escalation_queue() if let Some(queue) = ctx.root_escalation_queue()
&& queue.has_pending() && queue.has_pending()
{ {
@@ -1068,10 +1080,18 @@ async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result<Value>
tokio::select! { tokio::select! {
_ = time::sleep(Duration::from_millis(200)) => {} _ = time::sleep(Duration::from_millis(200)) => {}
_ = wait_abort_signal(abort) => {} _ = wait_abort_signal(abort) => {}
_ = wait_user_interrupt(ctx.session_abort.as_ref()) => {
return Ok(interrupted());
}
} }
} }
None => { None => {
time::sleep(Duration::from_millis(200)).await; tokio::select! {
_ = time::sleep(Duration::from_millis(200)) => {}
_ = wait_user_interrupt(ctx.session_abort.as_ref()) => {
return Ok(interrupted());
}
}
} }
} }
} }
@@ -2557,6 +2577,72 @@ mod tests {
); );
} }
#[test]
fn handle_collect_session_interrupt_returns_and_keeps_agent() {
run_async(async {
let mut ctx = ctx_with_supervisor(4, 3);
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let join_handle = tokio::spawn(async move {
let _ = rx.await;
Ok(AgentResult {
id: "a1".to_string(),
agent_name: "explore".to_string(),
output: "late output".to_string(),
exit_status: AgentExitStatus::Completed,
})
});
let handle = AgentHandle {
id: "a1".to_string(),
agent_name: "explore".to_string(),
depth: 1,
inbox: Arc::new(Inbox::new()),
abort_signal: create_abort_signal(),
join_handle,
child_supervisor: None,
};
ctx.supervisor
.as_ref()
.unwrap()
.write()
.register(handle)
.unwrap();
let session = create_abort_signal();
session.set_ctrlc();
ctx.session_abort = Some(session.clone());
let result = time::timeout(
Duration::from_secs(2),
handle_collect(&mut ctx, &json!({"id": "a1"})),
)
.await
.expect("interrupted collect must return promptly")
.unwrap();
assert_eq!(result["status"], "interrupted");
assert_eq!(result["id"], "a1");
assert!(
result["message"]
.as_str()
.unwrap()
.contains("still running")
);
assert_eq!(
ctx.supervisor.as_ref().unwrap().read().is_finished("a1"),
Some(false)
);
session.reset();
tx.send(()).unwrap();
let collected = handle_collect(&mut ctx, &json!({"id": "a1"}))
.await
.unwrap();
assert_eq!(collected["status"], "completed");
assert_eq!(collected["output"], "late output");
});
}
#[test] #[test]
fn handle_collect_pending_escalations_early_out_keeps_handle() { fn handle_collect_pending_escalations_early_out_keeps_handle() {
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread()
+86 -1
View File
@@ -16,7 +16,9 @@ use crate::mcp::{
}; };
use crate::supervisor::notification::job_notification; use crate::supervisor::notification::job_notification;
use crate::supervisor::{JobHandle, JobResult, JobState, JobStatus, Supervisor}; use crate::supervisor::{JobHandle, JobResult, JobState, JobStatus, Supervisor};
use crate::utils::{create_abort_signal, muted_warning_text, temp_file, wait_abort_signal}; use crate::utils::{
create_abort_signal, muted_warning_text, temp_file, wait_abort_signal, wait_user_interrupt,
};
use anyhow::{Context, Result, anyhow, bail}; use anyhow::{Context, Result, anyhow, bail};
use indexmap::IndexMap; use indexmap::IndexMap;
@@ -623,6 +625,14 @@ async fn handle_collect(ctx: &RequestContext, args: &Value) -> Result<Value> {
job.abort_signal.clone() job.abort_signal.clone()
}; };
let interrupted = || {
json!({
"status": "interrupted",
"id": id,
"message": format!("job__collect was interrupted by the user; job '{id}' is still running in the background. Collect it again later, cancel it with job__cancel, or continue with other work."),
})
};
loop { loop {
let is_finished = { let is_finished = {
let sup = supervisor.read(); let sup = supervisor.read();
@@ -633,6 +643,10 @@ async fn handle_collect(ctx: &RequestContext, args: &Value) -> Result<Value> {
break; break;
} }
if ctx.session_abort.as_ref().is_some_and(|s| s.aborted()) {
return Ok(interrupted());
}
if let Some(queue) = ctx.root_escalation_queue() if let Some(queue) = ctx.root_escalation_queue()
&& queue.has_pending() && queue.has_pending()
{ {
@@ -666,6 +680,9 @@ async fn handle_collect(ctx: &RequestContext, args: &Value) -> Result<Value> {
tokio::select! { tokio::select! {
_ = time::sleep(Duration::from_millis(200)) => {} _ = time::sleep(Duration::from_millis(200)) => {}
_ = wait_abort_signal(&target_abort) => {} _ = wait_abort_signal(&target_abort) => {}
_ = wait_user_interrupt(ctx.session_abort.as_ref()) => {
return Ok(interrupted());
}
} }
} }
@@ -2140,6 +2157,74 @@ mod tests {
}); });
} }
#[test]
fn handle_collect_session_interrupt_returns_and_keeps_job() {
run_async(async {
let mut ctx = ctx_with_job_supervisor(4);
let state = Arc::new(Mutex::new(JobState {
status: JobStatus::Running,
pgid: None,
}));
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let task_state = Arc::clone(&state);
let join_handle = tokio::spawn(async move {
let _ = rx.await;
task_state.lock().status = JobStatus::Completed;
Ok(JobResult {
output: json!("late result"),
exit_code: Some(0),
output_bytes_captured: 0,
})
});
let handle = JobHandle {
id: "j1".to_string(),
tool: "execute_command".to_string(),
started_at: Instant::now(),
join_handle,
abort_signal: create_abort_signal(),
state,
output_buf: Arc::new(Mutex::new(RingBuf::default())),
no_change_checks: 0,
last_check_state: None,
};
ctx.supervisor
.as_ref()
.unwrap()
.write()
.register(handle)
.unwrap();
let session = create_abort_signal();
session.set_ctrlc();
ctx.session_abort = Some(session.clone());
let result = time::timeout(
Duration::from_secs(2),
handle_collect(&ctx, &json!({"id": "j1"})),
)
.await
.expect("interrupted collect must return promptly")
.unwrap();
assert_eq!(result["status"], "interrupted");
assert_eq!(result["id"], "j1");
assert!(
result["message"]
.as_str()
.unwrap()
.contains("still running")
);
assert!(ctx.supervisor.as_ref().unwrap().read().has_job("j1"));
session.reset();
tx.send(()).unwrap();
let collected = handle_collect(&ctx, &json!({"id": "j1"})).await.unwrap();
assert_eq!(collected["status"], "completed");
assert_eq!(collected["result"], "late result");
});
}
#[test] #[test]
fn handle_collect_full_result_returns_uncapped() { fn handle_collect_full_result_returns_uncapped() {
run_async(async { run_async(async {
+1
View File
@@ -695,6 +695,7 @@ nodes:
type: script type: script
script: doubler.py script: doubler.py
state_updates: {} state_updates: {}
timeout: 60
done: done:
type: end type: end
output: "{{doubled}}" output: "{{doubled}}"
+14 -8
View File
@@ -262,13 +262,16 @@ echo '{"quality": 0.85, "issues": 3, "_next": "approve"}'
#[tokio::test] #[tokio::test]
async fn bash_script_can_read_state_from_env() { async fn bash_script_can_read_state_from_env() {
if !cmd_available("bash") || !cmd_available("python3") { if !cmd_available("bash") {
eprintln!("skipping: bash or python3 not available"); eprintln!("skipping: bash not available");
return; return;
} }
// Pure-bash JSON extraction: spawning python3 here blew tight
// timeouts on Windows CI (first-start AV scans of the interpreter).
let (dir, path) = write_script( let (dir, path) = write_script(
r#"#!/bin/bash r#"#!/bin/bash
NAME=$(python3 -c 'import json,os; print(json.loads(os.environ["GRAPH_STATE"])["name"])') t=${GRAPH_STATE#*'"name":"'}
NAME=${t%%'"'*}
printf '{"greeting": "hello %s"}' "$NAME" printf '{"greeting": "hello %s"}' "$NAME"
"#, "#,
"sh", "sh",
@@ -280,7 +283,7 @@ printf '{"greeting": "hello %s"}' "$NAME"
let _ = executor let _ = executor
.execute( .execute(
&node_for(path.file_name().unwrap().to_str().unwrap(), 5), &node_for(path.file_name().unwrap().to_str().unwrap(), 30),
&mut state, &mut state,
) )
.await .await
@@ -466,7 +469,7 @@ echo '{"ok":true}'
#[tokio::test] #[tokio::test]
async fn large_state_is_delivered_via_file_env_var() { async fn large_state_is_delivered_via_file_env_var() {
if !cmd_available("bash") || !cmd_available("python3") { if !cmd_available("bash") {
return; return;
} }
let big = "x".repeat(MAX_STATE_SIZE_BYTES + 1024); let big = "x".repeat(MAX_STATE_SIZE_BYTES + 1024);
@@ -476,7 +479,10 @@ echo '{"ok":true}'
let (dir, path) = write_script( let (dir, path) = write_script(
r#"#!/bin/bash r#"#!/bin/bash
if [ -n "$GRAPH_STATE_FILE" ]; then if [ -n "$GRAPH_STATE_FILE" ]; then
LEN=$(python3 -c 'import json,os; print(len(json.load(open(os.environ["GRAPH_STATE_FILE"]))["blob"]))') CONTENT=$(cat "$GRAPH_STATE_FILE")
t=${CONTENT#*'"blob":"'}
BLOB=${t%%'"'*}
LEN=${#BLOB}
printf '{"blob_len": %s, "via_file": true}' "$LEN" printf '{"blob_len": %s, "via_file": true}' "$LEN"
elif [ -n "$GRAPH_STATE" ]; then elif [ -n "$GRAPH_STATE" ]; then
echo '{"via_file": false}' echo '{"via_file": false}'
@@ -489,7 +495,7 @@ fi
let executor = ScriptExecutor::new(&dir); let executor = ScriptExecutor::new(&dir);
executor executor
.execute( .execute(
&node_for(path.file_name().unwrap().to_str().unwrap(), 10), &node_for(path.file_name().unwrap().to_str().unwrap(), 30),
&mut state, &mut state,
) )
.await .await
@@ -524,7 +530,7 @@ print(json.dumps({
let executor = ScriptExecutor::new(&dir); let executor = ScriptExecutor::new(&dir);
let next = executor let next = executor
.execute( .execute(
&node_for(path.file_name().unwrap().to_str().unwrap(), 5), &node_for(path.file_name().unwrap().to_str().unwrap(), 60),
&mut state, &mut state,
) )
.await .await
+11 -3
View File
@@ -20,13 +20,14 @@ extern crate log;
use crate::cli::Cli; use crate::cli::Cli;
use crate::client::{ use crate::client::{
ModelType, call_chat_completions, call_chat_completions_streaming, list_models, oauth, ModelType, call_chat_completions, call_chat_completions_streaming, catalog, list_models, oauth,
}; };
use crate::config::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME; use crate::config::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME;
use crate::config::{ use crate::config::{
Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope, Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope,
RenderMode, RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists, RenderMode, RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists,
install_builtins, list_agents, load_env_file, macro_execute, sync_models, install_builtins, list_agents, load_env_file, macro_execute, maybe_spawn_models_refresh,
sync_models,
}; };
use crate::config::{memory, paths}; use crate::config::{memory, paths};
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail}; use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
@@ -101,6 +102,7 @@ async fn main() -> Result<()> {
let info_flag = cli.info let info_flag = cli.info
|| cli.sync_models || cli.sync_models
|| cli.generate_models.is_some()
|| cli.list_models || cli.list_models
|| cli.list_roles || cli.list_roles
|| cli.list_agents || cli.list_agents
@@ -292,7 +294,11 @@ async fn run(
) -> Result<()> { ) -> Result<()> {
if cli.sync_models { if cli.sync_models {
let url = ctx.app.config.sync_models_url(); let url = ctx.app.config.sync_models_url();
return sync_models(&url, abort_signal.clone()).await; return sync_models(url.as_deref(), abort_signal.clone()).await;
}
if let Some(path) = &cli.generate_models {
return catalog::generate_models_file(path).await;
} }
if cli.list_models { if cli.list_models {
@@ -544,6 +550,7 @@ async fn run(
if !*IS_STDOUT_TERMINAL { if !*IS_STDOUT_TERMINAL {
bail!("No TTY for REPL") bail!("No TTY for REPL")
} }
maybe_spawn_models_refresh(&ctx.app.config);
start_interactive(ctx).await start_interactive(ctx).await
} }
} }
@@ -557,6 +564,7 @@ async fn start_directive(
abort_signal: AbortSignal, abort_signal: AbortSignal,
) -> Result<()> { ) -> Result<()> {
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config); let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
ctx.session_abort = Some(abort_signal.clone());
if graph::active_agent_graph_name(ctx).is_some() { if graph::active_agent_graph_name(ctx).is_some() {
ctx.before_chat_completion(&input)?; ctx.before_chat_completion(&input)?;
+1
View File
@@ -1432,6 +1432,7 @@ async fn ask(
if input.is_empty() { if input.is_empty() {
return Ok(()); return Ok(());
} }
ctx.session_abort = Some(abort_signal.clone());
if with_embeddings { if with_embeddings {
input.use_embeddings(abort_signal.clone()).await?; input.use_embeddings(abort_signal.clone()).await?;
} }
+38
View File
@@ -68,6 +68,29 @@ pub async fn wait_abort_signal(abort_signal: &AbortSignal) {
} }
} }
/// Completes when the user interrupts: a SIGINT arrives, or the session's turn
/// signal is (or becomes) aborted. Needed because tools run in cooked mode --
/// tokio's global SIGINT handler (installed by the first `ctrl_c()` poll)
/// swallows the signal with nobody listening. On SIGINT, sets ctrl-c on the
/// session signal so the surrounding turn aborts too. With `None`, only the
/// SIGINT arm applies.
pub async fn wait_user_interrupt(session: Option<&AbortSignal>) {
let ctrl_c = async {
if tokio::signal::ctrl_c().await.is_err() {
std::future::pending::<()>().await;
}
};
match session {
Some(signal) => {
tokio::select! {
_ = ctrl_c => signal.set_ctrlc(),
_ = wait_abort_signal(signal) => {}
}
}
None => ctrl_c.await,
}
}
pub fn poll_abort_signal(abort_signal: &AbortSignal) -> Result<bool> { pub fn poll_abort_signal(abort_signal: &AbortSignal) -> Result<bool> {
if event::poll(Duration::from_millis(25))? if event::poll(Duration::from_millis(25))?
&& let Event::Key(key) = event::read()? && let Event::Key(key) = event::read()?
@@ -86,3 +109,18 @@ pub fn poll_abort_signal(abort_signal: &AbortSignal) -> Result<bool> {
} }
Ok(false) Ok(false)
} }
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn wait_user_interrupt_returns_promptly_on_preset_session_signal() {
let signal = create_abort_signal();
signal.set_ctrlc();
tokio::time::timeout(Duration::from_secs(1), wait_user_interrupt(Some(&signal)))
.await
.expect("must return promptly when the session signal is already set");
}
}