From 85f4619ee2f3eef6e33eb96874ee114c2cf22e8a Mon Sep 17 00:00:00 2001 From: Trey Kim Date: Sun, 11 Oct 2026 03:43:29 +0800 Subject: [PATCH 1/7] ci: run on exp/** branches --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eaf7a4d97..67c660ced 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [master] + branches: [master, "exp/**"] pull_request: - branches: [master] + branches: [master, "exp/**"] env: CARGO_TERM_COLOR: always From a37299b7fc3429e6e44f86a5e42067554dae024f Mon Sep 17 00:00:00 2001 From: Trey Kim Date: Sun, 11 Oct 2026 03:43:29 +0800 Subject: [PATCH 2/7] ci: run on exp/** branches --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eaf7a4d97..67c660ced 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [master] + branches: [master, "exp/**"] pull_request: - branches: [master] + branches: [master, "exp/**"] env: CARGO_TERM_COLOR: always From 915478aadb70c8053ddeb77fbc636d2d05017760 Mon Sep 17 00:00:00 2001 From: Trey Kim Date: Sun, 11 Oct 2026 04:13:19 +0800 Subject: [PATCH 3/7] docs(api): regenerate providers.md (gen_providers_doc.py) --- docs/api/providers.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/api/providers.md b/docs/api/providers.md index 2f9a2f273..addef4710 100644 --- a/docs/api/providers.md +++ b/docs/api/providers.md @@ -9,7 +9,7 @@ | category | count | |----------|-------| -| Registry-backed OpenAI-compatible (`provider(name, ...)`) | 281 | +| Registry-backed OpenAI-compatible (`provider(name, ...)`) | 282 | | Non-registry: Native protocol providers | 14 | | Non-registry: Speech-only providers (TTS) | 4 | | Non-registry: Transcription-only providers (STT) | 5 | @@ -22,11 +22,11 @@ | Non-registry: Stability image provider (image modality only) | 1 | | Non-registry: Video-only provider (runwayml) | 1 | | Non-registry: Search-only providers (web search modality) | 11 | -| **Total providers** | **326** | +| **Total providers** | **327** | -**281 registry-backed OpenAI-compatible providers** (create presets by name via `provider(name, ...)`) + **45 non-registry providers** (construct via the typed factories listed below). +**282 registry-backed OpenAI-compatible providers** (create presets by name via `provider(name, ...)`) + **45 non-registry providers** (construct via the typed factories listed below). -## Registry-backed (OpenAI-compatible) — 281 +## Registry-backed (OpenAI-compatible) — 282 | name | display | auth | env var | base_url | |------|---------|------|---------|----------| @@ -153,6 +153,7 @@ | `krutrim` | Krutrim | api_key | `KRUTRIM_API_KEY` | `https://api.krutrim.ai/v1` | | `kuae_cloud_coding_plan` | KUAE Cloud Coding Plan | api_key | `KUAE_API_KEY` | `https://coding-plan-endpoint.kuaecloud.net/v1` | | `lambda_ai` | Lambda AI | api_key | `LAMBDA_API_KEY` | `https://api.lambda.ai/v1` | +| `laya` | Laya | none | — | `http://127.0.0.1:8000/v1` (URL from `LAYA_BASE_URL`) | | `lemonade` | Lemonade | api_key | `LEMONADE_API_KEY` | `http://localhost:13305/v1` | | `lemonfox_ai` | Lemonfox AI | api_key | `LEMONFOX_API_KEY` | `https://api.lemonfox.ai/v1` | | `libertai` | Libertai | api_key | `LIBERTAI_API_KEY` | `https://api.libertai.io/v1` | From 0c4c1a4b7aa97c6eb6d50b9ac0140809215508ba Mon Sep 17 00:00:00 2001 From: Trey Kim Date: Sun, 11 Oct 2026 05:45:20 +0800 Subject: [PATCH 4/7] feat(core): optional inputSchemaInput on ToolApprovalRequestOutput, passed through to response messages (1.2) --- aimux-core/src/generate.rs | 3 +- aimux-core/src/parse_tool_call.rs | 1 + aimux-core/src/response_messages.rs | 50 ++++++++++++++++++- aimux-core/src/result.rs | 7 +++ .../tool_approval_request_output_test.rs | 50 +++++++++++++++++++ .../src/types/ToolApprovalRequestOutput.ts | 10 +++- 6 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 aimux-core/tests/tool_approval_request_output_test.rs diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index 0df13af5d..2ff3420c7 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -787,6 +787,7 @@ pub(crate) async fn generate_text_from_language_model_prompt( reason: None, is_automatic: None, signature: None, + input_schema_input: None, }); } GenerateContent::ToolResult(result) => rm.tool_result(result.clone()), @@ -1225,7 +1226,7 @@ pub async fn stream_text( if let Some(call) = tool_calls.get(&approval.tool_call_id) { yield Ok(TextStreamPart::ToolApprovalRequest(ToolApprovalRequestOutput { approval_id: approval.approval_id, tool_call: call.clone(), - reason: None, is_automatic: None, signature: None, + reason: None, is_automatic: None, signature: None, input_schema_input: None, })); } else { yield Ok(TextStreamPart::Error { error: AiMuxError::ToolCallNotFoundForApproval { diff --git a/aimux-core/src/parse_tool_call.rs b/aimux-core/src/parse_tool_call.rs index 79be0bacd..873455d3a 100644 --- a/aimux-core/src/parse_tool_call.rs +++ b/aimux-core/src/parse_tool_call.rs @@ -726,6 +726,7 @@ mod tests { reason: None, is_automatic: None, signature: None, + input_schema_input: None, }); } let assembled = builder.finish(); diff --git a/aimux-core/src/response_messages.rs b/aimux-core/src/response_messages.rs index 6d89336f5..d6225e4e6 100644 --- a/aimux-core/src/response_messages.rs +++ b/aimux-core/src/response_messages.rs @@ -243,7 +243,7 @@ impl ResponseMessageBuilder { reason: approval.reason.clone(), is_automatic: approval.is_automatic, signature: approval.signature.clone(), - input_schema_input: None, + input_schema_input: approval.input_schema_input.clone(), } } GenerateContent::ToolCall(call) => ContentPart::ToolCall { @@ -312,3 +312,51 @@ fn file_base64(data: &FileBytes) -> String { FileBytes::Binary(data) => base64::engine::general_purpose::STANDARD.encode(data), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn approval(input_schema_input: Option) -> ToolApprovalRequestOutput { + ToolApprovalRequestOutput { + approval_id: "approval-1".into(), + tool_call: crate::tool::ToolCall { + tool_call_id: "call-1".into(), + tool_name: "weather".into(), + input: serde_json::json!({"city": "Paris"}), + provider_executed: Some(true), + dynamic: None, + provider_metadata: None, + invalid: None, + error: None, + }, + reason: None, + is_automatic: None, + signature: None, + input_schema_input, + } + } + + fn approval_part(input_schema_input: Option) -> Value { + let mut builder = ResponseMessageBuilder::new(); + builder.approval(&approval(input_schema_input)); + let messages = serde_json::to_value(builder.finish().messages).unwrap(); + messages[0]["content"] + .as_array() + .unwrap() + .iter() + .find(|part| part["type"] == "tool-approval-request") + .cloned() + .unwrap() + } + + #[test] + fn approval_passes_input_schema_input_to_response_messages() { + let part = approval_part(Some(serde_json::json!({"city": "paris"}))); + assert_eq!( + part["inputSchemaInput"], + serde_json::json!({"city": "paris"}) + ); + assert!(approval_part(None).get("inputSchemaInput").is_none()); + } +} diff --git a/aimux-core/src/result.rs b/aimux-core/src/result.rs index a3cd58f55..6001aaac4 100644 --- a/aimux-core/src/result.rs +++ b/aimux-core/src/result.rs @@ -93,6 +93,13 @@ pub struct ToolApprovalRequestOutput { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub signature: Option, + /// Tool input before input-schema validation and transformation, present + /// when it differs from the validated input (AI SDK + /// `ToolApprovalRequest.inputSchemaInput`). Pass-through only: core runs + /// no host approval (#200 §0.4 Q1). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub input_schema_input: Option, } /// A source / citation (e.g. URL citation from search-preview models). The diff --git a/aimux-core/tests/tool_approval_request_output_test.rs b/aimux-core/tests/tool_approval_request_output_test.rs new file mode 100644 index 000000000..599ef3f83 --- /dev/null +++ b/aimux-core/tests/tool_approval_request_output_test.rs @@ -0,0 +1,50 @@ +//! `ToolApprovalRequestOutput.inputSchemaInput` (#200:1952, AI SDK +//! `ToolApprovalRequest.inputSchemaInput`): optional, camelCase on the wire, +//! omitted when absent, and passed through unchanged. + +use aimux_core::result::ToolApprovalRequestOutput; +use serde_json::json; + +fn approval_json(input_schema_input: Option) -> serde_json::Value { + let mut value = json!({ + "approvalId": "approval-1", + "toolCall": { + "toolCallId": "call-1", + "toolName": "weather", + "input": {"city": "Paris", "unit": "celsius"}, + "providerExecuted": true + }, + "reason": "needs confirmation" + }); + if let Some(raw) = input_schema_input { + value["inputSchemaInput"] = raw; + } + value +} + +#[test] +fn round_trips_with_input_schema_input() { + let wire = approval_json(Some(json!({"city": "paris"}))); + let approval: ToolApprovalRequestOutput = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(approval.input_schema_input, Some(json!({"city": "paris"}))); + assert_eq!(serde_json::to_value(&approval).unwrap(), wire); +} + +#[test] +fn round_trips_without_input_schema_input() { + let wire = approval_json(None); + let approval: ToolApprovalRequestOutput = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(approval.input_schema_input, None); + let back = serde_json::to_value(&approval).unwrap(); + assert!(back.get("inputSchemaInput").is_none()); + assert!(back.get("input_schema_input").is_none()); + assert_eq!(back, wire); +} + +#[test] +fn non_object_input_schema_input_is_preserved() { + // `unknown` upstream: any JSON value, including a raw string. + let wire = approval_json(Some(json!("{\"city\":\"paris\""))); + let approval: ToolApprovalRequestOutput = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(&approval).unwrap(), wire); +} diff --git a/bindings/node/src/types/ToolApprovalRequestOutput.ts b/bindings/node/src/types/ToolApprovalRequestOutput.ts index 9b4f34fc1..f4e725767 100644 --- a/bindings/node/src/types/ToolApprovalRequestOutput.ts +++ b/bindings/node/src/types/ToolApprovalRequestOutput.ts @@ -1,7 +1,15 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ToolCall } from "./ToolCall"; +import type { JsonValue } from "./serde_json/JsonValue"; /** * Approval request exposed by text generation. */ -export type ToolApprovalRequestOutput = { approvalId: string, toolCall: ToolCall, reason?: string, isAutomatic?: boolean, signature?: string, }; +export type ToolApprovalRequestOutput = { approvalId: string, toolCall: ToolCall, reason?: string, isAutomatic?: boolean, signature?: string, +/** + * Tool input before input-schema validation and transformation, present + * when it differs from the validated input (AI SDK + * `ToolApprovalRequest.inputSchemaInput`). Pass-through only: core runs + * no host approval (#200 §0.4 Q1). + */ +inputSchemaInput?: JsonValue, }; From 054b4bc0d6102ce54d77dca3f2524f025aa41404 Mon Sep 17 00:00:00 2001 From: Trey Kim Date: Sun, 11 Oct 2026 06:15:10 +0800 Subject: [PATCH 5/7] fix(core): inputSchemaInput lives on the tool-approval-request content part, written only when it differs from toolCall.input (1.2 rework) --- aimux-core/src/response_messages.rs | 16 ++++- aimux-core/src/result.rs | 13 ++-- .../tool_approval_request_output_test.rs | 68 ++++++++++++------- .../src/types/ToolApprovalRequestOutput.ts | 10 +-- 4 files changed, 64 insertions(+), 43 deletions(-) diff --git a/aimux-core/src/response_messages.rs b/aimux-core/src/response_messages.rs index d6225e4e6..d348a3ace 100644 --- a/aimux-core/src/response_messages.rs +++ b/aimux-core/src/response_messages.rs @@ -243,7 +243,13 @@ impl ResponseMessageBuilder { reason: approval.reason.clone(), is_automatic: approval.is_automatic, signature: approval.signature.clone(), - input_schema_input: approval.input_schema_input.clone(), + // As AI SDK `to-response-messages.ts`: only when it + // differs from the validated input. + input_schema_input: approval + .input_schema_input + .as_ref() + .filter(|raw| **raw != approval.tool_call.input) + .cloned(), } } GenerateContent::ToolCall(call) => ContentPart::ToolCall { @@ -351,12 +357,18 @@ mod tests { } #[test] - fn approval_passes_input_schema_input_to_response_messages() { + fn approval_writes_input_schema_input_when_it_differs_from_the_input() { let part = approval_part(Some(serde_json::json!({"city": "paris"}))); assert_eq!( part["inputSchemaInput"], serde_json::json!({"city": "paris"}) ); + } + + #[test] + fn approval_omits_input_schema_input_when_equal_or_absent() { + let equal = approval_part(Some(serde_json::json!({"city": "Paris"}))); + assert!(equal.get("inputSchemaInput").is_none()); assert!(approval_part(None).get("inputSchemaInput").is_none()); } } diff --git a/aimux-core/src/result.rs b/aimux-core/src/result.rs index 6001aaac4..966166ab6 100644 --- a/aimux-core/src/result.rs +++ b/aimux-core/src/result.rs @@ -93,12 +93,13 @@ pub struct ToolApprovalRequestOutput { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub signature: Option, - /// Tool input before input-schema validation and transformation, present - /// when it differs from the validated input (AI SDK - /// `ToolApprovalRequest.inputSchemaInput`). Pass-through only: core runs - /// no host approval (#200 §0.4 Q1). - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] + /// Tool input before input-schema validation and transformation. Carried + /// internally only: it is not part of this type's JSON. It reaches the + /// response message's `tool-approval-request` part when it differs from + /// `tool_call.input` (AI SDK `to-response-messages.ts`). Pass-through + /// only: core runs no host approval (#200 §0.4 Q1). + #[serde(skip)] + #[ts(skip)] pub input_schema_input: Option, } diff --git a/aimux-core/tests/tool_approval_request_output_test.rs b/aimux-core/tests/tool_approval_request_output_test.rs index 599ef3f83..4cad8e89a 100644 --- a/aimux-core/tests/tool_approval_request_output_test.rs +++ b/aimux-core/tests/tool_approval_request_output_test.rs @@ -1,19 +1,20 @@ -//! `ToolApprovalRequestOutput.inputSchemaInput` (#200:1952, AI SDK -//! `ToolApprovalRequest.inputSchemaInput`): optional, camelCase on the wire, -//! omitted when absent, and passed through unchanged. +//! `inputSchemaInput` (#200:1952 lists it on the model-message +//! `tool-approval-request` content part). +//! +//! - The prompt/response content part (`ContentPart::ToolApprovalRequest`) +//! carries it, camelCase, optional, and round-trips it. +//! - `ToolApprovalRequestOutput` keeps it internally only; it is not part of +//! that type's JSON. +use aimux_core::content::ContentPart; use aimux_core::result::ToolApprovalRequestOutput; use serde_json::json; -fn approval_json(input_schema_input: Option) -> serde_json::Value { +fn part_json(input_schema_input: Option) -> serde_json::Value { let mut value = json!({ + "type": "tool-approval-request", "approvalId": "approval-1", - "toolCall": { - "toolCallId": "call-1", - "toolName": "weather", - "input": {"city": "Paris", "unit": "celsius"}, - "providerExecuted": true - }, + "toolCallId": "call-1", "reason": "needs confirmation" }); if let Some(raw) = input_schema_input { @@ -23,28 +24,43 @@ fn approval_json(input_schema_input: Option) -> serde_json::V } #[test] -fn round_trips_with_input_schema_input() { - let wire = approval_json(Some(json!({"city": "paris"}))); - let approval: ToolApprovalRequestOutput = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!(approval.input_schema_input, Some(json!({"city": "paris"}))); - assert_eq!(serde_json::to_value(&approval).unwrap(), wire); +fn content_part_round_trips_with_input_schema_input() { + for raw in [json!({"city": "paris"}), json!("{\"city\":\"paris\"")] { + let wire = part_json(Some(raw.clone())); + let part: ContentPart = serde_json::from_value(wire.clone()).unwrap(); + let ContentPart::ToolApprovalRequest { + input_schema_input, .. + } = &part + else { + panic!("expected a tool-approval-request part"); + }; + assert_eq!(input_schema_input.as_ref(), Some(&raw)); + assert_eq!(serde_json::to_value(&part).unwrap(), wire); + } } #[test] -fn round_trips_without_input_schema_input() { - let wire = approval_json(None); - let approval: ToolApprovalRequestOutput = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!(approval.input_schema_input, None); - let back = serde_json::to_value(&approval).unwrap(); +fn content_part_round_trips_without_input_schema_input() { + let wire = part_json(None); + let part: ContentPart = serde_json::from_value(wire.clone()).unwrap(); + let back = serde_json::to_value(&part).unwrap(); assert!(back.get("inputSchemaInput").is_none()); - assert!(back.get("input_schema_input").is_none()); assert_eq!(back, wire); } #[test] -fn non_object_input_schema_input_is_preserved() { - // `unknown` upstream: any JSON value, including a raw string. - let wire = approval_json(Some(json!("{\"city\":\"paris\""))); - let approval: ToolApprovalRequestOutput = serde_json::from_value(wire.clone()).unwrap(); - assert_eq!(serde_json::to_value(&approval).unwrap(), wire); +fn approval_output_json_has_no_input_schema_input() { + let wire = json!({ + "approvalId": "approval-1", + "toolCall": {"toolCallId": "call-1", "toolName": "weather", "input": {"city": "Paris"}}, + "inputSchemaInput": {"city": "paris"} + }); + let mut approval: ToolApprovalRequestOutput = serde_json::from_value(wire).unwrap(); + assert_eq!(approval.input_schema_input, None, "not read from JSON"); + approval.input_schema_input = Some(json!({"city": "paris"})); + let back = serde_json::to_value(&approval).unwrap(); + assert!( + back.get("inputSchemaInput").is_none(), + "not written to JSON" + ); } diff --git a/bindings/node/src/types/ToolApprovalRequestOutput.ts b/bindings/node/src/types/ToolApprovalRequestOutput.ts index f4e725767..9b4f34fc1 100644 --- a/bindings/node/src/types/ToolApprovalRequestOutput.ts +++ b/bindings/node/src/types/ToolApprovalRequestOutput.ts @@ -1,15 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ToolCall } from "./ToolCall"; -import type { JsonValue } from "./serde_json/JsonValue"; /** * Approval request exposed by text generation. */ -export type ToolApprovalRequestOutput = { approvalId: string, toolCall: ToolCall, reason?: string, isAutomatic?: boolean, signature?: string, -/** - * Tool input before input-schema validation and transformation, present - * when it differs from the validated input (AI SDK - * `ToolApprovalRequest.inputSchemaInput`). Pass-through only: core runs - * no host approval (#200 §0.4 Q1). - */ -inputSchemaInput?: JsonValue, }; +export type ToolApprovalRequestOutput = { approvalId: string, toolCall: ToolCall, reason?: string, isAutomatic?: boolean, signature?: string, }; From 874f261fb3aba28333c1cac068116abf3cf5d180 Mon Sep 17 00:00:00 2001 From: Trey Kim Date: Sun, 11 Oct 2026 12:25:06 +0800 Subject: [PATCH 6/7] ci: run push builds only on master; exp/** via pull_request --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67c660ced..7a3ed2a18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [master, "exp/**"] + branches: [master] pull_request: branches: [master, "exp/**"] From 8455db9abe5315ebbc2d542cc419490e228cb6f6 Mon Sep 17 00:00:00 2001 From: Trey Kim Date: Sun, 11 Oct 2026 12:25:06 +0800 Subject: [PATCH 7/7] ci: cancel superseded PR runs via concurrency group Only pull_request runs cancel in-progress runs in the same group; master push runs are never cancelled. Note: with push limited to master (previous commit), direct pushes to exp/restart no longer run CI separately; exp/** branches are covered only via pull_request. --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a3ed2a18..5c1c7c748 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [master, "exp/**"] +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: CARGO_TERM_COLOR: always