mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be57a7684d | ||
|
|
2016693b0c | ||
|
|
bcef04b821 | ||
|
|
6e12ce6f2d | ||
|
|
b53986f00b | ||
|
|
1440ec7422 |
@@ -2,7 +2,7 @@ name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, labeled]
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -28,6 +28,7 @@ jobs:
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
allowed_bots: "ironclaw-ci[bot]"
|
||||
claude_args: "--max-turns 50 --model claude-haiku-4-5-20251001 --allowedTools 'Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*),Bash(git blame:*),Bash(git log:*),Bash(git diff:*)'"
|
||||
prompt: |
|
||||
Code review this pull request. Follow these steps precisely:
|
||||
|
||||
@@ -44,6 +44,7 @@ jobs:
|
||||
|
||||
clippy-windows:
|
||||
name: Clippy Windows (${{ matrix.name }})
|
||||
if: github.base_ref == 'main'
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -76,7 +77,12 @@ jobs:
|
||||
needs: [format, clippy, clippy-windows]
|
||||
steps:
|
||||
- run: |
|
||||
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" || "${{ needs.clippy-windows.result }}" != "success" ]]; then
|
||||
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" ]]; then
|
||||
echo "One or more jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
# clippy-windows only runs on main PRs, so skip/success are both acceptable
|
||||
if [[ "${{ needs.clippy-windows.result }}" == "failure" ]]; then
|
||||
echo "Windows clippy failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -115,7 +115,6 @@ jobs:
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
if: ${{ secrets.GH_RELEASES_MANAGER_APP_ID != '' }}
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
|
||||
@@ -230,7 +229,6 @@ jobs:
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
if: ${{ secrets.GH_RELEASES_MANAGER_APP_ID != '' }}
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
|
||||
|
||||
@@ -2,6 +2,8 @@ name: Run Tests
|
||||
on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
@@ -7,6 +7,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.17.0](https://github.com/nearai/ironclaw/compare/v0.16.1...v0.17.0) - 2026-03-10
|
||||
|
||||
### Added
|
||||
|
||||
- *(llm)* per-provider unsupported parameter filtering (#749, #728) ([#809](https://github.com/nearai/ironclaw/pull/809))
|
||||
- persist user_id in save_job and expose job_id on routine runs ([#709](https://github.com/nearai/ironclaw/pull/709))
|
||||
- *(ci)* chained promotion PRs with multi-agent Claude review ([#776](https://github.com/nearai/ironclaw/pull/776))
|
||||
- add background sandbox reaper for orphaned Docker containers ([#634](https://github.com/nearai/ironclaw/pull/634))
|
||||
- *(wasm)* lazy schema injection on WASM tool errors ([#638](https://github.com/nearai/ironclaw/pull/638))
|
||||
- add AWS Bedrock LLM provider via native Converse API ([#713](https://github.com/nearai/ironclaw/pull/713))
|
||||
- full image support across all channels ([#725](https://github.com/nearai/ironclaw/pull/725))
|
||||
- *(skills)* exclude_keywords veto in skill activation scoring ([#688](https://github.com/nearai/ironclaw/pull/688))
|
||||
- *(mcp)* transport abstraction, stdio/UDS transports, and OAuth fixes ([#721](https://github.com/nearai/ironclaw/pull/721))
|
||||
- add PID-based gateway lock to prevent multiple instances ([#717](https://github.com/nearai/ironclaw/pull/717))
|
||||
- configurable LLM request timeout via LLM_REQUEST_TIMEOUT_SECS ([#615](https://github.com/nearai/ironclaw/pull/615)) ([#630](https://github.com/nearai/ironclaw/pull/630))
|
||||
- *(timezone)* add timezone-aware session context ([#671](https://github.com/nearai/ironclaw/pull/671))
|
||||
- *(setup)* Anthropic OAuth onboarding with setup-token support ([#384](https://github.com/nearai/ironclaw/pull/384))
|
||||
- *(llm)* add Google Gemini, AWS Bedrock, io.net, Mistral, Yandex, and Cloudflare WS AI providers ([#676](https://github.com/nearai/ironclaw/pull/676))
|
||||
- unified thread model for web gateway ([#607](https://github.com/nearai/ironclaw/pull/607))
|
||||
- WASM channel attachments with LLM pipeline integration ([#596](https://github.com/nearai/ironclaw/pull/596))
|
||||
- enable Anthropic prompt caching via automatic cache_control injection ([#660](https://github.com/nearai/ironclaw/pull/660))
|
||||
- *(routines)* approval context for autonomous job execution ([#577](https://github.com/nearai/ironclaw/pull/577))
|
||||
- *(llm)* declarative provider registry ([#618](https://github.com/nearai/ironclaw/pull/618))
|
||||
- *(gateway)* show IronClaw version in status popover [skip-regression-check] ([#636](https://github.com/nearai/ironclaw/pull/636))
|
||||
- Wire memory hygiene retention policy into heartbeat loop ([#629](https://github.com/nearai/ironclaw/pull/629))
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(ci)* run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] ([#802](https://github.com/nearai/ironclaw/pull/802))
|
||||
- *(ci)* clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] ([#794](https://github.com/nearai/ironclaw/pull/794))
|
||||
- *(ci)* secrets can't be used in step if conditions [skip-regression-check] ([#787](https://github.com/nearai/ironclaw/pull/787))
|
||||
- prevent irreversible context loss when compaction archive write fails ([#754](https://github.com/nearai/ironclaw/pull/754))
|
||||
- button styles ([#637](https://github.com/nearai/ironclaw/pull/637))
|
||||
- *(mcp)* JSON-RPC spec compliance — flexible id, correct notification format ([#685](https://github.com/nearai/ironclaw/pull/685))
|
||||
- preserve tool-call history across thread hydration ([#568](https://github.com/nearai/ironclaw/pull/568)) ([#670](https://github.com/nearai/ironclaw/pull/670))
|
||||
- CLI commands ignore runtime DATABASE_BACKEND when both features compiled ([#740](https://github.com/nearai/ironclaw/pull/740))
|
||||
- *(web)* prevent fetch error when hostname is an IP address in TEE check ([#672](https://github.com/nearai/ironclaw/pull/672))
|
||||
- add timezone conversion support to time tool ([#687](https://github.com/nearai/ironclaw/pull/687))
|
||||
- standardize libSQL timestamps as RFC 3339 UTC ([#683](https://github.com/nearai/ironclaw/pull/683))
|
||||
- *(docker)* bind postgres to localhost only ([#686](https://github.com/nearai/ironclaw/pull/686))
|
||||
- *(repl)* skip /quit on EOF when stdin is not a TTY ([#724](https://github.com/nearai/ironclaw/pull/724))
|
||||
- *(web)* prevent Enter key from sending message during IME composition ([#715](https://github.com/nearai/ironclaw/pull/715))
|
||||
- *(config)* init_secrets no longer overwrites entire config ([#726](https://github.com/nearai/ironclaw/pull/726))
|
||||
- *(cli)* status command ignores config.toml and settings.json ([#354](https://github.com/nearai/ironclaw/pull/354)) ([#734](https://github.com/nearai/ironclaw/pull/734))
|
||||
- *(setup)* preserve model name when re-running onboarding with same provider ([#600](https://github.com/nearai/ironclaw/pull/600)) ([#694](https://github.com/nearai/ironclaw/pull/694))
|
||||
- *(setup)* initialize secrets crypto for env-var security option ([#666](https://github.com/nearai/ironclaw/pull/666)) ([#706](https://github.com/nearai/ironclaw/pull/706))
|
||||
- persist /model selection across restarts ([#707](https://github.com/nearai/ironclaw/pull/707))
|
||||
- *(routines)* resolve message tool channel/target from per-job metadata ([#708](https://github.com/nearai/ironclaw/pull/708))
|
||||
- sanitize HTML error bodies from MCP servers to prevent web UI white screen ([#263](https://github.com/nearai/ironclaw/pull/263)) ([#656](https://github.com/nearai/ironclaw/pull/656))
|
||||
- prevent Instant duration overflow on Windows ([#657](https://github.com/nearai/ironclaw/pull/657)) ([#664](https://github.com/nearai/ironclaw/pull/664))
|
||||
- enable libsql remote + tls features for Turso cloud sync ([#587](https://github.com/nearai/ironclaw/pull/587))
|
||||
- *(tests)* replace hardcoded /tmp paths with tempdir + add 300 unit tests ([#659](https://github.com/nearai/ironclaw/pull/659))
|
||||
- *(llm)* nudge LLM when it expresses tool intent without calling tools ([#653](https://github.com/nearai/ironclaw/pull/653))
|
||||
- *(llm)* report zero cost for OpenRouter free-tier models ([#463](https://github.com/nearai/ironclaw/pull/463)) ([#613](https://github.com/nearai/ironclaw/pull/613))
|
||||
- reliable network tests and improved tool error messages ([#626](https://github.com/nearai/ironclaw/pull/626))
|
||||
- *(wasm)* use per-engine cache dirs on Windows to avoid file lock error ([#624](https://github.com/nearai/ironclaw/pull/624))
|
||||
- *(libsql)* support flexible embedding dimensions ([#534](https://github.com/nearai/ironclaw/pull/534))
|
||||
|
||||
### Other
|
||||
|
||||
- Restructure CLAUDE.md into modular rules + add pr-shepherd command ([#750](https://github.com/nearai/ironclaw/pull/750))
|
||||
- make src/llm/ self-contained for crate extraction ([#767](https://github.com/nearai/ironclaw/pull/767))
|
||||
- add simplified Chinese (zh-CN) README translation ([#488](https://github.com/nearai/ironclaw/pull/488))
|
||||
- *(job)* cover job tool validation and state transitions ([#681](https://github.com/nearai/ironclaw/pull/681))
|
||||
- *(agent)* wire TestRig job tools through the scheduler ([#716](https://github.com/nearai/ironclaw/pull/716))
|
||||
- Fix single-message mode to exit after one turn when background channels are enabled ([#719](https://github.com/nearai/ironclaw/pull/719))
|
||||
- remove dead code ([#648](https://github.com/nearai/ironclaw/pull/648)) ([#703](https://github.com/nearai/ironclaw/pull/703))
|
||||
- add reviewer-feedback guardrails (CLAUDE.md, pre-commit hook, skill) ([#665](https://github.com/nearai/ironclaw/pull/665))
|
||||
- update WASM artifact SHA256 checksums [skip ci] ([#631](https://github.com/nearai/ironclaw/pull/631))
|
||||
- add explanatory comments to coverage workflow ([#610](https://github.com/nearai/ironclaw/pull/610))
|
||||
- build system prompt once per turn, skip tools on force-text ([#583](https://github.com/nearai/ironclaw/pull/583))
|
||||
- add comprehensive subdirectory CLAUDE.md files and update root ([#589](https://github.com/nearai/ironclaw/pull/589))
|
||||
- Improve test infrastructure: StubChannel, gateway helpers, security tests, search edge cases ([#623](https://github.com/nearai/ironclaw/pull/623))
|
||||
- *(workspace)* regression test for document_path in search results ([#509](https://github.com/nearai/ironclaw/pull/509))
|
||||
|
||||
### Added
|
||||
|
||||
- AWS Bedrock LLM provider via native Converse API with IAM and SSO auth support (feature-gated: `--features bedrock`)
|
||||
|
||||
Generated
+1
-1
@@ -3350,7 +3350,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ exclude = [
|
||||
|
||||
[package]
|
||||
name = "ironclaw"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||
|
||||
+3
-1
@@ -9,8 +9,9 @@
|
||||
"api_key_required": true,
|
||||
"base_url_env": "OPENAI_BASE_URL",
|
||||
"model_env": "OPENAI_MODEL",
|
||||
"default_model": "gpt-4o",
|
||||
"default_model": "gpt-5-mini",
|
||||
"description": "OpenAI GPT models (direct API)",
|
||||
"unsupported_params": ["temperature"],
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_openai_api_key",
|
||||
@@ -86,6 +87,7 @@
|
||||
"model_env": "TINFOIL_MODEL",
|
||||
"default_model": "kimi-k2-5",
|
||||
"description": "Tinfoil private inference (hardware-attested TEE)",
|
||||
"unsupported_params": ["temperature"],
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_tinfoil_api_key",
|
||||
|
||||
@@ -108,6 +108,7 @@ pub async fn routines_detail_handler(
|
||||
status: format!("{:?}", run.status),
|
||||
result_summary: run.result_summary.clone(),
|
||||
tokens_used: run.tokens_used,
|
||||
job_id: run.job_id,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -252,6 +253,7 @@ pub async fn routines_runs_handler(
|
||||
status: format!("{:?}", run.status),
|
||||
result_summary: run.result_summary.clone(),
|
||||
tokens_used: run.tokens_used,
|
||||
job_id: run.job_id,
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -2017,6 +2017,7 @@ async fn routines_detail_handler(
|
||||
status: format!("{:?}", run.status),
|
||||
result_summary: run.result_summary.clone(),
|
||||
tokens_used: run.tokens_used,
|
||||
job_id: run.job_id,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -2169,6 +2170,7 @@ async fn routines_runs_handler(
|
||||
status: format!("{:?}", run.status),
|
||||
result_summary: run.result_summary.clone(),
|
||||
tokens_used: run.tokens_used,
|
||||
job_id: run.job_id,
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -776,6 +776,7 @@ pub struct RoutineRunInfo {
|
||||
pub status: String,
|
||||
pub result_summary: Option<String>,
|
||||
pub tokens_used: Option<i32>,
|
||||
pub job_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
|
||||
@@ -209,6 +209,7 @@ impl LlmConfig {
|
||||
extra_headers_env,
|
||||
api_key_required,
|
||||
base_url_required,
|
||||
unsupported_params,
|
||||
) = if let Some(def) = def {
|
||||
(
|
||||
def.id.as_str(),
|
||||
@@ -221,6 +222,7 @@ impl LlmConfig {
|
||||
def.extra_headers_env.as_deref(),
|
||||
def.api_key_required,
|
||||
def.base_url_required,
|
||||
def.unsupported_params.clone(),
|
||||
)
|
||||
} else {
|
||||
// Absolute fallback: treat as generic openai_completions
|
||||
@@ -235,6 +237,7 @@ impl LlmConfig {
|
||||
Some("LLM_EXTRA_HEADERS"),
|
||||
false,
|
||||
true,
|
||||
Vec::new(),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -338,6 +341,7 @@ impl LlmConfig {
|
||||
extra_headers,
|
||||
oauth_token,
|
||||
cache_retention,
|
||||
unsupported_params,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -624,6 +628,12 @@ mod tests {
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
assert_eq!(provider.base_url, "https://inference.tinfoil.sh/v1");
|
||||
assert_eq!(provider.model, "kimi-k2-5");
|
||||
assert!(
|
||||
provider
|
||||
.unsupported_params
|
||||
.contains(&"temperature".to_string()),
|
||||
"tinfoil should propagate unsupported_params from registry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -28,14 +28,16 @@ impl JobStore for LibSqlBackend {
|
||||
r#"
|
||||
INSERT INTO agent_jobs (
|
||||
id, conversation_id, title, description, category, status, source,
|
||||
user_id,
|
||||
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
|
||||
actual_cost, repair_attempts, created_at, started_at, completed_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
description = excluded.description,
|
||||
category = excluded.category,
|
||||
status = excluded.status,
|
||||
user_id = excluded.user_id,
|
||||
estimated_cost = excluded.estimated_cost,
|
||||
estimated_time_secs = excluded.estimated_time_secs,
|
||||
actual_cost = excluded.actual_cost,
|
||||
@@ -51,6 +53,7 @@ impl JobStore for LibSqlBackend {
|
||||
opt_text(ctx.category.as_deref()),
|
||||
status,
|
||||
"direct",
|
||||
ctx.user_id.as_str(),
|
||||
opt_text_owned(ctx.budget.map(|d| d.to_string())),
|
||||
opt_text(ctx.budget_token.as_deref()),
|
||||
opt_text_owned(ctx.bid_amount.map(|d| d.to_string())),
|
||||
|
||||
@@ -482,6 +482,24 @@ mod tests {
|
||||
assert_eq!(timeout, 5000);
|
||||
}
|
||||
|
||||
/// Regression test: save_job must persist user_id and get_job must return it.
|
||||
#[tokio::test]
|
||||
async fn test_save_job_persists_user_id() {
|
||||
use crate::context::JobContext;
|
||||
use crate::db::JobStore;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db_path = dir.path().join("test_user_id.db");
|
||||
let backend = LibSqlBackend::new_local(&db_path).await.unwrap();
|
||||
backend.run_migrations().await.unwrap();
|
||||
|
||||
let ctx = JobContext::with_user("test-user-42", "Test Job", "A test job");
|
||||
backend.save_job(&ctx).await.unwrap();
|
||||
|
||||
let loaded = backend.get_job(ctx.job_id).await.unwrap().unwrap();
|
||||
assert_eq!(loaded.user_id, "test-user-42");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_concurrent_writes_succeed() {
|
||||
// Use a temp file so connections share state (in-memory DBs are connection-local)
|
||||
|
||||
+36
-1
@@ -149,14 +149,16 @@ impl Store {
|
||||
r#"
|
||||
INSERT INTO agent_jobs (
|
||||
id, conversation_id, title, description, category, status, source,
|
||||
user_id,
|
||||
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
|
||||
actual_cost, repair_attempts, created_at, started_at, completed_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
category = EXCLUDED.category,
|
||||
status = EXCLUDED.status,
|
||||
user_id = EXCLUDED.user_id,
|
||||
estimated_cost = EXCLUDED.estimated_cost,
|
||||
estimated_time_secs = EXCLUDED.estimated_time_secs,
|
||||
actual_cost = EXCLUDED.actual_cost,
|
||||
@@ -172,6 +174,7 @@ impl Store {
|
||||
&ctx.category,
|
||||
&status,
|
||||
&"direct", // source
|
||||
&ctx.user_id,
|
||||
&ctx.budget,
|
||||
&ctx.budget_token,
|
||||
&ctx.bid_amount,
|
||||
@@ -2133,4 +2136,36 @@ mod tests {
|
||||
assert_eq!(summary.channel, ch);
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test: save_job must persist user_id and get_job must return it.
|
||||
/// Requires a running PostgreSQL instance (integration tier).
|
||||
#[cfg(feature = "postgres")]
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_save_job_persists_user_id() {
|
||||
use crate::config::Config;
|
||||
use crate::context::JobContext;
|
||||
|
||||
let _ = dotenvy::dotenv();
|
||||
let config = Config::from_env().await.expect("Failed to load config");
|
||||
let store = Store::new(&config.database)
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
store
|
||||
.run_migrations()
|
||||
.await
|
||||
.expect("Failed to run migrations");
|
||||
|
||||
let ctx = JobContext::with_user("test-user-42", "PG user_id test", "regression test");
|
||||
store.save_job(&ctx).await.unwrap();
|
||||
|
||||
let loaded = store.get_job(ctx.job_id).await.unwrap().unwrap();
|
||||
assert_eq!(loaded.user_id, "test-user-42");
|
||||
|
||||
// Clean up
|
||||
let conn = store.conn().await.unwrap();
|
||||
conn.execute("DELETE FROM agent_jobs WHERE id = $1", &[&ctx.job_id])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
//!
|
||||
//! Pattern follows `nearai_chat.rs`: direct HTTP calls via `reqwest::Client`.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use rust_decimal::Decimal;
|
||||
@@ -35,6 +37,8 @@ pub struct AnthropicOAuthProvider {
|
||||
model: String,
|
||||
base_url: Option<String>,
|
||||
active_model: std::sync::RwLock<String>,
|
||||
/// Parameter names that this provider does not support.
|
||||
unsupported_params: HashSet<String>,
|
||||
}
|
||||
|
||||
impl AnthropicOAuthProvider {
|
||||
@@ -61,15 +65,45 @@ impl AnthropicOAuthProvider {
|
||||
Some(config.base_url.clone())
|
||||
};
|
||||
|
||||
let unsupported_params: HashSet<String> =
|
||||
config.unsupported_params.iter().cloned().collect();
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
token,
|
||||
model: config.model.clone(),
|
||||
base_url,
|
||||
active_model,
|
||||
unsupported_params,
|
||||
})
|
||||
}
|
||||
|
||||
/// Strip unsupported fields from a `CompletionRequest` in place.
|
||||
fn strip_unsupported_completion_params(&self, req: &mut CompletionRequest) {
|
||||
if self.unsupported_params.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.unsupported_params.contains("temperature") {
|
||||
req.temperature = None;
|
||||
}
|
||||
if self.unsupported_params.contains("max_tokens") {
|
||||
req.max_tokens = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip unsupported fields from a `ToolCompletionRequest` in place.
|
||||
fn strip_unsupported_tool_params(&self, req: &mut ToolCompletionRequest) {
|
||||
if self.unsupported_params.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.unsupported_params.contains("temperature") {
|
||||
req.temperature = None;
|
||||
}
|
||||
if self.unsupported_params.contains("max_tokens") {
|
||||
req.max_tokens = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn api_url(&self) -> String {
|
||||
if let Some(ref base) = self.base_url {
|
||||
let base = base.trim_end_matches('/');
|
||||
@@ -197,8 +231,9 @@ impl AnthropicOAuthProvider {
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for AnthropicOAuthProvider {
|
||||
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
let model = req.model.unwrap_or_else(|| self.active_model_name());
|
||||
async fn complete(&self, mut req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
let model = req.model.take().unwrap_or_else(|| self.active_model_name());
|
||||
self.strip_unsupported_completion_params(&mut req);
|
||||
let (system, messages) = convert_messages(req.messages);
|
||||
|
||||
let request = AnthropicRequest {
|
||||
@@ -233,9 +268,10 @@ impl LlmProvider for AnthropicOAuthProvider {
|
||||
|
||||
async fn complete_with_tools(
|
||||
&self,
|
||||
req: ToolCompletionRequest,
|
||||
mut req: ToolCompletionRequest,
|
||||
) -> Result<ToolCompletionResponse, LlmError> {
|
||||
let model = req.model.unwrap_or_else(|| self.active_model_name());
|
||||
let model = req.model.take().unwrap_or_else(|| self.active_model_name());
|
||||
self.strip_unsupported_tool_params(&mut req);
|
||||
let (system, messages) = convert_messages(req.messages);
|
||||
|
||||
let tools: Vec<AnthropicTool> = req
|
||||
|
||||
@@ -87,6 +87,10 @@ pub struct RegistryProviderConfig {
|
||||
pub oauth_token: Option<SecretString>,
|
||||
/// Prompt cache retention (Anthropic-specific).
|
||||
pub cache_retention: CacheRetention,
|
||||
/// Parameter names that this provider does not support (e.g., `["temperature"]`).
|
||||
/// Supported keys: `"temperature"`, `"max_tokens"`, `"stop_sequences"`.
|
||||
/// Listed parameters are stripped from requests before sending to avoid 400 errors.
|
||||
pub unsupported_params: Vec<String>,
|
||||
}
|
||||
|
||||
/// Configuration for AWS Bedrock (native Converse API).
|
||||
|
||||
+9
-3
@@ -228,7 +228,9 @@ fn create_openai_compat_from_registry(
|
||||
"Using OpenAI-compatible provider"
|
||||
);
|
||||
|
||||
Ok(Arc::new(RigAdapter::new(model, &config.model)))
|
||||
let adapter = RigAdapter::new(model, &config.model)
|
||||
.with_unsupported_params(config.unsupported_params.clone());
|
||||
Ok(Arc::new(adapter))
|
||||
}
|
||||
|
||||
fn create_anthropic_from_registry(
|
||||
@@ -296,7 +298,9 @@ fn create_anthropic_from_registry(
|
||||
);
|
||||
|
||||
Ok(Arc::new(
|
||||
RigAdapter::new(model, &config.model).with_cache_retention(cache_retention),
|
||||
RigAdapter::new(model, &config.model)
|
||||
.with_cache_retention(cache_retention)
|
||||
.with_unsupported_params(config.unsupported_params.clone()),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -324,7 +328,9 @@ fn create_ollama_from_registry(
|
||||
"Using Ollama provider"
|
||||
);
|
||||
|
||||
Ok(Arc::new(RigAdapter::new(model, &config.model)))
|
||||
let adapter = RigAdapter::new(model, &config.model)
|
||||
.with_unsupported_params(config.unsupported_params.clone());
|
||||
Ok(Arc::new(adapter))
|
||||
}
|
||||
|
||||
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
|
||||
|
||||
@@ -152,6 +152,11 @@ pub struct ProviderDefinition {
|
||||
/// Setup wizard hints.
|
||||
#[serde(default)]
|
||||
pub setup: Option<SetupHint>,
|
||||
/// Parameter names that this provider does not support (e.g., `["temperature"]`).
|
||||
/// Supported keys: `"temperature"`, `"max_tokens"`, `"stop_sequences"`.
|
||||
/// Listed parameters are stripped from requests before sending to avoid 400 errors.
|
||||
#[serde(default)]
|
||||
pub unsupported_params: Vec<String>,
|
||||
}
|
||||
|
||||
/// Registry of known LLM providers.
|
||||
@@ -378,6 +383,7 @@ mod tests {
|
||||
description: "Custom tinfoil".to_string(),
|
||||
extra_headers_env: None,
|
||||
setup: None,
|
||||
unsupported_params: vec![],
|
||||
});
|
||||
let registry = ProviderRegistry::new(all);
|
||||
let tf = registry.find("tinfoil").expect("tinfoil should exist");
|
||||
@@ -517,6 +523,7 @@ mod tests {
|
||||
description: "No setup".to_string(),
|
||||
extra_headers_env: None,
|
||||
setup: None, // no setup hint
|
||||
unsupported_params: vec![],
|
||||
}];
|
||||
|
||||
let registry = ProviderRegistry::new(providers.clone());
|
||||
@@ -546,6 +553,7 @@ mod tests {
|
||||
can_list_models: false,
|
||||
models_filter: None,
|
||||
}),
|
||||
unsupported_params: vec![],
|
||||
});
|
||||
|
||||
let registry = ProviderRegistry::new(providers);
|
||||
@@ -587,6 +595,7 @@ mod tests {
|
||||
can_list_models: false,
|
||||
models_filter: None,
|
||||
}),
|
||||
unsupported_params: vec![],
|
||||
},
|
||||
// User override removes setup
|
||||
ProviderDefinition {
|
||||
@@ -603,6 +612,7 @@ mod tests {
|
||||
description: "No setup now".to_string(),
|
||||
extra_headers_env: None,
|
||||
setup: None,
|
||||
unsupported_params: vec![],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -640,6 +650,7 @@ mod tests {
|
||||
display_name: "A".to_string(),
|
||||
can_list_models: false,
|
||||
}),
|
||||
unsupported_params: vec![],
|
||||
},
|
||||
ProviderDefinition {
|
||||
id: "bbb".to_string(),
|
||||
@@ -658,6 +669,7 @@ mod tests {
|
||||
display_name: "B".to_string(),
|
||||
can_list_models: false,
|
||||
}),
|
||||
unsupported_params: vec![],
|
||||
},
|
||||
ProviderDefinition {
|
||||
id: "ccc".to_string(),
|
||||
@@ -676,6 +688,7 @@ mod tests {
|
||||
display_name: "C".to_string(),
|
||||
can_list_models: false,
|
||||
}),
|
||||
unsupported_params: vec![],
|
||||
},
|
||||
// User override for B
|
||||
ProviderDefinition {
|
||||
@@ -695,6 +708,7 @@ mod tests {
|
||||
display_name: "B".to_string(),
|
||||
can_list_models: false,
|
||||
}),
|
||||
unsupported_params: vec![],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -708,6 +722,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unsupported_params_deserialized() {
|
||||
let providers: Vec<ProviderDefinition> =
|
||||
serde_json::from_str(include_str!("../../providers.json")).unwrap();
|
||||
|
||||
// Tinfoil should have temperature in unsupported_params
|
||||
let tinfoil = providers.iter().find(|p| p.id == "tinfoil").unwrap();
|
||||
assert!(
|
||||
tinfoil
|
||||
.unsupported_params
|
||||
.contains(&"temperature".to_string()),
|
||||
"tinfoil should have 'temperature' in unsupported_params"
|
||||
);
|
||||
|
||||
// OpenAI should also have temperature in unsupported_params
|
||||
let openai = providers.iter().find(|p| p.id == "openai").unwrap();
|
||||
assert!(
|
||||
openai
|
||||
.unsupported_params
|
||||
.contains(&"temperature".to_string()),
|
||||
"openai should have 'temperature' in unsupported_params"
|
||||
);
|
||||
|
||||
// Providers without the field in JSON should deserialize to empty vec
|
||||
let groq = providers.iter().find(|p| p.id == "groq").unwrap();
|
||||
assert!(
|
||||
groq.unsupported_params.is_empty(),
|
||||
"groq should have empty unsupported_params (field absent in JSON)"
|
||||
);
|
||||
|
||||
// Every non-empty entry should contain valid param names
|
||||
for def in &providers {
|
||||
for param in &def.unsupported_params {
|
||||
assert!(
|
||||
!param.is_empty(),
|
||||
"{}: unsupported_params contains empty string",
|
||||
def.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_builtin_api_key_providers_have_api_key_env() {
|
||||
// Every built-in provider with SetupHint::ApiKey must have api_key_env
|
||||
|
||||
+144
-2
@@ -42,6 +42,9 @@ pub struct RigAdapter<M: CompletionModel> {
|
||||
/// via `additional_params` for Anthropic automatic caching. Also controls
|
||||
/// the cost multiplier for cache-creation tokens.
|
||||
cache_retention: CacheRetention,
|
||||
/// Parameter names that this provider does not support (e.g., `"temperature"`).
|
||||
/// These are stripped from requests before sending to avoid 400 errors.
|
||||
unsupported_params: HashSet<String>,
|
||||
}
|
||||
|
||||
impl<M: CompletionModel> RigAdapter<M> {
|
||||
@@ -56,6 +59,7 @@ impl<M: CompletionModel> RigAdapter<M> {
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_retention: CacheRetention::None,
|
||||
unsupported_params: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +88,44 @@ impl<M: CompletionModel> RigAdapter<M> {
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the list of unsupported parameter names for this provider.
|
||||
///
|
||||
/// Parameters in this set are stripped from requests before sending.
|
||||
/// Supported parameter names: `"temperature"`, `"max_tokens"`, `"stop_sequences"`.
|
||||
pub fn with_unsupported_params(mut self, params: Vec<String>) -> Self {
|
||||
self.unsupported_params = params.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Strip unsupported fields from a `CompletionRequest` in place.
|
||||
fn strip_unsupported_completion_params(&self, req: &mut CompletionRequest) {
|
||||
if self.unsupported_params.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.unsupported_params.contains("temperature") {
|
||||
req.temperature = None;
|
||||
}
|
||||
if self.unsupported_params.contains("max_tokens") {
|
||||
req.max_tokens = None;
|
||||
}
|
||||
if self.unsupported_params.contains("stop_sequences") {
|
||||
req.stop_sequences = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip unsupported fields from a `ToolCompletionRequest` in place.
|
||||
fn strip_unsupported_tool_params(&self, req: &mut ToolCompletionRequest) {
|
||||
if self.unsupported_params.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.unsupported_params.contains("temperature") {
|
||||
req.temperature = None;
|
||||
}
|
||||
if self.unsupported_params.contains("max_tokens") {
|
||||
req.max_tokens = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Type conversion helpers --
|
||||
@@ -539,7 +581,10 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
async fn complete(
|
||||
&self,
|
||||
mut request: CompletionRequest,
|
||||
) -> Result<CompletionResponse, LlmError> {
|
||||
if let Some(requested_model) = request.model.as_deref()
|
||||
&& requested_model != self.model_name.as_str()
|
||||
{
|
||||
@@ -550,6 +595,8 @@ where
|
||||
);
|
||||
}
|
||||
|
||||
self.strip_unsupported_completion_params(&mut request);
|
||||
|
||||
let mut messages = request.messages;
|
||||
crate::llm::provider::sanitize_tool_messages(&mut messages);
|
||||
let (preamble, history) = convert_messages(&messages);
|
||||
@@ -599,7 +646,7 @@ where
|
||||
|
||||
async fn complete_with_tools(
|
||||
&self,
|
||||
request: ToolCompletionRequest,
|
||||
mut request: ToolCompletionRequest,
|
||||
) -> Result<ToolCompletionResponse, LlmError> {
|
||||
if let Some(requested_model) = request.model.as_deref()
|
||||
&& requested_model != self.model_name.as_str()
|
||||
@@ -611,6 +658,8 @@ where
|
||||
);
|
||||
}
|
||||
|
||||
self.strip_unsupported_tool_params(&mut request);
|
||||
|
||||
let known_tool_names: HashSet<String> =
|
||||
request.tools.iter().map(|t| t.name.clone()).collect();
|
||||
|
||||
@@ -1156,4 +1205,97 @@ mod tests {
|
||||
assert!(!supports_prompt_cache("gpt-4o"));
|
||||
assert!(!supports_prompt_cache("llama3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_unsupported_params_populates_set() {
|
||||
use rig::client::CompletionClient;
|
||||
use rig::providers::openai;
|
||||
|
||||
let client: openai::Client = openai::Client::builder()
|
||||
.api_key("test-key")
|
||||
.base_url("http://localhost:0")
|
||||
.build()
|
||||
.unwrap();
|
||||
let client = client.completions_api();
|
||||
let model = client.completion_model("test-model");
|
||||
let adapter = RigAdapter::new(model, "test-model")
|
||||
.with_unsupported_params(vec!["temperature".to_string()]);
|
||||
|
||||
assert!(adapter.unsupported_params.contains("temperature"));
|
||||
assert!(!adapter.unsupported_params.contains("max_tokens"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_unsupported_completion_params() {
|
||||
use rig::client::CompletionClient;
|
||||
use rig::providers::openai;
|
||||
|
||||
let client: openai::Client = openai::Client::builder()
|
||||
.api_key("test-key")
|
||||
.base_url("http://localhost:0")
|
||||
.build()
|
||||
.unwrap();
|
||||
let client = client.completions_api();
|
||||
let model = client.completion_model("test-model");
|
||||
let adapter = RigAdapter::new(model, "test-model").with_unsupported_params(vec![
|
||||
"temperature".to_string(),
|
||||
"stop_sequences".to_string(),
|
||||
]);
|
||||
|
||||
let mut req = CompletionRequest::new(vec![ChatMessage::user("hi")]);
|
||||
req.temperature = Some(0.7);
|
||||
req.max_tokens = Some(100);
|
||||
req.stop_sequences = Some(vec!["STOP".to_string()]);
|
||||
|
||||
adapter.strip_unsupported_completion_params(&mut req);
|
||||
|
||||
assert!(req.temperature.is_none(), "temperature should be stripped");
|
||||
assert_eq!(req.max_tokens, Some(100), "max_tokens should be preserved");
|
||||
assert!(
|
||||
req.stop_sequences.is_none(),
|
||||
"stop_sequences should be stripped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_unsupported_tool_params() {
|
||||
use rig::client::CompletionClient;
|
||||
use rig::providers::openai;
|
||||
|
||||
let client: openai::Client = openai::Client::builder()
|
||||
.api_key("test-key")
|
||||
.base_url("http://localhost:0")
|
||||
.build()
|
||||
.unwrap();
|
||||
let client = client.completions_api();
|
||||
let model = client.completion_model("test-model");
|
||||
let adapter = RigAdapter::new(model, "test-model")
|
||||
.with_unsupported_params(vec!["temperature".to_string(), "max_tokens".to_string()]);
|
||||
|
||||
let mut req = ToolCompletionRequest::new(vec![ChatMessage::user("hi")], vec![]);
|
||||
req.temperature = Some(0.5);
|
||||
req.max_tokens = Some(200);
|
||||
|
||||
adapter.strip_unsupported_tool_params(&mut req);
|
||||
|
||||
assert!(req.temperature.is_none(), "temperature should be stripped");
|
||||
assert!(req.max_tokens.is_none(), "max_tokens should be stripped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unsupported_params_empty_by_default() {
|
||||
use rig::client::CompletionClient;
|
||||
use rig::providers::openai;
|
||||
|
||||
let client: openai::Client = openai::Client::builder()
|
||||
.api_key("test-key")
|
||||
.base_url("http://localhost:0")
|
||||
.build()
|
||||
.unwrap();
|
||||
let client = client.completions_api();
|
||||
let model = client.completion_model("test-model");
|
||||
let adapter = RigAdapter::new(model, "test-model");
|
||||
|
||||
assert!(adapter.unsupported_params.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3787,6 +3787,7 @@ mod tests {
|
||||
description: "Custom provider with no setup wizard".to_string(),
|
||||
extra_headers_env: None,
|
||||
setup: None,
|
||||
unsupported_params: vec![],
|
||||
});
|
||||
let registry = crate::llm::ProviderRegistry::new(providers);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user