Compare commits

...
Author SHA1 Message Date
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
be57a7684d chore: release v0.17.0 (#842)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-10 16:30:41 +00:00
2016693b0c feat(llm): per-provider unsupported parameter filtering (#749, #728) (#809)
Add declarative `unsupported_params` field to provider definitions in
providers.json. Parameters listed are stripped from requests before
sending, preventing 400 errors from providers that reject them (e.g.
gpt-5 family and kimi-k2.5 rejecting custom temperature values).

- Add `unsupported_params` to ProviderDefinition and RegistryProviderConfig
- Propagate from registry through config resolution
- Generic strip helpers handle temperature, max_tokens, stop_sequences
- Apply filtering in RigAdapter and AnthropicOAuthProvider
- Mark openai and tinfoil providers as unsupporting temperature
- Update openai default model to gpt-5-mini

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-10 07:11:26 +00:00
bcef04b821 feat: persist user_id in save_job and expose job_id on routine runs (#709)
* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-10 01:51:43 +00:00
6e12ce6f2d fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)
- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-09 18:43:16 -07:00
b53986f00b fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)
- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-09 17:35:06 -07:00
1440ec7422 fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)
GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-09 23:58:56 +00:00
21 changed files with 418 additions and 18 deletions
+2 -1
View File
@@ -2,7 +2,7 @@ name: Claude Code Review
on: on:
pull_request: pull_request:
types: [opened, labeled] types: [labeled]
permissions: permissions:
contents: read contents: read
@@ -28,6 +28,7 @@ jobs:
uses: anthropics/claude-code-action@v1 uses: anthropics/claude-code-action@v1
with: with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 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:*)'" 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: | prompt: |
Code review this pull request. Follow these steps precisely: Code review this pull request. Follow these steps precisely:
+7 -1
View File
@@ -44,6 +44,7 @@ jobs:
clippy-windows: clippy-windows:
name: Clippy Windows (${{ matrix.name }}) name: Clippy Windows (${{ matrix.name }})
if: github.base_ref == 'main'
runs-on: windows-latest runs-on: windows-latest
strategy: strategy:
fail-fast: false fail-fast: false
@@ -76,7 +77,12 @@ jobs:
needs: [format, clippy, clippy-windows] needs: [format, clippy, clippy-windows]
steps: steps:
- run: | - 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" echo "One or more jobs failed"
exit 1 exit 1
fi 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
-2
View File
@@ -115,7 +115,6 @@ jobs:
- name: Generate GitHub App token - name: Generate GitHub App token
id: app-token id: app-token
if: ${{ secrets.GH_RELEASES_MANAGER_APP_ID != '' }}
uses: actions/create-github-app-token@v2 uses: actions/create-github-app-token@v2
with: with:
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }} app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
@@ -230,7 +229,6 @@ jobs:
- name: Generate GitHub App token - name: Generate GitHub App token
id: app-token id: app-token
if: ${{ secrets.GH_RELEASES_MANAGER_APP_ID != '' }}
uses: actions/create-github-app-token@v2 uses: actions/create-github-app-token@v2
with: with:
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }} app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
+2
View File
@@ -2,6 +2,8 @@ name: Run Tests
on: on:
workflow_call: workflow_call:
pull_request: pull_request:
branches:
- main
push: push:
branches: branches:
- main - main
+75
View File
@@ -7,6 +7,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ### Added
- AWS Bedrock LLM provider via native Converse API with IAM and SSO auth support (feature-gated: `--features bedrock`) - AWS Bedrock LLM provider via native Converse API with IAM and SSO auth support (feature-gated: `--features bedrock`)
Generated
+1 -1
View File
@@ -3350,7 +3350,7 @@ dependencies = [
[[package]] [[package]]
name = "ironclaw" name = "ironclaw"
version = "0.16.1" version = "0.17.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"aho-corasick", "aho-corasick",
+1 -1
View File
@@ -18,7 +18,7 @@ exclude = [
[package] [package]
name = "ironclaw" name = "ironclaw"
version = "0.16.1" version = "0.17.0"
edition = "2024" edition = "2024"
rust-version = "1.92" rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
+3 -1
View File
@@ -9,8 +9,9 @@
"api_key_required": true, "api_key_required": true,
"base_url_env": "OPENAI_BASE_URL", "base_url_env": "OPENAI_BASE_URL",
"model_env": "OPENAI_MODEL", "model_env": "OPENAI_MODEL",
"default_model": "gpt-4o", "default_model": "gpt-5-mini",
"description": "OpenAI GPT models (direct API)", "description": "OpenAI GPT models (direct API)",
"unsupported_params": ["temperature"],
"setup": { "setup": {
"kind": "api_key", "kind": "api_key",
"secret_name": "llm_openai_api_key", "secret_name": "llm_openai_api_key",
@@ -86,6 +87,7 @@
"model_env": "TINFOIL_MODEL", "model_env": "TINFOIL_MODEL",
"default_model": "kimi-k2-5", "default_model": "kimi-k2-5",
"description": "Tinfoil private inference (hardware-attested TEE)", "description": "Tinfoil private inference (hardware-attested TEE)",
"unsupported_params": ["temperature"],
"setup": { "setup": {
"kind": "api_key", "kind": "api_key",
"secret_name": "llm_tinfoil_api_key", "secret_name": "llm_tinfoil_api_key",
+2
View File
@@ -108,6 +108,7 @@ pub async fn routines_detail_handler(
status: format!("{:?}", run.status), status: format!("{:?}", run.status),
result_summary: run.result_summary.clone(), result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used, tokens_used: run.tokens_used,
job_id: run.job_id,
}) })
.collect(); .collect();
@@ -252,6 +253,7 @@ pub async fn routines_runs_handler(
status: format!("{:?}", run.status), status: format!("{:?}", run.status),
result_summary: run.result_summary.clone(), result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used, tokens_used: run.tokens_used,
job_id: run.job_id,
}) })
.collect(); .collect();
+2
View File
@@ -2017,6 +2017,7 @@ async fn routines_detail_handler(
status: format!("{:?}", run.status), status: format!("{:?}", run.status),
result_summary: run.result_summary.clone(), result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used, tokens_used: run.tokens_used,
job_id: run.job_id,
}) })
.collect(); .collect();
@@ -2169,6 +2170,7 @@ async fn routines_runs_handler(
status: format!("{:?}", run.status), status: format!("{:?}", run.status),
result_summary: run.result_summary.clone(), result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used, tokens_used: run.tokens_used,
job_id: run.job_id,
}) })
.collect(); .collect();
+1
View File
@@ -776,6 +776,7 @@ pub struct RoutineRunInfo {
pub status: String, pub status: String,
pub result_summary: Option<String>, pub result_summary: Option<String>,
pub tokens_used: Option<i32>, pub tokens_used: Option<i32>,
pub job_id: Option<Uuid>,
} }
// --- Settings --- // --- Settings ---
+10
View File
@@ -209,6 +209,7 @@ impl LlmConfig {
extra_headers_env, extra_headers_env,
api_key_required, api_key_required,
base_url_required, base_url_required,
unsupported_params,
) = if let Some(def) = def { ) = if let Some(def) = def {
( (
def.id.as_str(), def.id.as_str(),
@@ -221,6 +222,7 @@ impl LlmConfig {
def.extra_headers_env.as_deref(), def.extra_headers_env.as_deref(),
def.api_key_required, def.api_key_required,
def.base_url_required, def.base_url_required,
def.unsupported_params.clone(),
) )
} else { } else {
// Absolute fallback: treat as generic openai_completions // Absolute fallback: treat as generic openai_completions
@@ -235,6 +237,7 @@ impl LlmConfig {
Some("LLM_EXTRA_HEADERS"), Some("LLM_EXTRA_HEADERS"),
false, false,
true, true,
Vec::new(),
) )
}; };
@@ -338,6 +341,7 @@ impl LlmConfig {
extra_headers, extra_headers,
oauth_token, oauth_token,
cache_retention, cache_retention,
unsupported_params,
}) })
} }
} }
@@ -624,6 +628,12 @@ mod tests {
let provider = cfg.provider.expect("provider config should be present"); let provider = cfg.provider.expect("provider config should be present");
assert_eq!(provider.base_url, "https://inference.tinfoil.sh/v1"); assert_eq!(provider.base_url, "https://inference.tinfoil.sh/v1");
assert_eq!(provider.model, "kimi-k2-5"); assert_eq!(provider.model, "kimi-k2-5");
assert!(
provider
.unsupported_params
.contains(&"temperature".to_string()),
"tinfoil should propagate unsupported_params from registry"
);
} }
#[test] #[test]
+4 -1
View File
@@ -28,14 +28,16 @@ impl JobStore for LibSqlBackend {
r#" r#"
INSERT INTO agent_jobs ( INSERT INTO agent_jobs (
id, conversation_id, title, description, category, status, source, id, conversation_id, title, description, category, status, source,
user_id,
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs, budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
actual_cost, repair_attempts, created_at, started_at, completed_at 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 ON CONFLICT (id) DO UPDATE SET
title = excluded.title, title = excluded.title,
description = excluded.description, description = excluded.description,
category = excluded.category, category = excluded.category,
status = excluded.status, status = excluded.status,
user_id = excluded.user_id,
estimated_cost = excluded.estimated_cost, estimated_cost = excluded.estimated_cost,
estimated_time_secs = excluded.estimated_time_secs, estimated_time_secs = excluded.estimated_time_secs,
actual_cost = excluded.actual_cost, actual_cost = excluded.actual_cost,
@@ -51,6 +53,7 @@ impl JobStore for LibSqlBackend {
opt_text(ctx.category.as_deref()), opt_text(ctx.category.as_deref()),
status, status,
"direct", "direct",
ctx.user_id.as_str(),
opt_text_owned(ctx.budget.map(|d| d.to_string())), opt_text_owned(ctx.budget.map(|d| d.to_string())),
opt_text(ctx.budget_token.as_deref()), opt_text(ctx.budget_token.as_deref()),
opt_text_owned(ctx.bid_amount.map(|d| d.to_string())), opt_text_owned(ctx.bid_amount.map(|d| d.to_string())),
+18
View File
@@ -482,6 +482,24 @@ mod tests {
assert_eq!(timeout, 5000); 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] #[tokio::test]
async fn test_concurrent_writes_succeed() { async fn test_concurrent_writes_succeed() {
// Use a temp file so connections share state (in-memory DBs are connection-local) // Use a temp file so connections share state (in-memory DBs are connection-local)
+36 -1
View File
@@ -149,14 +149,16 @@ impl Store {
r#" r#"
INSERT INTO agent_jobs ( INSERT INTO agent_jobs (
id, conversation_id, title, description, category, status, source, id, conversation_id, title, description, category, status, source,
user_id,
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs, budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
actual_cost, repair_attempts, created_at, started_at, completed_at 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 ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title, title = EXCLUDED.title,
description = EXCLUDED.description, description = EXCLUDED.description,
category = EXCLUDED.category, category = EXCLUDED.category,
status = EXCLUDED.status, status = EXCLUDED.status,
user_id = EXCLUDED.user_id,
estimated_cost = EXCLUDED.estimated_cost, estimated_cost = EXCLUDED.estimated_cost,
estimated_time_secs = EXCLUDED.estimated_time_secs, estimated_time_secs = EXCLUDED.estimated_time_secs,
actual_cost = EXCLUDED.actual_cost, actual_cost = EXCLUDED.actual_cost,
@@ -172,6 +174,7 @@ impl Store {
&ctx.category, &ctx.category,
&status, &status,
&"direct", // source &"direct", // source
&ctx.user_id,
&ctx.budget, &ctx.budget,
&ctx.budget_token, &ctx.budget_token,
&ctx.bid_amount, &ctx.bid_amount,
@@ -2133,4 +2136,36 @@ mod tests {
assert_eq!(summary.channel, ch); 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();
}
} }
+40 -4
View File
@@ -6,6 +6,8 @@
//! //!
//! Pattern follows `nearai_chat.rs`: direct HTTP calls via `reqwest::Client`. //! Pattern follows `nearai_chat.rs`: direct HTTP calls via `reqwest::Client`.
use std::collections::HashSet;
use async_trait::async_trait; use async_trait::async_trait;
use reqwest::Client; use reqwest::Client;
use rust_decimal::Decimal; use rust_decimal::Decimal;
@@ -35,6 +37,8 @@ pub struct AnthropicOAuthProvider {
model: String, model: String,
base_url: Option<String>, base_url: Option<String>,
active_model: std::sync::RwLock<String>, active_model: std::sync::RwLock<String>,
/// Parameter names that this provider does not support.
unsupported_params: HashSet<String>,
} }
impl AnthropicOAuthProvider { impl AnthropicOAuthProvider {
@@ -61,15 +65,45 @@ impl AnthropicOAuthProvider {
Some(config.base_url.clone()) Some(config.base_url.clone())
}; };
let unsupported_params: HashSet<String> =
config.unsupported_params.iter().cloned().collect();
Ok(Self { Ok(Self {
client, client,
token, token,
model: config.model.clone(), model: config.model.clone(),
base_url, base_url,
active_model, 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 { fn api_url(&self) -> String {
if let Some(ref base) = self.base_url { if let Some(ref base) = self.base_url {
let base = base.trim_end_matches('/'); let base = base.trim_end_matches('/');
@@ -197,8 +231,9 @@ impl AnthropicOAuthProvider {
#[async_trait] #[async_trait]
impl LlmProvider for AnthropicOAuthProvider { impl LlmProvider for AnthropicOAuthProvider {
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> { async fn complete(&self, mut req: CompletionRequest) -> Result<CompletionResponse, 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_completion_params(&mut req);
let (system, messages) = convert_messages(req.messages); let (system, messages) = convert_messages(req.messages);
let request = AnthropicRequest { let request = AnthropicRequest {
@@ -233,9 +268,10 @@ impl LlmProvider for AnthropicOAuthProvider {
async fn complete_with_tools( async fn complete_with_tools(
&self, &self,
req: ToolCompletionRequest, mut req: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> { ) -> 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 (system, messages) = convert_messages(req.messages);
let tools: Vec<AnthropicTool> = req let tools: Vec<AnthropicTool> = req
+4
View File
@@ -87,6 +87,10 @@ pub struct RegistryProviderConfig {
pub oauth_token: Option<SecretString>, pub oauth_token: Option<SecretString>,
/// Prompt cache retention (Anthropic-specific). /// Prompt cache retention (Anthropic-specific).
pub cache_retention: CacheRetention, 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). /// Configuration for AWS Bedrock (native Converse API).
+9 -3
View File
@@ -228,7 +228,9 @@ fn create_openai_compat_from_registry(
"Using OpenAI-compatible provider" "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( fn create_anthropic_from_registry(
@@ -296,7 +298,9 @@ fn create_anthropic_from_registry(
); );
Ok(Arc::new( 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" "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). /// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
+56
View File
@@ -152,6 +152,11 @@ pub struct ProviderDefinition {
/// Setup wizard hints. /// Setup wizard hints.
#[serde(default)] #[serde(default)]
pub setup: Option<SetupHint>, 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. /// Registry of known LLM providers.
@@ -378,6 +383,7 @@ mod tests {
description: "Custom tinfoil".to_string(), description: "Custom tinfoil".to_string(),
extra_headers_env: None, extra_headers_env: None,
setup: None, setup: None,
unsupported_params: vec![],
}); });
let registry = ProviderRegistry::new(all); let registry = ProviderRegistry::new(all);
let tf = registry.find("tinfoil").expect("tinfoil should exist"); let tf = registry.find("tinfoil").expect("tinfoil should exist");
@@ -517,6 +523,7 @@ mod tests {
description: "No setup".to_string(), description: "No setup".to_string(),
extra_headers_env: None, extra_headers_env: None,
setup: None, // no setup hint setup: None, // no setup hint
unsupported_params: vec![],
}]; }];
let registry = ProviderRegistry::new(providers.clone()); let registry = ProviderRegistry::new(providers.clone());
@@ -546,6 +553,7 @@ mod tests {
can_list_models: false, can_list_models: false,
models_filter: None, models_filter: None,
}), }),
unsupported_params: vec![],
}); });
let registry = ProviderRegistry::new(providers); let registry = ProviderRegistry::new(providers);
@@ -587,6 +595,7 @@ mod tests {
can_list_models: false, can_list_models: false,
models_filter: None, models_filter: None,
}), }),
unsupported_params: vec![],
}, },
// User override removes setup // User override removes setup
ProviderDefinition { ProviderDefinition {
@@ -603,6 +612,7 @@ mod tests {
description: "No setup now".to_string(), description: "No setup now".to_string(),
extra_headers_env: None, extra_headers_env: None,
setup: None, setup: None,
unsupported_params: vec![],
}, },
]; ];
@@ -640,6 +650,7 @@ mod tests {
display_name: "A".to_string(), display_name: "A".to_string(),
can_list_models: false, can_list_models: false,
}), }),
unsupported_params: vec![],
}, },
ProviderDefinition { ProviderDefinition {
id: "bbb".to_string(), id: "bbb".to_string(),
@@ -658,6 +669,7 @@ mod tests {
display_name: "B".to_string(), display_name: "B".to_string(),
can_list_models: false, can_list_models: false,
}), }),
unsupported_params: vec![],
}, },
ProviderDefinition { ProviderDefinition {
id: "ccc".to_string(), id: "ccc".to_string(),
@@ -676,6 +688,7 @@ mod tests {
display_name: "C".to_string(), display_name: "C".to_string(),
can_list_models: false, can_list_models: false,
}), }),
unsupported_params: vec![],
}, },
// User override for B // User override for B
ProviderDefinition { ProviderDefinition {
@@ -695,6 +708,7 @@ mod tests {
display_name: "B".to_string(), display_name: "B".to_string(),
can_list_models: false, 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] #[test]
fn test_all_builtin_api_key_providers_have_api_key_env() { fn test_all_builtin_api_key_providers_have_api_key_env() {
// Every built-in provider with SetupHint::ApiKey must have api_key_env // Every built-in provider with SetupHint::ApiKey must have api_key_env
+144 -2
View File
@@ -42,6 +42,9 @@ pub struct RigAdapter<M: CompletionModel> {
/// via `additional_params` for Anthropic automatic caching. Also controls /// via `additional_params` for Anthropic automatic caching. Also controls
/// the cost multiplier for cache-creation tokens. /// the cost multiplier for cache-creation tokens.
cache_retention: CacheRetention, 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> { impl<M: CompletionModel> RigAdapter<M> {
@@ -56,6 +59,7 @@ impl<M: CompletionModel> RigAdapter<M> {
input_cost, input_cost,
output_cost, output_cost,
cache_retention: CacheRetention::None, cache_retention: CacheRetention::None,
unsupported_params: HashSet::new(),
} }
} }
@@ -84,6 +88,44 @@ impl<M: CompletionModel> RigAdapter<M> {
} }
self 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 -- // -- 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() if let Some(requested_model) = request.model.as_deref()
&& requested_model != self.model_name.as_str() && requested_model != self.model_name.as_str()
{ {
@@ -550,6 +595,8 @@ where
); );
} }
self.strip_unsupported_completion_params(&mut request);
let mut messages = request.messages; let mut messages = request.messages;
crate::llm::provider::sanitize_tool_messages(&mut messages); crate::llm::provider::sanitize_tool_messages(&mut messages);
let (preamble, history) = convert_messages(&messages); let (preamble, history) = convert_messages(&messages);
@@ -599,7 +646,7 @@ where
async fn complete_with_tools( async fn complete_with_tools(
&self, &self,
request: ToolCompletionRequest, mut request: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> { ) -> Result<ToolCompletionResponse, LlmError> {
if let Some(requested_model) = request.model.as_deref() if let Some(requested_model) = request.model.as_deref()
&& requested_model != self.model_name.as_str() && 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> = let known_tool_names: HashSet<String> =
request.tools.iter().map(|t| t.name.clone()).collect(); 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("gpt-4o"));
assert!(!supports_prompt_cache("llama3")); 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());
}
} }
+1
View File
@@ -3787,6 +3787,7 @@ mod tests {
description: "Custom provider with no setup wizard".to_string(), description: "Custom provider with no setup wizard".to_string(),
extra_headers_env: None, extra_headers_env: None,
setup: None, setup: None,
unsupported_params: vec![],
}); });
let registry = crate::llm::ProviderRegistry::new(providers); let registry = crate::llm::ProviderRegistry::new(providers);