diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd3ac75e7..26a5539ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,10 +18,12 @@ concurrency: env: CARGO_TERM_COLOR: always + # Node and Python replay the shared vectors from this checkout. + AIMUX_CONTRACT_VECTORS: ${{ github.workspace }}/contract-tests/ops # Test-only jobs do not ship binaries: skip single-threaded fat LTO and -Oz # size optimization. ffi-build uploads release artifacts and restores # Cargo.toml's release profile in its job env. - # opt-level 1, not 0: aimux_stream_text drives the whole stream future on + # opt-level 1, not 0: aimux_stream drives the whole stream future on # the caller's thread, and at opt-level 0 that needs more than the 512 KB # stack of a Swift cooperative thread (SIGBUS in the async Swift tests). # Measured for one stream through the C ABI: ~530 KB at 0, ~110 KB at 1, @@ -212,6 +214,8 @@ jobs: CARGO_PROFILE_DEV_DEBUG: "0" steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: @@ -224,6 +228,8 @@ jobs: run: cargo doc --workspace --no-deps env: RUSTDOCFLAGS: -D warnings + - name: Ops contract tests (matrix, lifecycle, streams, envelopes, manifest) + run: cargo test -p aimux-ops - name: Test workspace run: cargo test --workspace @@ -241,6 +247,8 @@ jobs: settings: ${{ fromJSON(needs.changes.outputs.node-legs) }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.settings.target }} @@ -310,7 +318,7 @@ jobs: - name: Rust unit tests if: matrix.settings.test && runner.os == 'Linux' run: cargo test --lib --manifest-path bindings/node/Cargo.toml - - name: Test + - name: Test Node binding and shared contract vectors if: matrix.settings.test working-directory: bindings/node run: npm test @@ -343,6 +351,8 @@ jobs: if: fromJSON(needs.changes.outputs.run)['python-binding'] steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: @@ -365,7 +375,7 @@ jobs: run: | maturin build --release pip install --force-reinstall target/wheels/*.whl - - name: Test + - name: Test Python binding and shared contract vectors working-directory: bindings/python run: | pip install pytest pydantic @@ -387,6 +397,8 @@ jobs: include: ${{ fromJSON(needs.changes.outputs.ffi-legs) }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} @@ -395,13 +407,20 @@ jobs: save-if: ${{ github.ref == 'refs/heads/master' }} - name: Build cdylib run: cargo build -p aimux-ffi --release --target ${{ matrix.target }} + - name: C11 and C++17 ABI contract tests + if: runner.os == 'Linux' + shell: bash + env: + AIMUX_C_LIB_DIR: ${{ github.workspace }}/target/${{ matrix.target }}/release + LD_LIBRARY_PATH: ${{ github.workspace }}/target/${{ matrix.target }}/release + run: bash scripts/test-c-abi.sh - name: Upload artifact uses: actions/upload-artifact@v4 with: name: ffi-${{ matrix.target }} path: | target/${{ matrix.target }}/release/${{ matrix.artifact }} - aimux-ffi/aimux-ffi.h + aimux-ffi/include/aimux.h # The Go binding links against the library this build produced. On # Linux the cgo LDFLAGS force the static archive; on macOS ld64 picks # the .dylib next to it (see bindings/go/aimux.go). macOS is covered @@ -426,6 +445,43 @@ jobs: export CGO_LDFLAGS="-L${{ github.workspace }}/target/${{ matrix.target }}/release" go test -race ./... + ffi-sanitizers: + name: C ABI (ASan + LSan) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + shared-key: "ffi-test" + save-if: ${{ github.ref == 'refs/heads/master' }} + - name: Build FFI library + run: cargo rustc -p aimux-ffi --release --crate-type cdylib + - name: Run C11 and C++17 ABI tests with ASan and LSan + env: + CC: clang + CXX: clang++ + AIMUX_C_LIB_DIR: ${{ github.workspace }}/target/release + run: bash scripts/test-c-abi.sh --asan-lsan + + ffi-linux-arm: + name: aimux-ffi (aarch64-unknown-linux-gnu) + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-unknown-linux-gnu + - uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/master' }} + - name: Build Linux ARM64 FFI library + run: cargo build -p aimux-ffi --release --target aarch64-unknown-linux-gnu + # ── Java + Kotlin bindings: one FFI build, every JDK in turn ───────────── # Gradle 8.14.3 runs on JDK 8+; the Java build script only sets --release on # JDK 9+, so JDK 8 compiles with plain source/target compatibility. The @@ -439,6 +495,8 @@ jobs: LD_LIBRARY_PATH: ${{ github.workspace }}/target/release steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: @@ -497,6 +555,8 @@ jobs: if: fromJSON(needs.changes.outputs.run)['flutter-binding'] steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: @@ -528,10 +588,18 @@ jobs: - name: Get dependencies working-directory: bindings/flutter run: flutter pub get - - name: Test + - name: Build the ABI test fixtures and the Dart bridge + working-directory: bindings/flutter + run: | + python3 tool/build_test_fixture.py + cc -std=c11 -Wall -Wextra -Werror -fPIC -shared -pthread native/aimux_dart.c \ + -o .dart_tool/native/libaimux_dart.so + - name: Test Flutter binding working-directory: bindings/flutter env: LD_LIBRARY_PATH: ${{ github.workspace }}/target/release + AIMUX_NATIVE_LIBRARY: ${{ github.workspace }}/target/release/libaimux_ffi.so + AIMUX_DART_BRIDGE: ${{ github.workspace }}/bindings/flutter/.dart_tool/native/libaimux_dart.so run: flutter test - name: Analyze working-directory: bindings/flutter @@ -557,6 +625,8 @@ jobs: include: ${{ fromJSON(needs.changes.outputs.example-legs) }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.targets }} @@ -610,21 +680,7 @@ jobs: run: | flutter config --no-enable-swift-package-manager flutter build ios --debug --no-codesign --simulator - # Debug builds link the app code into Runner.debug.dylib; the - # Runner stub carries no symbols — scan every executable in the app. - FOUND="" - while IFS= read -r f; do - if nm -g "$f" 2>/dev/null | grep -q 'aimux_openai_new'; then - echo "FOUND aimux_openai_new in: $f" - FOUND=yes - fi - done < <(find build/ios/iphonesimulator/Runner.app -type f -perm -111 2>/dev/null) - if [ -n "$FOUND" ]; then - echo 'OK: aimux symbols present' - else - echo 'FAIL: aimux_openai_new missing from all app binaries' - exit 1 - fi + bash ../tool/check_symbols.sh build/ios/iphonesimulator/Runner.app - name: Build example (Android) if: matrix.platform == 'android' working-directory: bindings/flutter/example @@ -650,6 +706,8 @@ jobs: if: fromJSON(needs.changes.outputs.run)['contract-tests'] steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} - name: Providers doc generator drift (docs/api/providers.md must match generator output) run: python3 scripts/gen_providers_doc.py --check - name: Provider boundaries (no call-level reads or provider-name branches in providers, removed names stay removed) @@ -658,6 +716,8 @@ jobs: - uses: Swatinem/rust-cache@v2 with: save-if: ${{ github.ref == 'refs/heads/master' }} + - name: Ops manifest and generated artifact drift + run: cargo run -p aimux-codegen -- --check - name: ts-rs export drift (checked-in types must match generator output) run: python3 scripts/gen_ts_types.py --check # The AI SDK pin tooling: VERSIONS.json format and integrity rules, and @@ -677,6 +737,8 @@ jobs: if: fromJSON(needs.changes.outputs.run)['swift-contract-tests'] steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b250bb3a..599cf2c38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,6 +98,17 @@ jobs: fi env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + - name: Publish aimux-ops + run: | + sleep 30 + VERSION=$(grep -m1 '^version = ' Cargo.toml | sed 's/version = "\(.*\)"/\1/') + if curl -sf -A "aimux-ci" "https://crates.io/api/v1/crates/aimux-ops/${VERSION}" > /dev/null 2>&1; then + echo "aimux-ops@${VERSION} already published, skipping" + else + cargo publish -p aimux-ops + fi + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - name: Publish aimux-ffi run: | sleep 30 @@ -334,6 +345,7 @@ jobs: shell: bash run: | mkdir -p staging + cp aimux-ffi/include/aimux.h staging/aimux.h case "${{ matrix.target }}" in *linux*) cp target/${{ matrix.target }}/release/libaimux_ffi.so staging/${{ matrix.artifact }} ;; *darwin*) cp target/${{ matrix.target }}/release/libaimux_ffi.dylib staging/${{ matrix.artifact }} ;; @@ -343,7 +355,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: ${{ matrix.artifact }} - path: staging/${{ matrix.artifact }} + path: | + staging/${{ matrix.artifact }} + staging/aimux.h # ── aimux-web console + CLI: self-contained binaries with the embedded # frontend, shipped on the GitHub Release so users download one file and diff --git a/Cargo.toml b/Cargo.toml index a0a03e1a3..9befa0ee1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,12 @@ [workspace] members = [ + "aimux-ops", "aimux-core", "aimux-ffi", "aimux-provider-utils", "aimux-providers", "aimux-stream", + "tools/aimux-codegen", "tools/aimux-cli", "tools/aimux-replay", "tools/aimux-web", diff --git a/aimux-core/src/error.rs b/aimux-core/src/error.rs index 7b1f551f7..be73d03ed 100644 --- a/aimux-core/src/error.rs +++ b/aimux-core/src/error.rs @@ -8,6 +8,54 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use ts_rs::TS; +/// Projects response retry headers to milliseconds using a caller-supplied clock. +/// +/// A numeric `retry-after-ms` takes precedence, even when negative or out of +/// range. An absent or malformed millisecond value falls back to +/// `retry-after` seconds, then an HTTP date. Only finite, non-negative delays +/// fitting in `u64` are returned; fractional milliseconds are truncated. +#[must_use] +pub fn retry_after_ms_from_headers( + headers: &HashMap, + now: SystemTime, +) -> Option { + fn header<'a>(headers: &'a HashMap, name: &str) -> Option<&'a str> { + headers + .get(name) + .or_else(|| { + headers + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(name)) + .map(|(_, value)| value) + }) + .map(|value| value.trim()) + } + + fn milliseconds(value: f64) -> Option { + // u64::MAX rounds up to 2^64 as f64, so the upper bound is exclusive. + if value.is_finite() && (0.0..u64::MAX as f64).contains(&value) { + Some(value.trunc() as u64) + } else { + None + } + } + + if let Some(value) = + header(headers, "retry-after-ms").and_then(|value| value.parse::().ok()) + { + return milliseconds(value); + } + + let value = header(headers, "retry-after")?; + if let Ok(seconds) = value.parse::() { + return milliseconds(seconds * 1_000.0); + } + + let date = httpdate::parse_http_date(value).ok()?; + let delay = date.duration_since(now).ok()?; + u64::try_from(delay.as_millis()).ok() +} + /// What a failed API call observed — the field set of the AI SDK's /// `APICallError` (`statusCode` / `responseBody`), which this project's error /// taxonomy mirrors (docs/internal/vercel-research/07-kernel-infrastructure.md). @@ -672,37 +720,8 @@ impl AiMuxError { return None; }; let headers = detail.response_headers.as_ref()?; - // Rust's float parser accepts `inf`, `Infinity` and `NaN`. A - // non-finite value is no hint: `as i64` would saturate it to - // `i64::MAX`, and upstream never uses it either (it fails the - // `ms < 60000 || ms < backoff` window). - headers - .get("retry-after-ms") - .and_then(|value| value.parse::().ok()) - .filter(|value| value.is_finite()) - .map(|value| value as i64) - .or_else(|| { - headers.get("retry-after").and_then(|value| { - value - .parse::() - .ok() - .map(|seconds| seconds * 1_000.0) - .filter(|milliseconds| milliseconds.is_finite()) - .map(|milliseconds| milliseconds as i64) - .or_else(|| { - httpdate::parse_http_date(value).ok().map(|date| { - match date.duration_since(SystemTime::now()) { - Ok(duration) => duration.as_millis() as i64, - Err(error) => -(error.duration().as_millis() as i64), - } - }) - }) - }) - }) - // Upstream's `0 <= ms` check: a hint in the past is no hint. It - // also keeps the value clear of the C ABI's -1 = absent sentinel - // (`aimux_error_retry_ms`). - .filter(|milliseconds| *milliseconds >= 0) + retry_after_ms_from_headers(headers, SystemTime::now()) + .and_then(|ms| i64::try_from(ms).ok()) } /// Returns the HTTP status code carried by this error, if any. diff --git a/aimux-core/src/language_model_middleware.rs b/aimux-core/src/language_model_middleware.rs index bebd88973..47850900a 100644 --- a/aimux-core/src/language_model_middleware.rs +++ b/aimux-core/src/language_model_middleware.rs @@ -20,6 +20,13 @@ pub enum LanguageModelOperation { /// The supplied callbacks invoke either operation using the transformed parameters. #[async_trait] pub trait LanguageModelMiddleware: Send + Sync { + /// Optional construction-time decorator for middleware that must retain the + /// model, such as tracing. Ordinary parameter and operation hooks then wrap + /// this decorated model. + fn wrap_model(&self, _model: Arc) -> Option> { + None + } + fn override_provider(&self, _model: &dyn LanguageModel) -> Option { None } @@ -83,6 +90,7 @@ pub fn wrap_language_model( provider_id: Option<&str>, ) -> Arc { middleware.iter().rev().fold(model, |model, middleware| { + let model = middleware.wrap_model(model.clone()).unwrap_or(model); let provider = provider_id .map(str::to_string) .or_else(|| middleware.override_provider(model.as_ref())) diff --git a/aimux-core/src/recording.rs b/aimux-core/src/recording.rs index 63ad7be41..98b8f75f1 100644 --- a/aimux-core/src/recording.rs +++ b/aimux-core/src/recording.rs @@ -540,6 +540,11 @@ pub enum RecordingError { /// 录制器 trait。 pub trait Recorder: Send + Sync { + /// Optional backend inspection for Rust adapters; custom recorders may opt out. + fn as_any(&self) -> Option<&dyn std::any::Any> { + None + } + /// 录制输入侧 + 配置侧最小信息(层 A 入口调用)。 fn record_input(&self, call_id: &str, options: &CallOptions, provider: &str, model_id: &str); /// Custom recorders can opt into decision input capture. Built-in recorders @@ -724,8 +729,26 @@ impl Stream for ScopedStream { /// 注册/替换/关闭全局录制器。传 None 关闭。 pub fn init_recording(recorder: Option>) { - if let Ok(mut g) = RECORDER.write() { - *g = recorder; + let previous = match RECORDER.write() { + Ok(mut current) => std::mem::replace(&mut *current, recorder), + Err(_) => None, + }; + // A recorder's final Arc can join its writer; release the state lock first. + drop(previous); +} + +/// Detach only the matching active recorder, returning it for cleanup outside the lock. +/// Existing call snapshots retain their strong references. +#[must_use] +pub fn detach_recording_if(expected: &Arc) -> Option> { + let mut current = RECORDER.write().ok()?; + if current + .as_ref() + .is_some_and(|active| Arc::ptr_eq(active, expected)) + { + current.take() + } else { + None } } @@ -951,10 +974,10 @@ impl JsonlRecorder { }) } - /// 在 `dir` 下创建录制器并启动 writer 线程(兼容入口:FFI `aimux_init_recording` - /// 及绑定均以 infallible `new` 调用,A9 要求保持兼容)。内部委托 [`JsonlRecorder::try_new`], - /// 失败时降级为无 writer 的 no-op recorder(`tx = None`,事件静默丢弃), - /// 行为等价于原先 `create_dir_all().ok()` + writer 打开文件失败早退。 + /// Create a recorder in `dir` and start its writer thread. + /// Delegates to [`JsonlRecorder::try_new`]; on failure this infallible Rust + /// constructor returns a disabled recorder that silently discards events. + /// The ops `recorder.create` path uses `try_new` and reports initialization errors. pub fn new(dir: impl Into) -> Self { let dir = dir.into(); Self::try_new(dir.clone()).unwrap_or_else(|_| Self::disabled(dir)) @@ -3598,4 +3621,52 @@ mod tests { assert!(rec.inconsistent_call_ids().contains(&"c".to_string())); let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn replacing_a_stalled_recorder_releases_the_state_lock_before_cleanup() { + struct StalledRecorder { + entered: std::sync::mpsc::Sender, + resume: std::sync::Mutex>, + } + impl Recorder for StalledRecorder { + fn record_input(&self, _: &str, _: &CallOptions, _: &str, _: &str) {} + fn record_provider(&self, _: &str, _: &ProviderRecord) {} + fn record_exchange(&self, _: &str, _: &HttpExchange) {} + fn record_exchange_update( + &self, + _: &str, + _: u32, + _: u32, + _: &ResponseRecord, + _: Option, + ) { + } + fn record_outcome(&self, _: &str, _: &OutcomeRecord) {} + fn flush(&self) {} + } + impl Drop for StalledRecorder { + fn drop(&mut self) { + let _ = self.entered.send(RECORDER.try_read().is_ok()); + let _ = self + .resume + .get_mut() + .unwrap() + .recv_timeout(std::time::Duration::from_secs(3)); + } + } + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (resume_tx, resume_rx) = std::sync::mpsc::channel(); + init_recording(Some(Arc::new(StalledRecorder { + entered: entered_tx, + resume: std::sync::Mutex::new(resume_rx), + }))); + let replacement = std::thread::spawn(|| init_recording(None)); + let unlocked_in_drop = entered_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + let concurrent_snapshot_unlocked = RECORDER.try_read().is_ok(); + resume_tx.send(()).unwrap(); + replacement.join().unwrap(); + assert!(unlocked_in_drop); + assert!(concurrent_snapshot_unlocked); + } } diff --git a/aimux-core/src/retry.rs b/aimux-core/src/retry.rs index a16e11926..0cd91d35e 100644 --- a/aimux-core/src/retry.rs +++ b/aimux-core/src/retry.rs @@ -500,7 +500,8 @@ mod tests { let nan_then_seconds = api_error_with_headers(503, &[("retry-after-ms", "NaN"), ("retry-after", "1")]); - assert_eq!(retry_header_delay(&nan_then_seconds, 2_000), Some(1_000)); + // A parsed non-finite preferred value blocks fallback (RFC-0039 §6.2). + assert_eq!(retry_header_delay(&nan_then_seconds, 2_000), None); } #[tokio::test] diff --git a/aimux-core/src/trace/store.rs b/aimux-core/src/trace/store.rs index 35e48a7df..1ba600333 100644 --- a/aimux-core/src/trace/store.rs +++ b/aimux-core/src/trace/store.rs @@ -381,9 +381,19 @@ pub enum BreakKind { Unknown, } -/// The built-in bounded sink: FIFO ring (global) + per-scope cap + per-session -/// index. Thread-safe; queries are consistent snapshots. +/// Rule-auditor policy inherited by decorators attached to a trace store. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TraceAuditMode { + #[default] + Off, + Shared, + Strict, +} + +/// The bounded sink: FIFO ring, per-scope cap and per-session index. +/// Queries are consistent snapshots. pub struct RingTraceStore { + audit_mode: TraceAuditMode, inner: Mutex, } @@ -407,6 +417,7 @@ impl RingTraceStore { pub fn with_capacity(cap: usize, per_scope_cap: usize) -> Self { assert!(cap > 0 && per_scope_cap > 0); Self { + audit_mode: TraceAuditMode::Off, inner: Mutex::new(Inner { store: TraceStore::new(cap, per_scope_cap), records: VecDeque::with_capacity(cap), @@ -415,6 +426,18 @@ impl RingTraceStore { } } + /// Audit policy inherited by trace decorators created for this store. + #[must_use] + pub fn with_audit_mode(mut self, mode: TraceAuditMode) -> Self { + self.audit_mode = mode; + self + } + + #[must_use] + pub fn audit_mode(&self) -> TraceAuditMode { + self.audit_mode + } + pub fn clear(&self) { let mut inner = self.inner.lock().unwrap(); inner.store = TraceStore::new(inner.store.cap, inner.store.per_scope_cap); diff --git a/aimux-ffi/ABI-MIGRATION.md b/aimux-ffi/ABI-MIGRATION.md new file mode 100644 index 000000000..098114ddf --- /dev/null +++ b/aimux-ffi/ABI-MIGRATION.md @@ -0,0 +1,143 @@ +# Legacy C export replacement ledger + +All 130 old implementations are removed in the atomic C ABI switch. Four shared +names are reimplemented solely in `src/abi.rs`; 126 retired names are absent. +The final ABI exports 14 primitives, including `aimux_handle_finalizer`. + +The table below maps every removed symbol to its replacement and covering test. +Business DTO generation remains in `scripts/gen_ts_types.py`; recording schema +migration is separate. Decision coverage is provided by `decision.decide` / +`decision.capabilities`. + +| Old symbol | Replacement op / primitive | Replacement test target | +|---|---|---| +| `aimux_error_free` | Same-name RFC §4.1 primitive, implemented only in abi.rs | abi_primitives; tests/c/primitives.c and tests/c/raii.cpp | +| `aimux_error_code` | Same-name RFC §4.1 primitive, implemented only in abi.rs | abi_primitives; tests/c/primitives.c and tests/c/raii.cpp | +| `aimux_error_message` | Same-name RFC §4.1 primitive, implemented only in abi.rs | abi_primitives; tests/c/primitives.c and tests/c/raii.cpp | +| `aimux_error_status` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_error_retry_ms` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_error_retryable` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_error_provider_code` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_error_provider_message` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_error_response_body` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_error_url` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_error_request_body_values` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_error_response_headers` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_error_provider_data` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_error_retry_reason` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_error_retry_count` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_error_retry_error_at` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_error_model_id` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_error_model_type` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_error_provider_id` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_error_tool_name` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_error_available_tools` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_error_tool_input` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_error_original_error` | aimux_error_json: ErrorEnvelope.error / retryAfterMs; no field getter aliases | error_envelope_golden; abi_primitives | +| `aimux_openai_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_openai_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_anthropic_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_anthropic_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_anthropic_aws_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_anthropic_aws_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_azure_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_azure_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_bedrock_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_bedrock_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_vertex_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_vertex_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_cohere_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_cohere_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_mistral_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_mistral_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_xai_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_xai_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_provider_new` | provider.create / provider.default → provider.model; one-shot construction removed | ops/provider.rs tests; ops_matrix | +| `aimux_provider_from_env` | provider.create / provider.default → provider.model; one-shot construction removed | ops/provider.rs tests; ops_matrix | +| `aimux_provider_handle_new` | provider.create | ops/provider.rs tests; handle_lifecycle | +| `aimux_provider_list_models` | provider.invoke (existing model discovery only) | ops/provider.rs tests; ops_matrix | +| `aimux_provider_model` | provider.model | ops/provider.rs tests; handle_lifecycle | +| `aimux_get_model_specs` | catalogue.fetch | ops/misc.rs tests | +| `aimux_generate_text` | text.generate | ops/text.rs tests; ops_matrix | +| `aimux_generate_object` | text.generateObject | ops/text.rs tests; ops_matrix | +| `aimux_consume_stream_text` | text.consume | ops/text.rs tests; ops_matrix | +| `aimux_stream_text` | text.stream via aimux_stream; optional Abort handle | ops/text.rs tests; stream_terminal; abi_primitives | +| `aimux_abort_signal_new` | abort.create | ops/handles.rs tests; abi_primitives | +| `aimux_abort_signal_abort` | abort.signal / aimux_abort | ops/handles.rs tests; ABI callback tests | +| `aimux_abort_signal_drop` | handle.drop / aimux_drop; pointer-shaped alias removed | ops/handles.rs tests; handle_lifecycle; C RAII tests | +| `aimux_stream_text_with_abort` | text.stream via aimux_stream; optional Abort handle | ops/text.rs tests; stream_terminal; abi_primitives | +| `aimux_tool_call_repair_context` | toolRepair.context | ops/misc.rs tests | +| `aimux_apply_tool_call_repair` | toolRepair.apply | ops/misc.rs tests | +| `aimux_apply_tool_call_repair_to_result` | toolRepair.applyResult | ops/misc.rs tests | +| `aimux_generate_text_result_as_openai` | output.toOpenAI | ops/text.rs tests; ops_matrix | +| `aimux_generate_text_as_openai` | text.generateOpenAI | ops/text.rs tests; ops_matrix | +| `aimux_stream_text_as_openai` | text.streamOpenAI via aimux_stream; optional Abort handle | ops/text.rs tests; stream_terminal; abi_primitives | +| `aimux_stream_text_as_openai_with_abort` | text.streamOpenAI via aimux_stream; optional Abort handle | ops/text.rs tests; stream_terminal; abi_primitives | +| `aimux_drop_handle` | handle.drop / aimux_drop; pointer-shaped alias removed | ops/handles.rs tests; handle_lifecycle; C RAII tests | +| `aimux_drop_handle_ptr` | handle.drop / aimux_drop; pointer-shaped alias removed | ops/handles.rs tests; handle_lifecycle; C RAII tests | +| `aimux_free_string` | Same-name RFC §4.1 primitive, implemented only in abi.rs | abi_primitives; tests/c/primitives.c and tests/c/raii.cpp | +| `aimux_openai_embedding_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_openai_embedding_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_cohere_embedding_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_cohere_embedding_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_google_embedding_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_google_embedding_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_embed` | embedding.embed / embedding.embedMany | ops/modalities.rs tests; ops_matrix | +| `aimux_openai_speech_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_openai_speech_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_speech_generate` | speech.generate | ops/modalities.rs tests; ops_matrix | +| `aimux_openai_image_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_openai_image_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_google_image_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_google_image_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_image_generate` | image.generate | ops/modalities.rs tests; ops_matrix | +| `aimux_openai_transcription_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_openai_transcription_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_transcription_generate` | transcription.generate | ops/modalities.rs tests; ops_matrix | +| `aimux_transcription_session_new` | transcription.start → Operation; no legacy session type | operation integration tests; abi_primitives | +| `aimux_transcription_push_audio` | operation.push / aimux_operation_push | operation integration tests; C pointer/length tests | +| `aimux_transcription_input_done` | operation.inputDone | operation admission/close tests | +| `aimux_transcription_next_part` | operation.next / aimux_operation_next_part | operation poll/single-consumer tests; abi_primitives | +| `aimux_transcription_session_drop` | handle.drop / aimux_drop on Operation | operation cleanup tests; handle_lifecycle | +| `aimux_openai_files_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_openai_files_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_file_upload` | files.upload | ops/files.rs tests | +| `aimux_cohere_reranking_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_cohere_reranking_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_rerank` | reranking.rerank | ops/modalities.rs tests; ops_matrix | +| `aimux_google_video_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_google_video_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_video_generate` | video.generate | ops/modalities.rs tests; ops_matrix | +| `aimux_provider_decision_model` | provider.model(method=decision) | Existing core/tests/decision_test.rs and providers/tests/runtime_decision_test.rs retained; ops/decision.rs tests, ops_matrix | +| `aimux_jev_decision_new` | provider.create(settings: existing Jev decision configuration) → provider.model(method=decision) | Existing core/tests/decision_test.rs and providers/tests/runtime_decision_test.rs retained; ops/decision.rs tests, ops_matrix | +| `aimux_jev_decision_new_with_probability_source` | provider.create(settings: existing Jev probability source) → provider.model(method=decision) | Existing core/tests/decision_test.rs and providers/tests/runtime_decision_test.rs retained; ops/decision.rs tests, ops_matrix | +| `aimux_decision_capabilities` | decision.capabilities | Existing core/tests/decision_test.rs and providers/tests/runtime_decision_test.rs retained; ops/decision.rs tests, ops_matrix | +| `aimux_decide` | decision.decide | Existing core/tests/decision_test.rs and providers/tests/runtime_decision_test.rs retained; ops/decision.rs tests, ops_matrix | +| `aimux_decide_with_abort` | decision.decide + owner-aware Abort handle | Existing core/tests/decision_test.rs and providers/tests/runtime_decision_test.rs retained; ops/decision.rs tests, ops_matrix | +| `aimux_tavily_search_new` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_tavily_search_new_with_base` | provider.create → provider.model (or provider.files for Files) | ops/provider.rs tests; ops_matrix; handle_lifecycle | +| `aimux_search` | search.search | ops/modalities.rs tests; ops_matrix | +| `aimux_codex_refresh` | auth.codexRefresh | ops/misc.rs tests | +| `aimux_init_logging` | runtime.configure (logging / startup proxy settings) | ops/runtime.rs tests | +| `aimux_session_store_init` | sessionStore.create → sessionStore.attach; explicit infer option | ops/session.rs tests | +| `aimux_session_infer_init` | sessionStore.create → sessionStore.attach; explicit infer option | ops/session.rs tests | +| `aimux_session_calls` | sessionStore.calls | ops/session.rs tests | +| `aimux_list_sessions` | sessionStore.list | ops/session.rs tests | +| `aimux_trace_new` | trace.create → middleware.create (trace) → model.wrap; explicit TraceStore handle | ops/trace.rs tests; ops/handles.rs tests; ops/registry.rs tests | +| `aimux_trace_new_audited` | trace.create → middleware.create (trace) → model.wrap; explicit TraceStore handle | ops/trace.rs tests; ops/handles.rs tests; ops/registry.rs tests | +| `aimux_trace_aggregate` | trace.aggregate | ops/trace.rs tests | +| `aimux_trace_session_chain` | trace.sessionChain | ops/trace.rs tests | +| `aimux_trace_session_trajectory` | trace.sessionTrajectory | ops/trace.rs tests | +| `aimux_trace_export_jsonl` | trace.export | ops/trace.rs tests | +| `aimux_trace_clear` | trace.clear | ops/trace.rs tests | +| `aimux_init_recording` | recorder.create → recorder.attach; explicit capture policy | ops/recording.rs tests | +| `aimux_init_recording_ring` | recorder.create → recorder.attach; explicit capture policy | ops/recording.rs tests | +| `aimux_init_recording_ring_default` | recorder.create → recorder.attach; explicit capture policy | ops/recording.rs tests | +| `aimux_recording_stop` | recorder.stop | ops/recording.rs tests | +| `aimux_recording_flush` | recorder.flush; failure-reporting semantics only | ops/recording.rs persistence-error tests; error_envelope_golden | +| `aimux_recording_try_flush` | recorder.flush; failure-reporting semantics only | ops/recording.rs persistence-error tests; error_envelope_golden | +| `aimux_register_providers` | provider.create → registry.create; process-global mutation removed | ops/provider.rs tests; ops/registry.rs tests | +| `aimux_init_proxy` | runtime.configure (logging / startup proxy settings) | ops/runtime.rs tests | +| `aimux_mock_replay_new` | replay.create / replay.capabilities; current recording format only, no legacy JSONL reader | ops/replay.rs fixture tests; providers/tests/replay_test.rs retained | +| `aimux_router_new` | composite.router | ops/composite.rs tests; handle_lifecycle | +| `aimux_moa_new` | composite.moa | ops/composite.rs tests; handle_lifecycle | diff --git a/aimux-ffi/Cargo.toml b/aimux-ffi/Cargo.toml index 32117f87e..51ef0cf9d 100644 --- a/aimux-ffi/Cargo.toml +++ b/aimux-ffi/Cargo.toml @@ -9,26 +9,17 @@ categories = ["api-bindings", "asynchronous", "development-tools::ffi"] documentation = "https://docs.rs/aimux-ffi" [lib] -# Produce linkable artifacts for C ABI consumers -# (C/C++, Go, Kotlin, Java, Swift, and Flutter). -# `rlib` lets other Rust crates depend on this crate too. crate-type = ["cdylib", "staticlib", "rlib"] [dependencies] -aimux-core = { workspace = true } -aimux-providers = { workspace = true } -aimux-provider-utils = { workspace = true } +aimux-ops = { path = "../aimux-ops", version = "0.5.0" } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } -tracing = { workspace = true } -# Needed to drive `Stream::next` while consuming `stream_text` output. futures = { workspace = true } -# Mock transcription stream construction in tests (RFC-0028). -async-stream = { workspace = true } [dev-dependencies] -serde_json = { workspace = true } +aimux-core = { workspace = true } async-trait = { workspace = true } [lints] diff --git a/aimux-ffi/aimux-error.h b/aimux-ffi/aimux-error.h deleted file mode 100644 index 20c14c130..000000000 --- a/aimux-ffi/aimux-error.h +++ /dev/null @@ -1,184 +0,0 @@ -/** - * aimux-error.h — C error model for aimux-ffi. - * - * Every fallible function returns `aimux_error_t *`: NULL on success and an - * owned error on failure. The normal result is written to the function's - * trailing out-parameter, which remains at its documented sentinel on failure. - * - * Every non-NULL error has one non-zero `aimux_error_code_t` and one message. - * Codes 1..20 come from `AiMuxError`, 100..105 from - * `RecordingError`, and 200..206 identify failures detected while crossing - * the C ABI. Higher-level bindings reconstruct their native error types from - * that code; they map all 200..206 codes to the language's existing - * argument/state/invariant error. - * - * Strings returned by getters are owned by the caller and must be released - * with `aimux_free_string()`. Release the error itself exactly once with - * `aimux_error_free()`; passing NULL to either release function is safe. - */ - -#ifndef AIMUX_ERROR_H -#define AIMUX_ERROR_H - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** An error returned by a failed Aimux C ABI invocation. Opaque and owned. */ -typedef struct aimux_error aimux_error_t; - -/** - * Stable machine-readable code returned by `aimux_error_code()`. - * - * Existing values are never renumbered or reused. New values are appended - * within a reserved range or added in a new range. A non-NULL error must never - * report AIMUX_OK; an unknown non-zero value means the header and loaded - * library are incompatible. - */ -typedef enum aimux_error_code { - AIMUX_OK = 0, - - /* AiMuxError: 1..20, except retired code 4. */ - AIMUX_E_OTHER = 1, - AIMUX_E_JSON_PARSE = 2, - AIMUX_E_INVALID_RESPONSE_DATA = 3, - AIMUX_E_INVALID_ARGUMENT = 5, - AIMUX_E_INVALID_PROMPT = 6, - AIMUX_E_TOKEN_EXPIRED = 7, - AIMUX_E_UNSUPPORTED_FUNCTIONALITY = 8, - AIMUX_E_NO_SUCH_MODEL = 9, - AIMUX_E_NO_SUCH_PROVIDER = 10, - AIMUX_E_API_CALL = 11, - AIMUX_E_TIMEOUT = 12, - AIMUX_E_ABORTED = 13, - /* Reclaims the slot the pre-unification `Other` vacated: the opaque-pointer - * ABI break means no pre-unification caller can link, so nothing can - * misread it. */ - AIMUX_E_RETRY = 14, - AIMUX_E_NO_SUCH_TOOL = 15, - AIMUX_E_INVALID_TOOL_INPUT = 16, - AIMUX_E_TOOL_CALL_REPAIR = 17, - AIMUX_E_NO_OUTPUT_GENERATED = 18, - /* No API key was passed and the fallback environment variable is unset - * (the AI SDK's LoadAPIKeyError). Pre-HTTP: no request was made. */ - AIMUX_E_LOAD_API_KEY = 19, - /* A required provider setting was not passed and its fallback environment - * variable is unset (the AI SDK's LoadSettingError). */ - AIMUX_E_LOAD_SETTING = 20, - - /* RecordingError: 100..105. */ - AIMUX_E_RECORDING_INIT = 100, - AIMUX_E_RECORDING_OPEN_FILE = 101, - AIMUX_E_RECORDING_SPAWN = 102, - AIMUX_E_RECORDING_WRITER_GONE = 103, - AIMUX_E_RECORDING_FLUSH_TIMEOUT = 104, - AIMUX_E_RECORDING_WRITE = 105, - - /* Failures detected while crossing the C ABI: 200..206. */ - AIMUX_E_FFI_NULL_POINTER = 200, - AIMUX_E_FFI_INVALID_UTF8 = 201, - AIMUX_E_FFI_INVALID_WIRE_JSON = 202, - AIMUX_E_FFI_INVALID_HANDLE = 203, - AIMUX_E_FFI_REENTRANT_CALL = 204, - AIMUX_E_FFI_RESULT_SERIALIZATION = 205, - AIMUX_E_FFI_CALLBACK_FAILURE = 206 -} aimux_error_code_t; - -/** Release an error. NULL-safe; call exactly once for a non-NULL error. */ -void aimux_error_free(aimux_error_t *error); - -/** AIMUX_OK for NULL; otherwise the error's single non-zero code. */ -int32_t aimux_error_code(const aimux_error_t *error); - -/** Human-readable description for every code; caller frees the result. */ -char *aimux_error_message(const aimux_error_t *error); - -/* - * AiMuxError facts. These getters answer only for the documented AiMuxError - * code and return NULL / -1 / 0 for every RecordingError, C ABI failure, - * unrelated AiMuxError code, or NULL. - */ - -/** 1 when retrying may help; only AIMUX_E_API_CALL can answer 1. */ -int32_t aimux_error_retryable(const aimux_error_t *error); - -/** - * Observed HTTP status for AIMUX_E_API_CALL; 401 for - * AIMUX_E_TOKEN_EXPIRED; -1 otherwise or when no response was observed. - */ -int32_t aimux_error_status(const aimux_error_t *error); - -/* AIMUX_E_API_CALL — returned strings are caller-owned. */ - -/** Retry hint in milliseconds (0 = retry now), or -1 when absent. */ -int64_t aimux_error_retry_ms(const aimux_error_t *error); -/** Provider's own error code, e.g. "insufficient_quota". Under - * AIMUX_E_LOAD_API_KEY / AIMUX_E_LOAD_SETTING: the environment variable that - * was consulted, e.g. "OPENAI_API_KEY". */ -char *aimux_error_provider_code(const aimux_error_t *error); -/** Failure text without Aimux's composed prefix. Under AIMUX_E_LOAD_API_KEY: - * what the key is for ("OpenAI"); under AIMUX_E_LOAD_SETTING: the setting's - * parameter name ("region"). */ -char *aimux_error_provider_message(const aimux_error_t *error); -/** Raw provider response body. */ -char *aimux_error_response_body(const aimux_error_t *error); -/** Sanitized request URL of the failed call. */ -char *aimux_error_url(const aimux_error_t *error); -/** Sanitized request body values, as a JSON string. */ -char *aimux_error_request_body_values(const aimux_error_t *error); -/** Sanitized response headers, as one JSON object string of - * name → value pairs, e.g. {"retry-after-ms":"1500"}. */ -char *aimux_error_response_headers(const aimux_error_t *error); -/** Parsed provider error data, as a JSON string. */ -char *aimux_error_provider_data(const aimux_error_t *error); - -/* AIMUX_E_NO_SUCH_MODEL — returned strings are caller-owned. */ - -char *aimux_error_model_id(const aimux_error_t *error); -char *aimux_error_model_type(const aimux_error_t *error); - -/* AIMUX_E_NO_SUCH_PROVIDER — returned string is caller-owned. */ - -char *aimux_error_provider_id(const aimux_error_t *error); - -/* - * AIMUX_E_RETRY — retrying stopped; the error keeps the per-attempt history. - * `aimux_error_message()` composes the summary ("Failed after N attempts…"). - */ - -/** Why retrying stopped: "maxRetriesExceeded" (every permitted attempt - * failed with a retryable error) or "errorNotRetryable" (a later attempt - * failed with a non-retryable error). Caller-owned string. */ -char *aimux_error_retry_reason(const aimux_error_t *error); -/** Number of recorded attempt errors; 0 under any other code or for NULL. */ -int32_t aimux_error_retry_count(const aimux_error_t *error); -/** The attempt error at `index` (0-based, oldest first; the last entry is - * the final attempt) as a NEW owned error — read it with these same getters - * and release it with aimux_error_free(), independently of the parent. - * NULL when `index` is out of range or under any other code. */ -aimux_error_t *aimux_error_retry_error_at(const aimux_error_t *error, - int32_t index); -/* Tool-contract errors — returned strings are caller-owned. */ - -/** AIMUX_E_NO_SUCH_TOOL / AIMUX_E_INVALID_TOOL_INPUT: the tool name called. */ -char *aimux_error_tool_name(const aimux_error_t *error); -/** - * AIMUX_E_NO_SUCH_TOOL: the available tool names as a JSON string array, - * or NULL when no tool set was supplied. - */ -char *aimux_error_available_tools(const aimux_error_t *error); -/** AIMUX_E_INVALID_TOOL_INPUT / AIMUX_E_NO_SUCH_TOOL: raw argument text, or NULL if unavailable. */ -char *aimux_error_tool_input(const aimux_error_t *error); -/** - * AIMUX_E_TOOL_CALL_REPAIR: the original lookup/parse/validation error as - * externally-tagged wire JSON (the same encoding as `ToolCall.error`). - */ -char *aimux_error_original_error(const aimux_error_t *error); - -#ifdef __cplusplus -} -#endif - -#endif /* AIMUX_ERROR_H */ diff --git a/aimux-ffi/aimux-ffi.h b/aimux-ffi/aimux-ffi.h deleted file mode 100644 index bf4dafd4c..000000000 --- a/aimux-ffi/aimux-ffi.h +++ /dev/null @@ -1,797 +0,0 @@ -/** - * aimux-ffi.h — C ABI for aimux multi-language bindings. - * - * This C ABI is used by C / C++ and the Go, Kotlin, Java, Swift, and Flutter - * bindings. Native bindings (Python / Node) - * bypass this layer and use aimux-providers directly. - * - * ## Errors - * - * Every fallible function returns `aimux_error_t *`: NULL on success - * (the result is in the out-parameter), non-NULL on failure (the out-parameter - * remains at its sentinel: handle 0, pointer NULL). Every non-NULL error has - * one code and one message, read with `aimux_error_code()` and - * `aimux_error_message()`. Release it exactly once with `aimux_error_free()`. - * Getter strings are caller-owned (`aimux_free_string`). See aimux-error.h. - * - * Each prototype below identifies its expected high-level error: - * [AiMuxError] codes 1..20 - * [RecordingError] codes 100..105 - * [C ABI] no expected high-level code - * Every fallible call can additionally return a C ABI failure (200..206). - * - * Functions with no failure path keep their natural signature (`void`, or a - * plain value such as the `uint64_t` of aimux_abort_signal_new). - * - * ## Memory ownership - * - * Handles (`uint64_t`, non-zero) are released with aimux_drop_handle — every - * handle category, including transcription sessions (whose driver task is aborted and - * joined; aimux_transcription_session_drop is the same operation under a - * clearer name). - * Result strings (`char *` written to `*out_json`) are freed with - * aimux_free_string (NULL is safe). Returned errors are released with - * aimux_error_free. - * aimux_stream_text callbacks receive `const char*` pointers that are valid - * **only for the duration of the callback**. The callback must copy the data - * synchronously. - * - * ## Callbacks - * - * Callbacks must not unwind across the Aimux C ABI. A Rust callback's - * unwinding panic (panic=unwind builds) is caught and reported as a C ABI - * failure; foreign exceptions (C++, JVM, Swift, Dart, Go) must be caught - * inside the callback trampoline. Callbacks execute on the calling thread; a - * blocking aimux_* call made from inside a callback is rejected as a C ABI - * failure (re-entrant call) rather than deadlocking the tokio runtime. - * - * ## Concurrency - * - * All functions are synchronous (block until the operation completes). - * `opts_json.timeout` (`{"totalMs","stepMs","firstChunkMs","chunkMs"}`, - * milliseconds; the latter two streaming-only) bounds a call from the callee - * side; `aimux_abort_signal_*` plus the `*_with_abort` entry points cancel a - * running call from another thread. - * - * ## Wire format - * - * - Internal panics abort the process (the workspace builds with panic=abort). - * - `prompt_json`: bare prompt value (`"text"` or `[{...}]`), or a - * single-key wrapper `{"prompt": }` (any extra key disables - * unwrapping and the whole object is parsed as the prompt) - * - `opts_json` for `aimux_generate_text`/`aimux_stream_text`: serialized - * GenerateTextOptions (NULL / empty / "null" for defaults). Multimodal - * calls (`aimux_speech_generate`, `aimux_image_generate`, `aimux_rerank`, - * `aimux_video_generate`, `aimux_search`) REQUIRE a valid JSON object — - * NULL or empty is a C ABI failure. - * - Malformed JSON text is a C ABI failure; well-formed JSON of the wrong - * shape is AiMuxError AIMUX_E_INVALID_ARGUMENT. - * - Every `opts_json` that is read, and the router / MoA `config_json`, is - * camelCase and strict: a key it does not have (aimux 0.5's snake_case - * `max_retries` or `timeout.total_ms`, a typo) is AIMUX_E_INVALID_ARGUMENT - * naming the key, never silently ignored. - * - Results: serialized JSON of GenerateTextResult on success - * - Stream parts: serialized JSON of TextStreamPart - */ - -#ifndef AIMUX_FFI_H -#define AIMUX_FFI_H - -#include -#include - -#include "aimux-error.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* ── Provider constructors ──────────────────────────────────────────────── */ -/* [AiMuxError] Every constructor writes a non-zero handle to *out_handle. - AiMuxError: invalid model id / config. C ABI failure: NULL or non-UTF-8 - argument. `_with_base` adds `base_url` (NULL or empty uses the provider - default). */ - -/** - * Create an OpenAI model instance. - * - * @param api_key NUL-terminated API key string. - * @param model_id NUL-terminated model ID (e.g. "gpt-4o"). - * @param out_handle Receives the model handle. - */ -aimux_error_t *aimux_openai_new(const char *api_key, const char *model_id, - uint64_t *out_handle); -aimux_error_t *aimux_openai_new_with_base(const char *api_key, const char *model_id, - const char *base_url, uint64_t *out_handle); - -/** Create an Anthropic model instance (e.g. "claude-3-5-sonnet-20241022"). */ -aimux_error_t *aimux_anthropic_new(const char *api_key, const char *model_id, - uint64_t *out_handle); -aimux_error_t *aimux_anthropic_new_with_base(const char *api_key, const char *model_id, - const char *base_url, uint64_t *out_handle); - -/** Create a Cohere model instance (API key + model ID). */ -aimux_error_t *aimux_cohere_new(const char *api_key, const char *model_id, - uint64_t *out_handle); -aimux_error_t *aimux_cohere_new_with_base(const char *api_key, const char *model_id, - const char *base_url, uint64_t *out_handle); - -/** Create a Mistral model instance (API key + model ID). */ -aimux_error_t *aimux_mistral_new(const char *api_key, const char *model_id, - uint64_t *out_handle); -aimux_error_t *aimux_mistral_new_with_base(const char *api_key, const char *model_id, - const char *base_url, uint64_t *out_handle); - -/** Create an xAI model instance (API key + model ID). */ -aimux_error_t *aimux_xai_new(const char *api_key, const char *model_id, - uint64_t *out_handle); -aimux_error_t *aimux_xai_new_with_base(const char *api_key, const char *model_id, - const char *base_url, uint64_t *out_handle); - -/** Create a Bedrock model instance (AWS SigV4 credentials). */ -aimux_error_t *aimux_bedrock_new(const char *access_key_id, - const char *secret_access_key, - const char *region, - const char *model_id, uint64_t *out_handle); -aimux_error_t *aimux_bedrock_new_with_base(const char *access_key_id, - const char *secret_access_key, - const char *region, - const char *model_id, - const char *base_url, uint64_t *out_handle); - -/** Create a Vertex AI model instance (GCP bearer token). */ -aimux_error_t *aimux_vertex_new(const char *access_token, - const char *project, - const char *location, - const char *model_id, uint64_t *out_handle); -aimux_error_t *aimux_vertex_new_with_base(const char *access_token, - const char *project, - const char *location, - const char *model_id, - const char *base_url, uint64_t *out_handle); - -/** Create an Anthropic-on-AWS model instance (API key + region). */ -aimux_error_t *aimux_anthropic_aws_new(const char *api_key, const char *region, - const char *model_id, uint64_t *out_handle); -aimux_error_t *aimux_anthropic_aws_new_with_base(const char *api_key, const char *region, - const char *model_id, const char *base_url, - uint64_t *out_handle); - -/** - * Create an Azure OpenAI model instance (API key + resource name; deployment - * passed as model_id; api_version NULL or empty uses the provider default). - * - * `_with_base` takes an explicit `base_url` IN PLACE OF `resource_name`. - * Unlike other `_with_base` variants, `base_url` here is REQUIRED — NULL is - * a C ABI failure ("base_url: must not be NULL"). - */ -aimux_error_t *aimux_azure_new(const char *api_key, const char *resource_name, - const char *deployment, const char *api_version, - uint64_t *out_handle); -aimux_error_t *aimux_azure_new_with_base(const char *api_key, const char *base_url, - const char *deployment, const char *api_version, - uint64_t *out_handle); - -/** - * Create a model from the provider registry by name (RFC-0017 phase 4). - * - * @param name NUL-terminated registry provider name (e.g. "deepseek", "groq"). - * @param api_key NUL-terminated API key; NULL reads the provider's env - * var from the registry entry. Non-NULL but non-UTF-8 is - * a C ABI failure. - * @param model_id NUL-terminated model ID. - * @param config_json Optional JSON object of ProviderOptions - * ({"base_url": "...", "headers": {...}, "organization": "...", - * "project": "..."}); "max_retries" (call-level) and - * "body_overrides" (removed) are rejected as invalid - * arguments. NULL / empty / "null" for defaults. - * @param out_handle Receives the model handle. AiMuxError: unknown - * provider, bad config, missing env key, invalid model id. - */ -aimux_error_t *aimux_provider_new(const char *name, const char *api_key, - const char *model_id, const char *config_json, - uint64_t *out_handle); - -/** - * Convenience: create a model by provider name, reading the API key from the - * provider's env var. - */ -aimux_error_t *aimux_provider_from_env(const char *name, const char *model_id, - uint64_t *out_handle); - -/** - * Provider handles (RFC-0027): create a provider handle for a registry-backed - * provider. Unlike aimux_provider_new (which binds to a single model_id), - * this returns a provider handle supporting aimux_provider_list_models and - * aimux_provider_model. - */ -aimux_error_t *aimux_provider_handle_new(const char *name, const char *api_key, - const char *config_json, uint64_t *out_handle); - -/** - * [AiMuxError] List models on a provider handle (RFC-0027 runtime discovery). - * Writes a JSON array of sparse RuntimeModel (id / ownedBy / created) — - * no community enrichment. To supplement with model specs, call - * aimux_get_model_specs separately and merge in the host. - */ -aimux_error_t *aimux_provider_list_models(uint64_t handle, char **out_models_json); - -/** [AiMuxError] Build a language model from a provider handle + model_id. */ -aimux_error_t *aimux_provider_model(uint64_t handle, const char *model_id, - uint64_t *out_handle); - -/** - * [AiMuxError] Fetch the community model catalogue (anya2a). Writes a - * JSON-serialized Catalogue (provider → model_id → ModelSpec). source_url may - * be NULL for the default endpoint. Thin fetch — no caching. - */ -aimux_error_t *aimux_get_model_specs(const char *source_url, char **out_specs_json); - -/* ── Generation ─────────────────────────────────────────────────────────── */ - -/** - * [AiMuxError] Non-streaming text generation. - * - * @param handle Language-model handle from aimux_*_new - * (a handle of another modality is a C ABI failure). - * @param prompt_json JSON prompt (see wire format above). - * @param opts_json JSON options (NULL or empty for defaults). - * @param out_json Receives the serialized GenerateTextResult (caller MUST - * free with aimux_free_string). Malformed - * prompt_json/opts_json fails before any network call. - */ -aimux_error_t *aimux_generate_text(uint64_t handle, - const char *prompt_json, - const char *opts_json, char **out_json); - -/* [AiMuxError] Generate a structured JSON object (M12, RFC-0016) with the core's - deprecated generate_object wrapper. Same signature as aimux_generate_text; - writes serialized GenerateObjectResult JSON. Pass - output: { "type": "object", "schema": ... } via opts_json to describe the value; - the model's text must parse and match the schema. */ -aimux_error_t *aimux_generate_object(uint64_t handle, - const char *prompt_json, - const char *opts_json, char **out_json); - -/* [AiMuxError] Consume a stream to completion and write its result - (M11, RFC-0016). Synchronous (blocks until the stream finishes). Same - signature as aimux_generate_text; writes serialized GenerateTextResult - JSON, the same result aimux_generate_text writes. */ -aimux_error_t *aimux_consume_stream_text(uint64_t handle, - const char *prompt_json, - const char *opts_json, char **out_json); - -/** - * [AiMuxError] Streaming text generation with push callbacks (blocks until the - * stream ends). - * - * @param handle Model handle from aimux_*_new. - * @param prompt_json JSON prompt (see wire format above). - * @param opts_json JSON options (NULL or empty for defaults). - * @param on_part Called for each TextStreamPart (JSON string, valid during - * the call only). Required: NULL is a C ABI failure - * ("on_part: must not be NULL"). - * @param on_done Called once when the stream ends normally. Required. - * @param stream_ctx Opaque pointer passed through to on_part / on_done. - * @return NULL after on_done; on failure an error and no on_done - * (provider failure → AiMuxError view; a part that cannot be serialized - * → C ABI failure). - */ -aimux_error_t *aimux_stream_text(uint64_t handle, - const char *prompt_json, - const char *opts_json, - void (*on_part)(const char *json, void *stream_ctx), - void (*on_done)(void *stream_ctx), - void *stream_ctx); - -/** - * Create a per-call abort signal. Infallible. - * - * @return A non-zero abort handle. Release it with aimux_abort_signal_drop. - */ -uint64_t aimux_abort_signal_new(void); - -/** - * Request cancellation. Invalid handles and repeated calls are safe. - * - * @param abort_handle Handle from aimux_abort_signal_new. - */ -void aimux_abort_signal_abort(uint64_t abort_handle); - -/** - * Release an abort handle. Active calls keep their signal alive. - * - * @param abort_handle Handle from aimux_abort_signal_new. Zero is safe. - */ -void aimux_abort_signal_drop(uint64_t abort_handle); - -/** - * [AiMuxError] Streaming text generation with per-call cancellation. - * - * This function blocks until the stream ends. Another thread can call - * aimux_abort_signal_abort while this function runs. Cancellation fails the - * call with AiMuxError AIMUX_E_ABORTED and does not call on_done. - * - * @param handle Model handle from aimux_*_new. - * @param abort_handle Handle from aimux_abort_signal_new. - * @param prompt_json JSON prompt. - * @param opts_json JSON options. NULL or empty uses defaults. - * @param on_part Called for each TextStreamPart. - * @param on_done Called once after normal completion. - * @param stream_ctx Opaque pointer passed through to callbacks. - */ -aimux_error_t *aimux_stream_text_with_abort(uint64_t handle, uint64_t abort_handle, - const char *prompt_json, - const char *opts_json, - void (*on_part)(const char *json, void *stream_ctx), - void (*on_done)(void *stream_ctx), - void *stream_ctx); - -/* ── OpenAI-compatible output (RFC-0026) ───────────────────────────────── */ - -/** - * [AiMuxError] Non-streaming text generation with OpenAI Chat Completions output. - * - * Same as aimux_generate_text, but writes a serialized ChatCompletion - * (OpenAI "chat.completion" object). Works with any provider. - */ -aimux_error_t *aimux_generate_text_as_openai(uint64_t handle, - const char *prompt_json, - const char *opts_json, char **out_json); - -/** - * [AiMuxError] Streaming text generation with OpenAI Chat Completions output. - * - * Same as aimux_stream_text, but each on_part receives a serialized - * ChatCompletionChunk (OpenAI "chat.completion.chunk" object). - * Works with any provider. - * - * @param opts_json May carry providerOptions.openai.stream_options with - * include_usage (bool, default true) and - * include_reasoning (bool, default true). - */ -aimux_error_t *aimux_stream_text_as_openai(uint64_t handle, - const char *prompt_json, - const char *opts_json, - void (*on_part)(const char *json, void *stream_ctx), - void (*on_done)(void *stream_ctx), - void *stream_ctx); - -/** - * [AiMuxError] Cancelable streaming OpenAI-compatible output (see - * aimux_stream_text_with_abort). - */ -aimux_error_t *aimux_stream_text_as_openai_with_abort(uint64_t handle, uint64_t abort_handle, - const char *prompt_json, - const char *opts_json, - void (*on_part)(const char *json, void *stream_ctx), - void (*on_done)(void *stream_ctx), - void *stream_ctx); - -/** - * [AiMuxError] Convert a serialized GenerateTextResult into a serialized - * ChatCompletion — the conversion half of aimux_generate_text_as_openai. For - * hosts that repair a native result themselves (RFC-0035): tool calls come - * from the result's tool_calls, so a patched result converts to a completion - * that reflects the repair. handle only supplies the fallback model id; no - * runtime work, so this is safe from inside a stream callback. - */ -aimux_error_t *aimux_generate_text_result_as_openai(uint64_t handle, const char *result_json, - char **out_json); - -/* ── Host-side tool-call repair (RFC-0035) ─────────────────────────────── */ - -/* - * These three take data only — no model handle, no tokio runtime, no I/O — so - * they are safe to call from inside an aimux_stream_text callback (the - * re-entrancy guard only rejects nested runtime work). - * - * The loop: run generation, find a tool call with "invalid": true in - * GenerateTextResult.tool_calls, build its repair argument with - * aimux_tool_call_repair_context, produce a reply in your own language, then - * apply it with aimux_apply_tool_call_repair (one call) or - * aimux_apply_tool_call_repair_to_result (the whole document). - * - * prompt_json and opts_json are the SAME two strings you passed to - * aimux_generate_text / aimux_stream_text: messages, instructions, and the - * tool set are derived from them inside the library, so no host reimplements - * that. opts_json follows the usual rule — NULL, empty, or "null" means - * defaults. - * - * reply_json is one of: - * {"type":"repaired","toolCall":{"toolCallId":…,"toolName":…,"input":""}} - * {"type":"unchanged"} - * {"type":"failed","message":"…"} - * - * The OpenAI-shaped output has no equivalent: ChatCompletion carries no - * invalid/error field, and aimux_stream_text_as_openai forwards the provider's - * argument deltas verbatim. Drive repair from the native result. - */ - -/** - * [AiMuxError] Build the repair argument for one invalid tool call. - * - * @param tool_call_json One GenerateTextResult.tool_calls entry, "invalid": true. - * @param prompt_json The prompt the call was generated with (same wire - * format as aimux_generate_text, {"prompt": …} wrapper - * included). - * @param opts_json The options the call was generated with (may be NULL). - * @param out_json {tool_call, error, input_schema, tools, messages, - * instructions} — the AI SDK repairToolCall argument. - * tool_call.input is the provider's raw argument text. - * The JSON literal null when opts_json carried no tools: - * such a call is never repaired (AI SDK rule), so skip - * it. That is a SUCCESS, not an error. - */ -aimux_error_t *aimux_tool_call_repair_context(const char *tool_call_json, - const char *prompt_json, - const char *opts_json, char **out_json); - -/** - * [AiMuxError] Resolve one invalid tool call against a repair reply. Writes - * the resulting ToolCall — valid, or invalid carrying a nested - * ToolCallRepairError. Options carrying no tools are - * AIMUX_E_INVALID_ARGUMENT: aimux_tool_call_repair_context already wrote null - * for such a call. - */ -aimux_error_t *aimux_apply_tool_call_repair(const char *tool_call_json, - const char *opts_json, - const char *reply_json, char **out_json); - -/** - * [AiMuxError] Apply a repair reply to a serialized GenerateTextResult (the - * output of aimux_generate_text or aimux_consume_stream_text). The call in - * the steps' content and the matching response-message tool-call part are - * rewritten (tool_call_id included — a repair may rename the call), and the - * top-level tool_calls and response_messages follow. - */ -aimux_error_t *aimux_apply_tool_call_repair_to_result(const char *result_json, - const char *opts_json, - const char *tool_call_id, - const char *reply_json, - char **out_json); - -/* ── Resource management ────────────────────────────────────────────────── */ - -/** - * Release a handle. Safe to call with 0 or an unknown handle (no-op). - * - * @param handle Handle from aimux_*_new. - */ -void aimux_drop_handle(uint64_t handle); - -/** - * aimux_drop_handle with the handle passed as a pointer-sized value, for a - * finalizer that can only call `void (*)(void *)` (Dart NativeFinalizer). - * The pointer is never dereferenced: its address is the handle. Safe to call - * with NULL or an unknown handle (no-op). - * - * @param handle Handle from aimux_*_new, cast to a pointer. - */ -void aimux_drop_handle_ptr(void *handle); - -/** - * Free a string written by any aimux function (`*out_json`, error getters). - * (Handles are released with aimux_drop_handle, errors with - * aimux_error_free, not here.) - * - * @param ptr Pointer from an aimux_* function (NULL is safe). - */ -void aimux_free_string(char *ptr); - -/* ── Embedding ───────────────────────────────────────────────────────────── */ -/* [C ABI] Multimodal constructors below cannot fail as an AiMuxError (they only - store the config); [AiMuxError] the `*_generate`-style calls write the - modality's Result JSON to *out_json. All strings must be freed with - aimux_free_string. */ - -aimux_error_t *aimux_openai_embedding_new(const char *api_key, const char *model_id, uint64_t *out_handle); -aimux_error_t *aimux_openai_embedding_new_with_base(const char *api_key, const char *model_id, const char *base_url, uint64_t *out_handle); -aimux_error_t *aimux_cohere_embedding_new(const char *api_key, const char *model_id, uint64_t *out_handle); -aimux_error_t *aimux_cohere_embedding_new_with_base(const char *api_key, const char *model_id, const char *base_url, uint64_t *out_handle); -aimux_error_t *aimux_google_embedding_new(const char *api_key, const char *model_id, uint64_t *out_handle); -aimux_error_t *aimux_google_embedding_new_with_base(const char *api_key, const char *model_id, const char *base_url, uint64_t *out_handle); -/* values_json: JSON array of strings; opts_json: EmbeddingCallOptions or NULL. */ -aimux_error_t *aimux_embed(uint64_t handle, const char *values_json, const char *opts_json, char **out_json); - -/* ── Speech (TTS) ────────────────────────────────────────────────────────── */ - -aimux_error_t *aimux_openai_speech_new(const char *api_key, const char *model_id, uint64_t *out_handle); -aimux_error_t *aimux_openai_speech_new_with_base(const char *api_key, const char *model_id, const char *base_url, uint64_t *out_handle); -/* opts_json is REQUIRED (it carries the input — text / prompt / documents / - query — not just options); NULL or empty is a C ABI failure. */ -aimux_error_t *aimux_speech_generate(uint64_t handle, const char *opts_json, char **out_json); - -/* ── Image ──────────────────────────────────────────────────────────────── */ - -aimux_error_t *aimux_openai_image_new(const char *api_key, const char *model_id, uint64_t *out_handle); -aimux_error_t *aimux_openai_image_new_with_base(const char *api_key, const char *model_id, const char *base_url, uint64_t *out_handle); -aimux_error_t *aimux_google_image_new(const char *api_key, const char *model_id, uint64_t *out_handle); -aimux_error_t *aimux_google_image_new_with_base(const char *api_key, const char *model_id, const char *base_url, uint64_t *out_handle); -/* opts_json is REQUIRED (it carries the input — text / prompt / documents / - query — not just options); NULL or empty is a C ABI failure. */ -aimux_error_t *aimux_image_generate(uint64_t handle, const char *opts_json, char **out_json); - -/* ── Transcription (STT, non-streaming) ──────────────────────────────────── */ - -aimux_error_t *aimux_openai_transcription_new(const char *api_key, const char *model_id, uint64_t *out_handle); -aimux_error_t *aimux_openai_transcription_new_with_base(const char *api_key, const char *model_id, const char *base_url, uint64_t *out_handle); -/* opts_json is currently IGNORED (reserved for future options). */ -aimux_error_t *aimux_transcription_generate(uint64_t handle, const char *audio_base64, const char *media_type, const char *opts_json, char **out_json); - -/* ── Transcription streaming sessions (RFC-0028) ─────────────────────────── */ - -/** - * Pull states of aimux_transcription_next_part, written to *out_state (an - * int32_t — the ABI does not depend on the C compiler's enum width) when the - * call returns NULL. - */ -typedef enum aimux_transcription_next_part_state { - AIMUX_TRANSCRIPTION_NEXT_PART_PART = 1, /* *out_part holds a part */ - AIMUX_TRANSCRIPTION_NEXT_PART_ENDED = 2, /* stream ended normally; *out_part NULL */ - AIMUX_TRANSCRIPTION_NEXT_PART_TIMEOUT = 3 /* no part in time, session live; *out_part NULL */ -} aimux_transcription_next_part_state_t; - -/* [AiMuxError] Start a streaming transcription session. The driver task spawns - immediately; push audio with aimux_transcription_push_audio, then pull - parts with aimux_transcription_next_part. model_handle must support - streaming (models without do_stream fail on the first next_part). - abort_handle: 0 = no cancellation, or an aimux_abort_signal_new handle. - opts_json (all optional): { "inputAudioFormat": {"formatType","rate"}, - "providerOptions", "headers", "includeRawChunks", "timeout": - {"totalMs","firstChunkMs","chunkMs"} }; NULL/empty/"null" = defaults - (wrong shape → AiMuxError AIMUX_E_INVALID_ARGUMENT). Writes the session handle. */ -aimux_error_t *aimux_transcription_session_new(uint64_t model_handle, uint64_t abort_handle, - const char *opts_json, uint64_t *out_handle); - -/* [AiMuxError] Push one binary audio chunk. BLOCKING: waits while the internal - channel is full (backpressure propagation). data may be NULL only when - len == 0 (no-op). Pushing after aimux_transcription_input_done or after - the session ended is an AiMuxError. Not callable from within an aimux - callback (re-entrancy guard — applies to next_part too). */ -aimux_error_t *aimux_transcription_push_audio(uint64_t session, const uint8_t *data, - size_t len); - -/* [C ABI] Signal end-of-audio (idempotent). Fails only for a dead handle. */ -aimux_error_t *aimux_transcription_input_done(uint64_t session); - -/* [AiMuxError] Pull the next part. timeout_ms: >0 = wait at most; 0 = immediate - poll; <0 = wait indefinitely. Both out-params are required. On NULL - return, *out_state says what happened: PART (*out_part holds JSON - TranscriptionStreamPart; free with aimux_free_string), ENDED or TIMEOUT - (*out_part NULL). A non-NULL return is a failure (abort / API error → - AiMuxError view; dead handle → C ABI failure); *out_part is NULL and *out_state is - unspecified. */ -aimux_error_t *aimux_transcription_next_part(uint64_t session, int64_t timeout_ms, - char **out_part, - int32_t *out_state); - -/* Terminate and release the session (aborts the driver; safe with 0). */ -void aimux_transcription_session_drop(uint64_t session); - -/* ── Files ──────────────────────────────────────────────────────────────── */ - -aimux_error_t *aimux_openai_files_new(const char *api_key, uint64_t *out_handle); -aimux_error_t *aimux_openai_files_new_with_base(const char *api_key, const char *base_url, uint64_t *out_handle); -/* opts_json is currently IGNORED (reserved for future options). */ -aimux_error_t *aimux_file_upload(uint64_t handle, const char *data_base64, const char *media_type, const char *opts_json, char **out_json); - -/* ── Reranking ───────────────────────────────────────────────────────────── */ - -aimux_error_t *aimux_cohere_reranking_new(const char *api_key, const char *model_id, uint64_t *out_handle); -aimux_error_t *aimux_cohere_reranking_new_with_base(const char *api_key, const char *model_id, const char *base_url, uint64_t *out_handle); -/* opts_json is REQUIRED (it carries the input — text / prompt / documents / - query — not just options); NULL or empty is a C ABI failure. */ -aimux_error_t *aimux_rerank(uint64_t handle, const char *opts_json, char **out_json); - -/* ── Video ───────────────────────────────────────────────────────────────── */ - -aimux_error_t *aimux_google_video_new(const char *api_key, const char *model_id, uint64_t *out_handle); -aimux_error_t *aimux_google_video_new_with_base(const char *api_key, const char *model_id, const char *base_url, uint64_t *out_handle); -/* opts_json is REQUIRED (it carries the input — text / prompt / documents / - query — not just options); NULL or empty is a C ABI failure. */ -aimux_error_t *aimux_video_generate(uint64_t handle, const char *opts_json, char **out_json); - -/* ── Search ──────────────────────────────────────────────────────────────── */ - -/* model_id is accepted for API symmetry but ignored (Tavily uses a fixed - endpoint). */ -aimux_error_t *aimux_tavily_search_new(const char *api_key, const char *model_id, uint64_t *out_handle); -aimux_error_t *aimux_tavily_search_new_with_base(const char *api_key, const char *model_id, const char *base_url, uint64_t *out_handle); -/* opts_json is REQUIRED (it carries the input — text / prompt / documents / - query — not just options); NULL or empty is a C ABI failure. */ -aimux_error_t *aimux_search(uint64_t handle, const char *opts_json, char **out_json); - -/* Typed decisions (DecisionModelV4). - * opts_json is DecideOptions JSON: state and questions (an object keyed by - * question ID), plus optional max_retries, timeout, headers and - * provider_options. The result is DecideResult JSON. - * Free model handles with aimux_drop_handle and results with aimux_free_string. - * abort_handle=0 means no caller cancellation. */ -/* Build a decision model using an existing provider handle. */ -aimux_error_t *aimux_provider_decision_model(uint64_t handle, const char *model_id, - uint64_t *out_handle); - -/* endpoint is NULL for hosted Jev, or a complete POST URL. */ -aimux_error_t *aimux_jev_decision_new(const char *api_key, const char *model_id, - const char *endpoint, uint64_t *out_handle); -/* probability_source: native, logit_scoring, model_estimate; NULL uses native. */ -aimux_error_t *aimux_jev_decision_new_with_probability_source( - const char *api_key, const char *model_id, const char *endpoint, - const char *probability_source, uint64_t *out_handle); -aimux_error_t *aimux_decide(uint64_t handle, const char *opts_json, char **out_json); -/* Owned JSON {specificationVersion, provider, modelId, - supportedQuestionTypes}; no network request. */ -aimux_error_t *aimux_decision_capabilities(uint64_t handle, char **out_json); -aimux_error_t *aimux_decide_with_abort(uint64_t handle, const char *opts_json, - uint64_t abort_handle, char **out_json); - -/* Codex (RFC-0018) */ - -/* [AiMuxError] Refresh a Codex subscription access token (stateless OAuth - helper). Writes JSON {"access_token","refresh_token","expires_in_secs"}; - caller frees. Caller owns token persistence and the 401 -> refresh -> - retry orchestration. */ -aimux_error_t *aimux_codex_refresh(const char *refresh_token, const char *client_id, - char **out_json); - -/* Logging (RFC-0014) */ - -/* [C ABI] Initialize the global logger (idempotent, thread-safe, no-op if the - host already registered its own subscriber). level: "off"|"error"|"warn"| - "info"|"debug"|"trace" (NULL = default "warn"; non-UTF-8 is a C ABI - failure); AIMUX_LOG and AIMUX_LOG_LEVEL env vars take precedence. Logs go - to stderr. */ -aimux_error_t *aimux_init_logging(const char *level); - -/* Session grouping (RFC-0024) */ - -/* Register the global session store (replaces any previous one). Until - called, calls are not grouped and the session query functions return - empty results. Infallible. */ -void aimux_session_store_init(void); - -/* Enable/disable the global session inferer (opt-in, off by default). - enabled nonzero = on; explicit session_id always wins. Infallible. */ -void aimux_session_infer_init(int32_t enabled); - -/* [C ABI] Query: all calls of a session, ordered by step. Writes a JSON - SessionCall[] (empty if unknown / no store); caller frees with - aimux_free_string. */ -aimux_error_t *aimux_session_calls(const char *session_id, char **out_json); - -/* [C ABI] Query: all known sessions. Writes a JSON SessionView[]; caller - frees with aimux_free_string. */ -aimux_error_t *aimux_list_sessions(char **out_json); - -/* Cache probing (RFC-0015) */ - -/* [C ABI] Wrap a model handle in a probe layer. The new handle works with - aimux_generate_text / aimux_stream_text (probed) and the aimux_trace_* - queries; release with aimux_drop_handle. */ -aimux_error_t *aimux_trace_new(uint64_t handle, uint64_t *out_handle); - -/* [C ABI] Same, with the built-in rules auditor attached. strict nonzero = - strict mode; zero = shared (safe default). */ -aimux_error_t *aimux_trace_new_audited(uint64_t handle, int32_t strict, uint64_t *out_handle); - -/* [AiMuxError] Query: aggregated probe statistics, filtered by filter_json - (serialized TraceFilter; "{}" = all; NULL is a C ABI failure). Writes - JSON TraceStats[]; caller frees. */ -aimux_error_t *aimux_trace_aggregate(uint64_t handle, const char *filter_json, char **out_json); - -/* [AiMuxError] Query: one session's chain view. Writes JSON SessionChainView; - caller frees. An unknown session_id is a lookup miss on a caller-supplied - string key and is AiMuxError AIMUX_E_INVALID_ARGUMENT ("unknown session"), not - a C ABI handle failure. */ -aimux_error_t *aimux_trace_session_chain(uint64_t handle, const char *session_id, char **out_json); - -/* [C ABI] Query: one session's per-step trajectory. Writes a JSON array - (empty for unknown sessions); caller frees. */ -aimux_error_t *aimux_trace_session_trajectory(uint64_t handle, const char *session_id, char **out_json); - -/* [C ABI] Export all probe records as JSONL (one TraceRecord per line). Writes - a string with embedded newlines; caller frees. */ -aimux_error_t *aimux_trace_export_jsonl(uint64_t handle, char **out_jsonl); - -/* [C ABI] Clear all probe records of a trace handle. Fails only for a dead - handle. */ -aimux_error_t *aimux_trace_clear(uint64_t handle); - -/* ── Recording + mock replay (RFC-0023) ──────────────────────────────── */ - -/* [RecordingError] Start recording: complete Recording JSONL is written to - {dir}/recordings.jsonl (dir auto-created). Recording is opt-in; calling - again replaces the recorder. A NULL or non-UTF-8 dir is a C ABI - failure; the recorder failing to create the directory, open the file or - start its writer carries the recording view (AIMUX_E_RECORDING_INIT / - _OPEN_FILE / _SPAWN). */ -aimux_error_t *aimux_init_recording(const char *dir); - -/* [AiMuxError] Start in-memory bounded recording (RingRecorder, FIFO eviction, - dropped count queryable). cap == 0 is AiMuxError AIMUX_E_INVALID_ARGUMENT. */ -aimux_error_t *aimux_init_recording_ring(uint64_t cap); - -/* No-argument variant: start in-memory bounded recording with the library - default ring capacity (2048 entries). Ordinary callers should prefer this - entry point; pass an explicit cap via aimux_init_recording_ring only when a - different size is required. Infallible. */ -void aimux_init_recording_ring_default(void); - -/* Stop recording: the global recorder becomes None. Infallible. */ -void aimux_recording_stop(void); - -/* Flush the global recorder (blocks until JSONL is on disk; no-op for the - ring recorder). Write failures are not reported here — see - aimux_recording_try_flush. Infallible. */ -void aimux_recording_flush(void); - -/* [RecordingError] Flush the global recorder and report write failures. NULL - when the JSONL is confirmed on disk (also when recording was never - initialized); otherwise an error with the recording view — read it - with aimux_error_code() / aimux_error_message() (see - aimux-error.h). Reachable codes: - AIMUX_E_RECORDING_WRITE (6) — a prior write failed (sticky, e.g. ENOSPC) - AIMUX_E_RECORDING_WRITER_GONE (4) — writer unavailable - AIMUX_E_RECORDING_FLUSH_TIMEOUT (5) — no writer ack within 30s */ -aimux_error_t *aimux_recording_try_flush(void); - -/* [AiMuxError] Register external OpenAI-compatible providers from a JSON config - string (RFC-0020). config_json is { "providers": [ { "name", "base_url", - ... } ] }. Entries override same-named built-ins or add new ones. Malformed - JSON text is a C ABI failure; a well-formed document the registry - rejects (bad schema, unknown protocol) is AiMuxError AIMUX_E_INVALID_ARGUMENT. */ -aimux_error_t *aimux_register_providers(const char *config_json); - -/* [AiMuxError] Replace the default transport with one routed by this proxy - configuration (M6, RFC-0016). Requests started afterwards (HTTP, built-in - WebSocket connects, download hops) use it; calling it again replaces the - previous configuration. config_json is a serialized ProxyConfig: { "http_url", "https_url", "all_url", "no_proxy" } (all - optional). Malformed text is a C ABI failure; wrong shape is AiMuxError - AIMUX_E_INVALID_ARGUMENT. */ -aimux_error_t *aimux_init_proxy(const char *config_json); - -/* [AiMuxError] Create a mock replay model from recorded JSONL (one Recording per - line). The handle works with aimux_generate_text / aimux_stream_text (no - real API sent); release with aimux_drop_handle. Malformed line → C ABI - failure; wrong shape or no recordings → AiMuxError AIMUX_E_INVALID_ARGUMENT. */ -aimux_error_t *aimux_mock_replay_new(const char *recordings_jsonl, uint64_t *out_handle); - -/* ── Composite models (RFC-0021 / RFC-0022) ─────────────────────────── */ - -/* [AiMuxError] Create a RouterModel (RFC-0021) over the given child-model handles. - The new handle is itself a model handle (works with aimux_generate_text / - aimux_stream_text); release with aimux_drop_handle. - - handles: array of `len` live model handles (e.g. from aimux_openai_new). - Any dead handle in the array is a C ABI failure — nothing is silently - dropped; len == 0 (no children) is a C ABI failure too; NULL with len > 0 - is a C ABI failure. config_json selects the router + fallback policy: - { "router": "rule"|"weighted", "weights": [..], "fallback": "on_error"|"none", - "providerName": "router", "modelId": "router" } — all optional; defaults - are rule / on_error / "router" / "router" (wrong shape → AiMuxError - AIMUX_E_INVALID_ARGUMENT). */ -aimux_error_t *aimux_router_new(const uint64_t *handles, size_t len, - const char *config_json, uint64_t *out_handle); - -/* [AiMuxError] Create a MoaModel (RFC-0022) over reference handles + one aggregator - handle. The new handle is a model handle (works with aimux_generate_text / - aimux_stream_text); release with aimux_drop_handle. - - reference_handles: array of `ref_len` live model handles (NULL with - ref_len == 0 = no references — MoaModel then runs just the aggregator; NULL - with ref_len > 0 is a C ABI failure). aggregator: a single live handle. - Any dead reference or aggregator handle is a C ABI failure — nothing is - silently dropped. config_json is a serialized MoaConfig (all fields - optional): { "providerName", "modelId", "aggregatorInstructions", - "stripReferenceTools", "failMode": "best-effort"|"fail-fast" } (wrong shape → - AiMuxError AIMUX_E_INVALID_ARGUMENT). */ -aimux_error_t *aimux_moa_new(const uint64_t *reference_handles, size_t ref_len, - uint64_t aggregator, const char *config_json, - uint64_t *out_handle); - -#ifdef __cplusplus -} /* extern "C" */ -#endif - -#endif /* AIMUX_FFI_H */ diff --git a/aimux-ffi/include/aimux.h b/aimux-ffi/include/aimux.h new file mode 100644 index 000000000..f991d83d8 --- /dev/null +++ b/aimux-ffi/include/aimux.h @@ -0,0 +1,182 @@ +/* Generated by aimux-codegen. Do not edit. Manifest SHA-256: 2bc7cb7ec77a0ed20496411f0dbd199b949b9887cc74d119e11627c8573d82a2 */ +#ifndef AIMUX_H +#define AIMUX_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define AIMUX_ABI_VERSION UINT32_C(65536) +#define AIMUX_OPS_VERSION "1.0" +#define AIMUX_MANIFEST_DIGEST "2bc7cb7ec77a0ed20496411f0dbd199b949b9887cc74d119e11627c8573d82a2" + +#define AIMUX_OP_ABORT_CREATE "abort.create" +#define AIMUX_OP_ABORT_SIGNAL "abort.signal" +#define AIMUX_OP_AUTH_CODEX_REFRESH "auth.codexRefresh" +#define AIMUX_OP_CATALOGUE_FETCH "catalogue.fetch" +#define AIMUX_OP_COMPOSITE_MOA "composite.moa" +#define AIMUX_OP_COMPOSITE_ROUTER "composite.router" +#define AIMUX_OP_CREDENTIAL_CREATE "credential.create" +#define AIMUX_OP_DECISION_CAPABILITIES "decision.capabilities" +#define AIMUX_OP_DECISION_DECIDE "decision.decide" +#define AIMUX_OP_EMBEDDING_EMBED "embedding.embed" +#define AIMUX_OP_EMBEDDING_EMBED_MANY "embedding.embedMany" +#define AIMUX_OP_FILES_UPLOAD "files.upload" +#define AIMUX_OP_HANDLE_DROP "handle.drop" +#define AIMUX_OP_IMAGE_GENERATE "image.generate" +#define AIMUX_OP_MIDDLEWARE_CREATE "middleware.create" +#define AIMUX_OP_MODEL_INFO "model.info" +#define AIMUX_OP_MODEL_RESOLVE "model.resolve" +#define AIMUX_OP_MODEL_WRAP "model.wrap" +#define AIMUX_OP_OPERATION_CANCEL "operation.cancel" +#define AIMUX_OP_OPERATION_INPUT_DONE "operation.inputDone" +#define AIMUX_OP_OPERATION_NEXT "operation.next" +#define AIMUX_OP_OPERATION_PUSH "operation.push" +#define AIMUX_OP_OPERATION_STATUS "operation.status" +#define AIMUX_OP_OUTPUT_TO_OPEN_AI "output.toOpenAI" +#define AIMUX_OP_PROVIDER_CREATE "provider.create" +#define AIMUX_OP_PROVIDER_CUSTOM "provider.custom" +#define AIMUX_OP_PROVIDER_DEFAULT "provider.default" +#define AIMUX_OP_PROVIDER_FILES "provider.files" +#define AIMUX_OP_PROVIDER_INVOKE "provider.invoke" +#define AIMUX_OP_PROVIDER_MODEL "provider.model" +#define AIMUX_OP_PROVIDER_WRAP "provider.wrap" +#define AIMUX_OP_RECORDER_ATTACH "recorder.attach" +#define AIMUX_OP_RECORDER_CREATE "recorder.create" +#define AIMUX_OP_RECORDER_EXPORT "recorder.export" +#define AIMUX_OP_RECORDER_FLUSH "recorder.flush" +#define AIMUX_OP_RECORDER_STATS "recorder.stats" +#define AIMUX_OP_RECORDER_STOP "recorder.stop" +#define AIMUX_OP_REGISTRY_CREATE "registry.create" +#define AIMUX_OP_REGISTRY_PROVIDER "registry.provider" +#define AIMUX_OP_REPLAY_CAPABILITIES "replay.capabilities" +#define AIMUX_OP_REPLAY_CREATE "replay.create" +#define AIMUX_OP_REPLAY_DRY_RUN "replay.dryRun" +#define AIMUX_OP_REPLAY_INSPECT "replay.inspect" +#define AIMUX_OP_REPLAY_LIVE "replay.live" +#define AIMUX_OP_RERANKING_RERANK "reranking.rerank" +#define AIMUX_OP_RUNTIME_CONFIGURE "runtime.configure" +#define AIMUX_OP_RUNTIME_DESCRIBE "runtime.describe" +#define AIMUX_OP_SEARCH_SEARCH "search.search" +#define AIMUX_OP_SESSION_STORE_ATTACH "sessionStore.attach" +#define AIMUX_OP_SESSION_STORE_CALLS "sessionStore.calls" +#define AIMUX_OP_SESSION_STORE_CREATE "sessionStore.create" +#define AIMUX_OP_SESSION_STORE_LIST "sessionStore.list" +#define AIMUX_OP_SPEECH_GENERATE "speech.generate" +#define AIMUX_OP_TEXT_CONSUME "text.consume" +#define AIMUX_OP_TEXT_GENERATE "text.generate" +#define AIMUX_OP_TEXT_GENERATE_OBJECT "text.generateObject" +#define AIMUX_OP_TEXT_GENERATE_OPEN_AI "text.generateOpenAI" +#define AIMUX_OP_TEXT_STREAM "text.stream" +#define AIMUX_OP_TEXT_STREAM_OPEN_AI "text.streamOpenAI" +#define AIMUX_OP_TOOL_REPAIR_APPLY "toolRepair.apply" +#define AIMUX_OP_TOOL_REPAIR_APPLY_RESULT "toolRepair.applyResult" +#define AIMUX_OP_TOOL_REPAIR_CONTEXT "toolRepair.context" +#define AIMUX_OP_TRACE_AGGREGATE "trace.aggregate" +#define AIMUX_OP_TRACE_CLEAR "trace.clear" +#define AIMUX_OP_TRACE_CREATE "trace.create" +#define AIMUX_OP_TRACE_EXPORT "trace.export" +#define AIMUX_OP_TRACE_SESSION_CHAIN "trace.sessionChain" +#define AIMUX_OP_TRACE_SESSION_TRAJECTORY "trace.sessionTrajectory" +#define AIMUX_OP_TRANSCRIPTION_GENERATE "transcription.generate" +#define AIMUX_OP_TRANSCRIPTION_START "transcription.start" +#define AIMUX_OP_TRANSPORT_CREATE "transport.create" +#define AIMUX_OP_VIDEO_GENERATE "video.generate" + +typedef struct aimux_error aimux_error_t; + +typedef enum aimux_error_code { + AIMUX_ERROR_NONE = 0, + AIMUX_ERROR_AIMUX_OTHER = 1, + AIMUX_ERROR_AIMUX_JSON_PARSE = 2, + AIMUX_ERROR_AIMUX_INVALID_RESPONSE_DATA = 3, + AIMUX_ERROR_AIMUX_INVALID_ARGUMENT = 5, + AIMUX_ERROR_AIMUX_INVALID_PROMPT = 6, + AIMUX_ERROR_AIMUX_TOKEN_EXPIRED = 7, + AIMUX_ERROR_AIMUX_UNSUPPORTED_FUNCTIONALITY = 8, + AIMUX_ERROR_AIMUX_NO_SUCH_MODEL = 9, + AIMUX_ERROR_AIMUX_NO_SUCH_PROVIDER = 10, + AIMUX_ERROR_AIMUX_API_CALL = 11, + AIMUX_ERROR_AIMUX_TIMEOUT = 12, + AIMUX_ERROR_AIMUX_ABORTED = 13, + AIMUX_ERROR_AIMUX_RETRY = 14, + AIMUX_ERROR_AIMUX_NO_SUCH_TOOL = 15, + AIMUX_ERROR_AIMUX_INVALID_TOOL_INPUT = 16, + AIMUX_ERROR_AIMUX_TOOL_CALL_REPAIR = 17, + AIMUX_ERROR_AIMUX_NO_OUTPUT_GENERATED = 18, + AIMUX_ERROR_AIMUX_LOAD_API_KEY = 19, + AIMUX_ERROR_AIMUX_LOAD_SETTING = 20, + AIMUX_ERROR_RECORDING_INIT = 100, + AIMUX_ERROR_RECORDING_OPEN_FILE = 101, + AIMUX_ERROR_RECORDING_SPAWN = 102, + AIMUX_ERROR_RECORDING_WRITER_GONE = 103, + AIMUX_ERROR_RECORDING_FLUSH_TIMEOUT = 104, + AIMUX_ERROR_RECORDING_WRITE = 105, + AIMUX_ERROR_BINDING_NULL_POINTER = 200, + AIMUX_ERROR_BINDING_INVALID_UTF8 = 201, + AIMUX_ERROR_BINDING_INVALID_WIRE_JSON = 202, + AIMUX_ERROR_BINDING_INVALID_HANDLE = 203, + AIMUX_ERROR_BINDING_REENTRANT_CALL = 204, + AIMUX_ERROR_BINDING_RESULT_SERIALIZATION = 205, + AIMUX_ERROR_BINDING_CALLBACK_FAILURE = 206, + AIMUX_ERROR_PROTOCOL_INVALID_OP = 300, + AIMUX_ERROR_PROTOCOL_INVALID_PARAMS = 301, + AIMUX_ERROR_PROTOCOL_UNSUPPORTED_OPS_VERSION = 302, + AIMUX_ERROR_PROTOCOL_CONCURRENT_CONSUMER = 303, + AIMUX_ERROR_PROTOCOL_INVALID_STATE = 304, + AIMUX_ERROR_PROTOCOL_RESOURCE_LIMIT = 305, + AIMUX_ERROR_PROTOCOL_HANDLE_EXHAUSTED = 306 +} aimux_error_code_t; + +typedef enum aimux_poll_state { + AIMUX_POLL_PART = 1, + AIMUX_POLL_ENDED = 2, + AIMUX_POLL_TIMEOUT = 3 +} aimux_poll_state_t; + +/* frame_json is borrowed until the callback returns; copy it to retain it. + * context must remain valid until aimux_stream returns. */ +typedef void (*aimux_event_cb)(const char *frame_json, void *context); + +/* ABI = (major << 16) | minor. Verify ABI and manifest before calling ops. + * JSON inputs are borrowed NUL-terminated UTF-8, never implicit empty objects. + * Handle 0 is global scope; nonzero IDs are process-owned and never reused. + * Fallible calls return NULL on success or an owned error on failure. + * Outputs are reset to NULL/0 before validation, including on failure. + * All output JSON and error getter strings are owned: aimux_free_string. + * Release errors with aimux_error_free. Do not free callback JSON. + * Blocking calls cannot be reentered from a callback (ReentrantCall). */ +uint32_t aimux_abi_version(void); +aimux_error_t *aimux_manifest_json(char **out_json); +aimux_error_t *aimux_call(uint64_t handle, const char *op, + const char *params_json, uint64_t abort, char **out_json); +aimux_error_t *aimux_stream(uint64_t handle, const char *op, + const char *params_json, uint64_t abort, + aimux_event_cb on_event, void *context); +/* Input bytes are copied/consumed before return. NULL is valid only at length 0. */ +aimux_error_t *aimux_operation_push(uint64_t operation, + const uint8_t *data, size_t length); +/* PART allocates out_part; ENDED/TIMEOUT do not. timeout_ms must be nonnegative. */ +aimux_error_t *aimux_operation_next_part(uint64_t operation, int64_t timeout_ms, + char **out_part, int32_t *out_state); +/* Signal only; callback-safe. Use abort.signal to report an invalid handle. */ +void aimux_abort(uint64_t abort); +/* Drop is idempotent, accepts 0, and cancels resources without implying flush. */ +void aimux_drop(uint64_t handle); +/* Non-blocking finalizer; token encodes the handle in uintptr_t (never dereferenced). */ +void aimux_handle_finalizer(void *token); +/* NULL-safe. Each owned allocation must be freed exactly once. */ +void aimux_free_string(char *string); +void aimux_error_free(aimux_error_t *error); +int32_t aimux_error_code(const aimux_error_t *error); +char *aimux_error_message(const aimux_error_t *error); +char *aimux_error_json(const aimux_error_t *error); + +#ifdef __cplusplus +} +#endif + +#endif /* AIMUX_H */ diff --git a/aimux-ffi/src/abi.rs b/aimux-ffi/src/abi.rs new file mode 100644 index 000000000..2444fc558 --- /dev/null +++ b/aimux-ffi/src/abi.rs @@ -0,0 +1,682 @@ +//! RFC-0039 synchronous C adapter. Callbacks borrow their frame only until return. + +use std::ffi::{CStr, CString, c_char, c_void}; +use std::future::Future; +use std::ptr; +use std::sync::OnceLock; +use std::time::{Duration, SystemTime}; + +use aimux_ops::dispatch::PollResult; +use aimux_ops::dispatch::{self, Ctx}; +use aimux_ops::envelope::{BindingError, ErrorEnvelope, OpsError, ProtocolError}; +use aimux_ops::frame::{Outcome, StreamFrame}; +use aimux_ops::handle::{Entry, HandleId, HandleKind, HandleRegistry, Owner}; +use futures::{Stream, StreamExt}; +use serde_json::Value; +use tokio::runtime::{Handle, Runtime}; + +/// Owned error projection, captured once at the boundary. +#[allow(non_camel_case_types)] +pub struct aimux_error_t { + envelope: ErrorEnvelope, +} + +type Result = std::result::Result; +type EventCallback = extern "C" fn(*const c_char, *mut c_void); + +fn null_pointer(argument: &'static str) -> OpsError { + OpsError::Binding(BindingError::NullPointer { argument }) +} + +fn serialization(message: impl ToString) -> OpsError { + OpsError::Binding(BindingError::ResultSerialization(message.to_string())) +} + +fn finish(result: Result<()>) -> *mut aimux_error_t { + match result { + Ok(()) => ptr::null_mut(), + Err(error) => Box::into_raw(Box::new(aimux_error_t { + envelope: match error { + OpsError::Projected(projected) => projected.envelope, + error => error.envelope(SystemTime::now()), + }, + })), + } +} + +fn json_string(value: &impl serde::Serialize) -> Result { + CString::new(serde_json::to_string(value).map_err(serialization)?).map_err(serialization) +} + +// The caller supplies live, NUL-terminated strings for the duration of the call. +unsafe fn text<'a>(value: *const c_char, argument: &'static str) -> Result<&'a str> { + if value.is_null() { + return Err(null_pointer(argument)); + } + unsafe { CStr::from_ptr(value) } + .to_str() + .map_err(|_| OpsError::Binding(BindingError::InvalidUtf8 { argument })) +} + +unsafe fn params(value: *const c_char) -> Result { + serde_json::from_str(unsafe { text(value, "params_json")? }).map_err(|error| { + OpsError::Binding(BindingError::InvalidWireJson { + argument: "params_json".into(), + message: error.to_string(), + }) + }) +} + +unsafe fn clear_string(out: *mut *mut c_char, argument: &'static str) -> Result<()> { + if out.is_null() { + return Err(null_pointer(argument)); + } + unsafe { *out = ptr::null_mut() }; + Ok(()) +} + +fn context(abort: u64) -> Result { + let mut ctx = Ctx { + owner: Owner::CProcess, + abort: None, + now: SystemTime::now, + }; + if abort != 0 { + let entry = dispatch::resolve_secondary( + HandleRegistry::global(), + &ctx, + &Value::String(abort.to_string()), + &[HandleKind::Abort], + )?; + if let Entry::Abort(signal) = entry { + ctx.abort = Some((*signal).clone()); + } + } + Ok(ctx) +} + +static RUNTIME: OnceLock> = OnceLock::new(); + +fn runtime() -> Result<&'static Runtime> { + match RUNTIME.get_or_init(|| Runtime::new().map_err(|error| error.to_string())) { + Ok(runtime) => Ok(runtime), + Err(message) => Err(OpsError::Protocol(ProtocolError::InvalidState( + message.clone(), + ))), + } +} + +fn block_on(future: impl Future>) -> Result { + if Handle::try_current().is_ok() { + return Err(OpsError::Binding(BindingError::ReentrantCall)); + } + runtime()?.block_on(future) +} + +/// ABI major and minor encoded as `(major << 16) | minor`. +#[must_use] +#[unsafe(no_mangle)] +pub extern "C" fn aimux_abi_version() -> u32 { + aimux_ops::op::ABI_VERSION +} + +/// Return an owned manifest string; clear the output before doing any work. +#[must_use] +#[unsafe(no_mangle)] +pub extern "C" fn aimux_manifest_json(out_json: *mut *mut c_char) -> *mut aimux_error_t { + finish((|| { + unsafe { clear_string(out_json, "out_json")? }; + let json = json_string(&aimux_ops::manifest::manifest())?; + unsafe { *out_json = json.into_raw() }; + Ok(()) + })()) +} + +/// Dispatch one call. All pointers must remain valid until this function returns. +#[must_use] +#[unsafe(no_mangle)] +pub extern "C" fn aimux_call( + handle: u64, + op: *const c_char, + params_json: *const c_char, + abort: u64, + out_json: *mut *mut c_char, +) -> *mut aimux_error_t { + finish((|| { + unsafe { clear_string(out_json, "out_json")? }; + let op = unsafe { text(op, "op")? }; + let params = unsafe { params(params_json)? }; + let value = block_on(async { + dispatch::call(context(abort)?, HandleId(handle), op, params).await + })?; + let json = json_string(&value)?; + unsafe { *out_json = json.into_raw() }; + Ok(()) + })()) +} + +async fn deliver_frames( + frames: impl Stream, + callback: EventCallback, + context: *mut c_void, +) -> Result<()> { + futures::pin_mut!(frames); + let mut next_seq = 0_u64; + while let Some(frame) = frames.next().await { + let terminal = matches!(frame, StreamFrame::End { .. }); + let (StreamFrame::Event { seq, .. } | StreamFrame::End { seq, .. }) = &frame; + if let Ok(seq) = seq.parse::() { + next_seq = seq.saturating_add(1); + } + // Frame serialization is infallible for the dispatcher's JSON payloads. + // If a future serializer changes that, still end the accepted stream. + let json = match json_string(&frame) { + Ok(json) => json, + Err(error) => { + let end = StreamFrame::End { + seq: match frame { + StreamFrame::Event { seq, .. } | StreamFrame::End { seq, .. } => seq, + }, + outcome: Outcome::Error, + error: Some(error.envelope(SystemTime::now())), + }; + let json = json_string(&end)?; + callback(json.as_ptr(), context); + return Ok(()); + } + }; + callback(json.as_ptr(), context); + if terminal { + return Ok(()); + } + } + // Keep accepted failures in the terminal callback even if dispatch breaks its invariant. + let error = OpsError::Protocol(ProtocolError::InvalidState( + "accepted stream ended without an End frame".into(), + )); + let end = StreamFrame::End { + seq: next_seq.to_string(), + outcome: Outcome::Error, + error: Some(error.envelope(SystemTime::now())), + }; + let json = json_string(&end)?; + callback(json.as_ptr(), context); + Ok(()) +} + +/// Synchronously deliver frames. No callback runs after this function returns. +#[must_use] +#[unsafe(no_mangle)] +pub extern "C" fn aimux_stream( + handle: u64, + op: *const c_char, + params_json: *const c_char, + abort: u64, + on_event: Option, + context_ptr: *mut c_void, +) -> *mut aimux_error_t { + finish((|| { + let callback = on_event.ok_or_else(|| null_pointer("on_event"))?; + let op = unsafe { text(op, "op")? }; + let params = unsafe { params(params_json)? }; + block_on(async { + let frames = dispatch::stream(context(abort)?, HandleId(handle), op, params)?; + deliver_frames(frames, callback, context_ptr).await + }) + })()) +} + +/// Admit bytes before copying, using shared queue limits. NULL+0 is legal. +#[must_use] +#[unsafe(no_mangle)] +pub extern "C" fn aimux_operation_push( + operation: u64, + data: *const u8, + length: usize, +) -> *mut aimux_error_t { + finish((|| { + if data.is_null() && length != 0 { + return Err(null_pointer("data")); + } + // Reject oversize chunks before forming a slice or allocating a copy. + if length > aimux_ops::limits::ITEM_BYTES || (data as usize).checked_add(length).is_none() { + return Err(OpsError::Protocol(ProtocolError::ResourceLimit( + "audio chunk exceeds 1 MiB".into(), + ))); + } + block_on(async { + let bytes = if length == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(data, length) } + }; + dispatch::operation_push(&context(0)?, HandleId(operation), bytes) + }) + })()) +} + +/// Poll one part. State: PART=1, ENDED=2, TIMEOUT=3; errors leave both outputs zeroed. +#[must_use] +#[unsafe(no_mangle)] +pub extern "C" fn aimux_operation_next_part( + operation: u64, + timeout_ms: i64, + out_part: *mut *mut c_char, + out_state: *mut i32, +) -> *mut aimux_error_t { + // Clear every supplied output, even when another output pointer is NULL. + if !out_part.is_null() { + unsafe { *out_part = ptr::null_mut() }; + } + if !out_state.is_null() { + unsafe { *out_state = 0 }; + } + finish((|| { + if out_part.is_null() { + return Err(null_pointer("out_part")); + } + if out_state.is_null() { + return Err(null_pointer("out_state")); + } + let timeout = u64::try_from(timeout_ms).map_err(|_| { + OpsError::Protocol(ProtocolError::InvalidParams( + "timeout_ms must be nonnegative".into(), + )) + })?; + let result = block_on(async { + dispatch::operation_next( + &context(0)?, + HandleId(operation), + Duration::from_millis(timeout), + ) + .await + })?; + let (part, state) = match result { + PollResult::Part(part) => (json_string(&part)?.into_raw(), 1), + PollResult::Ended => (ptr::null_mut(), 2), + PollResult::Timeout => (ptr::null_mut(), 3), + }; + unsafe { + *out_part = part; + *out_state = state; + } + Ok(()) + })()) +} + +/// Signal cancellation without entering a runtime; valid inside callbacks. +#[unsafe(no_mangle)] +pub extern "C" fn aimux_abort(abort: u64) { + if let Ok(Entry::Abort(signal)) = + HandleRegistry::global().get(Owner::CProcess, HandleId(abort), &[HandleKind::Abort]) + { + signal.abort(); + } +} + +/// Release a C-owned handle. Zero and unknown handles are harmless. +#[unsafe(no_mangle)] +pub extern "C" fn aimux_drop(handle: u64) { + let removed = HandleRegistry::global().remove(Owner::CProcess, HandleId(handle)); + if let Some(Entry::Operation(operation)) = &removed { + operation.consumer_close(); + } + drop(removed); +} + +/// Enqueue handle cleanup without waiting on the finalizer thread. +/// The token encodes the handle in its pointer value and is never dereferenced. +#[unsafe(no_mangle)] +pub extern "C" fn aimux_handle_finalizer(token: *mut c_void) { + let handle = token as usize as u64; + // Valid C handles are created through block_on, which initializes RUNTIME. + // A first-call/unknown token must not initialize threads on a GC thread. + if handle != 0 + && let Some(Ok(runtime)) = RUNTIME.get() + { + runtime.spawn(async move { + tokio::task::spawn_blocking(move || aimux_drop(handle)); + }); + } +} + +/// Release an owned string; NULL is harmless. +/// # Safety +/// The pointer must be NULL or an unfreed string returned by this ABI. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn aimux_free_string(value: *mut c_char) { + if !value.is_null() { + drop(unsafe { CString::from_raw(value) }); + } +} + +/// Release an owned error; NULL is harmless. +#[unsafe(no_mangle)] +pub extern "C" fn aimux_error_free(error: *mut aimux_error_t) { + if !error.is_null() { + drop(unsafe { Box::from_raw(error) }); + } +} + +/// Read the saved error code, or zero for NULL. +#[must_use] +#[unsafe(no_mangle)] +pub extern "C" fn aimux_error_code(error: *const aimux_error_t) -> i32 { + unsafe { error.as_ref() }.map_or(0, |error| error.envelope.code) +} + +/// Return an owned error message, or NULL for a NULL error. +#[must_use] +#[unsafe(no_mangle)] +pub extern "C" fn aimux_error_message(error: *const aimux_error_t) -> *mut c_char { + unsafe { error.as_ref() } + .and_then(|error| CString::new(error.envelope.message.replace('\0', "\u{fffd}")).ok()) + .map_or(ptr::null_mut(), CString::into_raw) +} + +/// Return the saved JSON envelope, without recomputing retry hints. +#[must_use] +#[unsafe(no_mangle)] +pub extern "C" fn aimux_error_json(error: *const aimux_error_t) -> *mut c_char { + unsafe { error.as_ref() } + .and_then(|error| json_string(&error.envelope).ok()) + .map_or(ptr::null_mut(), CString::into_raw) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[derive(Default)] + struct Consumer { + frames: Vec, + abort: u64, + nested_codes: Vec, + } + + extern "C" fn consume(frame: *const c_char, context: *mut c_void) { + let consumer = unsafe { &mut *context.cast::() }; + consumer.frames.push( + serde_json::from_str(unsafe { CStr::from_ptr(frame) }.to_str().unwrap()).unwrap(), + ); + // The frame CString stays alive across nested calls into the adapter. + let before = unsafe { CStr::from_ptr(frame) }.to_owned(); + aimux_abort(consumer.abort); + let mut out = ptr::dangling_mut(); + let mut state = 99; + let errors = [ + aimux_call(0, c"runtime.describe".as_ptr(), c"{}".as_ptr(), 0, &mut out), + aimux_stream( + 0, + c"text.stream".as_ptr(), + c"{}".as_ptr(), + 0, + Some(consume), + ptr::null_mut(), + ), + aimux_operation_push(0, ptr::null(), 0), + aimux_operation_next_part(0, 0, &mut out, &mut state), + ]; + for error in errors { + consumer.nested_codes.push(aimux_error_code(error)); + aimux_error_free(error); + } + assert!(out.is_null()); + assert_eq!(state, 0); + assert_eq!(before.as_c_str(), unsafe { CStr::from_ptr(frame) }); + } + + #[test] + fn callback_borrows_frames_rejects_reentry_and_allows_abort() { + let signal = Arc::new(aimux_core::AbortSignal::new()); + let abort = HandleRegistry::global() + .insert(Owner::CProcess, Entry::Abort(signal.clone())) + .ok() + .unwrap(); + let mut consumer = Consumer { + abort: abort.0, + ..Consumer::default() + }; + let frames = futures::stream::iter([ + StreamFrame::Event { + seq: "0".into(), + event: serde_json::json!({"text":"hello"}), + }, + StreamFrame::End { + seq: "1".into(), + outcome: Outcome::Cancelled, + error: None, + }, + StreamFrame::Event { + seq: "2".into(), + event: Value::Null, + }, + ]); + assert!( + block_on(deliver_frames( + frames, + consume, + (&mut consumer as *mut Consumer).cast() + )) + .is_ok() + ); + assert!(signal.is_aborted()); + assert_eq!(consumer.frames.len(), 2); + assert_eq!(consumer.frames[1]["type"], "end"); + assert_eq!(consumer.frames[1]["outcome"], "cancelled"); + assert_eq!(consumer.nested_codes, vec![204; 8]); + aimux_drop(abort.0); + assert!(block_on(async { Ok(()) }).is_ok()); + } + + #[test] + fn terminal_error_is_only_in_callback_and_missing_end_is_reported_there() { + for frames in [ + vec![StreamFrame::End { + seq: "0".into(), + outcome: Outcome::Error, + error: Some( + OpsError::Protocol(ProtocolError::InvalidState("failed".into())) + .envelope(SystemTime::now()), + ), + }], + vec![], + ] { + let mut consumer = Consumer::default(); + assert!( + block_on(deliver_frames( + futures::stream::iter(frames), + consume, + (&mut consumer as *mut Consumer).cast() + )) + .is_ok() + ); + assert_eq!(consumer.frames.len(), 1); + assert_eq!(consumer.frames[0]["type"], "end"); + assert_eq!(consumer.frames[0]["outcome"], "error"); + assert_eq!(consumer.frames[0]["error"]["code"], 304); + } + } + + struct EchoTranscription; + + #[async_trait::async_trait] + impl aimux_core::transcription_model::TranscriptionModel for EchoTranscription { + fn provider(&self) -> &str { + "abi-test" + } + fn model_id(&self) -> &str { + "echo" + } + async fn do_generate( + &self, + _: &aimux_core::transcription_model::TranscriptionCallOptions, + ) -> std::result::Result< + aimux_core::transcription_model::TranscriptionResult, + aimux_core::AiMuxError, + > { + Err(aimux_core::AiMuxError::UnsupportedFunctionality( + "batch".into(), + )) + } + fn do_stream( + &self, + options: aimux_core::transcription_model::TranscriptionStreamOptions, + ) -> Option< + futures::future::BoxFuture< + '_, + std::result::Result< + aimux_core::transcription_model::TranscriptionStreamResult, + aimux_core::AiMuxError, + >, + >, + > { + use aimux_core::transcription_model::{ + AudioChunk, TranscriptionStreamPart, TranscriptionStreamResult, + }; + Some(Box::pin(async move { + Ok(TranscriptionStreamResult { + stream: Box::pin(options.audio.map(|audio| { + let AudioChunk::Binary(bytes) = audio else { + panic!("binary ABI input") + }; + Ok(TranscriptionStreamPart::TranscriptDelta { + id: None, + delta: format!("{bytes:?}"), + provider_metadata: None, + }) + })), + request: None, + response: None, + }) + })) + } + } + + #[test] + fn real_operation_push_copies_bytes_and_poll_uses_the_shared_service() { + use aimux_ops::operation::{Operation, OperationOptions}; + let operation = block_on(Operation::start( + Arc::new(EchoTranscription), + OperationOptions::default(), + None, + )) + .unwrap(); + let id = HandleRegistry::global() + .insert(Owner::CProcess, Entry::Operation(operation.clone())) + .unwrap(); + let mut part = ptr::dangling_mut(); + let mut state = 99; + assert!(aimux_operation_next_part(id.0, 0, &mut part, &mut state).is_null()); + assert_eq!(state, 3); + assert!(part.is_null()); + let error = aimux_operation_next_part(id.0, -1, &mut part, &mut state); + assert_eq!(aimux_error_code(error), 301); + aimux_error_free(error); + assert!(part.is_null()); + assert_eq!(state, 0); + let mut bytes = [1, 2, 3]; + assert!(aimux_operation_push(id.0, bytes.as_ptr(), bytes.len()).is_null()); + bytes.fill(9); + assert!(aimux_operation_next_part(id.0, 2000, &mut part, &mut state).is_null()); + assert_eq!(state, 1); + let value: Value = + serde_json::from_str(unsafe { CStr::from_ptr(part) }.to_str().unwrap()).unwrap(); + assert_eq!(value["delta"], "[1, 2, 3]"); + unsafe { aimux_free_string(part) }; + assert!(aimux_operation_push(id.0, ptr::null(), 0).is_null()); + assert!(aimux_operation_next_part(id.0, 2000, &mut part, &mut state).is_null()); + assert_eq!(state, 1); + unsafe { aimux_free_string(part) }; + let mut output = ptr::null_mut(); + assert!( + aimux_call( + id.0, + c"operation.inputDone".as_ptr(), + c"{}".as_ptr(), + 0, + &mut output + ) + .is_null() + ); + unsafe { aimux_free_string(output) }; + for _ in 0..2 { + assert!(aimux_operation_next_part(id.0, 2000, &mut part, &mut state).is_null()); + assert_eq!(state, 2); + assert!(part.is_null()); + } + aimux_drop(id.0); + assert!(operation.status().consumer_closed); + } + + #[test] + fn message_getter_sanitizes_nul_and_keeps_the_json_envelope_exact() { + let error = finish(Err(OpsError::AiMux(aimux_core::AiMuxError::Other( + "a\0b".into(), + )))); + let message = aimux_error_message(error); + assert!(!message.is_null()); + assert!( + unsafe { CStr::from_ptr(message) } + .to_str() + .unwrap() + .contains("a\u{fffd}b") + ); + let raw = aimux_error_json(error); + let value: Value = + serde_json::from_str(unsafe { CStr::from_ptr(raw) }.to_str().unwrap()).unwrap(); + assert!(value["message"].as_str().unwrap().contains("a\0b")); + unsafe { + aimux_free_string(message); + aimux_free_string(raw); + } + aimux_error_free(error); + } + + #[test] + fn opaque_error_keeps_the_dispatch_retry_projection() { + use aimux_core::error::{AiMuxError, ApiCallError}; + use std::time::UNIX_EPOCH; + let mut api = ApiCallError::new("retry later", "https://example.test", Value::Null); + api.response_headers = Some(std::collections::HashMap::from([( + "retry-after".into(), + "Tue, 14 Nov 2023 22:13:22 GMT".into(), + )])); + let saved = OpsError::from(AiMuxError::ApiCall(Box::new(api))) + .project(UNIX_EPOCH + Duration::from_secs(1_700_000_000)); + let error = finish(Err(saved)); + for _ in 0..2 { + let raw = aimux_error_json(error); + let value: Value = + serde_json::from_str(unsafe { CStr::from_ptr(raw) }.to_str().unwrap()).unwrap(); + assert_eq!(value["retryAfterMs"], 2000); + assert_eq!(aimux_error_code(error), 11); + unsafe { aimux_free_string(raw) }; + } + aimux_error_free(error); + } + + #[test] + fn finalizer_enqueues_idempotent_c_owned_cleanup() { + runtime().unwrap(); + let registry = HandleRegistry::global(); + let handle = registry + .insert( + Owner::CProcess, + Entry::Abort(Arc::new(aimux_core::AbortSignal::new())), + ) + .unwrap(); + aimux_handle_finalizer(handle.0 as usize as *mut c_void); + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while registry + .get(Owner::CProcess, handle, &[HandleKind::Abort]) + .is_ok() + { + assert!(std::time::Instant::now() < deadline); + std::thread::yield_now(); + } + aimux_handle_finalizer(handle.0 as usize as *mut c_void); + aimux_handle_finalizer(ptr::null_mut()); + } +} diff --git a/aimux-ffi/src/lib.rs b/aimux-ffi/src/lib.rs index 5aaf85be5..1c0d7005c 100644 --- a/aimux-ffi/src/lib.rs +++ b/aimux-ffi/src/lib.rs @@ -1,5149 +1,12 @@ -//! aimux-ffi: C ABI for multi-language bindings. +//! C ABI adapter for the shared aimux-ops dispatch layer. //! -//! Provides an opaque handle registry + JSON wire format + push callback -//! stream. The C ABI is consumed by C/C++, Go, Kotlin, Java, Swift, and -//! Flutter. Native bindings (Python / Node) bypass this layer and use -//! `aimux-providers` directly. -//! -//! ## Errors -//! -//! Every fallible C function returns `*mut aimux_error_t`: NULL on -//! success (the result is written to the out-parameter), a heap-owned error -//! value on failure (the out-parameter is left at its sentinel: handle 0, -//! pointer NULL). Every non-NULL error has one code from [`aimux_error_code`] -//! and one message from [`aimux_error_message`], and is released exactly once -//! with [`aimux_error_free`]. Codes 1..20 come from `AiMuxError`, 100..105 -//! from `RecordingError`, and 200..206 identify failures detected while -//! crossing the C ABI. -//! -//! ## Memory ownership -//! -//! - Every function returning `*mut c_char` — error getters included — -//! transfers ownership to the caller, who MUST free it with -//! [`aimux_free_string`]. Handles (`u64`) are released with -//! [`aimux_drop_handle`]. -//! - [`aimux_stream_text`] callbacks receive `*const c_char` pointers that are -//! valid **only for the duration of the callback**. The callback must copy -//! the data synchronously; the backing buffer is freed when the callback -//! returns. -//! -//! ## Concurrency -//! -//! All async provider work runs on a shared multi-threaded tokio runtime. The -//! C ABI functions are synchronous: they `block_on` the runtime until the -//! operation completes. Callbacks execute on the same thread/call-stack that -//! invoked the FFI function, so they must **not** re-enter the FFI layer: -//! a nested `block_on` on the same thread makes tokio **panic** ("Cannot start -//! a runtime from within a runtime"). Rust's non-unwind `extern "C"` ABI does -//! not allow a panic to propagate — the process terminates (and under this -//! workspace's release profile, `panic = "abort"`, it terminates at the panic -//! site). Either way the re-entrant call must be rejected before it reaches -//! the runtime; the thread-local guard in `ffi_block_on` does that -//! (issue M7). -#![allow(clippy::not_unsafe_ptr_arg_deref)] -// `extern "C"` entry points dereference raw pointers (`*const c_char`) by -// design: the C ABI contract requires callers to pass valid pointers (see -// memory-ownership docs above), so the functions are safe only on the C side. - -use std::collections::HashMap; -use std::ffi::{CStr, CString}; -use std::os::raw::{c_char, c_void}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; - -use serde::de::DeserializeOwned; - -use aimux_core::AbortSignal; -use aimux_core::AiMuxError; -#[allow(deprecated)] -use aimux_core::generate::generate_object; -use aimux_core::generate::{ - GenerateTextOptions, GenerateTextResult, generate_text, generate_text_as_openai, - generate_text_result_to_chat_completion, stream_text, stream_text_as_openai, -}; -use aimux_core::language_model::LanguageModel; -use aimux_core::message::ModelPrompt; -use aimux_core::openai_output::OpenAiStreamOptions; -use aimux_core::parse_tool_call::{ - ToolCallRepairReply, apply_tool_call_repair, apply_tool_call_repair_to_result, - tool_call_repair_context, tool_call_repair_inputs, -}; -use aimux_core::provider::Provider; -use aimux_core::recording::RecordingError; -use aimux_core::tool::ToolCall; -use aimux_core::trace::{RingTraceStore, TraceFilter, TraceLayer}; -use aimux_providers::anthropic::{AnthropicProviderSettings, create_anthropic}; -use aimux_providers::anthropic_aws::{ - AnthropicAwsAuth, AnthropicAwsProviderSettings, create_anthropic_aws, -}; -use aimux_providers::azure::{AzureOpenAIProviderSettings, create_azure}; -use aimux_providers::bedrock::{AmazonBedrockProviderSettings, create_amazon_bedrock}; -use aimux_providers::cohere::{CohereProviderSettings, create_cohere}; -use aimux_providers::google::{GoogleProviderSettings, create_google}; -use aimux_providers::mistral::{MistralProviderSettings, create_mistral}; -use aimux_providers::openai::{OpenAIProviderSettings, create_openai}; -use aimux_providers::provider::{ProviderOptions, provider, provider_handle}; -use aimux_providers::tavily::{TavilyProvider, TavilyProviderSettings, create_tavily}; -use aimux_providers::vertex::{VertexProviderSettings, create_google_vertex}; -use aimux_providers::xai::{XAIProviderSettings, create_xai}; - -use futures::StreamExt; -use tokio::runtime::Runtime; - -// ───────────────────────────────────────────────────────────────────────────── -// Global state: handle registry + tokio runtime -// ───────────────────────────────────────────────────────────────────────────── - -/// A type-erased FFI handle. One registry holds models, providers, sessions -/// and abort signals. -#[derive(Clone)] -enum HandleEntry { - Language(Arc), - Provider(ProviderEntry), - Embedding(Arc), - Speech(Arc), - Image(Arc), - Transcription(Arc), - Reranking(Arc), - Video(Arc), - Search(Arc), - Decision(Arc), - Files(Arc), - /// Live transcription streaming session (RFC-0028 Phase 2). - TranscriptionSession(Arc), - Abort(AbortSignal), -} - -/// A provider handle: the model factory plus its runtime-discovery side. -/// -/// Discovery is accessed through `Provider::discovery()`. -#[derive(Clone)] -struct ProviderEntry { - provider: Arc, -} - -type Registry = HashMap; - -static REGISTRY: OnceLock> = OnceLock::new(); -static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1); - -fn registry() -> &'static Mutex { - REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) -} - -/// Register a model instance, returning its opaque `u64` handle. -/// -/// Handles start at 1; 0 is reserved for "failure / invalid". -fn intern_model(model: Arc) -> u64 { - intern_handle(HandleEntry::Language(model)) -} - -fn intern_handle(h: HandleEntry) -> u64 { - let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed); - registry() - .lock() - .expect("aimux-ffi: registry mutex poisoned") - .insert(handle, h); - handle -} - -/// Look up any handle. -fn get_handle(handle: u64) -> Option { - registry() - .lock() - .expect("aimux-ffi: registry mutex poisoned") - .get(&handle) - .cloned() -} - -/// Look up a model by handle, cloning the `Arc` out of the registry. -fn get_model(handle: u64) -> Option> { - match get_handle(handle)? { - HandleEntry::Language(m) => Some(m), - _ => None, - } -} - -/// A model handle, or [`FfiError::InvalidHandle`] (`"model"`). -fn model_of(handle: u64) -> Result, FfiError> { - get_model(handle).ok_or(FfiError::InvalidHandle { expected: "model" }) -} - -/// A live handle of any registered type, or [`FfiError::InvalidHandle`] naming what was -/// expected. Callers match the arm they need and fall back to the same error. -fn entry_of(handle: u64, expected: &'static str) -> Result { - get_handle(handle).ok_or(FfiError::InvalidHandle { expected }) -} - -fn get_abort_signal(handle: u64) -> Option { - match get_handle(handle)? { - HandleEntry::Abort(signal) => Some(signal), - _ => None, - } -} - -fn abort_of(handle: u64) -> Result { - get_abort_signal(handle).ok_or(FfiError::InvalidHandle { expected: "abort" }) -} - -/// Remove a handle from the registry (the model drops when the last ref goes). -/// Trace stores bound to the handle are released with it. -fn drop_handle(handle: u64) { - let removed = registry() - .lock() - .expect("aimux-ffi: registry mutex poisoned") - .remove(&handle); - // A transcription session owns a driver task: abort and join it here too, - // so the generic drop is never a silent leak (the registry mutex is - // already released — the join must not hold it). - if let Some(HandleEntry::TranscriptionSession(s)) = removed { - s.terminate(); - } - if let Some(stores) = TRACE_STORES.get() { - stores - .lock() - .expect("aimux-ffi: trace registry mutex poisoned") - .remove(&handle); - } -} - -fn drop_abort_signal(handle: u64) { - let mut registry = registry() - .lock() - .expect("aimux-ffi: registry mutex poisoned"); - if matches!(registry.get(&handle), Some(HandleEntry::Abort(_))) { - registry.remove(&handle); - } -} - -/// Transcription streaming sessions (RFC-0028 Phase 2). -mod transcription_session; - -/// The shared tokio runtime driving all async provider calls. -fn runtime() -> &'static Runtime { - static RUNTIME: OnceLock = OnceLock::new(); - RUNTIME.get_or_init(|| { - tokio::runtime::Runtime::new().expect("aimux-ffi: failed to build tokio runtime") - }) -} - -thread_local! { - /// Re-entrancy guard: set while an FFI entry point is `block_on`-ing the - /// shared runtime on the current thread. - /// - /// Stream callbacks (`on_part`/`on_done`) run synchronously on - /// the same thread/call-stack that entered the FFI function, so a callback - /// that calls back into the FFI layer would enter a second `block_on` on - /// this thread. tokio rejects nested `block_on` with a **panic**; Rust's - /// non-unwind `extern "C"` ABI terminates the process rather than letting - /// the panic propagate (and `panic = "abort"` terminates at the panic site). - /// `ffi_block_on` checks this guard and turns that re-entrant call into - /// a [`FfiError::ReentrantCall`] instead (issue M7). - static IN_FFI_BLOCK_ON: std::cell::Cell = const { std::cell::Cell::new(false) }; -} - -/// Run a future on the shared runtime from an FFI entry point (issue M7). -/// -/// Rejects re-entrant calls made from inside a stream callback, returning -/// [`FfiError::ReentrantCall`] instead of letting tokio's nested `block_on` panic — -/// a non-unwind `extern "C"` call never lets the panic propagate, so the -/// process would terminate. The guard is released when the future completes, -/// including when it panics. -fn ffi_block_on(f: F) -> Result -where - F: std::future::Future, -{ - IN_FFI_BLOCK_ON.with(|flag| { - if flag.replace(true) { - return Err(FfiError::ReentrantCall); - } - struct Reset; - impl Drop for Reset { - fn drop(&mut self) { - IN_FFI_BLOCK_ON.with(|flag| flag.set(false)); - } - } - let _reset = Reset; - Ok(runtime().block_on(f)) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Error model (aimux-error.h) -// ───────────────────────────────────────────────────────────────────────────── - -/// A failure detected while lifting arguments, using handles, invoking a -/// callback, or lowering a result across the C ABI. -#[derive(Debug, Clone)] -enum FfiError { - /// A required pointer argument was NULL. - NullPointer { argument: &'static str }, - /// A string argument was not valid UTF-8. - InvalidUtf8 { argument: &'static str }, - /// A JSON-text argument of this wire format (`prompt_json`, `opts_json`, - /// `config_json`, …) did not parse. Not `AiMuxError::JsonParse` — that - /// one is about provider responses. - InvalidWireJson { - argument: &'static str, - message: String, - }, - /// A handle argument is 0, released, or has the wrong handle type. - InvalidHandle { expected: &'static str }, - /// The FFI was re-entered from inside one of its own callbacks. - ReentrantCall, - /// A result could not be serialized to the wire format. - ResultSerialization { message: String }, - /// A host callback panicked (caught inside the C ABI). - CallbackFailure { message: String }, -} - -impl std::fmt::Display for FfiError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - FfiError::NullPointer { argument } => write!(f, "{argument}: must not be NULL"), - FfiError::InvalidUtf8 { argument } => write!(f, "{argument}: must be valid UTF-8"), - FfiError::InvalidWireJson { argument, message } => { - write!(f, "{argument}: invalid JSON: {message}") - } - FfiError::InvalidHandle { expected } => { - write!(f, "invalid or expired {expected} handle") - } - FfiError::ReentrantCall => { - f.write_str("re-entrant FFI call from within a callback is not allowed") - } - FfiError::ResultSerialization { message } => { - write!(f, "could not serialize result: {message}") - } - FfiError::CallbackFailure { message } => write!(f, "host callback failed: {message}"), - } - } -} - -/// The errors that can cross the Aimux C ABI. -enum AiMuxFfiError { - AiMux(AiMuxError), - Recording(RecordingError), - Ffi(FfiError), -} - -impl From for AiMuxFfiError { - fn from(inner: AiMuxError) -> Self { - AiMuxFfiError::AiMux(inner) - } -} -impl From for AiMuxFfiError { - fn from(inner: RecordingError) -> Self { - AiMuxFfiError::Recording(inner) - } -} -impl From for AiMuxFfiError { - fn from(e: FfiError) -> Self { - AiMuxFfiError::Ffi(e) - } -} - -impl std::fmt::Display for AiMuxFfiError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - AiMuxFfiError::AiMux(e) => e.fmt(f), - AiMuxFfiError::Recording(e) => e.fmt(f), - AiMuxFfiError::Ffi(e) => e.fmt(f), - } - } -} - -/// The owned error returned by a failed C ABI invocation. Opaque to C: read it -/// with the `aimux_error_*` getters, then release it once with -/// `aimux_error_free`. -#[allow(non_camel_case_types)] -pub struct aimux_error_t { - error: AiMuxFfiError, -} - -type FfiResult = Result; - -/// Ok → run `write` with the value and return NULL; Err → hand the owned -/// error to the caller. -fn finish(r: FfiResult, write: impl FnOnce(T)) -> *mut aimux_error_t { - match r { - Ok(v) => { - write(v); - std::ptr::null_mut() - } - Err(error) => Box::into_raw(Box::new(aimux_error_t { error })), - } -} - -/// Entry-point shape: `uint64_t *out_handle`. NULL out-param is a -/// `NullPointer("out_handle")`; the sentinel 0 is written before `f` runs. -fn with_out_handle(out_handle: *mut u64, f: impl FnOnce() -> FfiResult) -> *mut aimux_error_t { - if out_handle.is_null() { - return finish( - Err(FfiError::NullPointer { - argument: "out_handle", - } - .into()), - |_: u64| {}, - ); - } - unsafe { *out_handle = 0 }; - finish(f(), |h| unsafe { *out_handle = h }) -} - -/// Entry-point shape: `char **out_json` (name given by `argument`). NULL -/// out-param is a `NullPointer`; the sentinel NULL is written before `f` runs. -fn with_out_string( - out: *mut *mut c_char, - argument: &'static str, - f: impl FnOnce() -> FfiResult, -) -> *mut aimux_error_t { - if out.is_null() { - return finish( - Err(FfiError::NullPointer { argument }.into()), - |_: String| {}, - ); - } - unsafe { *out = std::ptr::null_mut() }; - finish(f(), |s| unsafe { *out = into_cstring_raw(s) }) -} - -/// Entry-point shape: no result. -fn no_result(f: impl FnOnce() -> FfiResult<()>) -> *mut aimux_error_t { - finish(f(), |()| {}) -} - -// ── aimux_error_* ──────────────────────────────────────────────────────────── - -/// Release a returned error. NULL-safe; call exactly once. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_free(err: *mut aimux_error_t) { - if !err.is_null() { - // SAFETY: `err` came from `Box::into_raw` in `finish` and the caller - // passes it exactly once. - drop(unsafe { Box::from_raw(err) }); - } -} - -// ── Unified error codes (aimux-error.h `aimux_error_code_t`) ───────────────── - -pub const AIMUX_OK: i32 = 0; -// 1 is the catch-all `Other` (it was `AIMUX_E_UNKNOWN`, whose slot it inherits: -// a code a binding does not know is a header mismatch, not a user-facing code). -pub const AIMUX_E_OTHER: i32 = 1; -pub const AIMUX_E_JSON_PARSE: i32 = 2; -pub const AIMUX_E_INVALID_RESPONSE_DATA: i32 = 3; -// 4 is retired: it was the legacy catch-all `Tool` variant, which nothing -// ever produced; the typed tool-contract codes are 15..17. 14 is claimed by -// the in-flight request-pipeline change (`Retry`). -pub const AIMUX_E_INVALID_ARGUMENT: i32 = 5; -pub const AIMUX_E_INVALID_PROMPT: i32 = 6; -pub const AIMUX_E_TOKEN_EXPIRED: i32 = 7; -pub const AIMUX_E_UNSUPPORTED_FUNCTIONALITY: i32 = 8; -pub const AIMUX_E_NO_SUCH_MODEL: i32 = 9; -pub const AIMUX_E_NO_SUCH_PROVIDER: i32 = 10; -pub const AIMUX_E_API_CALL: i32 = 11; -pub const AIMUX_E_TIMEOUT: i32 = 12; -pub const AIMUX_E_ABORTED: i32 = 13; -// `Retry` (newest variant) reclaims the slot the pre-unification `Other` -// vacated — the opaque-pointer ABI break means no old caller can misread it. -pub const AIMUX_E_RETRY: i32 = 14; -pub const AIMUX_E_NO_SUCH_TOOL: i32 = 15; -pub const AIMUX_E_INVALID_TOOL_INPUT: i32 = 16; -pub const AIMUX_E_TOOL_CALL_REPAIR: i32 = 17; -pub const AIMUX_E_NO_OUTPUT_GENERATED: i32 = 18; -// Appended after NoOutputGenerated (4 stays retired): the AI SDK's -// `LoadAPIKeyError` and `LoadSettingError`. Their facts travel on the -// provider-code / provider-message getters: `env_var` and the description -// (key) or setting name. -pub const AIMUX_E_LOAD_API_KEY: i32 = 19; -pub const AIMUX_E_LOAD_SETTING: i32 = 20; - -// 100..105 preserve `RecordingError` as a separate high-level type while C -// uses one code space for every returned error. -pub const AIMUX_E_RECORDING_INIT: i32 = 100; -pub const AIMUX_E_RECORDING_OPEN_FILE: i32 = 101; -pub const AIMUX_E_RECORDING_SPAWN: i32 = 102; -pub const AIMUX_E_RECORDING_WRITER_GONE: i32 = 103; -pub const AIMUX_E_RECORDING_FLUSH_TIMEOUT: i32 = 104; -pub const AIMUX_E_RECORDING_WRITE: i32 = 105; - -// 200..206 are failures detected while lifting arguments, looking up handles, -// invoking callbacks, or lowering a result across the C ABI. -pub const AIMUX_E_FFI_NULL_POINTER: i32 = 200; -pub const AIMUX_E_FFI_INVALID_UTF8: i32 = 201; -pub const AIMUX_E_FFI_INVALID_WIRE_JSON: i32 = 202; -pub const AIMUX_E_FFI_INVALID_HANDLE: i32 = 203; -pub const AIMUX_E_FFI_REENTRANT_CALL: i32 = 204; -pub const AIMUX_E_FFI_RESULT_SERIALIZATION: i32 = 205; -pub const AIMUX_E_FFI_CALLBACK_FAILURE: i32 = 206; - -// ── `aimux_transcription_next_part_state_t` ────────────────────────────────── -pub const AIMUX_TRANSCRIPTION_NEXT_PART_PART: i32 = 1; -pub const AIMUX_TRANSCRIPTION_NEXT_PART_ENDED: i32 = 2; -pub const AIMUX_TRANSCRIPTION_NEXT_PART_TIMEOUT: i32 = 3; - -fn aimux_error_code_of(err: &AiMuxError) -> i32 { - match err { - AiMuxError::ApiCall { .. } => AIMUX_E_API_CALL, - AiMuxError::Retry(_) => AIMUX_E_RETRY, - AiMuxError::JsonParse(_) => AIMUX_E_JSON_PARSE, - AiMuxError::NoOutputGenerated(_) => AIMUX_E_NO_OUTPUT_GENERATED, - // The AI SDK's MissingToolResultsError / ToolChoiceViolationError have - // no code of their own yet; they join the prompt / response-data - // codes until the new C ABI (as ToolCallNotFoundForApproval does). - AiMuxError::InvalidResponseData(_) - | AiMuxError::ToolCallNotFoundForApproval { .. } - | AiMuxError::ToolChoiceViolation(_) => AIMUX_E_INVALID_RESPONSE_DATA, - AiMuxError::NoSuchTool { .. } => AIMUX_E_NO_SUCH_TOOL, - AiMuxError::InvalidToolInput { .. } => AIMUX_E_INVALID_TOOL_INPUT, - AiMuxError::ToolCallRepair { .. } => AIMUX_E_TOOL_CALL_REPAIR, - AiMuxError::InvalidArgument(_) => AIMUX_E_INVALID_ARGUMENT, - AiMuxError::InvalidPrompt(_) | AiMuxError::MissingToolResults { .. } => { - AIMUX_E_INVALID_PROMPT - } - AiMuxError::LoadApiKey { .. } => AIMUX_E_LOAD_API_KEY, - AiMuxError::LoadSetting { .. } => AIMUX_E_LOAD_SETTING, - AiMuxError::TokenExpired(_) => AIMUX_E_TOKEN_EXPIRED, - AiMuxError::UnsupportedFunctionality(_) => AIMUX_E_UNSUPPORTED_FUNCTIONALITY, - AiMuxError::NoSuchModel { .. } => AIMUX_E_NO_SUCH_MODEL, - AiMuxError::NoSuchProvider { .. } => AIMUX_E_NO_SUCH_PROVIDER, - AiMuxError::Timeout(_) => AIMUX_E_TIMEOUT, - AiMuxError::Aborted(_) => AIMUX_E_ABORTED, - AiMuxError::Other(_) => AIMUX_E_OTHER, - } -} - -fn recording_error_code_of(e: &RecordingError) -> i32 { - use RecordingError as R; - match e { - R::Init { .. } => AIMUX_E_RECORDING_INIT, - R::OpenFile { .. } => AIMUX_E_RECORDING_OPEN_FILE, - R::Spawn { .. } => AIMUX_E_RECORDING_SPAWN, - R::WriterGone => AIMUX_E_RECORDING_WRITER_GONE, - R::FlushTimeout => AIMUX_E_RECORDING_FLUSH_TIMEOUT, - R::Write(_) => AIMUX_E_RECORDING_WRITE, - } -} - -fn ffi_error_code_of(e: &FfiError) -> i32 { - match e { - FfiError::NullPointer { .. } => AIMUX_E_FFI_NULL_POINTER, - FfiError::InvalidUtf8 { .. } => AIMUX_E_FFI_INVALID_UTF8, - FfiError::InvalidWireJson { .. } => AIMUX_E_FFI_INVALID_WIRE_JSON, - FfiError::InvalidHandle { .. } => AIMUX_E_FFI_INVALID_HANDLE, - FfiError::ReentrantCall => AIMUX_E_FFI_REENTRANT_CALL, - FfiError::ResultSerialization { .. } => AIMUX_E_FFI_RESULT_SERIALIZATION, - FfiError::CallbackFailure { .. } => AIMUX_E_FFI_CALLBACK_FAILURE, - } -} - -fn error_code_of(e: &AiMuxFfiError) -> i32 { - match e { - AiMuxFfiError::AiMux(e) => aimux_error_code_of(e), - AiMuxFfiError::Recording(e) => recording_error_code_of(e), - AiMuxFfiError::Ffi(e) => ffi_error_code_of(e), - } -} - -fn api_call(e: &AiMuxError) -> Option<&aimux_core::ApiCallError> { - match e { - AiMuxError::ApiCall(d) => Some(d), - _ => None, - } -} - -fn retry(e: &AiMuxError) -> Option<&aimux_core::RetryError> { - match e { - AiMuxError::Retry(r) => Some(r), - _ => None, - } -} - -/// Apply `f` to the `AiMuxError` stored in a returned error. -/// -/// The closure keeps the borrowed error scoped to this call instead of -/// manufacturing an unconstrained lifetime from the raw pointer. -fn map_aimux_error(err: *const aimux_error_t, f: impl FnOnce(&AiMuxError) -> T) -> Option { - // SAFETY: the C API requires NULL or a live error returned by `finish`. - match unsafe { err.as_ref() } { - Some(aimux_error_t { - error: AiMuxFfiError::AiMux(e), - }) => Some(f(e)), - _ => None, - } -} - -fn opt_cstring(v: Option) -> *mut c_char { - v.map_or(std::ptr::null_mut(), into_cstring_raw) -} - -// ── aimux_error_* getters ──────────────────────────────────────────── -// -// One getter per fact. `code` and `message` answer for every returned error; -// the rest belong to one AiMuxError code and return NULL / -1 / 0 under every -// other code or for NULL. Strings are owned by the caller (aimux_free_string). - -/// Machine-readable code (`aimux_error_code_t`); `AIMUX_OK` for NULL. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_code(err: *const aimux_error_t) -> i32 { - // SAFETY: NULL or a live error returned by `finish`. - unsafe { err.as_ref() }.map_or(AIMUX_OK, |e| error_code_of(&e.error)) -} - -/// Human-readable description for every code; NULL only for NULL. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_message(err: *const aimux_error_t) -> *mut c_char { - // SAFETY: NULL or a live error returned by `finish`. - unsafe { err.as_ref() }.map_or(std::ptr::null_mut(), |e| { - into_cstring_raw(e.error.to_string()) - }) -} - -/// HTTP status: the observed status under `AIMUX_E_API_CALL`, 401 by -/// definition under `AIMUX_E_TOKEN_EXPIRED`, -1 otherwise or when no response -/// was observed (`AiMuxError::status_code`). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_status(err: *const aimux_error_t) -> i32 { - map_aimux_error(err, AiMuxError::status_code) - .flatten() - .map_or(-1, i32::from) -} - -/// `AIMUX_E_API_CALL`: retry hint in ms (0 = retry now), or -1 when none. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_retry_ms(err: *const aimux_error_t) -> i64 { - map_aimux_error(err, AiMuxError::retry_after_hint) - .flatten() - .unwrap_or(-1) -} - -/// 1 when retrying may help, 0 when it will not — the core's verdict. Do not -/// infer it from `status`: a statusless `AIMUX_E_API_CALL` may be either. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_retryable(err: *const aimux_error_t) -> i32 { - map_aimux_error(err, AiMuxError::is_retryable).unwrap_or(false) as i32 -} - -/// `AIMUX_E_API_CALL`: the provider's own error code, e.g. "insufficient_quota". -/// `AIMUX_E_LOAD_API_KEY` / `AIMUX_E_LOAD_SETTING`: the environment variable -/// that was consulted (`env_var`), e.g. "OPENAI_API_KEY". -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_provider_code(err: *const aimux_error_t) -> *mut c_char { - opt_cstring( - map_aimux_error(err, |e| match e { - AiMuxError::LoadApiKey { env_var, .. } | AiMuxError::LoadSetting { env_var, .. } => { - Some(env_var.clone()) - } - _ => api_call(e)?.provider_code.clone(), - }) - .flatten(), - ) -} - -/// `AIMUX_E_API_CALL`: the failure's own text ("slow down"), without the -/// composed prefix `message` carries ("API call error: HTTP 429: slow down"). -/// `AIMUX_E_LOAD_API_KEY`: what the key is for ("OpenAI"); -/// `AIMUX_E_LOAD_SETTING`: the setting's parameter name ("region"). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_provider_message(err: *const aimux_error_t) -> *mut c_char { - opt_cstring( - map_aimux_error(err, |e| match e { - AiMuxError::LoadApiKey { description, .. } => Some(description.clone()), - AiMuxError::LoadSetting { name, .. } => Some(name.clone()), - _ => Some(api_call(e)?.message.clone()), - }) - .flatten() - .filter(|m| !m.is_empty()), - ) -} - -/// `AIMUX_E_API_CALL`: raw response body. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_response_body(err: *const aimux_error_t) -> *mut c_char { - opt_cstring(map_aimux_error(err, |e| api_call(e)?.response_body.clone()).flatten()) -} - -/// `AIMUX_E_API_CALL`: sanitized request URL. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_url(err: *const aimux_error_t) -> *mut c_char { - opt_cstring( - map_aimux_error(err, |e| Some(api_call(e)?.url.clone())) - .flatten() - .filter(|u| !u.is_empty()), - ) -} - -/// `AIMUX_E_API_CALL`: sanitized request body values as a JSON string; NULL -/// when the request carried none (JSON `null`). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_request_body_values(err: *const aimux_error_t) -> *mut c_char { - opt_cstring( - map_aimux_error(err, |e| { - let values = &api_call(e)?.request_body_values; - if values.is_null() { - return None; - } - serde_json::to_string(values).ok() - }) - .flatten(), - ) -} - -/// `AIMUX_E_API_CALL`: sanitized response headers as one JSON object string -/// of name → value pairs, e.g. `{"retry-after-ms":"1500"}`. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_response_headers(err: *const aimux_error_t) -> *mut c_char { - opt_cstring( - map_aimux_error(err, |e| { - serde_json::to_string(api_call(e)?.response_headers.as_ref()?).ok() - }) - .flatten(), - ) -} - -/// `AIMUX_E_API_CALL`: parsed provider error data as a JSON string. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_provider_data(err: *const aimux_error_t) -> *mut c_char { - opt_cstring( - map_aimux_error(err, |e| { - serde_json::to_string(api_call(e)?.data.as_ref()?).ok() - }) - .flatten(), - ) -} - -/// `AIMUX_E_RETRY`: why retrying stopped — the wire name of -/// `RetryErrorReason`: "maxRetriesExceeded" or "errorNotRetryable". -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_retry_reason(err: *const aimux_error_t) -> *mut c_char { - let reason = map_aimux_error(err, |e| { - Some(match retry(e)?.reason { - aimux_core::RetryErrorReason::MaxRetriesExceeded => "maxRetriesExceeded", - aimux_core::RetryErrorReason::ErrorNotRetryable => "errorNotRetryable", - }) - }) - .flatten(); - opt_cstring(reason.map(str::to_string)) -} - -/// `AIMUX_E_RETRY`: number of recorded attempt errors; 0 under any other -/// code or for NULL. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_retry_count(err: *const aimux_error_t) -> i32 { - map_aimux_error(err, |e| Some(retry(e)?.errors.len() as i32)) - .flatten() - .unwrap_or(0) -} - -/// `AIMUX_E_RETRY`: the attempt error at `index` (0-based, oldest first; the -/// last entry is the final attempt) as a NEW owned error the caller reads -/// with these same getters and releases with `aimux_error_free`, -/// independently of the parent. NULL when `index` is out of range or under -/// any other code. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_retry_error_at( - err: *const aimux_error_t, - index: i32, -) -> *mut aimux_error_t { - if index < 0 { - return std::ptr::null_mut(); - } - map_aimux_error(err, |e| { - retry(e)?.errors.get(index as usize).map(|attempt| { - Box::into_raw(Box::new(aimux_error_t { - error: AiMuxFfiError::AiMux(attempt.clone()), - })) - }) - }) - .flatten() - .unwrap_or_else(std::ptr::null_mut) -} - -/// `AIMUX_E_NO_SUCH_MODEL`: the model id that was asked for. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_model_id(err: *const aimux_error_t) -> *mut c_char { - opt_cstring( - map_aimux_error(err, |e| match e { - AiMuxError::NoSuchModel { model_id, .. } => Some(model_id.clone()), - _ => None, - }) - .flatten(), - ) -} - -/// `AIMUX_E_NO_SUCH_MODEL`: the model type it was asked for as. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_model_type(err: *const aimux_error_t) -> *mut c_char { - opt_cstring( - map_aimux_error(err, |e| match e { - AiMuxError::NoSuchModel { model_type, .. } => Some(model_type.clone()), - _ => None, - }) - .flatten(), - ) -} - -/// `AIMUX_E_NO_SUCH_PROVIDER`: the provider id that was asked for. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_provider_id(err: *const aimux_error_t) -> *mut c_char { - opt_cstring( - map_aimux_error(err, |e| match e { - AiMuxError::NoSuchProvider { provider_id, .. } => Some(provider_id.clone()), - _ => None, - }) - .flatten(), - ) -} - -/// `AIMUX_E_NO_SUCH_TOOL` / `AIMUX_E_INVALID_TOOL_INPUT`: the tool name the -/// model called. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_tool_name(err: *const aimux_error_t) -> *mut c_char { - opt_cstring( - map_aimux_error(err, |e| match e { - AiMuxError::NoSuchTool { tool_name, .. } - | AiMuxError::InvalidToolInput { tool_name, .. } => Some(tool_name.clone()), - _ => None, - }) - .flatten(), - ) -} - -/// `AIMUX_E_NO_SUCH_TOOL`: the available tool names as a JSON string array, -/// or NULL when no tool set was supplied. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_available_tools(err: *const aimux_error_t) -> *mut c_char { - opt_cstring( - map_aimux_error(err, |e| match e { - AiMuxError::NoSuchTool { - available_tools: Some(tools), - .. - } => serde_json::to_string(tools).ok(), - _ => None, - }) - .flatten(), - ) -} - -/// `AIMUX_E_INVALID_TOOL_INPUT` / `AIMUX_E_NO_SUCH_TOOL`: the raw argument -/// text the model produced, or NULL when unavailable. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_tool_input(err: *const aimux_error_t) -> *mut c_char { - opt_cstring( - map_aimux_error(err, |e| match e { - AiMuxError::InvalidToolInput { tool_input, .. } => Some(tool_input.clone()), - AiMuxError::NoSuchTool { tool_input, .. } => tool_input.clone(), - _ => None, - }) - .flatten(), - ) -} - -/// `AIMUX_E_TOOL_CALL_REPAIR`: the original lookup/parse/validation error as -/// externally-tagged wire JSON — the same encoding as `ToolCall.error`. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_error_original_error(err: *const aimux_error_t) -> *mut c_char { - opt_cstring( - map_aimux_error(err, |e| match e { - AiMuxError::ToolCallRepair { original_error, .. } => { - serde_json::to_string(original_error).ok() - } - _ => None, - }) - .flatten(), - ) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Argument helpers -// ───────────────────────────────────────────────────────────────────────────── - -/// Read a required string argument, naming it in the failure -/// (`NullPointer` / `InvalidUtf8`). -/// -/// # Safety: `ptr` is null or a valid NUL-terminated C string. -fn str_arg(ptr: *const c_char, argument: &'static str) -> Result { - if ptr.is_null() { - return Err(FfiError::NullPointer { argument }); - } - // SAFETY: caller guarantees `ptr` is a valid NUL-terminated C string. - unsafe { CStr::from_ptr(ptr) } - .to_str() - .map(str::to_owned) - .map_err(|_| FfiError::InvalidUtf8 { argument }) -} - -/// Read an optional string argument: NULL means "absent"; a non-NULL pointer -/// must be valid UTF-8 or it is [`FfiError::InvalidUtf8`] — absent and -/// malformed are different things. -fn opt_str_arg(ptr: *const c_char, argument: &'static str) -> Result, FfiError> { - if ptr.is_null() { - return Ok(None); - } - str_arg(ptr, argument).map(Some) -} - -/// Build (a, b) from two required C strings. -fn parse_two_args( - a: *const c_char, - an: &'static str, - b: *const c_char, - bn: &'static str, -) -> Result<(String, String), FfiError> { - Ok((str_arg(a, an)?, str_arg(b, bn)?)) -} - -/// Parse four required C string arguments; any null fails the whole call. -#[allow(clippy::too_many_arguments)] -fn parse_four_args( - a: *const c_char, - an: &'static str, - b: *const c_char, - bn: &'static str, - c: *const c_char, - cn: &'static str, - d: *const c_char, - dn: &'static str, -) -> Result<(String, String, String, String), FfiError> { - Ok(( - str_arg(a, an)?, - str_arg(b, bn)?, - str_arg(c, cn)?, - str_arg(d, dn)?, - )) -} - -/// Parse the base_url argument; NULL or an empty string means unset. -fn parse_base_url(base_url: *const c_char) -> Result, FfiError> { - Ok(opt_str_arg(base_url, "base_url")?.filter(|url| !url.is_empty())) -} - -/// Where a serde failure on a wire-JSON argument belongs. Malformed text -/// (syntax / EOF) is this layer's finding: [`FfiError::InvalidWireJson`]. -/// Well-formed JSON of the wrong shape (missing field, wrong type) is what the -/// core would reject: [`AiMuxError::InvalidArgument`]. `detail` is the -/// message to carry (usually `e.to_string()`, sometimes with a line prefix). -fn wire_failure(argument: &'static str, e: &serde_json::Error, detail: String) -> AiMuxFfiError { - match e.classify() { - serde_json::error::Category::Data => { - AiMuxError::InvalidArgument(format!("{argument}: {detail}")).into() - } - _ => FfiError::InvalidWireJson { - argument, - message: detail, - } - .into(), - } -} - -/// [`wire_failure`] with the serde message as the detail. -fn wire_err(argument: &'static str, e: serde_json::Error) -> AiMuxFfiError { - let detail = e.to_string(); - wire_failure(argument, &e, detail) -} - -/// Parse a JSON C-string argument into `T`. NULL / non-UTF-8 → FFI -/// `NullPointer` / `InvalidUtf8`; text that does not parse → FFI -/// `InvalidWireJson`; text that parses but violates `T`'s schema → -/// `AiMuxError::InvalidArgument` (see [`wire_failure`]). -fn parse_json_arg(json: *const c_char, name: &'static str) -> FfiResult { - let s = str_arg(json, name)?; - serde_json::from_str::(&s).map_err(|e| wire_err(name, e)) -} - -/// Parse the prompt JSON accepted by the FFI (`prompt_json`, required). -/// -/// Accepts either a bare prompt value (`"text"` or `[{...}]`) or a wrapper -/// object `{"prompt": }`. -fn parse_prompt_arg(prompt_json: *const c_char) -> FfiResult { - let s = str_arg(prompt_json, "prompt_json")?; - let parse = |json: &str| -> Result { - let value: serde_json::Value = serde_json::from_str(json)?; - let inner = match &value { - serde_json::Value::Object(obj) if obj.len() == 1 && obj.contains_key("prompt") => { - obj.get("prompt").expect("checked by guard") - } - _ => &value, - }; - serde_json::from_value(inner.clone()) - }; - parse(&s).map_err(|e| wire_err("prompt_json", e)) -} - -/// Parse the options JSON (`opts_json`, optional). NULL / empty / `null` -/// yields the default options. -fn parse_opts_arg(opts_json: *const c_char) -> FfiResult { - match opt_str_arg(opts_json, "opts_json")? { - Some(s) if !s.trim().is_empty() && s.trim() != "null" => { - serde_json::from_str(&s).map_err(|e| wire_err("opts_json", e)) - } - _ => Ok(GenerateTextOptions::default()), - } -} - -/// Parse the optional `config_json` argument (`ProviderOptions`); NULL, -/// empty or "null" means unset. -fn parse_provider_options(config_json: *const c_char) -> FfiResult> { - match opt_str_arg(config_json, "config_json")? { - Some(s) if !s.trim().is_empty() && s.trim() != "null" => { - serde_json::from_str::(&s) - .map(Some) - .map_err(|e| wire_err("config_json", e)) - } - _ => Ok(None), - } -} - -/// Read a config-style JSON C string and normalize it for lenient -/// deserialization: NULL, empty, whitespace-only and `"null"` all become -/// `"{}"` (defaults). Invalid UTF-8 is [`FfiError::InvalidUtf8`]. -fn normalize_config_json(json: *const c_char, argument: &'static str) -> Result { - Ok(match opt_str_arg(json, argument)? { - Some(s) if !s.trim().is_empty() && s.trim() != "null" => s, - _ => String::from("{}"), - }) -} - -/// Build an owned C string (`*mut c_char`) from a `String`, transferring -/// ownership to the caller (who must free it with [`aimux_free_string`]). -/// -/// Never returns null (issue M1): interior NUL bytes — which would make -/// `CString::new` fail — are replaced with U+FFFD so the C-side contract -/// ("a non-null NUL-terminated buffer to free, or null") always holds. The -/// replacement is reported through `tracing` (RFC-0014 logging, which C hosts -/// route via `aimux_init_logging`) so accidental NULs stay visible without -/// the library writing to the host's stderr directly. -fn into_cstring_raw(s: String) -> *mut c_char { - if s.contains('\0') { - tracing::warn!( - target: "aimux_ffi", - nul_bytes = s.bytes().filter(|&b| b == 0).count(), - "string contains NUL byte(s); replaced with U+FFFD before FFI return" - ); - } - let sanitized = s.replace('\0', "\u{FFFD}"); - // No interior NUL remains: this cannot fail. - CString::new(sanitized) - .expect("aimux-ffi: impossible: NUL-free string rejected by CString::new") - .into_raw() -} - -/// Serialize a result to its wire JSON, or [`FfiError::ResultSerialization`]. -fn to_json(v: &T) -> FfiResult { - serde_json::to_string(v).map_err(|e| { - FfiError::ResultSerialization { - message: format!("serialize: {e}"), - } - .into() - }) -} - -/// Run an async model operation on the runtime and serialize its result. -fn run_json(f: F) -> FfiResult -where - F: std::future::Future>, - T: serde::Serialize, -{ - let v = ffi_block_on(f)??; - to_json(&v) -} - -/// Invoke a stream callback (`on_part`/`on_done`) while catching any panic. -/// -/// The callbacks are declared `extern "C-unwind"`, so a *Rust* panic raised -/// inside a Rust-implemented callback (panic=unwind builds, same runtime) -/// propagates back here instead of aborting at the ABI edge (issue #64); this -/// wrapper catches it and converts it to a structured -/// [`FfiError::CallbackFailure`] that ends the stream. Foreign exceptions -/// (C++, JVM, Swift, Dart, Go) are NOT covered — `catch_unwind` may abort on -/// them — so callbacks must not unwind across the C ABI; binding trampolines -/// catch their own language's exceptions. (Release builds use `panic = -/// "abort"`, in which case the -/// process aborts before this point and the code is never produced). -/// -/// `AssertUnwindSafe` is required because the callback receives raw pointers -/// (`*const c_char`/`*mut c_void`) that are not `UnwindSafe`; this is sound -/// because we abort the stream on any panic rather than continuing. -fn invoke_stream_callback(callback_name: &str, f: impl FnOnce()) -> Result<(), FfiError> { - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) { - Ok(()) => Ok(()), - Err(payload) => { - let msg = payload - .downcast_ref::<&'static str>() - .copied() - .or_else(|| payload.downcast_ref::().map(String::as_str)) - .unwrap_or(""); - Err(FfiError::CallbackFailure { - message: format!("stream callback '{callback_name}' panicked: {msg}"), - }) - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: provider constructors -// ───────────────────────────────────────────────────────────────────────────── - -/// An OpenAI provider for a key handed over the C ABI. The key is an explicit -/// value, so an empty string is sent as given and never falls back to -/// `OPENAI_API_KEY`. -fn openai_provider( - api_key: String, - base_url: Option, -) -> Result { - create_openai(OpenAIProviderSettings { - api_key: Some(api_key), - base_url, - ..Default::default() - }) -} - -/// Create an OpenAI model instance. AiMuxError: invalid model id. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_openai_new( - api_key: *const c_char, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - Ok(intern_model( - openai_provider(api_key, None)?.language_model(&model_id)?, - )) - }) -} - -/// Create an OpenAI model instance with a custom base URL. -/// -/// `base_url` may be null (defaults to the provider's standard URL). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_openai_new_with_base( - api_key: *const c_char, - model_id: *const c_char, - base_url: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let provider = openai_provider(api_key, parse_base_url(base_url)?)?; - Ok(intern_model(provider.language_model(&model_id)?)) - }) -} - -/// An Anthropic provider for an explicit key handed over by the host, with an -/// optional base URL. The key is an explicit value (`""` included), so it -/// never falls back to `ANTHROPIC_API_KEY`. -fn anthropic_provider( - api_key: String, - base_url: Option, -) -> Result { - create_anthropic(AnthropicProviderSettings { - api_key: Some(api_key), - base_url, - ..Default::default() - }) -} - -/// An Anthropic-on-AWS provider for an explicit API key and region. -fn anthropic_aws_provider( - api_key: String, - region: String, - base_url: Option, -) -> Result { - create_anthropic_aws(AnthropicAwsProviderSettings { - region: Some(region), - auth: Some(AnthropicAwsAuth::ApiKey( - aimux_provider_utils::Resolvable::Value(api_key), - )), - base_url, - ..Default::default() - }) -} - -/// Create an Anthropic model instance. AiMuxError: invalid model id. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_anthropic_new( - api_key: *const c_char, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let m = anthropic_provider(api_key, None)?.language_model(&model_id)?; - Ok(intern_model(m)) - }) -} - -/// Create an Anthropic model instance with a custom base URL. -/// -/// `base_url` may be null (defaults to the provider's standard URL). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_anthropic_new_with_base( - api_key: *const c_char, - model_id: *const c_char, - base_url: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let m = - anthropic_provider(api_key, parse_base_url(base_url)?)?.language_model(&model_id)?; - Ok(intern_model(m)) - }) -} - -/// Create an Anthropic-on-AWS model instance (API key + region). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_anthropic_aws_new( - api_key: *const c_char, - region: *const c_char, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let api_key = str_arg(api_key, "api_key")?; - let region = str_arg(region, "region")?; - let model_id = str_arg(model_id, "model_id")?; - let m = anthropic_aws_provider(api_key, region, None)?.language_model(&model_id)?; - Ok(intern_model(m)) - }) -} - -/// Create an Anthropic-on-AWS model instance with a custom base URL. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_anthropic_aws_new_with_base( - api_key: *const c_char, - region: *const c_char, - model_id: *const c_char, - base_url: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let api_key = str_arg(api_key, "api_key")?; - let region = str_arg(region, "region")?; - let model_id = str_arg(model_id, "model_id")?; - let m = anthropic_aws_provider(api_key, region, parse_base_url(base_url)?)? - .language_model(&model_id)?; - Ok(intern_model(m)) - }) -} - -/// Create an Azure OpenAI model instance (API key + resource name). -/// -/// `api_version` may be null (uses the provider default). The deployment -/// name is passed as `model_id`. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_azure_new( - api_key: *const c_char, - resource_name: *const c_char, - deployment: *const c_char, - api_version: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let api_key = str_arg(api_key, "api_key")?; - let resource_name = str_arg(resource_name, "resource_name")?; - let deployment = str_arg(deployment, "deployment")?; - let api_version = opt_str_arg(api_version, "api_version")?.filter(|v| !v.is_empty()); - let m = azure_provider(api_key, Some(resource_name), None, api_version)? - .language_model(&deployment)?; - Ok(intern_model(m)) - }) -} - -/// Create an Azure OpenAI model instance with a custom base URL (required, -/// in place of `resource_name`). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_azure_new_with_base( - api_key: *const c_char, - base_url: *const c_char, - deployment: *const c_char, - api_version: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let api_key = str_arg(api_key, "api_key")?; - let base_url = str_arg(base_url, "base_url")?; - let deployment = str_arg(deployment, "deployment")?; - let api_version = opt_str_arg(api_version, "api_version")?.filter(|v| !v.is_empty()); - let m = azure_provider(api_key, None, Some(base_url), api_version)? - .language_model(&deployment)?; - Ok(intern_model(m)) - }) -} - -/// A Bedrock provider for explicit SigV4 credentials handed over by the host, -/// with an optional base URL. The credentials are explicit values, so they -/// never fall back to the environment. -fn bedrock_provider( - access_key_id: String, - secret_access_key: String, - region: String, - base_url: Option, -) -> Result { - create_amazon_bedrock(AmazonBedrockProviderSettings { - credential_provider: Some(aimux_provider_utils::Resolvable::Value( - aimux_provider_utils::AwsCredentials { - access_key_id, - secret_access_key, - session_token: None, - region: region.clone(), - }, - )), - region: Some(region), - base_url, - ..Default::default() - }) -} - -/// A Vertex AI provider for an explicit access token, project and location, -/// with an optional base URL. -fn vertex_provider( - access_token: String, - project: String, - location: String, - base_url: Option, -) -> Result { - create_google_vertex(VertexProviderSettings { - access_token: Some(aimux_provider_utils::Resolvable::Value(access_token)), - project: Some(project), - location: Some(location), - base_url, - ..Default::default() - }) -} - -/// A Google provider for an explicit key handed over by the host, with an -/// optional base URL. -fn google_provider( - api_key: String, - base_url: Option, -) -> Result { - create_google(GoogleProviderSettings { - api_key: Some(api_key), - base_url, - ..Default::default() - }) -} - -/// A Cohere provider for an explicit key handed over by the host, with an -/// optional base URL. -fn cohere_provider( - api_key: String, - base_url: Option, -) -> Result { - create_cohere(CohereProviderSettings { - api_key: Some(api_key), - base_url, - ..Default::default() - }) -} - -/// A Mistral provider for an explicit key handed over by the host, with an -/// optional base URL. -fn mistral_provider( - api_key: String, - base_url: Option, -) -> Result { - create_mistral(MistralProviderSettings { - api_key: Some(api_key), - base_url, - ..Default::default() - }) -} - -/// An xAI provider for an explicit key handed over by the host, with an -/// optional base URL. -fn xai_provider( - api_key: String, - base_url: Option, -) -> Result { - create_xai(XAIProviderSettings { - api_key: Some(api_key), - base_url, - ..Default::default() - }) -} - -/// An Azure OpenAI provider for an explicit key, with either a resource name -/// or a base URL. A dated `api_version` belongs to the deployment URL form -/// (`{base}/deployments/{deployment}{path}?api-version=`), so passing one -/// selects it; without one the AI SDK's v1 form is used. -fn azure_provider( - api_key: String, - resource_name: Option, - base_url: Option, - api_version: Option, -) -> Result { - let mut settings = AzureOpenAIProviderSettings { - api_key: Some(aimux_provider_utils::Resolvable::Value(api_key)), - resource_name, - base_url, - ..Default::default() - }; - if let Some(version) = api_version { - settings.api_version = Some(version); - settings.use_deployment_based_urls = true; - } - create_azure(settings) -} - -/// Create a Bedrock model instance (AWS SigV4 credentials). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_bedrock_new( - access_key_id: *const c_char, - secret_access_key: *const c_char, - region: *const c_char, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (access_key_id, secret_access_key, region, model_id) = parse_four_args( - access_key_id, - "access_key_id", - secret_access_key, - "secret_access_key", - region, - "region", - model_id, - "model_id", - )?; - let m = bedrock_provider(access_key_id, secret_access_key, region, None)? - .language_model(&model_id)?; - Ok(intern_model(m)) - }) -} - -/// Create a Bedrock model instance with a custom base URL. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_bedrock_new_with_base( - access_key_id: *const c_char, - secret_access_key: *const c_char, - region: *const c_char, - model_id: *const c_char, - base_url: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (access_key_id, secret_access_key, region, model_id) = parse_four_args( - access_key_id, - "access_key_id", - secret_access_key, - "secret_access_key", - region, - "region", - model_id, - "model_id", - )?; - let m = bedrock_provider( - access_key_id, - secret_access_key, - region, - parse_base_url(base_url)?, - )? - .language_model(&model_id)?; - Ok(intern_model(m)) - }) -} - -/// Create a Vertex AI model instance (GCP bearer token). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_vertex_new( - access_token: *const c_char, - project: *const c_char, - location: *const c_char, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (access_token, project, location, model_id) = parse_four_args( - access_token, - "access_token", - project, - "project", - location, - "location", - model_id, - "model_id", - )?; - let m = - vertex_provider(access_token, project, location, None)?.language_model(&model_id)?; - Ok(intern_model(m)) - }) -} - -/// Create a Vertex AI model instance with a custom base URL. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_vertex_new_with_base( - access_token: *const c_char, - project: *const c_char, - location: *const c_char, - model_id: *const c_char, - base_url: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (access_token, project, location, model_id) = parse_four_args( - access_token, - "access_token", - project, - "project", - location, - "location", - model_id, - "model_id", - )?; - let m = vertex_provider(access_token, project, location, parse_base_url(base_url)?)? - .language_model(&model_id)?; - Ok(intern_model(m)) - }) -} - -/// Create a Cohere model instance. AiMuxError: invalid model id. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_cohere_new( - api_key: *const c_char, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let m = cohere_provider(api_key, None)?.language_model(&model_id)?; - Ok(intern_model(m)) - }) -} - -/// Create a Cohere model instance with a custom base URL. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_cohere_new_with_base( - api_key: *const c_char, - model_id: *const c_char, - base_url: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let m = cohere_provider(api_key, parse_base_url(base_url)?)?.language_model(&model_id)?; - Ok(intern_model(m)) - }) -} - -/// Create a Mistral model instance. AiMuxError: invalid model id. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_mistral_new( - api_key: *const c_char, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let m = mistral_provider(api_key, None)?.language_model(&model_id)?; - Ok(intern_model(m)) - }) -} - -/// Create a Mistral model instance with a custom base URL. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_mistral_new_with_base( - api_key: *const c_char, - model_id: *const c_char, - base_url: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let m = mistral_provider(api_key, parse_base_url(base_url)?)?.language_model(&model_id)?; - Ok(intern_model(m)) - }) -} - -/// Create an xAI model instance. AiMuxError: invalid model id. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_xai_new( - api_key: *const c_char, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let m = xai_provider(api_key, None)?.language_model(&model_id)?; - Ok(intern_model(m)) - }) -} - -/// Create an xAI model instance with a custom base URL. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_xai_new_with_base( - api_key: *const c_char, - model_id: *const c_char, - base_url: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let m = xai_provider(api_key, parse_base_url(base_url)?)?.language_model(&model_id)?; - Ok(intern_model(m)) - }) -} - -/// Create a language model from the registry by provider name (RFC-0017 phase 4). -/// -/// - `name` — registry provider name, e.g. `"groq"` / `"deepseek"`. -/// - `api_key` — may be NULL to read the provider's env var from the registry -/// entry (replaces the retired `aimux_deepseek_new` etc.). -/// - `model_id` — model id string. -/// - `config_json` — optional JSON object of `ProviderOptions` -/// (`{"base_url": "...", "headers": {...}, "organization": "...", "project": "..."}`); -/// NULL / empty / "null" for defaults. `max_retries` (call-level) and -/// `body_overrides` (removed) are rejected as invalid arguments. -/// -/// AiMuxError: unknown provider, bad config shape, or invalid model id. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_provider_new( - name: *const c_char, - api_key: *const c_char, - model_id: *const c_char, - config_json: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let name = str_arg(name, "name")?; - let model_id = str_arg(model_id, "model_id")?; - // NULL => the registry entry's env var; a non-NULL key must be UTF-8. - let key = opt_str_arg(api_key, "api_key")?; - let opts = parse_provider_options(config_json)?; - let m = provider(&name, key, &model_id, opts)?; - Ok(intern_model(m)) - }) -} - -/// Convenience: create a language model by provider name, reading the API key -/// from the provider's env var at request time. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_provider_from_env( - name: *const c_char, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let name = str_arg(name, "name")?; - let model_id = str_arg(model_id, "model_id")?; - let m = provider_handle(&name, None, None)?.language_model(&model_id)?; - Ok(intern_model(m)) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: provider handles (RFC-0027) — createProvider / listModels / model -// ───────────────────────────────────────────────────────────────────────────── - -/// Create a **provider handle** (RFC-0027) for a registry-backed provider. -/// -/// Unlike `aimux_provider_new` (which binds to a single model_id), this returns -/// a provider handle that supports `aimux_provider_list_models` (runtime -/// discovery) and `aimux_provider_model` (build a model from a discovered id). -/// -/// `api_key = null` reads the provider's env var from the registry entry. -/// `config_json` is an optional `ProviderOptions` JSON string (same as -/// `aimux_provider_new`). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_provider_handle_new( - name: *const c_char, - api_key: *const c_char, - config_json: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let name = str_arg(name, "name")?; - let key = opt_str_arg(api_key, "api_key")?; - let opts = parse_provider_options(config_json)?; - let provider = provider_handle(&name, key, opts)?; - Ok(intern_handle(HandleEntry::Provider(ProviderEntry { - provider, - }))) - }) -} - -/// List models on a provider handle (RFC-0027 runtime discovery). -/// -/// `handle` is from `aimux_provider_handle_new`. Writes a JSON array of -/// sparse `RuntimeModel` (id / ownedBy / created) — **no community -/// enrichment**. To supplement with model specs, call `aimux_get_model_specs` -/// separately and merge in the host. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_provider_list_models( - handle: u64, - out_models_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_models_json, "out_models_json", || { - let HandleEntry::Provider(p) = entry_of(handle, "provider")? else { - return Err(FfiError::InvalidHandle { - expected: "provider", - } - .into()); - }; - let discovery = p.provider.discovery().ok_or_else(|| { - AiMuxError::InvalidArgument("provider does not support model discovery".into()) - })?; - run_json(discovery.list_models()) - }) -} - -/// Build a language model from a provider handle + model_id (RFC-0027). -/// -/// `handle` is from `aimux_provider_handle_new`. The new handle is a model -/// handle (same as `aimux_provider_new`), usable with `aimux_generate_text` -/// etc. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_provider_model( - handle: u64, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let HandleEntry::Provider(p) = entry_of(handle, "provider")? else { - return Err(FfiError::InvalidHandle { - expected: "provider", - } - .into()); - }; - let model_id = str_arg(model_id, "model_id")?; - let m = p.provider.language_model(&model_id)?; - Ok(intern_model(m)) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: model specs (RFC-0027) — get_model_specs -// ───────────────────────────────────────────────────────────────────────────── - -/// Fetch the community model catalogue (anya2a). Writes a JSON-serialized -/// `Catalogue` (provider → model_id → ModelSpec). -/// -/// `source_url` is an optional URL override (null = default anya2a endpoint). -/// This is a **thin fetch** — no caching, no FS writes. The host decides how -/// to cache/persist the result. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_get_model_specs( - source_url: *const c_char, - out_specs_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_specs_json, "out_specs_json", || { - let url = opt_str_arg(source_url, "source_url")?; - run_json(aimux_providers::get_model_specs(url.as_deref())) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: non-streaming generation -// ───────────────────────────────────────────────────────────────────────────── - -/// Non-streaming generation. -/// -/// `prompt_json` is either a bare prompt value (`"text"` or a messages array) -/// or `{"prompt": }`. `opts_json` is a serialized `GenerateTextOptions` -/// (empty / null for defaults). Writes the serialized `GenerateTextResult` -/// to `*out_json` (caller frees with [`aimux_free_string`]). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_generate_text( - handle: u64, - prompt_json: *const c_char, - opts_json: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let model = model_of(handle)?; - let prompt = parse_prompt_arg(prompt_json)?; - let opts = parse_opts_arg(opts_json)?; - run_json(async move { generate_text(&*model, prompt, opts).await }) - }) -} - -/// Generate a structured JSON object from the model (M12, RFC-0016), with the -/// core's deprecated `generate_object` wrapper. -/// -/// Same signature as [`aimux_generate_text`]; writes the serialized -/// `GenerateObjectResult`. The caller passes `output` (for example -/// `{ "type": "object", "schema": ... }`) via `opts_json` to describe the value; -/// without one any JSON value is accepted. The model's text must parse as -/// JSON and match the schema. -#[unsafe(no_mangle)] -#[allow(deprecated)] -pub extern "C" fn aimux_generate_object( - handle: u64, - prompt_json: *const c_char, - opts_json: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let model = model_of(handle)?; - let prompt = parse_prompt_arg(prompt_json)?; - let opts = parse_opts_arg(opts_json)?; - run_json(async move { generate_object(&*model, prompt, opts).await }) - }) -} - -/// Consume a stream to completion and write the aggregated result (M11, -/// RFC-0016). Synchronous — blocks until the stream finishes. -/// -/// Same signature as [`aimux_generate_text`]; writes the serialized -/// `GenerateTextResult`, the same result `aimux_generate_text` writes. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_consume_stream_text( - handle: u64, - prompt_json: *const c_char, - opts_json: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let model = model_of(handle)?; - let prompt = parse_prompt_arg(prompt_json)?; - let opts = parse_opts_arg(opts_json)?; - run_json(async move { - let stream_result = stream_text(&*model, prompt, opts).await?; - stream_result.consume().await - }) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: streaming generation (push callbacks) -// ───────────────────────────────────────────────────────────────────────────── - -/// Streaming generation with push callbacks. -/// -/// Blocks the calling thread until the stream completes (synchronous + -/// callback mode). Callbacks are invoked in the same call stack: -/// - `on_part(json, stream_ctx)`: each `TextStreamPart` as JSON (valid only -/// during the call). `TextStreamPart::Error` is data on this path, not a -/// terminal fail. -/// - `on_done(stream_ctx)`: once on normal completion (not on failure). -/// -/// NULL on success (after `on_done`); a returned error otherwise (no -/// `on_done`): a NULL callback is `NullPointer`, a provider failure is the -/// model's, a part that cannot be serialized is `ResultSerialization`. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_stream_text( - handle: u64, - prompt_json: *const c_char, - opts_json: *const c_char, - on_part: Option, - on_done: Option, - stream_ctx: *mut c_void, -) -> *mut aimux_error_t { - no_result(|| { - stream_text_with_signal( - handle, - prompt_json, - opts_json, - on_part, - on_done, - stream_ctx, - None, - ) - }) -} - -/// Create a per-call abort signal for a cancelable FFI operation. -/// -/// The caller must release the returned handle with -/// [`aimux_abort_signal_drop`]. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_abort_signal_new() -> u64 { - intern_handle(HandleEntry::Abort(AbortSignal::new())) -} - -/// Request cancellation for an abort-signal handle. -/// -/// Invalid handles and repeated calls are no-ops. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_abort_signal_abort(handle: u64) { - if let Some(signal) = get_abort_signal(handle) { - signal.abort(); - } -} - -/// Release an abort-signal handle. -/// -/// This function does not remove model handles if the caller passes the wrong -/// handle type. Active calls keep their cloned signal alive. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_abort_signal_drop(handle: u64) { - if handle != 0 { - drop_abort_signal(handle); - } -} - -/// Stream text with a per-call abort signal. -/// -/// This function blocks like [`aimux_stream_text`]. Another thread can call -/// [`aimux_abort_signal_abort`] with `abort_handle` to stop this call -/// (`AiMuxError::Aborted`, no `on_done`). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_stream_text_with_abort( - handle: u64, - abort_handle: u64, - prompt_json: *const c_char, - opts_json: *const c_char, - on_part: Option, - on_done: Option, - stream_ctx: *mut c_void, -) -> *mut aimux_error_t { - no_result(|| { - let abort_signal = abort_of(abort_handle)?; - stream_text_with_signal( - handle, - prompt_json, - opts_json, - on_part, - on_done, - stream_ctx, - Some(abort_signal), - ) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: host-side tool-call repair (RFC-0035) -// ───────────────────────────────────────────────────────────────────────────── - -/// Build the repair argument for one invalid tool call (RFC-0035). -/// -/// `tool_call_json` is one entry of `GenerateTextResult.toolCalls` with -/// `"invalid": true`. `prompt_json` and `opts_json` are **the same two -/// strings the call was generated with** ([`aimux_generate_text`] / -/// [`aimux_stream_text`]); messages, instructions, and the tool set are -/// derived from them here, so no host repeats that derivation. -/// -/// Writes `{toolCall, error, inputSchema, tools, messages, instructions}` — -/// the AI SDK `repairToolCall` argument — to `*out_json`, or the JSON literal -/// `null` when `opts_json` carried no tools: as in the AI SDK, a call made -/// without a tool set is never repaired, and the host skips it. `null` is a -/// success, not an error. -/// -/// Pure: no model handle, no runtime, no I/O. Safe to call from inside a -/// stream callback (the re-entrancy guard only covers runtime `block_on`). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_tool_call_repair_context( - tool_call_json: *const c_char, - prompt_json: *const c_char, - opts_json: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let tool_call: ToolCall = parse_json_arg(tool_call_json, "tool_call_json")?; - let prompt = parse_prompt_arg(prompt_json)?; - let opts = parse_opts_arg(opts_json)?; - let (messages, instructions, tools) = tool_call_repair_inputs(prompt, &opts); - to_json(&tool_call_repair_context( - &tool_call, - tools, - &messages, - instructions, - )?) - }) -} - -/// Resolve one invalid tool call against a host's repair reply (RFC-0035). -/// -/// `opts_json` is the same string the call was generated with; the tool set -/// comes from it. `reply_json` is `{"type":"repaired","toolCall":{…}}`, -/// `{"type":"unchanged"}`, or `{"type":"failed","message":"…"}`. Writes the -/// resulting `ToolCall`, tagged `"type": "tool-call"` as a standalone call -/// is — valid, or invalid with the AI SDK's nested -/// `ToolCallRepairError` — to `*out_json`. Same purity as -/// [`aimux_tool_call_repair_context`]. -/// -/// Options carrying no tools are `AIMUX_E_INVALID_ARGUMENT`: such a call is -/// not repairable, and `aimux_tool_call_repair_context` already said so by -/// writing `null`. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_apply_tool_call_repair( - tool_call_json: *const c_char, - opts_json: *const c_char, - reply_json: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let tool_call: ToolCall = parse_json_arg(tool_call_json, "tool_call_json")?; - let opts = parse_opts_arg(opts_json)?; - let reply: ToolCallRepairReply = parse_json_arg(reply_json, "reply_json")?; - let repaired = apply_tool_call_repair(&tool_call, opts.tools.as_deref(), reply)?; - to_json(&aimux_core::wire::Tagged(&repaired)) - }) -} - -/// Apply a repair reply to a whole result document (RFC-0035). -/// -/// `result_json` is a serialized `GenerateTextResult` (from -/// `aimux_generate_text` or `aimux_consume_stream_text`); `opts_json` is the -/// same string the call was generated with. The call is repaired in the -/// result's steps (content, approval requests and response messages) and -/// the derived `toolCalls`, `content` and `responseMessages` are written -/// from them. The OpenAI-shaped -/// `ChatCompletion` is not supported — it carries no `invalid`/`error`, so -/// repair is driven from the native result, which -/// [`aimux_generate_text_result_as_openai`] then converts. Same purity as -/// [`aimux_tool_call_repair_context`]. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_apply_tool_call_repair_to_result( - result_json: *const c_char, - opts_json: *const c_char, - tool_call_id: *const c_char, - reply_json: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let result: serde_json::Value = parse_json_arg(result_json, "result_json")?; - let opts = parse_opts_arg(opts_json)?; - let tool_call_id = str_arg(tool_call_id, "tool_call_id")?; - let reply: ToolCallRepairReply = parse_json_arg(reply_json, "reply_json")?; - to_json(&apply_tool_call_repair_to_result( - &result, - opts.tools.as_deref(), - &tool_call_id, - reply, - )?) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: OpenAI-compatible output (RFC-0026) -// ───────────────────────────────────────────────────────────────────────────── - -/// Convert a serialized `GenerateTextResult` into a serialized `ChatCompletion`. -/// -/// The conversion half of [`aimux_generate_text_as_openai`], for hosts that -/// repair a native result themselves (RFC-0035) and then want the OpenAI -/// shape: tool calls come from the content of `result_json`'s `steps`, so a -/// result patched by [`aimux_apply_tool_call_repair_to_result`] converts to a -/// completion that reflects the repair. `handle` only -/// supplies the model id fallback for `ChatCompletion.model`; no runtime work, -/// so this is safe from inside a stream callback. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_generate_text_result_as_openai( - handle: u64, - result_json: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let model = model_of(handle)?; - let result: GenerateTextResult = parse_json_arg(result_json, "result_json")?; - to_json(&generate_text_result_to_chat_completion( - &result, - model.model_id(), - )) - }) -} - -/// Non-streaming text generation with OpenAI Chat Completions output. -/// -/// Identical to [`aimux_generate_text`] except the written JSON string is a -/// serialized `ChatCompletion` (OpenAI `chat.completion` object) rather than a -/// `GenerateTextResult`. Works with any provider — the result is always -/// standard OpenAI format. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_generate_text_as_openai( - handle: u64, - prompt_json: *const c_char, - opts_json: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let model = model_of(handle)?; - let prompt = parse_prompt_arg(prompt_json)?; - let opts = parse_opts_arg(opts_json)?; - run_json(async move { generate_text_as_openai(&*model, prompt, opts).await }) - }) -} - -/// Streaming text generation with OpenAI Chat Completions output. -/// -/// Identical to [`aimux_stream_text`] except each `on_part` callback receives a -/// serialized `ChatCompletionChunk` (OpenAI `chat.completion.chunk` object) -/// rather than a `StreamPart`. Works with any provider. -/// -/// `opts_json` may carry an extra `openai_stream_options` object with -/// `include_usage` (bool, default true) and `include_reasoning` (bool, default -/// true) fields to control the chunk output. -/// -/// `StreamPart::Error` is mapped to a content delta + finish chunk; terminal -/// failures return a returned error (same polarity as [`aimux_stream_text`]). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_stream_text_as_openai( - handle: u64, - prompt_json: *const c_char, - opts_json: *const c_char, - on_part: Option, - on_done: Option, - stream_ctx: *mut c_void, -) -> *mut aimux_error_t { - no_result(|| { - stream_text_as_openai_with_signal( - handle, - prompt_json, - opts_json, - on_part, - on_done, - stream_ctx, - None, - ) - }) -} - -/// Cancelable streaming OpenAI-compatible output (see -/// [`aimux_stream_text_with_abort`]). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_stream_text_as_openai_with_abort( - handle: u64, - abort_handle: u64, - prompt_json: *const c_char, - opts_json: *const c_char, - on_part: Option, - on_done: Option, - stream_ctx: *mut c_void, -) -> *mut aimux_error_t { - no_result(|| { - let abort_signal = abort_of(abort_handle)?; - stream_text_as_openai_with_signal( - handle, - prompt_json, - opts_json, - on_part, - on_done, - stream_ctx, - Some(abort_signal), - ) - }) -} - -/// Serialize a stream item for `on_part`, or [`FfiError::ResultSerialization`]. -fn stream_part_cstring(part: &T) -> Result { - let json = serde_json::to_string(part).map_err(|e| FfiError::ResultSerialization { - message: format!("stream part: {e}"), - })?; - CString::new(json).map_err(|e| FfiError::ResultSerialization { - message: format!("stream part contains NUL: {e}"), - }) -} - -fn stream_text_as_openai_with_signal( - handle: u64, - prompt_json: *const c_char, - opts_json: *const c_char, - on_part: Option, - on_done: Option, - stream_ctx: *mut c_void, - abort_signal: Option, -) -> FfiResult<()> { - // A NULL callback is this layer's finding, before anything else runs. - let on_part = on_part.ok_or(FfiError::NullPointer { - argument: "on_part", - })?; - let on_done = on_done.ok_or(FfiError::NullPointer { - argument: "on_done", - })?; - let model = model_of(handle)?; - let prompt = parse_prompt_arg(prompt_json)?; - let mut opts = parse_opts_arg(opts_json)?; - - // Extract OpenAI stream options from the opts JSON, then remove them so - // they don't confuse the provider (which doesn't know this field). - let stream_options = opts - .provider_options - .as_ref() - .and_then(|po| po.get("openai")) - .and_then(|o| o.get("stream_options")) - .cloned() - .map(|v| OpenAiStreamOptions { - include_usage: v - .get("include_usage") - .and_then(serde_json::Value::as_bool) - .unwrap_or(true), - include_reasoning: v - .get("include_reasoning") - .and_then(serde_json::Value::as_bool) - .unwrap_or(true), - }) - .unwrap_or_default(); - - opts.abort_signal = abort_signal.clone(); - // stream_ctx is only for C callbacks; not Send into the async task. - let stream_ctx = stream_ctx as usize; - - ffi_block_on(async move { - let sr = match abort_signal.as_ref() { - Some(signal) => { - tokio::select! { - biased; - _ = signal.cancelled() => Err(AiMuxError::from_abort_signal(signal)), - result = stream_text_as_openai(&*model, prompt, opts, stream_options) => result, - } - } - None => stream_text_as_openai(&*model, prompt, opts, stream_options).await, - }?; - let mut stream = sr.stream; - loop { - let next = match abort_signal.as_ref() { - Some(signal) => { - tokio::select! { - biased; - _ = signal.cancelled() => { - return Err(AiMuxError::from_abort_signal(signal).into()); - } - item = stream.next() => item, - } - } - None => stream.next().await, - }; - let Some(item) = next else { - break; - }; - // A chunk that cannot be serialized ends the stream with this - // layer's ResultSerialization — never a silent "{}" placeholder. - // Core yields a malformed frame as a recoverable Err and keeps the - // stream alive. Every consumer of this path types items as - // ChatCompletionChunk, so the error cannot ride this wire — skip - // it and keep pumping (full fidelity lives on the plain - // StreamPart path). - let cstr = match item { - Ok(chunk) => stream_part_cstring(&chunk)?, - Err(e) if e.is_recoverable_stream_error() => continue, - Err(e) => return Err(e.into()), - }; - invoke_stream_callback("on_part", || { - on_part(cstr.as_ptr(), stream_ctx as *mut c_void); - })?; - } - invoke_stream_callback("on_done", || { - on_done(stream_ctx as *mut c_void); - })?; - Ok::<(), AiMuxFfiError>(()) - })? -} - -fn stream_text_with_signal( - handle: u64, - prompt_json: *const c_char, - opts_json: *const c_char, - on_part: Option, - on_done: Option, - stream_ctx: *mut c_void, - abort_signal: Option, -) -> FfiResult<()> { - // A NULL callback is this layer's finding, before anything else runs. - let on_part = on_part.ok_or(FfiError::NullPointer { - argument: "on_part", - })?; - let on_done = on_done.ok_or(FfiError::NullPointer { - argument: "on_done", - })?; - let model = model_of(handle)?; - let prompt = parse_prompt_arg(prompt_json)?; - let mut opts = parse_opts_arg(opts_json)?; - opts.abort_signal = abort_signal.clone(); - let stream_ctx = stream_ctx as usize; - - ffi_block_on(async move { - let sr = match abort_signal.as_ref() { - Some(signal) => { - tokio::select! { - biased; - _ = signal.cancelled() => Err(AiMuxError::from_abort_signal(signal)), - result = stream_text(&*model, prompt, opts) => result, - } - } - None => stream_text(&*model, prompt, opts).await, - }?; - let mut stream = sr.stream; - loop { - let next = match abort_signal.as_ref() { - Some(signal) => { - tokio::select! { - biased; - _ = signal.cancelled() => { - return Err(AiMuxError::from_abort_signal(signal).into()); - } - item = stream.next() => item, - } - } - None => stream.next().await, - }; - let Some(item) = next else { - break; - }; - // A part that cannot be serialized ends the stream with this - // layer's ResultSerialization — never a silent "{}" placeholder. - // Core keeps the stream alive across a malformed frame; mirror it - // by delivering the error as a StreamPart::Error data frame (the - // documented non-terminal shape on this path) and keep pumping. - let cstr = match item { - Ok(part) => stream_part_cstring(&part)?, - Err(e) if e.is_recoverable_stream_error() => { - stream_part_cstring(&aimux_core::stream_part::TextStreamPart::Error { - error: e, - })? - } - Err(e) => return Err(e.into()), - }; - invoke_stream_callback("on_part", || { - on_part(cstr.as_ptr(), stream_ctx as *mut c_void); - })?; - } - invoke_stream_callback("on_done", || { - on_done(stream_ctx as *mut c_void); - })?; - Ok::<(), AiMuxFfiError>(()) - })? -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: resource management -// ───────────────────────────────────────────────────────────────────────────── - -/// Release a handle previously returned by `aimux_*_new` (any handle type — a -/// transcription session's driver task is aborted and joined as well, so -/// `aimux_transcription_session_drop` is only a clearer name for the same -/// thing). -/// -/// Safe to call with `0` or an unknown handle (no-op). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_drop_handle(handle: u64) { - if handle != 0 { - drop_handle(handle); - } -} - -/// [`aimux_drop_handle`] with the handle passed as a pointer-sized value, for -/// callers that can only register a `void (*)(void *)` callback, such as a -/// Dart `NativeFinalizer`. The pointer is never dereferenced: its address is -/// the handle. Calling `aimux_drop_handle` through a `void (*)(void *)` -/// pointer instead passes a garbage high word on 32-bit targets, where a -/// `uint64_t` argument does not travel like a pointer. -/// -/// Safe to call with NULL or an unknown handle (no-op). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_drop_handle_ptr(handle: *mut c_void) { - aimux_drop_handle(handle.addr() as u64); -} - -/// Free a C string previously returned by any aimux function that hands out -/// `*mut c_char` — result JSON, error getters. -/// -/// # Safety -/// -/// `ptr` must be null or a pointer previously produced by an aimux `char*` -/// return (i.e. via `CString::into_raw`). Passing any other pointer is -/// undefined behavior. -#[unsafe(no_mangle)] -pub unsafe extern "C" fn aimux_free_string(ptr: *mut c_char) { - if ptr.is_null() { - return; - } - // SAFETY: caller guarantees `ptr` came from `CString::into_raw`. - drop(unsafe { CString::from_raw(ptr) }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: Embedding -// ───────────────────────────────────────────────────────────────────────────── - -/// Create an OpenAI embedding model instance. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_openai_embedding_new( - api_key: *const c_char, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let model = openai_provider(api_key, None)?.embedding(&model_id); - Ok(intern_handle(HandleEntry::Embedding(Arc::new(model)))) - }) -} - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_openai_embedding_new_with_base( - api_key: *const c_char, - model_id: *const c_char, - base_url: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let provider = openai_provider(api_key, parse_base_url(base_url)?)?; - let model = provider.embedding(&model_id); - Ok(intern_handle(HandleEntry::Embedding(Arc::new(model)))) - }) -} +//! C consumers use the versioned primitives in [`abi`]. Native Node and +//! Python bindings call aimux-ops directly. The generated C header lives at +//! `include/aimux.h`; operation-specific behavior belongs in aimux-ops. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_cohere_embedding_new( - api_key: *const c_char, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let model = cohere_provider(api_key, None)?.embedding(&model_id); - Ok(intern_handle(HandleEntry::Embedding(Arc::new(model)))) - }) -} - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_cohere_embedding_new_with_base( - api_key: *const c_char, - model_id: *const c_char, - base_url: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let model = cohere_provider(api_key, parse_base_url(base_url)?)?.embedding(&model_id); - Ok(intern_handle(HandleEntry::Embedding(Arc::new(model)))) - }) -} - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_google_embedding_new( - api_key: *const c_char, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let model = google_provider(api_key, None)?.embedding(&model_id); - Ok(intern_handle(HandleEntry::Embedding(Arc::new(model)))) - }) -} - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_google_embedding_new_with_base( - api_key: *const c_char, - model_id: *const c_char, - base_url: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let model = google_provider(api_key, parse_base_url(base_url)?)?.embedding(&model_id); - Ok(intern_handle(HandleEntry::Embedding(Arc::new(model)))) - }) -} - -/// Generate embeddings. `values_json` is a JSON array of strings. -/// Writes `EmbeddingResult` JSON (caller frees). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_embed( - handle: u64, - values_json: *const c_char, - opts_json: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let HandleEntry::Embedding(model) = entry_of(handle, "embedding")? else { - return Err(FfiError::InvalidHandle { - expected: "embedding", - } - .into()); - }; - let values_json = str_arg(values_json, "values_json")?; - let mut opts = aimux_core::embedding_model::EmbeddingCallOptions::new(""); - if let Some(s) = opt_str_arg(opts_json, "opts_json")? - && !s.trim().is_empty() - && s.trim() != "null" - { - opts = serde_json::from_str(&s).map_err(|e| wire_err("opts_json", e))?; - } - opts.values = serde_json::from_str(&values_json).map_err(|e| wire_err("values_json", e))?; - run_json(async move { aimux_core::embedding_model::embed(model.as_ref(), opts).await }) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: Speech (TTS) -// ───────────────────────────────────────────────────────────────────────────── - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_openai_speech_new( - api_key: *const c_char, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let model = openai_provider(api_key, None)?.speech(&model_id); - Ok(intern_handle(HandleEntry::Speech(Arc::new(model)))) - }) -} - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_openai_speech_new_with_base( - api_key: *const c_char, - model_id: *const c_char, - base_url: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let provider = openai_provider(api_key, parse_base_url(base_url)?)?; - let model = provider.speech(&model_id); - Ok(intern_handle(HandleEntry::Speech(Arc::new(model)))) - }) -} - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_speech_generate( - handle: u64, - opts_json: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let HandleEntry::Speech(model) = entry_of(handle, "speech")? else { - return Err(FfiError::InvalidHandle { expected: "speech" }.into()); - }; - let opts: aimux_core::speech_model::SpeechCallOptions = - parse_json_arg(opts_json, "opts_json")?; - run_json( - async move { aimux_core::speech_model::generate_speech(model.as_ref(), opts).await }, - ) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: Image -// ───────────────────────────────────────────────────────────────────────────── - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_openai_image_new( - api_key: *const c_char, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let model = openai_provider(api_key, None)?.image(&model_id); - Ok(intern_handle(HandleEntry::Image(Arc::new(model)))) - }) -} - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_openai_image_new_with_base( - api_key: *const c_char, - model_id: *const c_char, - base_url: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let provider = openai_provider(api_key, parse_base_url(base_url)?)?; - let model = provider.image(&model_id); - Ok(intern_handle(HandleEntry::Image(Arc::new(model)))) - }) -} - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_google_image_new( - api_key: *const c_char, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let model = google_provider(api_key, None)?.image(&model_id); - Ok(intern_handle(HandleEntry::Image(Arc::new(model)))) - }) -} - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_google_image_new_with_base( - api_key: *const c_char, - model_id: *const c_char, - base_url: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let model = google_provider(api_key, parse_base_url(base_url)?)?.image(&model_id); - Ok(intern_handle(HandleEntry::Image(Arc::new(model)))) - }) -} - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_image_generate( - handle: u64, - opts_json: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let HandleEntry::Image(model) = entry_of(handle, "image")? else { - return Err(FfiError::InvalidHandle { expected: "image" }.into()); - }; - let opts: aimux_core::image_model::ImageCallOptions = - parse_json_arg(opts_json, "opts_json")?; - run_json(async move { aimux_core::image_model::generate_image(model.as_ref(), opts).await }) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: Transcription (non-streaming) -// ───────────────────────────────────────────────────────────────────────────── - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_openai_transcription_new( - api_key: *const c_char, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let model = openai_provider(api_key, None)?.transcription(&model_id); - Ok(intern_handle(HandleEntry::Transcription(Arc::new(model)))) - }) -} - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_openai_transcription_new_with_base( - api_key: *const c_char, - model_id: *const c_char, - base_url: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let provider = openai_provider(api_key, parse_base_url(base_url)?)?; - let model = provider.transcription(&model_id); - Ok(intern_handle(HandleEntry::Transcription(Arc::new(model)))) - }) -} - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_transcription_generate( - handle: u64, - audio_base64: *const c_char, - media_type: *const c_char, - _opts_json: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let HandleEntry::Transcription(model) = entry_of(handle, "transcription")? else { - return Err(FfiError::InvalidHandle { - expected: "transcription", - } - .into()); - }; - let audio_base64 = str_arg(audio_base64, "audio_base64")?; - let media_type = str_arg(media_type, "media_type")?; - let opts = aimux_core::transcription_model::TranscriptionCallOptions::new( - aimux_core::transcription_model::AudioInput::Base64(audio_base64), - media_type, - ); - run_json( - async move { aimux_core::transcription_model::transcribe(model.as_ref(), opts).await }, - ) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: transcription streaming sessions (RFC-0028 Phase 2) -// ───────────────────────────────────────────────────────────────────────────── - -/// JSON options for `aimux_transcription_session_new` (all fields optional): -/// `{ "inputAudioFormat": { "formatType": "audio/pcm", "rate": 24000 }, -/// "providerOptions": { … }, "headers": { … }, "includeRawChunks": false }`. -#[derive(serde::Deserialize, Default)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct TranscriptionSessionFfiOptions { - input_audio_format: Option, - provider_options: Option, - headers: Option>, - include_raw_chunks: Option, - timeout: Option, -} - -/// Look up a transcription streaming session handle. -fn session_of( - handle: u64, -) -> Result, FfiError> { - match entry_of(handle, "transcription session")? { - HandleEntry::TranscriptionSession(s) => Ok(s), - _ => Err(FfiError::InvalidHandle { - expected: "transcription session", - }), - } -} - -/// Start a transcription streaming session (RFC-0028 Phase 2). Internally -/// spawns the driver task immediately; audio is then pushed with -/// `aimux_transcription_push_audio` and parts pulled with -/// `aimux_transcription_next_part`. -/// -/// `model_handle` must be a transcription model handle that supports -/// streaming (e.g. OpenAI `gpt-realtime-whisper`); models without `do_stream` -/// fail on the first `next_part` (the connect/establishment error surfaces -/// there). `abort_handle` may be 0 (no cancellation) or an -/// `aimux_abort_signal_new` handle — firing it aborts the session. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_transcription_session_new( - model_handle: u64, - abort_handle: u64, - opts_json: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let HandleEntry::Transcription(model) = entry_of(model_handle, "transcription")? else { - return Err(FfiError::InvalidHandle { - expected: "transcription", - } - .into()); - }; - let abort = if abort_handle == 0 { - None - } else { - Some(abort_of(abort_handle)?) - }; - // NULL / empty / "null" = defaults (shared convention). - let opts_json = normalize_config_json(opts_json, "opts_json")?; - let ffi_opts: TranscriptionSessionFfiOptions = - serde_json::from_str(&opts_json).map_err(|e| wire_err("opts_json", e))?; - let session = transcription_session::TranscriptionFfiSession::spawn( - model, - transcription_session::SessionOptions { - input_audio_format: ffi_opts.input_audio_format, - provider_options: ffi_opts.provider_options, - headers: ffi_opts.headers, - include_raw_chunks: ffi_opts.include_raw_chunks.unwrap_or(false), - timeout: ffi_opts.timeout, - }, - abort, - ); - Ok(intern_handle(HandleEntry::TranscriptionSession(session))) - }) -} - -/// Push one binary audio chunk into the session. **Blocking**: waits while -/// the internal channel is full (backpressure propagation; the caller's -/// capture loop throttles). Must not be called from within an aimux callback -/// (re-entrancy guard rejects it). -/// -/// `data` may be NULL only when `len == 0` (no-op). Failures: session ended / -/// input already finished → AiMuxError; NULL data with `len > 0` → -/// `NullPointer`; dead session → `InvalidHandle`. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_transcription_push_audio( - session: u64, - data: *const u8, - len: usize, -) -> *mut aimux_error_t { - no_result(|| { - let session = session_of(session)?; - if data.is_null() { - if len == 0 { - return Ok(()); // no-op - } - return Err(FfiError::NullPointer { argument: "data" }.into()); - } - // SAFETY: caller guarantees `data` points to `len` valid bytes; the copy - // happens synchronously before any await. - let bytes = unsafe { std::slice::from_raw_parts(data, len) }.to_vec(); - ffi_block_on(session.push_audio(bytes))??; - Ok(()) - }) -} - -/// Signal end-of-audio (the audio stream ends; the provider flushes). Safe to -/// call multiple times. Fails only for a dead session handle. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_transcription_input_done(session: u64) -> *mut aimux_error_t { - no_result(|| { - session_of(session)?.input_done(); - Ok(()) - }) -} - -/// Pull the next transcription part (JSON-serialized `TranscriptionStreamPart`; -/// free with `aimux_free_string`). -/// -/// `timeout_ms`: >0 = wait at most that long; 0 = immediate poll; <0 = wait -/// indefinitely. Both out-params are required. On NULL return `*out_state` -/// says what happened: -/// - `AIMUX_TRANSCRIPTION_NEXT_PART_PART` — `*out_part` holds a part; -/// - `AIMUX_TRANSCRIPTION_NEXT_PART_ENDED` — the stream ended normally (a -/// `Finish` part was delivered earlier); `*out_part` is NULL; -/// - `AIMUX_TRANSCRIPTION_NEXT_PART_TIMEOUT` — no part in time; the session -/// is still live, call again; `*out_part` is NULL. -/// -/// A non-NULL return is a failure (abort / API error / dead handle); -/// `*out_part` is NULL and `*out_state` is unspecified. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_transcription_next_part( - session: u64, - timeout_ms: i64, - out_part: *mut *mut c_char, - out_state: *mut i32, -) -> *mut aimux_error_t { - if out_part.is_null() { - return no_result(|| { - Err(FfiError::NullPointer { - argument: "out_part", - } - .into()) - }); - } - // Sentinel first, so a failure below (including a NULL out_state) never - // leaves a stale pointer in *out_part. - unsafe { *out_part = std::ptr::null_mut() }; - if out_state.is_null() { - return no_result(|| { - Err(FfiError::NullPointer { - argument: "out_state", - } - .into()) - }); - } - let r: FfiResult<(i32, Option)> = (|| { - let session = session_of(session)?; - let timeout = if timeout_ms < 0 { - None - } else { - Some(std::time::Duration::from_millis(timeout_ms as u64)) - }; - Ok(match ffi_block_on(session.next_part(timeout))? { - // A part arrived. - Ok(Some(Ok(part))) => (AIMUX_TRANSCRIPTION_NEXT_PART_PART, Some(to_json(&part)?)), - // Channel closed: normal end. - Ok(None) => (AIMUX_TRANSCRIPTION_NEXT_PART_ENDED, None), - // No part within `timeout`; the session is still live. - Err(AiMuxError::Timeout(_)) => (AIMUX_TRANSCRIPTION_NEXT_PART_TIMEOUT, None), - // The part stream itself errored (abort / API error) — final error. - Ok(Some(Err(e))) | Err(e) => return Err(e.into()), - }) - })(); - finish(r, |(state, part)| unsafe { - *out_state = state; - if let Some(p) = part { - *out_part = into_cstring_raw(p); - } - }) -} - -/// Terminate a transcription session and release it: aborts the driver, -/// joins (bounded), and drops the handle. Safe with 0 or an unknown handle. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_transcription_session_drop(session: u64) { - // Remove from the registry FIRST (the join below must not hold the - // registry mutex). - let removed = registry() - .lock() - .expect("aimux-ffi: registry mutex poisoned") - .remove(&session); - if let Some(HandleEntry::TranscriptionSession(s)) = removed { - s.terminate(); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: Files -// ───────────────────────────────────────────────────────────────────────────── - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_openai_files_new( - api_key: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let api_key = str_arg(api_key, "api_key")?; - let files = openai_provider(api_key, None)?.files(); - Ok(intern_handle(HandleEntry::Files(Arc::new(files)))) - }) -} - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_openai_files_new_with_base( - api_key: *const c_char, - base_url: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let api_key = str_arg(api_key, "api_key")?; - let provider = openai_provider(api_key, parse_base_url(base_url)?)?; - let files = provider.files(); - Ok(intern_handle(HandleEntry::Files(Arc::new(files)))) - }) -} - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_file_upload( - handle: u64, - data_base64: *const c_char, - media_type: *const c_char, - _opts_json: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let HandleEntry::Files(model) = entry_of(handle, "files")? else { - return Err(FfiError::InvalidHandle { expected: "files" }.into()); - }; - let data_base64 = str_arg(data_base64, "data_base64")?; - let media_type = str_arg(media_type, "media_type")?; - let opts = aimux_core::files_model::UploadFileCallOptions::new( - aimux_core::files_model::UploadFileData::Data { - data: aimux_core::shared::FileBytes::Base64(data_base64), - }, - media_type, - ); - run_json(async move { model.upload_file(&opts).await }) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: Reranking -// ───────────────────────────────────────────────────────────────────────────── - -/// Create a Cohere reranking model instance. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_cohere_reranking_new( - api_key: *const c_char, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let model = cohere_provider(api_key, None)?.reranking(&model_id); - Ok(intern_handle(HandleEntry::Reranking(Arc::new(model)))) - }) -} - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_cohere_reranking_new_with_base( - api_key: *const c_char, - model_id: *const c_char, - base_url: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let model = cohere_provider(api_key, parse_base_url(base_url)?)?.reranking(&model_id); - Ok(intern_handle(HandleEntry::Reranking(Arc::new(model)))) - }) -} - -/// Rerank documents. `opts_json` is JSON-serialized `RerankingCallOptions` -/// (must contain `query` and `documents`). Writes `RerankingResult` JSON. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_rerank( - handle: u64, - opts_json: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let HandleEntry::Reranking(model) = entry_of(handle, "reranking")? else { - return Err(FfiError::InvalidHandle { - expected: "reranking", - } - .into()); - }; - let opts: aimux_core::reranking_model::RerankingCallOptions = - parse_json_arg(opts_json, "opts_json")?; - run_json(async move { aimux_core::reranking_model::rerank(model.as_ref(), opts).await }) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: Video -// ───────────────────────────────────────────────────────────────────────────── - -/// Create a Google video model instance. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_google_video_new( - api_key: *const c_char, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let model = google_provider(api_key, None)?.video(&model_id); - Ok(intern_handle(HandleEntry::Video(Arc::new(model)))) - }) -} - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_google_video_new_with_base( - api_key: *const c_char, - model_id: *const c_char, - base_url: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?; - let model = google_provider(api_key, parse_base_url(base_url)?)?.video(&model_id); - Ok(intern_handle(HandleEntry::Video(Arc::new(model)))) - }) -} - -/// Generate video. `opts_json` is JSON-serialized `VideoCallOptions` -/// (must contain `prompt`). Writes `VideoResult` JSON. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_video_generate( - handle: u64, - opts_json: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let HandleEntry::Video(model) = entry_of(handle, "video")? else { - return Err(FfiError::InvalidHandle { expected: "video" }.into()); - }; - let opts: aimux_core::video_model::VideoCallOptions = - parse_json_arg(opts_json, "opts_json")?; - run_json(async move { aimux_core::video_model::generate_video(model.as_ref(), opts).await }) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: Decisions -// ───────────────────────────────────────────────────────────────────────────── - -/// Build a decision model using an existing provider's connection config. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_provider_decision_model( - handle: u64, - model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let HandleEntry::Provider(provider) = entry_of(handle, "provider")? else { - return Err(FfiError::InvalidHandle { - expected: "provider", - } - .into()); - }; - let model_id = str_arg(model_id, "model_id")?; - let model = provider - .provider - .decision_model(&model_id) - .unwrap_or_else(|| Err(AiMuxError::no_such_model(&model_id, "decisionModel")))?; - Ok(intern_handle(HandleEntry::Decision(model))) - }) -} - -/// Create a Jev decision model. Optional endpoint is a complete POST URL. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_jev_decision_new( - api_key: *const c_char, - model_id: *const c_char, - endpoint: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - aimux_jev_decision_new_with_probability_source( - api_key, - model_id, - endpoint, - std::ptr::null(), - out_handle, - ) -} - -/// Create a Jev-compatible model with explicit probability provenance. -/// `probability_source` is native, logit_scoring or model_estimate; NULL uses native. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_jev_decision_new_with_probability_source( - api_key: *const c_char, - model_id: *const c_char, - endpoint: *const c_char, - probability_source: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let key = str_arg(api_key, "api_key")?; - let model_id = str_arg(model_id, "model_id")?; - let mut config = aimux_providers::JevConfig::new(key); - if let Some(endpoint) = parse_base_url(endpoint)? { - config = config.with_endpoint(endpoint); - } - if let Some(source) = opt_str_arg(probability_source, "probability_source")? { - config.probability_source = source.parse()?; - } - let model = aimux_providers::JevProvider::new(config) - .decision_model(&model_id) - .unwrap_or_else(|| Err(AiMuxError::no_such_model(&model_id, "decisionModel")))?; - Ok(intern_handle(HandleEntry::Decision(model))) - }) -} - -/// Describe the model without a network call. Writes JSON -/// `{specificationVersion, provider, modelId, supportedQuestionTypes}`, -/// the `DecisionModelV4` properties. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_decision_capabilities( - handle: u64, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let HandleEntry::Decision(model) = entry_of(handle, "decision")? else { - return Err(FfiError::InvalidHandle { - expected: "decision", - } - .into()); - }; - Ok(aimux_core::decision_model::decision_model_properties(model.as_ref()).to_string()) - }) -} - -/// Execute a typed decision. `opts_json` is `DecideOptions` JSON; writes -/// `DecideResult` JSON. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_decide( - handle: u64, - opts_json: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - aimux_decide_with_abort(handle, opts_json, 0, out_json) -} - -/// Execute a decision with an optional abort handle (zero means none). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_decide_with_abort( - handle: u64, - opts_json: *const c_char, - abort_handle: u64, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let HandleEntry::Decision(model) = entry_of(handle, "decision")? else { - return Err(FfiError::InvalidHandle { - expected: "decision", - } - .into()); - }; - let mut opts: aimux_core::decision_model::DecideOptions = - parse_json_arg(opts_json, "opts_json")?; - if abort_handle != 0 { - opts.abort_signal = Some(abort_of(abort_handle)?); - } - run_json(async move { aimux_core::decision_model::decide(model.as_ref(), opts).await }) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: Search -// ───────────────────────────────────────────────────────────────────────────── - -/// A Tavily provider for an explicit key handed over by the host, with an -/// optional base URL. -fn tavily_provider( - api_key: String, - base_url: Option, -) -> Result { - create_tavily(TavilyProviderSettings { - api_key: Some(aimux_provider_utils::Resolvable::Value(api_key)), - base_url, - ..Default::default() - }) -} - -/// Create a Tavily search model instance. `model_id` is accepted for API -/// symmetry but ignored (Tavily uses a fixed endpoint). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_tavily_search_new( - api_key: *const c_char, - _model_id: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let api_key = str_arg(api_key, "api_key")?; - let model = tavily_provider(api_key, None)?.search_model(); - Ok(intern_handle(HandleEntry::Search(Arc::new(model)))) - }) -} - -#[unsafe(no_mangle)] -pub extern "C" fn aimux_tavily_search_new_with_base( - api_key: *const c_char, - _model_id: *const c_char, - base_url: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let api_key = str_arg(api_key, "api_key")?; - let model = tavily_provider(api_key, parse_base_url(base_url)?)?.search_model(); - Ok(intern_handle(HandleEntry::Search(Arc::new(model)))) - }) -} - -/// Execute a search. `opts_json` is JSON-serialized `SearchCallOptions` -/// (must contain `query`). Writes `SearchResult` JSON. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_search( - handle: u64, - opts_json: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let HandleEntry::Search(model) = entry_of(handle, "search")? else { - return Err(FfiError::InvalidHandle { expected: "search" }.into()); - }; - let opts: aimux_core::search_model::SearchCallOptions = - parse_json_arg(opts_json, "opts_json")?; - run_json(async move { aimux_core::search_model::search(model.as_ref(), opts).await }) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: Codex subscription helper (RFC-0018 §3.2) -// ───────────────────────────────────────────────────────────────────────────── - -/// Refresh a Codex subscription access token (RFC-0018 §3.2). -/// -/// Stateless: performs one OAuth `refresh_token` grant against -/// `auth.openai.com/oauth/token`. Writes -/// `{"access_token","refresh_token","expires_in_secs"}` JSON on success -/// (caller frees with `aimux_free_string`). The caller owns token persistence -/// and the 401 → refresh → retry orchestration — the library never stores -/// credentials. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_codex_refresh( - refresh_token: *const c_char, - client_id: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let (refresh_token, client_id) = - parse_two_args(refresh_token, "refresh_token", client_id, "client_id")?; - run_json(async move { aimux_providers::codex_refresh(&refresh_token, &client_id).await }) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: Logging (RFC-0014) -// ───────────────────────────────────────────────────────────────────────────── - -/// Initialize the global logger. Idempotent — safe to call any number of -/// times from any thread; only the first call has an effect. -/// -/// If the host already registered its own `tracing` subscriber, this is a -/// no-op (aimux never overrides a consumer's logger). -/// -/// @param level NUL-terminated level string: "off" / "error" / "warn" / -/// "info" / "debug" / "trace". NULL falls back to the default -/// ("warn"); a non-UTF-8 pointer is `InvalidUtf8`. `AIMUX_LOG` -/// (RUST_LOG-style) and `AIMUX_LOG_LEVEL` env vars take -/// precedence when set. Logs go to stderr. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_init_logging(level: *const c_char) -> *mut aimux_error_t { - no_result(|| { - let level = opt_str_arg(level, "level")?; - aimux_providers::init_logging(level.as_deref().unwrap_or("warn")); - Ok(()) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: session grouping (RFC-0024) -// ───────────────────────────────────────────────────────────────────────────── - -/// Register the global session store (RFC-0024). Replaces any previous one. -/// Until called, calls are not grouped and the session query functions return -/// empty results. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_session_store_init() { - aimux_core::session::init_session_store(std::sync::Arc::new( - aimux_core::session::SessionStore::new(), - )); -} - -/// Enable/disable the global session inferer (RFC-0024, opt-in, off by -/// default). Explicit `session_id` values always win regardless. -/// `enabled` nonzero = on. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_session_infer_init(enabled: i32) { - aimux_core::session::init_session_infer(enabled != 0); -} - -/// Query: all calls of a session, ordered by step (RFC-0024). -/// -/// Writes a serialized `SessionCall[]` (empty array if the session is unknown -/// or no store is registered); the caller frees it with [`aimux_free_string`]. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_session_calls( - session_id: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let id = str_arg(session_id, "session_id")?; - to_json(&aimux_core::session::session_calls(&id)) - }) -} - -/// Query: all known sessions (RFC-0024). Writes a serialized `SessionView[]`. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_list_sessions(out_json: *mut *mut c_char) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - to_json(&aimux_core::session::list_sessions()) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: cache probing (RFC-0015) -// ───────────────────────────────────────────────────────────────────────────── - -static TRACE_STORES: OnceLock>>> = OnceLock::new(); - -fn trace_stores() -> &'static Mutex>> { - TRACE_STORES.get_or_init(|| Mutex::new(HashMap::new())) -} - -fn trace_store_of(handle: u64) -> Result, FfiError> { - trace_stores() - .lock() - .expect("aimux-ffi: trace registry mutex poisoned") - .get(&handle) - .cloned() - .ok_or(FfiError::InvalidHandle { expected: "trace" }) -} - -fn trace_wrap(handle: u64, audited: Option) -> FfiResult { - let model = model_of(handle)?; - let store = Arc::new(RingTraceStore::new()); - let mut layer = TraceLayer::new(model, store.clone()); - if let Some(strict) = audited { - layer = layer.with_rules_auditor(strict); - } - let new_handle = intern_model(Arc::new(layer)); - trace_stores() - .lock() - .expect("aimux-ffi: trace registry mutex poisoned") - .insert(new_handle, store); - Ok(new_handle) -} - -/// Wrap a model handle in a probe layer (RFC-0015). The new handle can be -/// used with `aimux_generate_text` / `aimux_stream_text` (probed) and with -/// the `aimux_trace_*` query functions; release with `aimux_drop_handle`. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_trace_new(handle: u64, out_handle: *mut u64) -> *mut aimux_error_t { - with_out_handle(out_handle, || trace_wrap(handle, None)) -} - -/// Wrap a model handle in a probe layer WITH the built-in rules auditor -/// (RFC-0015 §4). `strict` nonzero = strict mode (self-hosted single -/// instance); zero = shared mode (safe default). Release with -/// `aimux_drop_handle`. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_trace_new_audited( - handle: u64, - strict: i32, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || trace_wrap(handle, Some(strict != 0))) -} - -/// Query: aggregated probe statistics, filtered by `filter_json` (a serialized -/// `TraceFilter`; pass `"{}"` for all rows — NULL is `NullPointer`). Writes -/// JSON `TraceStats[]`; caller frees with `aimux_free_string`. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_trace_aggregate( - handle: u64, - filter_json: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let store = trace_store_of(handle)?; - let filter = parse_json_arg::(filter_json, "filter_json")?; - to_json(&store.aggregate(&filter)) - }) -} - -/// Query: one session's chain view. Writes JSON `SessionChainView`; caller -/// frees with `aimux_free_string`. An unknown `session_id` is a lookup miss -/// on a caller-supplied value and is reported as -/// `AiMuxError::InvalidArgument` ("unknown session"), not as an invalid C -/// handle — the id is a string key, not a registry handle. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_trace_session_chain( - handle: u64, - session_id: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let store = trace_store_of(handle)?; - let id = str_arg(session_id, "session_id")?; - let view = store - .session_chain(&id) - .ok_or_else(|| AiMuxError::InvalidArgument("unknown session".into()))?; - to_json(&view) - }) -} - -/// Query: one session's per-step cache-hit trajectory (RFC-0024 §4.3). -/// Writes a JSON array of `SessionStepStat` (empty for unknown sessions); -/// caller frees with `aimux_free_string`. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_trace_session_trajectory( - handle: u64, - session_id: *const c_char, - out_json: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_json, "out_json", || { - let store = trace_store_of(handle)?; - let id = str_arg(session_id, "session_id")?; - to_json(&store.session_cache_trajectory(&id)) - }) -} - -/// Export all probe records as JSONL (one `TraceRecord` per line). Writes a -/// string with embedded newlines; caller frees with `aimux_free_string`. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_trace_export_jsonl( - handle: u64, - out_jsonl: *mut *mut c_char, -) -> *mut aimux_error_t { - with_out_string(out_jsonl, "out_jsonl", || { - let store = trace_store_of(handle)?; - let mut buf = Vec::new(); - store - .export_jsonl(&mut buf) - .map_err(|e| FfiError::ResultSerialization { - message: format!("export: {e}"), - })?; - String::from_utf8(buf).map_err(|e| { - FfiError::ResultSerialization { - message: format!("utf8: {e}"), - } - .into() - }) - }) -} - -/// Clear all probe records of a trace handle. Fails only for a dead handle. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_trace_clear(handle: u64) -> *mut aimux_error_t { - no_result(|| { - trace_store_of(handle)?.clear(); - Ok(()) - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// C ABI: recording + mock replay (RFC-0023) -// ───────────────────────────────────────────────────────────────────────────── - -/// Start recording (RFC-0023 P1/P2): writes complete `Recording` JSONL to -/// `{dir}/recordings.jsonl` (dir auto-created). Recording is **opt-in**. -/// Calling again with a different dir replaces the recorder. -/// -/// A null / non-UTF-8 `dir` is a C ABI failure; recorder construction -/// failures carry the recording view (`AIMUX_E_RECORDING_INIT`, -/// `AIMUX_E_RECORDING_OPEN_FILE`, or `AIMUX_E_RECORDING_SPAWN`). On failure -/// the previous recorder, if any, is left in place. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_init_recording(dir: *const c_char) -> *mut aimux_error_t { - no_result(|| { - let dir = str_arg(dir, "dir")?; - let rec = aimux_core::recording::JsonlRecorder::try_new(dir)?; - aimux_core::recording::init_recording(Some(std::sync::Arc::new(rec))); - Ok(()) - }) -} - -/// Start in-memory bounded recording (RFC-0023 P6): `RingRecorder` with `cap` -/// entries, FIFO eviction, dropped-count queryable. `cap == 0` is -/// `AiMuxError::InvalidArgument`. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_init_recording_ring(cap: u64) -> *mut aimux_error_t { - no_result(|| { - if cap == 0 { - return Err(AiMuxError::InvalidArgument("cap: must be > 0".into()).into()); - } - aimux_core::recording::init_recording(Some(std::sync::Arc::new( - aimux_core::recording::RingRecorder::with_capacity(cap as usize), - ))); - Ok(()) - }) -} - -/// No-argument variant of [`aimux_init_recording_ring`]: initialize the global -/// recorder with a `RingRecorder` at the library default capacity (2048 -/// entries, [`aimux_core::recording::RingRecorder::default`]). Ordinary callers -/// should prefer this entry point and leave the ring size to the library; pass -/// an explicit `cap` via [`aimux_init_recording_ring`] only when a different -/// size is required. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_init_recording_ring_default() { - aimux_core::recording::init_recording(Some(std::sync::Arc::new( - aimux_core::recording::RingRecorder::default(), - ))); -} - -/// Stop recording: global recorder = None (new calls are unrecorded). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_recording_stop() { - aimux_core::recording::init_recording(None); -} - -/// Flush the global recorder (blocks until JSONL is on disk; no-op for the -/// ring recorder). Write failures are not reported here — see -/// [`aimux_recording_try_flush`]. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_recording_flush() { - if let Some(rec) = aimux_core::recording::recorder() { - rec.flush(); - } -} - -/// Flush the global recorder and **report write failures** (see #136). -/// -/// Unlike [`aimux_recording_flush`], this surface makes the recorder's -/// durability observable across the C ABI: NULL when the data is confirmed -/// on disk (also when recording was never initialized: nothing to flush), an -/// returned error with the recording view otherwise. Codes reachable from a -/// flush: -/// - `AIMUX_E_RECORDING_WRITE` — a prior write failed (e.g. ENOSPC); the -/// first error is sticky and every later flush keeps reporting it. -/// - `AIMUX_E_RECORDING_WRITER_GONE` — the writer thread is unavailable. -/// - `AIMUX_E_RECORDING_FLUSH_TIMEOUT` — no writer ack within 30s. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_recording_try_flush() -> *mut aimux_error_t { - no_result(|| { - if let Some(rec) = aimux_core::recording::recorder() { - rec.try_flush()?; - } - Ok(()) - }) -} - -/// Register external OpenAI-compatible providers from a JSON config string -/// (RFC-0020). `config_json` is `{ "providers": [ { "name": ..., "base_url": -/// ..., ... }, ... ] }`. Entries override same-named built-ins or add new -/// ones. Malformed JSON text is `InvalidWireJson`; a well-formed document the -/// registry rejects is `AiMuxError::InvalidArgument`. -#[unsafe(no_mangle)] -pub extern "C" fn aimux_register_providers(config_json: *const c_char) -> *mut aimux_error_t { - no_result(|| { - let json = str_arg(config_json, "config_json")?; - // Malformed JSON text is this layer's finding; a well-formed document - // that the registry rejects (bad schema, unknown protocol) is an AiMuxError. - serde_json::from_str::(&json).map_err(|e| wire_err("config_json", e))?; - aimux_providers::provider::load_providers_from_json(&json).map_err(|e| match e { - // The registry reports a schema mismatch as `JsonParse`; the text - // already parsed above, so what it rejected is the shape — - // `AiMuxError::InvalidArgument`, not a provider-response parse failure. - AiMuxError::JsonParse(m) => AiMuxError::InvalidArgument(format!("config_json: {m}")), - e => e, - })?; - Ok(()) - }) -} - -/// Replace the default Rust transport with one routed by this proxy -/// configuration (M6, RFC-0016). Every later request of a provider without -/// its own transport, every built-in WebSocket connect and every validated -/// download hop uses it; requests already in flight keep theirs. Calling it -/// again replaces the previous configuration. -/// -/// `config_json` is a serialized `ProxyConfig`: -/// `{ "http_url": "...", "https_url": "...", "all_url": "...", "no_proxy": -/// "..." }` (all fields optional; omitting all is equivalent to relying on the -/// `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` env vars). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_init_proxy(config_json: *const c_char) -> *mut aimux_error_t { - no_result(|| { - let config: aimux_provider_utils::ProxyConfig = parse_json_arg(config_json, "config_json")?; - aimux_provider_utils::set_default_transport(aimux_provider_utils::ReqwestFetch::new( - config, - )); - Ok(()) - }) -} - -/// Create a mock replay model from recorded JSONL (RFC-0023 P3). `recordings` -/// is one `Recording` JSON per line. The handle works with -/// `aimux_generate_text` / `aimux_stream_text` (no real API is sent). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_mock_replay_new( - recordings_jsonl: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let recordings_jsonl = str_arg(recordings_jsonl, "recordings_jsonl")?; - let mut recordings: Vec = Vec::new(); - for (idx, line) in recordings_jsonl.lines().enumerate() { - let line = line.trim(); - if line.is_empty() { - continue; - } - recordings.push(serde_json::from_str(line).map_err(|e| { - wire_failure("recordings_jsonl", &e, format!("line {}: {e}", idx + 1)) - })?); - } - if recordings.is_empty() { - return Err( - AiMuxError::InvalidArgument("recordings_jsonl: no recordings".into()).into(), - ); - } - let model = aimux_core::replay::MockReplayModel::new( - recordings[0].provider.provider.clone(), - recordings[0].provider.model_id.clone(), - recordings, - ); - Ok(intern_model(Arc::new(model))) - }) -} - -/// Resolve an array of model handles for a composite (router / moa). -/// -/// `NULL` with `len == 0` is an empty list; `NULL` with `len > 0` is -/// `NullPointer`; any element that is not a live model handle is -/// `InvalidHandle { expected }` — a dead child is a caller bug, never -/// silently dropped (the composite would otherwise run with fewer members -/// than the caller believes). -fn model_handles( - handles: *const u64, - len: usize, - argument: &'static str, - expected: &'static str, -) -> FfiResult>> { - let slice: &[u64] = if handles.is_null() { - if len != 0 { - return Err(FfiError::NullPointer { argument }.into()); - } - &[] - } else { - // SAFETY: caller guarantees `handles` points to `len` u64s. - unsafe { std::slice::from_raw_parts(handles, len) } - }; - slice - .iter() - .map(|&h| get_model(h).ok_or_else(|| FfiError::InvalidHandle { expected }.into())) - .collect() -} - -/// Create a `RouterModel` (RFC-0021) over the given child-model handles. The -/// new handle is itself a model handle (works with `aimux_generate_text` / -/// `aimux_stream_text`); free it with `aimux_drop_handle`. -/// -/// `handles` is an array of `len` existing model handles (e.g. from -/// `aimux_openai_new`). `config_json` selects the router + fallback policy: -/// `{ "router": "rule" | "weighted", "weights": [..], "fallback": "on_error" | -/// "none", "providerName": "router", "modelId": "router" }`. All keys are -/// optional; defaults are `rule` / `on_error` / `"router"` / `"router"`. -/// -/// Fails on: NULL `handles` with `len > 0`, bad JSON, zero-length `handles`, -/// or any dead child handle (nothing is silently dropped). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_router_new( - handles: *const u64, - len: usize, - config_json: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let models = model_handles(handles, len, "handles", "router child")?; - if models.is_empty() { - // No children at all: nothing to route to. - return Err(FfiError::InvalidHandle { - expected: "router child", - } - .into()); - } - // NULL / empty / "null" all mean "defaults" (matching parse_provider_options - // convention). Invalid UTF-8 → FFI `InvalidUtf8`. - let config_json = normalize_config_json(config_json, "config_json")?; - let cfg: RouterFfiConfig = - serde_json::from_str(&config_json).map_err(|e| wire_err("config_json", e))?; - let router: Box = match cfg.router.as_deref() { - Some("weighted") => { - let weights = cfg.weights.unwrap_or_else(|| vec![1.0; models.len()]); - Box::new(aimux_core::router::WeightedRouter::new(weights)) - } - // "rule", None, and any unknown value fall back to RuleRouter (safest - // default: child 0 + ordered fallback). - _ => Box::new(aimux_core::router::RuleRouter), - }; - let fallback = if cfg.fallback.as_deref() == Some("none") { - aimux_core::router::FallbackPolicy::None - } else { - aimux_core::router::FallbackPolicy::OnError - }; - let router_cfg = aimux_core::router::RouterConfig { - provider_name: cfg.provider_name.unwrap_or_else(|| "router".into()), - model_id: cfg.model_id.unwrap_or_else(|| "router".into()), - }; - let model = aimux_core::router::RouterModel::new(models, router, fallback, router_cfg); - Ok(intern_model(Arc::new(model))) - }) -} - -/// Create a `MoaModel` (RFC-0022) over the given reference handles + one -/// aggregator handle. The new handle is a model handle (works with -/// `aimux_generate_text` / `aimux_stream_text`); free it with -/// `aimux_drop_handle`. -/// -/// `reference_handles` is an array of `ref_len` existing model handles (may be -/// 0 — MoaModel then degrades to running just the aggregator). `aggregator` -/// is a single existing model handle. `config_json` is a serialized `MoaConfig` -/// (all fields optional): `{ "providerName": "moa", "modelId": "moa", -/// "aggregatorInstructions": null, "stripReferenceTools": true, -/// "failMode": "best-effort" | "fail-fast" }`. -/// -/// Fails on: bad JSON, an unknown aggregator handle (references may be empty; -/// aggregator may not). -#[unsafe(no_mangle)] -pub extern "C" fn aimux_moa_new( - reference_handles: *const u64, - ref_len: usize, - aggregator: u64, - config_json: *const c_char, - out_handle: *mut u64, -) -> *mut aimux_error_t { - with_out_handle(out_handle, || { - let aggregator_model = get_model(aggregator).ok_or(FfiError::InvalidHandle { - expected: "moa aggregator", - })?; - let references = model_handles( - reference_handles, - ref_len, - "reference_handles", - "moa reference", - )?; - // NULL / empty / "null" all mean "defaults" (matching parse_provider_options - // convention). Invalid UTF-8 → FFI `InvalidUtf8`. - let config_json = normalize_config_json(config_json, "config_json")?; - let cfg: aimux_core::moa::MoaConfig = - serde_json::from_str(&config_json).map_err(|e| wire_err("config_json", e))?; - let model = aimux_core::moa::MoaModel::new(references, aggregator_model, cfg); - Ok(intern_model(Arc::new(model))) - }) -} - -/// FFI-side router config: camelCase, all fields optional, unknown keys -/// rejected. -#[derive(Default, serde::Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RouterFfiConfig { - router: Option, - weights: Option>, - fallback: Option, - provider_name: Option, - model_id: Option, -} - -#[cfg(test)] -mod tests { - use aimux_core::{ApiCallError, RetryError, RetryErrorReason}; - use std::time::Duration; - - use super::*; - - /// Own the string a getter returns; None for NULL. - fn take(p: *mut c_char) -> Option { - if p.is_null() { - return None; - } - let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap().to_string(); - unsafe { aimux_free_string(p) }; - Some(s) - } - - /// The owned returned error a failing call would return for `p`. - fn boxed(p: impl Into) -> *mut aimux_error_t { - finish::<()>(Err(p.into()), |()| {}) - } - - /// Message of a returned error, which is then freed. - fn msg(e: *mut aimux_error_t) -> String { - assert!(!e.is_null(), "expected a returned error"); - let m = take(aimux_error_message(e)).unwrap(); - aimux_error_free(e); - m - } - - /// AiMuxError code and message; the returned error is freed. - fn expect_aimux_error(e: *mut aimux_error_t) -> (i32, String) { - assert!(!e.is_null(), "expected a returned error"); - let code = aimux_error_code(e); - if !(AIMUX_E_OTHER..=AIMUX_E_LOAD_SETTING).contains(&code) { - panic!("expected an AiMuxError code, got {code}: {}", msg(e)); - } - let out = (code, take(aimux_error_message(e)).unwrap()); - aimux_error_free(e); - out - } - - /// C ABI failure code and non-empty message; the returned error is freed. - fn expect_ffi_error(e: *mut aimux_error_t) -> String { - assert!(!e.is_null(), "expected a returned error"); - let code = aimux_error_code(e); - assert!( - (AIMUX_E_FFI_NULL_POINTER..=AIMUX_E_FFI_CALLBACK_FAILURE).contains(&code), - "expected a C ABI failure code, got {code}" - ); - let m = msg(e); - assert!(!m.is_empty()); - m - } - - fn zero_err() -> u64 { - 0 - } - - /// Each payload getter answers under the one code that owns it and is - /// NULL / -1 / 0 under every other; a NULL pointer answers as "no error". - #[test] - fn payload_getters_follow_the_code() { - let owner = boxed(AiMuxError::ApiCall(Box::new(ApiCallError { - status_code: Some(429), - provider_code: Some("insufficient_quota".into()), - response_body: Some("{}".into()), - response_headers: Some(std::collections::HashMap::from([( - "retry-after-ms".to_string(), - "1500".to_string(), - )])), - data: Some(serde_json::json!({"code": "insufficient_quota"})), - is_retryable: true, - ..ApiCallError::new( - "quota", - "https://api.example.test/v1", - serde_json::json!({"model": "m"}), - ) - }))); - let h = owner; - assert_eq!(aimux_error_code(h), AIMUX_E_API_CALL); - assert_eq!(aimux_error_status(h), 429); - // The retry hint is derived from the response headers. - assert_eq!(aimux_error_retry_ms(h), 1500); - assert_eq!(aimux_error_retryable(h), 1); - assert_eq!( - take(aimux_error_provider_code(h)).as_deref(), - Some("insufficient_quota") - ); - // The variant's own text, not the composed Display form in `message`. - assert_eq!( - take(aimux_error_provider_message(h)).as_deref(), - Some("quota") - ); - let m = take(aimux_error_message(h)).unwrap(); - assert!(m.contains("quota") && m != "quota", "{m}"); - assert_eq!(take(aimux_error_response_body(h)).as_deref(), Some("{}")); - assert_eq!( - take(aimux_error_url(h)).as_deref(), - Some("https://api.example.test/v1") - ); - assert_eq!( - take(aimux_error_request_body_values(h)).as_deref(), - Some(r#"{"model":"m"}"#) - ); - assert_eq!( - take(aimux_error_response_headers(h)).as_deref(), - Some(r#"{"retry-after-ms":"1500"}"#) - ); - assert_eq!( - take(aimux_error_provider_data(h)).as_deref(), - Some(r#"{"code":"insufficient_quota"}"#) - ); - assert!(aimux_error_model_id(h).is_null()); - assert!(aimux_error_model_type(h).is_null()); - assert!(aimux_error_provider_id(h).is_null()); - // Retry getters answer their sentinels under AIMUX_E_API_CALL. - assert!(aimux_error_retry_reason(h).is_null()); - assert_eq!(aimux_error_retry_count(h), 0); - assert!(aimux_error_retry_error_at(h, 0).is_null()); - aimux_error_free(owner); - - // Absent Option fields are NULL, not empty strings; a JSON `null` - // request body is NULL too. - let owner = boxed(AiMuxError::ApiCall(Box::new(ApiCallError::new( - "x", - "https://api.example.test/v1", - serde_json::Value::Null, - )))); - let h = owner; - assert!(aimux_error_provider_code(h).is_null()); - assert!(aimux_error_response_body(h).is_null()); - assert!(aimux_error_response_headers(h).is_null()); - assert!(aimux_error_provider_data(h).is_null()); - assert!(aimux_error_request_body_values(h).is_null()); - assert_eq!(take(aimux_error_provider_message(h)).as_deref(), Some("x")); - aimux_error_free(owner); - - // Retry owns reason + attempt history; each history entry crosses the - // ABI as a new owned error read with the same getters. - let owner = boxed(AiMuxError::Retry(RetryError { - reason: RetryErrorReason::MaxRetriesExceeded, - errors: vec![ - AiMuxError::Other("first".into()), - AiMuxError::ApiCall(Box::new(ApiCallError { - status_code: Some(500), - is_retryable: true, - ..ApiCallError::new( - "boom", - "https://api.example.test/v1", - serde_json::Value::Null, - ) - })), - ], - })); - let h = owner; - assert_eq!(aimux_error_code(h), AIMUX_E_RETRY); - assert_eq!( - take(aimux_error_retry_reason(h)).as_deref(), - Some("maxRetriesExceeded") - ); - assert_eq!(aimux_error_retry_count(h), 2); - // ApiCall-owned facts stay with the attempt that owns them. - assert_eq!(aimux_error_status(h), -1); - assert_eq!(aimux_error_retryable(h), 0); - assert!(aimux_error_provider_code(h).is_null()); - let m = take(aimux_error_message(h)).unwrap(); - // getErrorMessage text (provider-utils retry-with-exponential-backoff.ts:97, - // :103): the last attempt's message, without its HTTP status. - assert_eq!(m, "Failed after 2 attempts. Last error: boom"); - let first = aimux_error_retry_error_at(h, 0); - assert_eq!(aimux_error_code(first), AIMUX_E_OTHER); - assert_eq!(take(aimux_error_message(first)).as_deref(), Some("first")); - aimux_error_free(first); - let last = aimux_error_retry_error_at(h, 1); - assert_eq!(aimux_error_code(last), AIMUX_E_API_CALL); - assert_eq!(aimux_error_status(last), 500); - assert_eq!(aimux_error_retryable(last), 1); - // The history entry outlives the parent: free order is unconstrained. - aimux_error_free(owner); - assert_eq!( - take(aimux_error_provider_message(last)).as_deref(), - Some("boom") - ); - aimux_error_free(last); - let owner = boxed(AiMuxError::Retry(RetryError { - reason: RetryErrorReason::ErrorNotRetryable, - errors: vec![AiMuxError::Other("bad".into())], - })); - let h = owner; - assert_eq!( - take(aimux_error_retry_reason(h)).as_deref(), - Some("errorNotRetryable") - ); - assert!(aimux_error_retry_error_at(h, 1).is_null(), "out of range"); - assert!(aimux_error_retry_error_at(h, -1).is_null()); - aimux_error_free(owner); - - let owner = boxed(AiMuxError::NoSuchModel { - model_id: "m".into(), - model_type: "language".into(), - }); - let h = owner; - assert_eq!(aimux_error_code(h), AIMUX_E_NO_SUCH_MODEL); - assert_eq!(take(aimux_error_model_id(h)).as_deref(), Some("m")); - assert_eq!(take(aimux_error_model_type(h)).as_deref(), Some("language")); - assert!(aimux_error_provider_code(h).is_null()); - assert!(aimux_error_provider_id(h).is_null()); - assert_eq!(aimux_error_status(h), -1); - assert_eq!(aimux_error_retryable(h), 0); - aimux_error_free(owner); - - // LoadApiKey / LoadSetting carry the consulted environment variable on - // the provider-code channel and the description / setting name on the - // provider-message channel; every other getter answers its sentinel. - let owner = boxed(AiMuxError::LoadApiKey { - env_var: "OPENAI_API_KEY".into(), - description: "OpenAI".into(), - }); - let h = owner; - assert_eq!(aimux_error_code(h), AIMUX_E_LOAD_API_KEY); - assert_eq!( - take(aimux_error_provider_code(h)).as_deref(), - Some("OPENAI_API_KEY") - ); - assert_eq!( - take(aimux_error_provider_message(h)).as_deref(), - Some("OpenAI") - ); - assert!( - take(aimux_error_message(h)) - .unwrap() - .contains("OPENAI_API_KEY") - ); - assert!(aimux_error_response_body(h).is_null()); - assert!(aimux_error_model_id(h).is_null()); - assert_eq!(aimux_error_status(h), -1); - assert_eq!(aimux_error_retryable(h), 0); - aimux_error_free(owner); - - let owner = boxed(AiMuxError::LoadSetting { - env_var: "AWS_REGION".into(), - name: "region".into(), - }); - let h = owner; - assert_eq!(aimux_error_code(h), AIMUX_E_LOAD_SETTING); - assert_eq!( - take(aimux_error_provider_code(h)).as_deref(), - Some("AWS_REGION") - ); - assert_eq!( - take(aimux_error_provider_message(h)).as_deref(), - Some("region") - ); - assert!(aimux_error_url(h).is_null()); - assert_eq!(aimux_error_status(h), -1); - aimux_error_free(owner); - - let owner = boxed(AiMuxError::NoSuchProvider { - provider_id: "p".into(), - model_id: String::new(), - model_type: String::new(), - available_providers: Vec::new(), - }); - let h = owner; - assert_eq!(aimux_error_code(h), AIMUX_E_NO_SUCH_PROVIDER); - assert_eq!(take(aimux_error_provider_id(h)).as_deref(), Some("p")); - assert!(aimux_error_model_id(h).is_null()); - aimux_error_free(owner); - - // NULL pointer: never UB, answers "no error". - let n = std::ptr::null(); - assert_eq!(aimux_error_code(n), AIMUX_OK); - assert!(aimux_error_message(n).is_null()); - assert_eq!(aimux_error_status(n), -1); - assert_eq!(aimux_error_retry_ms(n), -1); - assert_eq!(aimux_error_retryable(n), 0); - assert!(aimux_error_provider_code(n).is_null()); - assert!(aimux_error_url(n).is_null()); - assert!(aimux_error_request_body_values(n).is_null()); - assert!(aimux_error_response_headers(n).is_null()); - assert!(aimux_error_provider_data(n).is_null()); - assert!(aimux_error_retry_reason(n).is_null()); - assert_eq!(aimux_error_retry_count(n), 0); - assert!(aimux_error_retry_error_at(n, 0).is_null()); - } - - /// The retry verdict crosses the ABI as its own getter. It is not - /// derivable from `status`: both cases below report -1, and they disagree. - #[test] - fn retryable_crosses_the_abi_and_status_cannot_stand_in() { - let transport_owner = boxed(AiMuxError::ApiCall(Box::new(ApiCallError { - is_retryable: true, - ..ApiCallError::new( - "connection reset", - "https://api.example.test/v1", - serde_json::Value::Null, - ) - }))); - let transport = transport_owner; - assert_eq!(aimux_error_status(transport), -1); - assert_eq!(aimux_error_retryable(transport), 1); - aimux_error_free(transport_owner); - - let no_key_owner = boxed(AiMuxError::ApiCall(Box::new(ApiCallError::new( - "no api key", - "https://api.example.test/v1", - serde_json::Value::Null, - )))); - let no_key = no_key_owner; - assert_eq!( - aimux_error_status(no_key), - -1, - "same sentinel as the transport failure" - ); - assert_eq!(aimux_error_retryable(no_key), 0, "but the opposite verdict"); - aimux_error_free(no_key_owner); - - // Non-ApiCall variants are never retryable. - let arg_owner = boxed(AiMuxError::InvalidArgument("bad".into())); - let arg = arg_owner; - assert_eq!(aimux_error_retryable(arg), 0); - aimux_error_free(arg_owner); - } - - /// The unified code space identifies every returned error source. - #[test] - fn unified_error_codes_cover_every_source() { - // AiMuxError. - let e = boxed(AiMuxError::Aborted("request aborted".into())); - assert_eq!(aimux_error_code(e), AIMUX_E_ABORTED); - assert_eq!(msg(e), "request aborted"); - - // Retry fills slot 14 between Aborted and the tool-call codes. - let e = boxed(AiMuxError::Retry(RetryError { - reason: RetryErrorReason::ErrorNotRetryable, - errors: vec![AiMuxError::Other("bad".into())], - })); - assert_eq!(aimux_error_code(e), AIMUX_E_RETRY); - assert!(msg(e).contains("non-retryable")); - - // Recording. - use RecordingError as R; - let cases = [ - (R::WriterGone, AIMUX_E_RECORDING_WRITER_GONE), - (R::FlushTimeout, AIMUX_E_RECORDING_FLUSH_TIMEOUT), - (R::Write("ENOSPC".into()), AIMUX_E_RECORDING_WRITE), - ( - R::Spawn { - source: std::io::Error::other("x"), - }, - AIMUX_E_RECORDING_SPAWN, - ), - ]; - for (r, code) in cases { - let e = boxed(r); - assert_eq!(aimux_error_code(e), code); - assert!(!msg(e).is_empty()); - } - // NULL means success. - assert_eq!(aimux_error_code(std::ptr::null()), AIMUX_OK); - assert!(aimux_error_message(std::ptr::null()).is_null()); - - // FfiError codes and Display texts are pinned. - let cases: Vec<(FfiError, i32, &str)> = vec![ - ( - FfiError::NullPointer { - argument: "api_key", - }, - AIMUX_E_FFI_NULL_POINTER, - "api_key: must not be NULL", - ), - ( - FfiError::InvalidUtf8 { - argument: "model_id", - }, - AIMUX_E_FFI_INVALID_UTF8, - "model_id: must be valid UTF-8", - ), - ( - FfiError::InvalidWireJson { - argument: "opts_json", - message: "expected `,`".into(), - }, - AIMUX_E_FFI_INVALID_WIRE_JSON, - "opts_json: invalid JSON: expected `,`", - ), - ( - FfiError::InvalidHandle { expected: "model" }, - AIMUX_E_FFI_INVALID_HANDLE, - "invalid or expired model handle", - ), - ( - FfiError::ReentrantCall, - AIMUX_E_FFI_REENTRANT_CALL, - "re-entrant FFI call from within a callback is not allowed", - ), - ( - FfiError::ResultSerialization { - message: "serialize".into(), - }, - AIMUX_E_FFI_RESULT_SERIALIZATION, - "could not serialize result: serialize", - ), - ( - FfiError::CallbackFailure { - message: "on_part panicked".into(), - }, - AIMUX_E_FFI_CALLBACK_FAILURE, - "host callback failed: on_part panicked", - ), - ]; - for (f, code, text) in cases { - let e = boxed(f); - assert_eq!(aimux_error_code(e), code); - assert_eq!(expect_ffi_error(e), text); - } - - // NULL owner: every accessor is NULL-safe. - let n: *mut aimux_error_t = std::ptr::null_mut(); - assert_eq!(aimux_error_code(n), AIMUX_OK); - assert!(aimux_error_message(n).is_null()); - aimux_error_free(n); - } - - /// Even on a recording entry point, malformed C input is an `FfiError`; - /// only failures produced by `JsonlRecorder` carry the recording - /// code. - #[test] - fn recording_init_reports_ffi_codes_for_invalid_input() { - assert_eq!( - expect_ffi_error(aimux_init_recording(std::ptr::null())), - "dir: must not be NULL" - ); - let invalid_utf8 = [0xff_u8, 0]; - assert_eq!( - expect_ffi_error(aimux_init_recording(invalid_utf8.as_ptr().cast())), - "dir: must be valid UTF-8" - ); - } - - /// Pin the full 19-variant → code mapping. - #[test] - fn error_code_mapping_covers_all_variants() { - assert_eq!(AIMUX_E_NO_OUTPUT_GENERATED, 18); - assert_eq!(AIMUX_E_LOAD_API_KEY, 19); - assert_eq!(AIMUX_E_LOAD_SETTING, 20); - let s = |t: &str| t.to_string(); - let cases: Vec<(AiMuxError, i32)> = vec![ - ( - AiMuxError::ApiCall(Box::new(ApiCallError::new( - "x", - "https://example.test", - serde_json::json!({}), - ))), - AIMUX_E_API_CALL, - ), - ( - AiMuxError::Retry(RetryError { - reason: RetryErrorReason::MaxRetriesExceeded, - errors: vec![AiMuxError::Other(s("first")), AiMuxError::Other(s("last"))], - }), - AIMUX_E_RETRY, - ), - (AiMuxError::JsonParse(s("x")), AIMUX_E_JSON_PARSE), - ( - AiMuxError::NoOutputGenerated(s("x")), - AIMUX_E_NO_OUTPUT_GENERATED, - ), - ( - AiMuxError::InvalidResponseData(s("x")), - AIMUX_E_INVALID_RESPONSE_DATA, - ), - ( - AiMuxError::NoSuchTool { - tool_name: s("t"), - available_tools: None, - tool_input: None, - }, - AIMUX_E_NO_SUCH_TOOL, - ), - ( - AiMuxError::InvalidToolInput { - tool_name: s("t"), - tool_input: s("{}"), - cause: s("x"), - }, - AIMUX_E_INVALID_TOOL_INPUT, - ), - ( - AiMuxError::ToolCallRepair { - original_error: Box::new(AiMuxError::NoSuchTool { - tool_name: s("t"), - available_tools: None, - tool_input: None, - }), - cause: Box::new(AiMuxError::Other(s("x"))), - }, - AIMUX_E_TOOL_CALL_REPAIR, - ), - ( - AiMuxError::InvalidArgument(s("x")), - AIMUX_E_INVALID_ARGUMENT, - ), - (AiMuxError::InvalidPrompt(s("x")), AIMUX_E_INVALID_PROMPT), - ( - AiMuxError::LoadApiKey { - env_var: s("OPENAI_API_KEY"), - description: s("OpenAI"), - }, - AIMUX_E_LOAD_API_KEY, - ), - ( - AiMuxError::LoadSetting { - env_var: s("AWS_REGION"), - name: s("region"), - }, - AIMUX_E_LOAD_SETTING, - ), - (AiMuxError::TokenExpired(s("x")), AIMUX_E_TOKEN_EXPIRED), - ( - AiMuxError::UnsupportedFunctionality(s("x")), - AIMUX_E_UNSUPPORTED_FUNCTIONALITY, - ), - ( - AiMuxError::NoSuchModel { - model_id: s("x"), - model_type: String::new(), - }, - AIMUX_E_NO_SUCH_MODEL, - ), - ( - AiMuxError::NoSuchProvider { - provider_id: s("x"), - model_id: String::new(), - model_type: String::new(), - available_providers: Vec::new(), - }, - AIMUX_E_NO_SUCH_PROVIDER, - ), - (AiMuxError::Timeout(s("x")), AIMUX_E_TIMEOUT), - ( - AiMuxError::Aborted("request aborted".into()), - AIMUX_E_ABORTED, - ), - (AiMuxError::Other(s("x")), AIMUX_E_OTHER), - ]; - for (e, code) in cases { - let expect = format!("variant {e:?}"); - let (got, _) = expect_aimux_error(boxed(e)); - assert_eq!(got, code, "{expect}"); - } - } - - #[test] - fn unknown_tool_error_exposes_original_argument_text() { - let raw = r#""hello""#; - let error = boxed(AiMuxError::NoSuchTool { - tool_name: "unknown".into(), - available_tools: None, - tool_input: Some(raw.into()), - }); - let text = aimux_error_tool_input(error); - assert!(!text.is_null()); - // The accessor returns an owned C string, released with the public API. - assert_eq!(unsafe { CStr::from_ptr(text) }.to_str().unwrap(), raw); - unsafe { aimux_free_string(text) }; - aimux_error_free(error); - } - - /// Interior NUL bytes must not corrupt or truncate the message. - #[test] - fn error_message_sanitizes_interior_nul() { - let (_, m) = expect_aimux_error(boxed(AiMuxError::Other("a\0b".into()))); - assert_eq!(m, "a\u{FFFD}b"); - } - - /// A NULL out-parameter is reported, not dereferenced. - #[test] - fn null_out_param_is_an_ffi_error() { - let key = std::ffi::CString::new("k").unwrap(); - assert_eq!( - expect_ffi_error(aimux_openai_new( - key.as_ptr(), - key.as_ptr(), - std::ptr::null_mut() - )), - "out_handle: must not be NULL" - ); - assert_eq!( - expect_ffi_error(aimux_list_sessions(std::ptr::null_mut())), - "out_json: must not be NULL" - ); - } - - // ── invoke_stream_callback (issue #64: FFI panic guard) ─────────────────── - - #[test] - fn stream_callback_ok_passthrough() { - // A callback that returns normally must pass through as Ok. - let called = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let called_clone = called.clone(); - let result = invoke_stream_callback("on_part", || { - called_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - }); - assert!(result.is_ok()); - assert_eq!( - called.load(std::sync::atomic::Ordering::SeqCst), - 1, - "callback must have been invoked" - ); - } - - #[test] - fn stream_callback_catches_str_panic() { - // A panic with a &'static str payload must be caught and converted. - let result = invoke_stream_callback("on_part", || { - panic!("callback explosion"); - }); - match result { - Err(FfiError::CallbackFailure { message: msg }) => { - assert!( - msg.contains("on_part"), - "message should name the callback: {msg}" - ); - assert!( - msg.contains("callback explosion"), - "message should include the panic payload: {msg}" - ); - } - other => panic!("expected FfiError::CallbackFailure, got {other:?}"), - } - } - - #[test] - fn stream_callback_catches_string_panic() { - // A panic with a String payload must also be caught. - let result = invoke_stream_callback("on_done", || { - panic!("{}", "dynamic boom".to_string()); - }); - match result { - Err(FfiError::CallbackFailure { message: msg }) => { - assert!( - msg.contains("on_done"), - "message should name the callback: {msg}" - ); - assert!( - msg.contains("dynamic boom"), - "message should include the panic payload: {msg}" - ); - } - other => panic!("expected FfiError::CallbackFailure, got {other:?}"), - } - } - - #[test] - fn stream_callback_catches_non_string_panic() { - // A panic with a non-string payload (e.g. a struct) must still be - // caught — the message falls back to the placeholder. - let result = invoke_stream_callback("on_part", || { - std::panic::panic_any(42i32); - }); - match result { - Err(FfiError::CallbackFailure { message: msg }) => { - assert!( - msg.contains("on_part"), - "message should name the callback: {msg}" - ); - assert!( - msg.contains(""), - "non-string payload should use placeholder: {msg}" - ); - } - other => panic!("expected FfiError::CallbackFailure, got {other:?}"), - } - } - - #[test] - fn stream_callback_catches_on_done_panic() { - // Symmetric coverage for the on_done callback path (the other panic - // tests exercise on_part). The guard is identical, but this pins the - // on_done name in the error message. - let result = invoke_stream_callback("on_done", || { - panic!("done callback failed"); - }); - match result { - Err(FfiError::CallbackFailure { message: msg }) => { - assert!( - msg.contains("on_done"), - "message should name the callback: {msg}" - ); - assert!( - msg.contains("done callback failed"), - "message should include the panic payload: {msg}" - ); - } - other => panic!("expected FfiError::CallbackFailure, got {other:?}"), - } - } - - /// A tiny mock model for composite-FFI tests (returns fixed text). - struct MockText { - provider: &'static str, - model_id: &'static str, - text: &'static str, - } - #[async_trait::async_trait] - impl aimux_core::LanguageModel for MockText { - fn provider(&self) -> &str { - self.provider - } - fn model_id(&self) -> &str { - self.model_id - } - async fn do_generate( - &self, - _options: &aimux_core::options::CallOptions, - ) -> Result { - Ok(aimux_core::result::GenerateResult { - content: vec![aimux_core::result::GenerateContent::Text { - text: self.text.into(), - provider_metadata: None, - }], - finish_reason: aimux_core::types::FinishReason { - unified: aimux_core::types::FinishReasonUnified::Stop, - raw: None, - }, - usage: aimux_core::types::Usage::default(), - warnings: vec![], - provider_metadata: None, - response: None, - request: None, - }) - } - async fn do_stream( - &self, - _options: &aimux_core::options::CallOptions, - ) -> Result { - unimplemented!() - } - } - - fn mock_handle(provider: &'static str, model_id: &'static str, text: &'static str) -> u64 { - intern_model(Arc::new(MockText { - provider, - model_id, - text, - })) - } - - /// Streams: delta, recoverable Err(JsonParse), delta, Finish. - struct RecoverableFrameModel; - #[async_trait::async_trait] - impl aimux_core::LanguageModel for RecoverableFrameModel { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "recoverable" - } - async fn do_generate( - &self, - _options: &aimux_core::options::CallOptions, - ) -> Result { - unimplemented!() - } - async fn do_stream( - &self, - _options: &aimux_core::options::CallOptions, - ) -> Result { - let delta = |text: &str| aimux_core::stream_part::StreamPart::TextDelta { - id: "1".into(), - delta: text.into(), - provider_metadata: None, - }; - Ok(aimux_core::result::StreamResult { - stream: Box::pin(futures::stream::iter([ - Ok(delta("a")), - Err(AiMuxError::JsonParse("bad frame".into())), - Ok(delta("b")), - Ok(aimux_core::stream_part::StreamPart::Finish { - finish_reason: aimux_core::types::FinishReason { - unified: aimux_core::types::FinishReasonUnified::Stop, - raw: None, - }, - usage: aimux_core::types::Usage::default(), - provider_metadata: None, - }), - ])), - request: None, - response: None, - }) - } - } - - /// The pump must deliver a recoverable frame error as a StreamPart::Error - /// data part and keep going — parts after it still arrive and on_done - /// fires (the pre-fix pump returned the error and skipped on_done). - #[test] - fn recoverable_frame_error_does_not_end_the_ffi_stream() { - struct Collected { - parts: Vec, - done: bool, - } - extern "C-unwind" fn on_part(json: *const c_char, ctx: *mut c_void) { - let collected = unsafe { &mut *(ctx as *mut Collected) }; - let text = unsafe { std::ffi::CStr::from_ptr(json) } - .to_string_lossy() - .into_owned(); - collected.parts.push(text); - } - extern "C-unwind" fn on_done(ctx: *mut c_void) { - let collected = unsafe { &mut *(ctx as *mut Collected) }; - collected.done = true; - } - - let handle = intern_model(Arc::new(RecoverableFrameModel)); - let mut collected = Collected { - parts: Vec::new(), - done: false, - }; - let prompt = std::ffi::CString::new("\"hi\"").unwrap(); - let e = aimux_stream_text( - handle, - prompt.as_ptr(), - std::ptr::null(), - Some(on_part), - Some(on_done), - &mut collected as *mut Collected as *mut c_void, - ); - assert!(e.is_null(), "{}", msg(e)); - assert!( - collected.done, - "on_done must fire after a recoverable frame" - ); - let error_index = collected - .parts - .iter() - .position(|p| p.contains(r#""type":"error""#)) - .expect("the recoverable frame arrives as an error part"); - assert!( - collected.parts[error_index + 1..] - .iter() - .any(|p| p.contains("\"b\"")), - "parts after the recoverable frame still arrive: {:?}", - collected.parts - ); - } - - /// On the OpenAI-compatible path every item is a ChatCompletionChunk, so a - /// recoverable frame error is skipped — no error item, no truncation, and - /// on_done still fires. - #[test] - fn recoverable_frame_error_is_skipped_on_the_openai_stream() { - struct Collected { - parts: Vec, - done: bool, - } - extern "C-unwind" fn on_part(json: *const c_char, ctx: *mut c_void) { - let collected = unsafe { &mut *(ctx as *mut Collected) }; - let text = unsafe { std::ffi::CStr::from_ptr(json) } - .to_string_lossy() - .into_owned(); - collected.parts.push(text); - } - extern "C-unwind" fn on_done(ctx: *mut c_void) { - let collected = unsafe { &mut *(ctx as *mut Collected) }; - collected.done = true; - } - - let handle = intern_model(Arc::new(RecoverableFrameModel)); - let mut collected = Collected { - parts: Vec::new(), - done: false, - }; - let prompt = std::ffi::CString::new("\"hi\"").unwrap(); - let e = aimux_stream_text_as_openai( - handle, - prompt.as_ptr(), - std::ptr::null(), - Some(on_part), - Some(on_done), - &mut collected as *mut Collected as *mut c_void, - ); - assert!(e.is_null(), "{}", msg(e)); - assert!( - collected.done, - "on_done must fire after a recoverable frame" - ); - assert!( - collected.parts.iter().all(|p| !p.contains("\"error\"")), - "no error item may ride the chunk-typed wire: {:?}", - collected.parts - ); - assert!( - collected.parts.iter().any(|p| p.contains("\"b\"")), - "chunks after the recoverable frame still arrive: {:?}", - collected.parts - ); - } - - #[test] - fn router_new_builds_router_model() { - let handles = [ - mock_handle("mock", "primary", "primary-out"), - mock_handle("mock", "backup", "backup-out"), - ]; - let mut h = zero_err(); - let e = aimux_router_new(handles.as_ptr(), handles.len(), std::ptr::null(), &mut h); - assert!(e.is_null(), "{}", msg(e)); - assert!(h != 0, "router handle must be non-zero"); - // Confirm it resolves to a model. - let model = get_model(h).expect("router handle resolves"); - assert_eq!(model.provider(), "router"); - assert_eq!(model.model_id(), "router"); - } - - #[test] - fn router_new_rejects_bad_json() { - let handles = [mock_handle("mock", "m", "x")]; - let bad = std::ffi::CString::new("{not json").unwrap(); - let mut h = zero_err(); - let e = aimux_router_new(handles.as_ptr(), handles.len(), bad.as_ptr(), &mut h); - assert_eq!(h, 0); - assert!(expect_ffi_error(e).starts_with("config_json: invalid JSON:")); - } - - #[test] - fn router_new_treats_empty_config_as_defaults() { - // S1 guard: empty string / "null" config must NOT be a JSON_PARSE error - // (consistent with other config-bearing FFI entry points). - let handles = [mock_handle("mock", "m", "x")]; - for cfg in ["", " ", "null"] { - let c = std::ffi::CString::new(cfg).unwrap(); - let mut h = zero_err(); - let e = aimux_router_new(handles.as_ptr(), handles.len(), c.as_ptr(), &mut h); - assert!(e.is_null(), "router with config {cfg:?}: {}", msg(e)); - assert!(h != 0); - } - } - - #[test] - fn moa_new_builds_moa_model() { - let refs = [ - mock_handle("mock", "ref-a", "A"), - mock_handle("mock", "ref-b", "B"), - ]; - let agg = mock_handle("mock", "aggregator", "agg"); - let mut h = zero_err(); - let e = aimux_moa_new(refs.as_ptr(), refs.len(), agg, std::ptr::null(), &mut h); - assert!(e.is_null(), "{}", msg(e)); - assert!(h != 0, "moa handle must be non-zero"); - let model = get_model(h).expect("moa handle resolves"); - assert_eq!(model.provider(), "moa"); - assert_eq!(model.model_id(), "moa"); - } - - #[test] - fn moa_new_rejects_bad_aggregator() { - let refs = [mock_handle("mock", "ref-a", "A")]; - let mut h = zero_err(); - // aggregator handle 999999 does not exist. - let e = aimux_moa_new(refs.as_ptr(), refs.len(), 999_999, std::ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e), - "invalid or expired moa aggregator handle" - ); - } - - #[test] - fn composites_reject_any_dead_member_handle() { - // A dead handle inside the array is a caller bug: reported, never - // silently dropped (the composite would otherwise run with fewer - // members than the caller believes). - let live = mock_handle("mock", "live", "L"); - let dead = mock_handle("mock", "dead", "D"); - aimux_drop_handle(dead); - let handles = [live, dead]; - - let mut h = zero_err(); - let e = aimux_router_new(handles.as_ptr(), handles.len(), std::ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e), - "invalid or expired router child handle" - ); - - let agg = mock_handle("mock", "aggregator", "agg"); - let e = aimux_moa_new( - handles.as_ptr(), - handles.len(), - agg, - std::ptr::null(), - &mut h, - ); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e), - "invalid or expired moa reference handle" - ); - - // NULL with ref_len > 0 is a null pointer, same as router. - let e = aimux_moa_new(std::ptr::null(), 2, agg, std::ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!(expect_ffi_error(e), "reference_handles: must not be NULL"); - } - - #[test] - fn moa_new_allows_zero_references() { - // 0 references is valid (degrades to aggregator-only). - let agg = mock_handle("mock", "aggregator", "agg"); - let mut h = zero_err(); - let e = aimux_moa_new(std::ptr::null(), 0, agg, std::ptr::null(), &mut h); - assert!(e.is_null(), "{}", msg(e)); - assert!(h != 0, "moa with 0 references should succeed"); - } - - #[test] - fn router_new_rejects_empty_handles() { - // len == 0 (with or without a NULL array) is "no usable child", not a - // null pointer; a NULL array with len > 0 is. - let mut h = zero_err(); - let e = aimux_router_new(std::ptr::null(), 0, std::ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e), - "invalid or expired router child handle" - ); - let e = aimux_router_new(std::ptr::null(), 2, std::ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!(expect_ffi_error(e), "handles: must not be NULL"); - } - - #[test] - fn drop_handle_ptr_releases_the_handle_its_address_names() { - let handle = aimux_abort_signal_new(); - let other = aimux_abort_signal_new(); - assert!(get_handle(handle).is_some()); - - aimux_drop_handle_ptr(std::ptr::without_provenance_mut(handle as usize)); - assert!(get_handle(handle).is_none()); - assert!( - get_handle(other).is_some(), - "only the named handle is released" - ); - - // NULL is handle 0: a no-op, like aimux_drop_handle(0). - aimux_drop_handle_ptr(std::ptr::null_mut()); - aimux_drop_handle(other); - } - - struct MockStreamingTranscriber; - - #[async_trait::async_trait] - impl aimux_core::transcription_model::TranscriptionModel for MockStreamingTranscriber { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-stream-stt" - } - async fn do_generate( - &self, - _options: &aimux_core::transcription_model::TranscriptionCallOptions, - ) -> Result { - unimplemented!() - } - fn do_stream( - &self, - options: aimux_core::transcription_model::TranscriptionStreamOptions, - ) -> Option< - futures::future::BoxFuture< - '_, - Result, - >, - > { - Some(Box::pin(async move { - use aimux_core::transcription_model::{ - AudioChunk, TranscriptionStreamPart, TranscriptionStreamResult, - }; - use futures::StreamExt; - let mut audio = options.audio; - let abort = options.abort_signal.clone(); - let stream = async_stream::stream! { - yield Ok(TranscriptionStreamPart::StreamStart { warnings: vec![] }); - let mut text = String::new(); - loop { - let aborted = async { - match &abort { - Some(s) => s.cancelled().await, - None => std::future::pending().await, - } - }; - tokio::select! { - _ = aborted => { - yield Err(AiMuxError::Aborted("request aborted".into())); - break; - } - chunk = audio.next() => match chunk { - Some(AudioChunk::Binary(b)) => { - let s = format!("{} ", b.len()); - text.push_str(&s); - yield Ok(TranscriptionStreamPart::TranscriptDelta { - id: None, - delta: s, - provider_metadata: None, - }); - } - Some(AudioChunk::Base64(s)) => { - text.push_str(&s); - yield Ok(TranscriptionStreamPart::TranscriptDelta { - id: None, - delta: s, - provider_metadata: None, - }); - } - None => { - yield Ok(TranscriptionStreamPart::TranscriptFinal { - id: None, - text: text.clone(), - start_second: None, - end_second: None, - channel_index: None, - provider_metadata: None, - }); - yield Ok(TranscriptionStreamPart::Finish { - text, - segments: vec![], - language: None, - duration_in_seconds: None, - provider_metadata: None, - }); - break; - } - }, - } - } - }; - Ok(TranscriptionStreamResult { - stream: Box::pin(stream), - request: None, - response: None, - }) - })) - } - } - - fn mock_transcriber_handle() -> u64 { - intern_handle(HandleEntry::Transcription(Arc::new( - MockStreamingTranscriber, - ))) - } - - fn session(model: u64, abort: u64) -> u64 { - let mut s = zero_err(); - let e = aimux_transcription_session_new(model, abort, std::ptr::null(), &mut s); - assert!(e.is_null(), "{}", msg(e)); - assert!(s != 0, "session should start"); - s - } - - /// One `next_part` pull: `(state, part)`; a non-NULL error is returned - /// as `Err`. - fn pull(session: u64, timeout_ms: i64) -> Result<(i32, Option), *mut aimux_error_t> { - let mut part: *mut c_char = std::ptr::null_mut(); - let mut state: i32 = 0; - let e = aimux_transcription_next_part(session, timeout_ms, &mut part, &mut state); - if !e.is_null() { - assert!(part.is_null(), "failure must leave *out_part NULL"); - return Err(e); - } - Ok((state, take(part))) - } - - fn pull_part(session: u64) -> String { - match pull(session, 2_000) { - Ok((AIMUX_TRANSCRIPTION_NEXT_PART_PART, Some(p))) => p, - Ok(other) => panic!("expected a part, got {other:?}"), - Err(e) => panic!("expected a part, got error: {}", msg(e)), - } - } - - #[test] - fn transcription_session_full_lifecycle() { - let model = mock_transcriber_handle(); - let session = session(model, 0); - - // Push two chunks + end-of-input. - let chunk1 = [1u8, 2, 3]; - let chunk2 = [4u8, 5]; - assert!(aimux_transcription_push_audio(session, chunk1.as_ptr(), 3).is_null()); - assert!(aimux_transcription_push_audio(session, chunk2.as_ptr(), 2).is_null()); - assert!(aimux_transcription_input_done(session).is_null()); - - // Pull parts: StreamStart, delta("3 "), delta("2 "), Final, Finish, - // then ENDED. - let p1 = pull_part(session); - assert!(p1.contains("\"type\":\"stream-start\""), "part 1: {p1}"); - let p2 = pull_part(session); - assert!(p2.contains("3 "), "part 2: {p2}"); - let p3 = pull_part(session); - assert!(p3.contains("2 "), "part 3: {p3}"); - let p4 = pull_part(session); - assert!(p4.contains("\"type\":\"transcript-final\""), "part 4: {p4}"); - let p5 = pull_part(session); - assert!(p5.contains("\"type\":\"finish\""), "part 5: {p5}"); - assert!(p5.contains("3 2 "), "finish text: {p5}"); - - // Stream ended: NULL error, ENDED state, no part. - assert_eq!( - pull(session, 2_000).ok(), - Some((AIMUX_TRANSCRIPTION_NEXT_PART_ENDED, None)) - ); - - aimux_transcription_session_drop(session); - } - - #[test] - fn transcription_session_timeout_is_a_state_not_an_error() { - let model = mock_transcriber_handle(); - let session = session(model, 0); - // Consume StreamStart, then DON'T push audio: next_part times out - // (the mock waits for more audio). - let _ = pull_part(session); - assert_eq!( - pull(session, 50).ok(), - Some((AIMUX_TRANSCRIPTION_NEXT_PART_TIMEOUT, None)) - ); - - // Session still alive: push + finish works after the timeout. - let chunk = [9u8]; - assert!(aimux_transcription_push_audio(session, chunk.as_ptr(), 1).is_null()); - assert!(aimux_transcription_input_done(session).is_null()); - let p = pull_part(session); - assert!(p.contains("1 "), "post-timeout part: {p}"); - aimux_transcription_session_drop(session); - } - - #[test] - fn transcription_session_abort_via_signal() { - let model = mock_transcriber_handle(); - let abort = crate::aimux_abort_signal_new(); - let session = session(model, abort); - // Drain StreamStart. - let _ = pull_part(session); - - // Abort; the mock yields Err(Aborted). - crate::aimux_abort_signal_abort(abort); - let e = pull(session, 2_000).expect_err("aborted stream must fail"); - assert_eq!(expect_aimux_error(e).0, AIMUX_E_ABORTED); - - // Push after abort fails or is buffered (the session's channels are - // closing); either way no panic. - let chunk = [1u8]; - let e = aimux_transcription_push_audio(session, chunk.as_ptr(), 1); - aimux_error_free(e); - aimux_transcription_session_drop(session); - } - - #[test] - fn transcription_session_drop_is_safe_and_idempotent() { - let model = mock_transcriber_handle(); - let session = session(model, 0); - aimux_transcription_session_drop(session); - aimux_transcription_session_drop(session); // idempotent - aimux_transcription_session_drop(0); // safe with 0 - aimux_transcription_session_drop(999_999); // safe with unknown - - // Operations on a dropped session fail with invalid handle. - assert_eq!( - expect_ffi_error(aimux_transcription_push_audio(session, std::ptr::null(), 0)), - "invalid or expired transcription session handle" - ); - assert_eq!( - expect_ffi_error(aimux_transcription_input_done(session)), - "invalid or expired transcription session handle" - ); - let e = pull(session, 0).expect_err("dead session"); - assert_eq!( - expect_ffi_error(e), - "invalid or expired transcription session handle" - ); - } - - #[test] - fn transcription_session_drop_unblocks_infinite_next_part() { - let model = mock_transcriber_handle(); - let session = session(model, 0); - // Consume StreamStart; with no audio, the next pull waits forever - // unless session_drop aborts the driver and closes the parts stream. - let _ = pull_part(session); - - let (done_tx, done_rx) = std::sync::mpsc::channel(); - let puller = std::thread::spawn(move || { - if let Err(e) = pull(session, -1) { - aimux_error_free(e); - } - done_tx.send(()).unwrap(); - }); - std::thread::sleep(Duration::from_millis(50)); - assert!( - matches!( - done_rx.try_recv(), - Err(std::sync::mpsc::TryRecvError::Empty) - ), - "next_part(-1) should still be blocked before drop" - ); - - let started = std::time::Instant::now(); - aimux_transcription_session_drop(session); - assert!( - started.elapsed() < Duration::from_secs(2), - "session_drop should not wait for next_part" - ); - done_rx - .recv_timeout(Duration::from_secs(2)) - .expect("session_drop must wake next_part(-1)"); - puller.join().unwrap(); - drop_handle(model); - } - - #[test] - fn transcription_session_drop_unblocks_backpressured_push_audio() { - let model = mock_transcriber_handle(); - let session = session(model, 0); - let sent = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let sent_by_pusher = Arc::clone(&sent); - let (done_tx, done_rx) = std::sync::mpsc::channel(); - let pusher = std::thread::spawn(move || { - let chunk = [1u8]; - for _ in 0..1024 { - let e = aimux_transcription_push_audio(session, chunk.as_ptr(), chunk.len()); - if !e.is_null() { - aimux_error_free(e); - break; - } - sent_by_pusher.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - } - done_tx.send(()).unwrap(); - }); - - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while sent.load(std::sync::atomic::Ordering::Relaxed) < 64 - && std::time::Instant::now() < deadline - { - std::thread::yield_now(); - } - assert!( - sent.load(std::sync::atomic::Ordering::Relaxed) >= 64, - "pusher never filled the bounded audio path" - ); - std::thread::sleep(Duration::from_millis(50)); - assert!( - matches!( - done_rx.try_recv(), - Err(std::sync::mpsc::TryRecvError::Empty) - ), - "pusher unexpectedly completed without backpressure" - ); - - let started = std::time::Instant::now(); - aimux_transcription_session_drop(session); - assert!( - started.elapsed() < Duration::from_secs(2), - "session_drop should not wait for push_audio" - ); - done_rx - .recv_timeout(Duration::from_secs(2)) - .expect("session_drop must wake a backpressured push_audio"); - pusher.join().unwrap(); - drop_handle(model); - } - - #[test] - fn transcription_session_new_rejects_bad_inputs() { - let model = mock_transcriber_handle(); - let mut h = zero_err(); - // Bad JSON. - let bad = std::ffi::CString::new("{not json").unwrap(); - let e = aimux_transcription_session_new(model, 0, bad.as_ptr(), &mut h); - assert_eq!(h, 0); - assert!(expect_ffi_error(e).starts_with("opts_json: invalid JSON:")); - - // Non-transcription model handle. - let lang = mock_handle("mock", "m", "x"); - let e = aimux_transcription_session_new(lang, 0, std::ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e), - "invalid or expired transcription handle" - ); +// C callers must supply valid pointers under the ABI ownership contract. +#![allow(clippy::not_unsafe_ptr_arg_deref)] - // Bad abort handle. - let e = aimux_transcription_session_new(model, 999_999, std::ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!(expect_ffi_error(e), "invalid or expired abort handle"); +pub mod abi; - // NULL out-params on next_part. - let mut part: *mut c_char = std::ptr::null_mut(); - assert_eq!( - expect_ffi_error(aimux_transcription_next_part( - 0, - 0, - std::ptr::null_mut(), - std::ptr::null_mut() - )), - "out_part: must not be NULL" - ); - assert_eq!( - expect_ffi_error(aimux_transcription_next_part( - 0, - 0, - &mut part, - std::ptr::null_mut() - )), - "out_state: must not be NULL" - ); - } -} +pub use abi::*; diff --git a/aimux-ffi/src/transcription_session.rs b/aimux-ffi/src/transcription_session.rs deleted file mode 100644 index a40909b12..000000000 --- a/aimux-ffi/src/transcription_session.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! Transcription streaming sessions across the C ABI (RFC-0028 Phase 2). -//! -//! Bidirectional streaming cannot reuse the blocking callback pattern of -//! `aimux_stream_text` (the host must push audio while receiving parts), so -//! sessions are a dedicated wire shape: a session handle plus -//! push / input-done / next-part operations. -//! -//! ```text -//! session_new ──► spawn tokio task ──► stream_transcribe(model, audio_rx) -//! │ parts flow out -//! push_audio ──► bounded mpsc sender ▼ -//! input_done ──► drop sender (= end of audio) -//! next_part ◄── mpsc receiver + recv_timeout -//! session_drop ─► deregister → abort → bounded join -//! ``` -//! -//! Deadlock notes (RFC-0028 §4.2): -//! - `push_audio` BLOCKS while the audio channel is full (capacity 64) — -//! WebSocket backpressure propagates back to the host's capture loop. -//! - The driver task's forward loop selects on the abort token, so a full -//! parts channel (host stopped consuming) cannot wedge `session_drop`'s -//! join. -//! - `session_drop` removes the handle from the registry BEFORE joining, so -//! the global registry mutex is never held across the join. - -use std::sync::Arc; -use std::time::Duration; - -use futures::channel::mpsc; -use futures::{SinkExt, StreamExt}; - -use aimux_core::AbortSignal; -use aimux_core::error::AiMuxError; -use aimux_core::transcription_model::{ - AudioChunk, InputAudioFormat, TranscriptionModel, TranscriptionStreamOptions, - TranscriptionStreamPart, -}; - -use crate::runtime; - -/// Capacity of the audio-in channel (chunks). Bounds host memory when the -/// provider is slower than the capture loop (RFC-0028 Open Question 1). -const AUDIO_CHANNEL_CAPACITY: usize = 64; -/// Capacity of the parts-out channel. -const PARTS_CHANNEL_CAPACITY: usize = 256; -/// Upper bound for `session_drop`'s join. The abort token should end the -/// driver promptly; this is a belt-and-suspenders guard (detaches on expiry). -const JOIN_TIMEOUT: Duration = Duration::from_secs(5); - -/// FFI-side transcription stream options (parsed from `session_new`'s JSON). -#[derive(Default)] -pub struct SessionOptions { - pub input_audio_format: Option, - pub provider_options: Option, - pub headers: Option>, - pub include_raw_chunks: bool, - pub timeout: Option, -} - -/// A live transcription streaming session (FFI-side). -pub struct TranscriptionFfiSession { - /// Audio-in sender. `None` once `input_done` was called (or construction - /// failed). Mutex> so `push_audio` after `input_done` errors - /// rather than silently buffering. - audio_tx: std::sync::Mutex>>, - /// Parts-out receiver. Tokio mutex: held across the `recv` await. - parts_rx: tokio::sync::Mutex>>, - /// Cancels the driver task (fired by `session_drop`). - token: AbortSignal, - /// Driver join handle (taken by `session_drop`). - task: std::sync::Mutex>>, -} - -impl TranscriptionFfiSession { - /// Spawn the live-transcription operation and return the session. - /// - /// `user_abort` (optional) and the internal drop token are both linked - /// into the effective abort signal passed to the model: either firing - /// aborts the call. - pub fn spawn( - model: Arc, - opts: SessionOptions, - user_abort: Option, - ) -> Arc { - let (audio_tx, audio_rx) = mpsc::channel::(AUDIO_CHANNEL_CAPACITY); - let (mut parts_tx, parts_rx) = - mpsc::channel::>(PARTS_CHANNEL_CAPACITY); - - let token = AbortSignal::new(); - // Effective abort = user abort OR drop token. `AbortSignal` has no - // OR-composition, so link both by forwarding into one signal. - let effective = AbortSignal::new(); - for source in std::iter::once(token.clone()).chain(user_abort) { - let linked = effective.clone(); - runtime().spawn(async move { - source.cancelled().await; - linked.abort(); - }); - } - - let task = runtime().spawn(async move { - let options = TranscriptionStreamOptions { - audio: Box::pin(audio_rx), - input_audio_format: opts.input_audio_format.unwrap_or(InputAudioFormat { - format_type: "audio/pcm".to_string(), - rate: None, - }), - provider_options: opts.provider_options, - abort_signal: Some(effective.clone()), - headers: opts.headers, - include_raw_chunks: opts.include_raw_chunks, - timeout: opts.timeout, - }; - let result = - aimux_core::transcription_model::stream_transcribe(model.as_ref(), options).await; - match result { - Ok(stream_result) => { - let mut stream = stream_result.stream; - // Forward loop. Delivery is immediate when the channel has - // capacity (so the terminal Err(Aborted) part from the - // model is never preempted); the abort token only unblocks - // a FULL channel (host stopped consuming), which would - // otherwise wedge `session_drop`'s join (RFC-0028 §4.2 S5). - while let Some(part) = stream.next().await { - let mut part = part; - loop { - match parts_tx.try_send(part) { - Ok(()) => break, - Err(send_err) => { - if send_err.is_disconnected() { - // Receiver dropped (session dropped). - return; - } - // Channel full: wait for capacity or - // abort. - // NOTE: if abort fires while full, the model's terminal - // Err(Aborted) part may be dropped — a host that stopped - // consuming then sees "ended normally" rather than Aborted. - // Accepted trade-off (bounded memory beats exact terminal - // delivery under a 256-part backlog). (Sink::flush on futures mpsc - // maps Disconnected to Ok — never rely on - // it for receiver-loss detection.) - part = send_err.into_inner(); - tokio::select! { - _ = effective.cancelled() => return, - res = parts_tx.flush() => { - let _ = res; - } - } - } - } - } - } - } - Err(e) => { - // Connect/establishment failure: surface as the first - // (and only) channel item so next_part reports it. Select - // on abort so a full channel can't stall the drop join. - let mut err = Some(e); - loop { - let e = err.take().expect("err only None after successful send"); - match parts_tx.try_send(Err(e)) { - Ok(()) => break, - Err(send_err) => { - if send_err.is_disconnected() { - return; - } - // into_inner returns the unsent Result — the - // Err payload is the original error. - err = send_err.into_inner().err(); - tokio::select! { - _ = effective.cancelled() => return, - res = parts_tx.flush() => { - let _ = res; - } - } - } - } - } - } - } - }); - - Arc::new(Self { - audio_tx: std::sync::Mutex::new(Some(audio_tx)), - parts_rx: tokio::sync::Mutex::new(parts_rx), - token, - task: std::sync::Mutex::new(Some(task)), - }) - } - - /// Push one audio chunk. **Blocking**: waits while the bounded channel is - /// full (backpressure propagation). Fails if the session already ended - /// (task finished / channel closed) or `input_done` was called. - pub async fn push_audio(&self, bytes: Vec) -> Result<(), AiMuxError> { - // Clone the sender and drop the guard BEFORE awaiting (await-holding- - // lock). Cloning is benign: `input_done` drops the stored sender; an - // in-flight push finishes sending on its clone and the channel then - // closes with it — no chunk is lost. - let mut tx = { - let guard = self.audio_tx.lock().expect("session audio mutex poisoned"); - match guard.as_ref() { - None => { - return Err(AiMuxError::Other( - "transcription session audio input already finished".into(), - )); - } - Some(tx) => tx.clone(), - } - }; - tx.send(AudioChunk::Binary(bytes)) - .await - .map_err(|_| AiMuxError::Other("transcription session ended".into())) - } - - /// Signal end-of-audio (the audio stream yields `None`). Idempotent. - pub fn input_done(&self) { - // Dropping the sender closes the channel: the receiver stream ends. - self.audio_tx - .lock() - .expect("session audio mutex poisoned") - .take(); - } - - /// Receive the next part. - /// - /// - `Ok(Some(part))` — a part arrived. - /// - `Ok(None)` — the part channel closed: stream ended normally (the - /// driver exits after the forward loop; a terminal stream `Err` is - /// delivered as `Err(..)` before closing). - /// - `Err(Timeout)` — no part within `timeout`. - pub async fn next_part( - &self, - timeout: Option, - ) -> Result>, AiMuxError> { - let mut rx = self.parts_rx.lock().await; - let recv = rx.next(); - match timeout { - None => Ok(recv.await), - Some(d) => match tokio::time::timeout(d, recv).await { - Ok(part) => Ok(part), - Err(_) => Err(AiMuxError::Timeout( - "no transcription part within timeout".into(), - )), - }, - } - } - - /// Terminate: fire the abort token and join the driver (bounded). - /// The caller must have removed the handle from the registry already — - /// this must run without the registry mutex held. - pub fn terminate(&self) { - self.input_done(); - self.token.abort(); - let task = self - .task - .lock() - .expect("session task mutex poisoned") - .take(); - if let Some(task) = task { - // Bounded join via ffi_block_on (re-entrancy guard): on expiry or - // on a re-entrant call (drop from within an aimux callback — not - // allowed to block_on) the task is detached; the abort token and - // closed channels end it shortly after. - // The timeout future is constructed inside the async block: - // creating a tokio Sleep needs a reactor, which only exists once - // block_on polls it. - let joined = - crate::ffi_block_on(async { tokio::time::timeout(JOIN_TIMEOUT, task).await }); - if joined.is_err() { - tracing::warn!( - "aimux-ffi: transcription session join timed out or was re-entrant; detaching" - ); - } - } - } -} diff --git a/aimux-ffi/tests/abi_primitives.rs b/aimux-ffi/tests/abi_primitives.rs new file mode 100644 index 000000000..7b85b5fb2 --- /dev/null +++ b/aimux-ffi/tests/abi_primitives.rs @@ -0,0 +1,334 @@ +//! Calls the exported C symbols, including ownership and argument failures. +use std::ffi::{CStr, CString, c_char, c_void}; +use std::ptr; +use std::sync::Arc; + +use aimux_core::AbortSignal; +use aimux_ffi::{ + aimux_error_code, aimux_error_free, aimux_error_message, aimux_error_t, aimux_free_string, +}; +use aimux_ops::handle::{Entry, HandleId, HandleKind, HandleRegistry, Owner}; +use serde_json::Value; + +// Errors are opaque pointers: their Rust payload is never passed by value. +#[allow(improper_ctypes)] +unsafe extern "C" { + fn aimux_abi_version() -> u32; + fn aimux_manifest_json(out: *mut *mut c_char) -> *mut aimux_error_t; + fn aimux_call( + handle: u64, + op: *const c_char, + params: *const c_char, + abort: u64, + out: *mut *mut c_char, + ) -> *mut aimux_error_t; + fn aimux_stream( + handle: u64, + op: *const c_char, + params: *const c_char, + abort: u64, + callback: Option, + context: *mut c_void, + ) -> *mut aimux_error_t; + fn aimux_operation_push(operation: u64, data: *const u8, length: usize) -> *mut aimux_error_t; + fn aimux_operation_next_part( + operation: u64, + timeout: i64, + part: *mut *mut c_char, + state: *mut i32, + ) -> *mut aimux_error_t; + fn aimux_abort(handle: u64); + fn aimux_drop(handle: u64); + fn aimux_error_json(error: *const aimux_error_t) -> *mut c_char; +} + +unsafe fn take_string(value: *mut c_char) -> String { + assert!(!value.is_null()); + let text = unsafe { CStr::from_ptr(value) } + .to_str() + .unwrap() + .to_owned(); + unsafe { aimux_free_string(value) }; + text +} + +fn check_error(error: *mut aimux_error_t, code: i32, name: &str) { + assert!(!error.is_null()); + assert_eq!(aimux_error_code(error), code); + let json: Value = + serde_json::from_str(&unsafe { take_string(aimux_error_json(error)) }).unwrap(); + assert_eq!(json["code"], code); + assert_eq!(json["error"]["name"], name); + assert!(json.get("retryable").is_none()); + assert!(json.get("type").is_none()); + let first = aimux_error_message(error); + let second = aimux_error_message(error); + assert_ne!(first, second); + assert_eq!(unsafe { take_string(first) }, unsafe { + take_string(second) + }); + aimux_error_free(error); +} + +#[test] +fn manifest_and_null_safe_release() { + unsafe { + assert_eq!(aimux_abi_version(), 1 << 16); + let mut output = ptr::dangling_mut::(); + assert!(aimux_manifest_json(&mut output).is_null()); + let manifest: Value = serde_json::from_str(&take_string(output)).unwrap(); + assert_eq!(manifest["abiVersion"], aimux_abi_version()); + assert_eq!(manifest["opsVersion"], "1.0"); + check_error(aimux_manifest_json(ptr::null_mut()), 200, "NullPointer"); + assert_eq!(aimux_error_code(ptr::null()), 0); + assert!(aimux_error_message(ptr::null()).is_null()); + assert!(aimux_error_json(ptr::null()).is_null()); + aimux_error_free(ptr::null_mut()); + aimux_free_string(ptr::null_mut()); + aimux_drop(0); + aimux_drop(u64::MAX); + aimux_abort(0); + aimux_abort(u64::MAX); + } +} + +#[test] +fn call_validates_strings_and_clears_output() { + let op = CString::new("runtime.describe").unwrap(); + let params = CString::new("{}").unwrap(); + let invalid_json = CString::new("{").unwrap(); + let invalid_utf8 = [0xff_u8, 0]; + unsafe { + for (op_arg, params_arg, code, name) in [ + (ptr::null(), params.as_ptr(), 200, "NullPointer"), + (op.as_ptr(), ptr::null(), 200, "NullPointer"), + ( + invalid_utf8.as_ptr().cast(), + params.as_ptr(), + 201, + "InvalidUtf8", + ), + ( + op.as_ptr(), + invalid_utf8.as_ptr().cast(), + 201, + "InvalidUtf8", + ), + (op.as_ptr(), invalid_json.as_ptr(), 202, "InvalidWireJson"), + ] { + let mut output = ptr::dangling_mut::(); + check_error( + aimux_call(0, op_arg, params_arg, 0, &mut output), + code, + name, + ); + assert!(output.is_null()); + } + check_error( + aimux_call(0, op.as_ptr(), params.as_ptr(), 0, ptr::null_mut()), + 200, + "NullPointer", + ); + let mut output = ptr::dangling_mut::(); + check_error( + aimux_call(0, op.as_ptr(), params.as_ptr(), u64::MAX, &mut output), + 203, + "InvalidHandle", + ); + assert!(output.is_null()); + } +} + +#[test] +fn unknown_op_is_a_return_error() { + let op = CString::new("not.a.registered.op").unwrap(); + let params = CString::new("{}").unwrap(); + let mut output = ptr::dangling_mut::(); + unsafe { + check_error( + aimux_call(0, op.as_ptr(), params.as_ptr(), 0, &mut output), + 300, + "InvalidOp", + ); + } + assert!(output.is_null()); +} + +extern "C" fn count_callback(_: *const c_char, context: *mut c_void) { + unsafe { *context.cast::() += 1 }; +} + +#[test] +fn rejected_stream_has_no_callback() { + let op = CString::new("not.a.registered.op").unwrap(); + let params = CString::new("{}").unwrap(); + let mut count = 0_usize; + unsafe { + check_error( + aimux_stream( + 0, + op.as_ptr(), + params.as_ptr(), + 0, + Some(count_callback), + (&mut count as *mut usize).cast(), + ), + 300, + "InvalidOp", + ); + check_error( + aimux_stream(0, op.as_ptr(), params.as_ptr(), 0, None, ptr::null_mut()), + 200, + "NullPointer", + ); + } + assert_eq!(count, 0); +} + +#[test] +fn push_validates_before_reading_and_accepts_null_empty_input() { + unsafe { + check_error(aimux_operation_push(0, ptr::null(), 1), 200, "NullPointer"); + check_error( + aimux_operation_push(0, ptr::dangling(), usize::MAX), + 305, + "ResourceLimit", + ); + check_error( + aimux_operation_push(0, usize::MAX as *const u8, 1), + 305, + "ResourceLimit", + ); + check_error( + aimux_operation_push(0, ptr::dangling(), 1024 * 1024 + 1), + 305, + "ResourceLimit", + ); + // NULL+0 reaches handle validation rather than causing NullPointer/UB. + check_error( + aimux_operation_push(0, ptr::null(), 0), + 203, + "InvalidHandle", + ); + } +} + +#[test] +fn poll_clears_both_outputs_including_partial_null_arguments() { + unsafe { + let mut part = ptr::dangling_mut::(); + let mut state = 99; + check_error( + aimux_operation_next_part(0, 0, &mut part, &mut state), + 203, + "InvalidHandle", + ); + assert!(part.is_null()); + assert_eq!(state, 0); + state = 99; + check_error( + aimux_operation_next_part(0, 0, ptr::null_mut(), &mut state), + 200, + "NullPointer", + ); + assert_eq!(state, 0); + part = ptr::dangling_mut(); + check_error( + aimux_operation_next_part(0, 0, &mut part, ptr::null_mut()), + 200, + "NullPointer", + ); + assert!(part.is_null()); + } +} + +#[test] +fn abort_and_drop_use_the_c_owner() { + let registry = HandleRegistry::global(); + let signal = Arc::new(AbortSignal::new()); + let handle = registry + .insert(Owner::CProcess, Entry::Abort(signal.clone())) + .ok() + .unwrap(); + unsafe { + aimux_abort(handle.0); + aimux_abort(handle.0); + } + assert!(signal.is_aborted()); + unsafe { + aimux_drop(handle.0); + aimux_drop(handle.0); + } + assert!( + registry + .get(Owner::CProcess, handle, &[HandleKind::Abort]) + .is_err() + ); + let signal = Arc::new(AbortSignal::new()); + let owner = Owner::new_node().unwrap(); + let foreign = registry + .insert(owner, Entry::Abort(signal.clone())) + .ok() + .unwrap(); + unsafe { + aimux_abort(foreign.0); + aimux_drop(foreign.0); + } + assert!(!signal.is_aborted()); + assert!( + registry + .get(owner, HandleId(foreign.0), &[HandleKind::Abort]) + .is_ok() + ); + drop(registry.remove(owner, foreign)); +} + +#[tokio::test] +async fn async_caller_is_rejected_before_nested_runtime_entry() { + let op = CString::new("runtime.describe").unwrap(); + let params = CString::new("{}").unwrap(); + let mut output = ptr::dangling_mut::(); + unsafe { + check_error( + aimux_call(0, op.as_ptr(), params.as_ptr(), 0, &mut output), + 204, + "ReentrantCall", + ); + } + assert!(output.is_null()); +} + +#[test] +fn abort_argument_uses_the_secondary_owner_resolver() { + let registry = HandleRegistry::global(); + let owner = Owner::new_python().unwrap(); + let foreign = registry + .insert(owner, Entry::Abort(Arc::new(AbortSignal::new()))) + .unwrap(); + let wrong = registry + .insert( + Owner::CProcess, + Entry::Registry(Arc::new( + aimux_core::provider_registry::create_provider_registry( + Default::default(), + Default::default(), + ), + )), + ) + .unwrap(); + let mut envelopes = Vec::new(); + for abort in [foreign.0, u64::MAX, wrong.0] { + let mut output = ptr::dangling_mut(); + let error = + unsafe { aimux_call(0, c"not.an.op".as_ptr(), c"{}".as_ptr(), abort, &mut output) }; + assert!(output.is_null()); + assert_eq!(aimux_error_code(error), 203); + let json = unsafe { take_string(aimux_error_json(error)) }; + envelopes.push(serde_json::from_str::(&json).unwrap()); + aimux_error_free(error); + } + assert_eq!(envelopes[0], envelopes[1]); + assert_eq!(envelopes[2]["error"]["actual"], "Registry"); + drop(registry.remove(owner, foreign)); + unsafe { aimux_drop(wrong.0) }; +} diff --git a/aimux-ffi/tests/c/primitives.c b/aimux-ffi/tests/c/primitives.c new file mode 100644 index 000000000..50bcf28b4 --- /dev/null +++ b/aimux-ffi/tests/c/primitives.c @@ -0,0 +1,182 @@ +#define _POSIX_C_SOURCE 200809L +#include "test_support.h" +#include +#include +#include + +static char sentinel; +static void rejection_callback(const char *frame, void *context) { + (void)frame; + ++*(unsigned *)context; +} + +static void invalid_inputs(void) { + const char invalid_utf8[] = {(char)0xff, 0}; + char *out = &sentinel; + check_error(aimux_manifest_json(NULL), 200); + check_error(aimux_call(0, "runtime.describe", "{}", 0, NULL), 200); + check_error(aimux_call(0, NULL, "{}", 0, &out), 200); + assert(out == NULL); + out = &sentinel; + check_error(aimux_call(0, "runtime.describe", NULL, 0, &out), 200); + assert(out == NULL); + out = &sentinel; + check_error(aimux_call(0, invalid_utf8, "{}", 0, &out), 201); + assert(out == NULL); + out = &sentinel; + check_error(aimux_call(0, "runtime.describe", invalid_utf8, 0, &out), 201); + assert(out == NULL); + out = &sentinel; + check_error(aimux_call(0, "runtime.describe", "{", 0, &out), 202); + assert(out == NULL); + out = &sentinel; + check_error(aimux_call(0, "not.an.op", "{}", 0, &out), 300); + assert(out == NULL); + + int32_t state = 99; + out = &sentinel; + check_error(aimux_operation_next_part(UINT64_MAX, 0, &out, &state), 203); + assert(out == NULL && state == 0); + state = 99; + check_error(aimux_operation_next_part(UINT64_MAX, 0, NULL, &state), 200); + assert(state == 0); + out = &sentinel; + check_error(aimux_operation_next_part(UINT64_MAX, 0, &out, NULL), 200); + assert(out == NULL); + check_error(aimux_operation_push(UINT64_MAX, NULL, 1), 200); + /* Empty input is valid at the pointer boundary, then fails handle lookup. */ + check_error(aimux_operation_push(UINT64_MAX, NULL, 0), 203); + uint8_t byte = 0; + aimux_error_t *error = aimux_operation_push(UINT64_MAX, &byte, SIZE_MAX); + assert(error != NULL); + /* Rejection may happen at length validation or handle lookup, never a read. */ + int32_t code = aimux_error_code(error); + assert(code == 203 || code == 301 || code == 305); + check_error(error, code); + + unsigned callbacks = 0; + check_error(aimux_stream(UINT64_MAX, "text.stream", "{}", 0, + rejection_callback, &callbacks), 203); + assert(callbacks == 0); + check_error(aimux_stream(UINT64_MAX, "text.stream", "{}", 0, NULL, NULL), 200); +} + +static void wrong_kind(void) { + uint64_t handle = create_handle("abort.create", 0, "{}"); + const char *ops[] = {"model.info", "provider.model", "registry.provider", + "text.generate", "embedding.embed", "operation.status"}; + for (size_t i = 0; i < sizeof ops / sizeof *ops; ++i) { + char *out = &sentinel; + aimux_error_t *error = aimux_call(handle, ops[i], "{}", 0, &out); + assert(error != NULL); + char *envelope = aimux_error_json(error); + assert(envelope != NULL); + assert(strstr(envelope, "\"expected\"") != NULL); + assert(strstr(envelope, "\"actual\"") != NULL); + aimux_free_string(envelope); + check_error(error, 203); + assert(out == NULL); + } + aimux_abort(handle); + aimux_drop(handle); + aimux_drop(handle); + char *out = &sentinel; + check_error(aimux_call(handle, "abort.signal", "{}", 0, &out), 203); + assert(out == NULL); +} + +struct race { uint64_t handle; atomic_bool start; }; +static void *drop_thread(void *arg) { + struct race *race = arg; + while (!atomic_load(&race->start)) { } + aimux_drop(race->handle); + return NULL; +} +static void drop_races(void) { + for (unsigned i = 0; i < 100; ++i) { + struct race race = {create_handle("abort.create", 0, "{}"), ATOMIC_VAR_INIT(0)}; + pthread_t thread; + assert(pthread_create(&thread, NULL, drop_thread, &race) == 0); + atomic_store(&race.start, 1); + char *out = &sentinel; + aimux_error_t *error = aimux_call(race.handle, "abort.signal", "{}", 0, &out); + if (error) { check_error(error, 203); assert(out == NULL); } + else { assert(out != NULL); aimux_free_string(out); } + aimux_abort(race.handle); + aimux_drop(race.handle); + assert(pthread_join(thread, NULL) == 0); + } +} + +struct stream_context { + uint64_t abort_handle; + char *last_frame; + unsigned frames, ends; + int returned; +}; +static void copy_frame(const char *frame, void *arg) { + struct stream_context *ctx = arg; + assert(!ctx->returned && frame != NULL && ctx->ends == 0); + free(ctx->last_frame); + ctx->last_frame = strdup(frame); + assert(ctx->last_frame != NULL); + ++ctx->frames; + /* Match compact or spaced JSON without retaining a borrowed pointer. */ + const char *type = strstr(frame, "\"type\""); + assert(type != NULL); + const char *value = strchr(type + 6, ':'); + assert(value != NULL); + ++value; + while (*value == ' ' || *value == '\n' || *value == '\r' || *value == '\t') ++value; + if (strncmp(value, "\"end\"", 5) == 0) ++ctx->ends; + aimux_abort(ctx->abort_handle); /* specifically permitted from callback */ +} +static void stream_lifetime(void) { + const char *provider = getenv("AIMUX_C_TEST_PROVIDER_PARAMS"); + const char *model = getenv("AIMUX_C_TEST_MODEL_PARAMS"); + const char *request = getenv("AIMUX_C_TEST_STREAM_PARAMS"); + if (!provider || !model || !request) { + puts("SKIP accepted stream lifetime: set AIMUX_C_TEST_{PROVIDER,MODEL,STREAM}_PARAMS"); + return; + } + uint64_t p = create_handle("provider.create", 0, provider); + uint64_t m = create_handle("provider.model", p, model); + struct stream_context ctx = {create_handle("abort.create", 0, "{}"), NULL, 0, 0, 0}; + aimux_error_t *error = aimux_stream(m, "text.stream", request, ctx.abort_handle, copy_frame, &ctx); + if (error) { + char *message = aimux_error_message(error); + fprintf(stderr, "stream rejected: %s\n", message); + aimux_free_string(message); + aimux_error_free(error); + abort(); + } + ctx.returned = 1; + assert(ctx.frames > 0 && ctx.ends == 1 && ctx.last_frame != NULL); + unsigned frames = ctx.frames; + struct timespec delay = {0, 50000000}; + nanosleep(&delay, NULL); + assert(ctx.frames == frames); + free(ctx.last_frame); + aimux_drop(ctx.abort_handle); + aimux_drop(m); + aimux_drop(p); + puts("PASS accepted stream lifetime / callback abort / one terminal"); +} +int main(void) { + assert(aimux_abi_version() == (1u << 16)); + char *manifest = &sentinel; + assert(aimux_manifest_json(&manifest) == NULL); + assert(manifest != NULL && strstr(manifest, "opsVersion") != NULL); + aimux_free_string(manifest); + aimux_free_string(NULL); + aimux_drop(0); + aimux_handle_finalizer(NULL); + invalid_inputs(); + puts("PASS C11 manifest / errors / sentinels / UTF-8 / JSON / size boundaries"); + fflush(stdout); + wrong_kind(); + drop_races(); + stream_lifetime(); + puts("PASS C11 primitives / errors / sentinels / wrong kinds / 100 drop races"); + return 0; +} diff --git a/aimux-ffi/tests/c/raii.cpp b/aimux-ffi/tests/c/raii.cpp new file mode 100644 index 000000000..7d24b8226 --- /dev/null +++ b/aimux-ffi/tests/c/raii.cpp @@ -0,0 +1,50 @@ +#include "test_support.h" +#include "aimux_raii.hpp" +#include +#include + +int main() { + static_assert(!std::is_copy_constructible::value, "unique handle ownership"); + static_assert(std::is_nothrow_move_constructible::value, "noexcept move"); + static_assert(noexcept(aimux_cpp_event(nullptr, nullptr)), "callback cannot unwind"); + char *raw = nullptr; + aimux::check(aimux_manifest_json(&raw)); + aimux::String manifest(raw); + assert(manifest && std::strstr(manifest.get(), "opsVersion")); + raw = nullptr; + aimux::Error error(aimux_call(0, "not.an.op", "{}", 0, &raw)); + assert(raw == nullptr); + check_error(error.release(), 300); + bool caught = false; + try { aimux::check(aimux_call(0, "not.an.op", "{}", 0, &raw)); } + catch (const std::runtime_error &) { caught = true; } + assert(caught); + + aimux::Handle first(create_handle("abort.create", 0, "{}")); + auto id = first.get(); + aimux::Handle second(std::move(first)); + assert(first.get() == 0 && second.get() == id); + aimux::Handle third(create_handle("abort.create", 0, "{}")); + auto old = third.get(); + third = std::move(second); + assert(second.get() == 0 && third.get() == id); + raw = nullptr; + check_error(aimux_call(old, "abort.signal", "{}", 0, &raw), 203); + assert(raw == nullptr); + + unsigned deliveries = 0; + aimux::StreamContext ctx{[&deliveries](const std::string &) { + ++deliveries; + throw std::runtime_error("consumer failure"); + }, third.get(), nullptr}; + aimux_cpp_event("{\"type\":\"event\",\"seq\":\"1\",\"event\":{}}", &ctx); + assert(ctx.failure && deliveries == 1); + aimux_cpp_event("{\"type\":\"end\",\"seq\":\"2\",\"outcome\":\"cancelled\"}", &ctx); + assert(deliveries == 1); + caught = false; + try { std::rethrow_exception(ctx.failure); } + catch (const std::runtime_error &e) { caught = std::string(e.what()) == "consumer failure"; } + assert(caught); + puts("PASS C++17 RAII / moves / exception latch (direct trampoline test)"); + return 0; +} diff --git a/aimux-ffi/tests/c/test_support.h b/aimux-ffi/tests/c/test_support.h new file mode 100644 index 000000000..213d076e2 --- /dev/null +++ b/aimux-ffi/tests/c/test_support.h @@ -0,0 +1,49 @@ +#ifndef AIMUX_C_TEST_SUPPORT_H +#define AIMUX_C_TEST_SUPPORT_H +#include "aimux.h" +#include +#include +#include +#include +#include + +/* All getter results are owned, even while the error remains alive. */ +static void check_error(aimux_error_t *error, int32_t expected) { + assert(error != NULL); + assert(aimux_error_code(error) == expected); + char *message = aimux_error_message(error); + char *json = aimux_error_json(error); + assert(message != NULL && json != NULL); + assert(strstr(json, "\"kind\"") != NULL); + assert(strstr(json, "\"error\"") != NULL); + aimux_free_string(message); + aimux_free_string(json); + aimux_error_free(error); +} + +static uint64_t create_handle(const char *op, uint64_t parent, const char *params) { + char *json = NULL; + aimux_error_t *error = aimux_call(parent, op, params, 0, &json); + if (error) { + char *message = aimux_error_message(error); + fprintf(stderr, "%s: %s\n", op, message ? message : "(no message)"); + aimux_free_string(message); + aimux_error_free(error); + abort(); + } + assert(json != NULL); + /* Handle-producing control DTOs return {"handle":""}. */ + const char *value = strstr(json, "\"handle\""); + assert(value != NULL); + value = strchr(value + 8, ':'); + assert(value != NULL); + ++value; + while (*value == ' ' || *value == '\n' || *value == '\r' || *value == '\t') ++value; + assert(*value++ == '"'); + char *end = NULL; + uint64_t handle = strtoull(value, &end, 10); + assert(end != value && *end == '"' && handle != 0); + aimux_free_string(json); + return handle; +} +#endif diff --git a/aimux-ffi/tests/common/mod.rs b/aimux-ffi/tests/common/mod.rs deleted file mode 100644 index 81777a24d..000000000 --- a/aimux-ffi/tests/common/mod.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! Shared helpers for the aimux-ffi integration tests: read and release the -//! `aimux_error_t` a failed call returns. -#![allow(dead_code)] - -use std::ffi::{CStr, CString}; -use std::os::raw::c_char; - -use aimux_ffi::{ - AIMUX_E_FFI_CALLBACK_FAILURE, AIMUX_E_FFI_NULL_POINTER, AIMUX_E_LOAD_SETTING, AIMUX_E_OTHER, - AIMUX_E_RECORDING_INIT, AIMUX_E_RECORDING_WRITE, aimux_error_code, aimux_error_free, - aimux_error_message, aimux_error_t, aimux_free_string, -}; - -pub fn c(s: &str) -> CString { - CString::new(s).expect("no interior NUL in test literals") -} - -/// Own the string a getter returns; empty for NULL. -pub fn take(p: *mut c_char) -> String { - if p.is_null() { - return String::new(); - } - // SAFETY: an owned NUL-terminated C string from an aimux getter. - let s = unsafe { CStr::from_ptr(p) } - .to_str() - .unwrap_or("") - .to_string(); - unsafe { aimux_free_string(p) }; - s -} - -/// Message of a returned error, which is then freed. -pub fn msg(e: *mut aimux_error_t) -> String { - assert!(!e.is_null(), "expected a returned error"); - let m = take(aimux_error_message(e)); - aimux_error_free(e); - m -} - -/// Assert success (NULL), printing the message otherwise. -pub fn ok(e: *mut aimux_error_t, name: &str) { - if !e.is_null() { - panic!("{name}: expected success, got: {}", msg(e)); - } -} - -/// AiMuxError code and message; the returned error is freed. -pub fn expect_aimux_error(e: *mut aimux_error_t, name: &str) -> (i32, String) { - assert!(!e.is_null(), "{name}: expected a returned error"); - let code = aimux_error_code(e); - if !(AIMUX_E_OTHER..=AIMUX_E_LOAD_SETTING).contains(&code) { - panic!( - "{name}: expected an AiMuxError code, got {code}: {}", - msg(e) - ); - } - let out = (code, take(aimux_error_message(e))); - assert!(!out.1.is_empty(), "{name}: expected a message"); - aimux_error_free(e); - out -} - -/// RecordingError code and message; the returned error is freed. -pub fn expect_recording_error(e: *mut aimux_error_t, name: &str) -> (i32, String) { - assert!(!e.is_null(), "{name}: expected a returned error"); - let code = aimux_error_code(e); - if !(AIMUX_E_RECORDING_INIT..=AIMUX_E_RECORDING_WRITE).contains(&code) { - panic!( - "{name}: expected a RecordingError code, got {code}: {}", - msg(e) - ); - } - let out = (code, take(aimux_error_message(e))); - assert!(!out.1.is_empty(), "{name}: expected a message"); - aimux_error_free(e); - out -} - -/// C ABI failure code and non-empty message; the returned error is freed. -pub fn expect_ffi_error(e: *mut aimux_error_t, name: &str) -> String { - assert!(!e.is_null(), "{name}: expected a returned error"); - let code = aimux_error_code(e); - assert!( - (AIMUX_E_FFI_NULL_POINTER..=AIMUX_E_FFI_CALLBACK_FAILURE).contains(&code), - "{name}: expected a C ABI failure code, got {code}" - ); - let m = msg(e); - assert!(!m.is_empty(), "{name}: expected a message"); - m -} - -/// Any failure with a non-empty message; the returned error is freed. -pub fn expect_failure(e: *mut aimux_error_t, name: &str) -> String { - assert!(!e.is_null(), "{name}: expected a returned error"); - let m = msg(e); - assert!(!m.is_empty(), "{name}: expected a message"); - m -} diff --git a/aimux-ffi/tests/decision_test.rs b/aimux-ffi/tests/decision_test.rs deleted file mode 100644 index 2aa154f75..000000000 --- a/aimux-ffi/tests/decision_test.rs +++ /dev/null @@ -1,163 +0,0 @@ -mod common; -use aimux_ffi::*; -use common::*; -use std::io::{BufRead, Read, Write}; -use std::net::TcpListener; -use std::ptr; - -#[test] -fn decision_handles_json_errors_and_abort_follow_c_abi_contract() { - let key = c("test-key"); - let model = c("jev-latest"); - let endpoint = c("http://127.0.0.1:1/v1/systemone"); - let mut handle = 0; - ok( - aimux_jev_decision_new(key.as_ptr(), model.as_ptr(), endpoint.as_ptr(), &mut handle), - "create decision model", - ); - assert_ne!(handle, 0); - let mut out = ptr::null_mut(); - let request = - c(r#"{"state":"text","questions":{"q":{"type":"boolean","instructions":"test"}}}"#); - let abort = aimux_abort_signal_new(); - aimux_abort_signal_abort(abort); - let (code, _) = expect_aimux_error( - aimux_decide_with_abort(handle, request.as_ptr(), abort, &mut out), - "aborted decision", - ); - assert_eq!(code, AIMUX_E_ABORTED); - assert!(out.is_null()); - let malformed = c("{"); - expect_ffi_error( - aimux_decide(handle, malformed.as_ptr(), &mut out), - "malformed JSON", - ); - expect_ffi_error( - aimux_decide(abort, request.as_ptr(), &mut out), - "wrong handle type", - ); - aimux_drop_handle(handle); - expect_ffi_error( - aimux_decide(handle, request.as_ptr(), &mut out), - "dropped decision", - ); - aimux_abort_signal_drop(abort); -} - -#[test] -fn probability_source_survives_c_abi_round_trip() { - for source in ["native", "logit_scoring", "model_estimate"] { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let endpoint = c(&format!( - "http://{}/v1/systemone", - listener.local_addr().unwrap() - )); - let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - stream - .set_read_timeout(Some(std::time::Duration::from_secs(2))) - .unwrap(); - let mut reader = std::io::BufReader::new(&stream); - let mut size = 0; - loop { - let mut line = String::new(); - assert_ne!(reader.read_line(&mut line).unwrap(), 0); - if line == "\r\n" { - break; - } - if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { - size = value.trim().parse::().unwrap(); - } - } - reader.read_exact(&mut vec![0; size]).unwrap(); - let body = r#"{"model":"jev-latest","answers":{"q":{"type":"noul","noul":0.89}},"usage":{"input_tokens":10,"output_tokens":1}}"#; - write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); - }); - let mut handle = 0; - ok( - aimux_jev_decision_new_with_probability_source( - c("test-key").as_ptr(), - c("jev-latest").as_ptr(), - endpoint.as_ptr(), - c(source).as_ptr(), - &mut handle, - ), - "create with provenance", - ); - let request = c( - r#"{"state":"text","questions":{"q":{"type":"boolean","instructions":"test"}},"maxRetries":0,"timeout":{"totalMs":2000}}"#, - ); - let mut out = ptr::null_mut(); - ok( - aimux_decide(handle, request.as_ptr(), &mut out), - "decide with provenance", - ); - let result: serde_json::Value = serde_json::from_str(&take(out)).unwrap(); - assert_eq!( - result["providerMetadata"]["jev"]["probabilitySource"], - source - ); - assert_eq!( - result["answers"]["q"], - serde_json::json!({"type": "boolean", "probability": 0.89}) - ); - assert_eq!(result["usage"]["totalTokens"], 11); - aimux_drop_handle(handle); - server.join().unwrap(); - } - let mut handle = 0; - let (code, _) = expect_aimux_error( - aimux_jev_decision_new_with_probability_source( - c("test-key").as_ptr(), - c("jev-latest").as_ptr(), - ptr::null(), - c("unknown").as_ptr(), - &mut handle, - ), - "unknown provenance", - ); - assert_eq!(code, AIMUX_E_INVALID_ARGUMENT); - assert_eq!(handle, 0); -} - -#[test] -fn decision_model_description_is_local_and_validates_handle_lifecycle() { - let mut handle = 0; - ok( - aimux_jev_decision_new( - c("test").as_ptr(), - c("jev-latest").as_ptr(), - ptr::null(), - &mut handle, - ), - "create", - ); - let mut out = ptr::null_mut(); - ok( - aimux_decision_capabilities(handle, &mut out), - "capabilities", - ); - // The DecisionModelV4 properties (`@ai-sdk/provider@4.0.22 - // src/decision-model/v4/decision-model-v4.ts:6-16`). - let description: serde_json::Value = serde_json::from_str(&take(out)).unwrap(); - assert_eq!( - description, - serde_json::json!({ - "specificationVersion": "v4", - "provider": "jev", - "modelId": "jev-latest", - "supportedQuestionTypes": ["choice", "score", "boolean"] - }) - ); - aimux_drop_handle(handle); - out = ptr::null_mut(); - expect_ffi_error( - aimux_decision_capabilities(handle, &mut out), - "dropped handle", - ); - assert!(out.is_null()); - let abort = aimux_abort_signal_new(); - expect_ffi_error(aimux_decision_capabilities(abort, &mut out), "wrong handle"); - assert!(out.is_null()); - aimux_abort_signal_drop(abort); -} diff --git a/aimux-ffi/tests/error_detail_test.rs b/aimux-ffi/tests/error_detail_test.rs deleted file mode 100644 index 0812486dd..000000000 --- a/aimux-ffi/tests/error_detail_test.rs +++ /dev/null @@ -1,479 +0,0 @@ -//! Regression tests for issue #17: FFI constructors and prompt parsing must -//! preserve detailed error information (`aimux_error_t` transport). -//! -//! All tests are offline: they only exercise argument parsing and config -//! construction, never the network. - -mod common; - -use std::ffi::CString; -use std::os::raw::c_char; -use std::ptr; -use std::sync::atomic::{AtomicUsize, Ordering}; - -use aimux_ffi::{ - AIMUX_E_INVALID_ARGUMENT, AIMUX_E_NO_SUCH_PROVIDER, aimux_abort_signal_abort, - aimux_abort_signal_drop, aimux_abort_signal_new, aimux_azure_new, aimux_cohere_reranking_new, - aimux_consume_stream_text, aimux_drop_handle, aimux_error_t, aimux_generate_object, - aimux_generate_text, aimux_google_image_new, aimux_google_video_new, aimux_init_proxy, - aimux_moa_new, aimux_openai_embedding_new, aimux_openai_files_new, aimux_openai_image_new, - aimux_openai_new, aimux_openai_speech_new, aimux_openai_transcription_new, aimux_provider_new, - aimux_register_providers, aimux_router_new, aimux_stream_text, aimux_tavily_search_new, -}; -use common::{c, expect_aimux_error, expect_ffi_error, ok}; - -fn valid_handle() -> u64 { - let mut h = 0; - ok( - aimux_openai_new( - c("sk-test-fake-key").as_ptr(), - c("gpt-4o-mini").as_ptr(), - &mut h, - ), - "openai_new", - ); - assert_ne!(h, 0); - h -} - -// ── Problem A: prompt_json serde detail ───────────────────────────────────── - -#[test] -fn generate_text_invalid_prompt_json_carries_serde_detail() { - let h = valid_handle(); - let mut out: *mut c_char = ptr::null_mut(); - let e = aimux_generate_text(h, c("hello").as_ptr(), ptr::null(), &mut out); - assert!(out.is_null(), "failure leaves *out_json NULL"); - // Malformed JSON text is this layer's finding, not an AiMuxError — a C - // caller must not go looking at provider responses (JsonParse) for it. - let m = expect_ffi_error(e, "generate_text"); - assert!( - m.starts_with("prompt_json: invalid JSON:"), - "expected serde detail in message, got: {m}" - ); - aimux_drop_handle(h); -} - -/// Wire-vs-schema split: text that does not parse is a C ABI failure; -/// text that parses but is not a prompt is AiMuxError::InvalidArgument. -#[test] -fn generate_text_prompt_json_wire_vs_schema() { - let h = valid_handle(); - let mut out: *mut c_char = ptr::null_mut(); - let e = aimux_generate_text(h, c("{not json").as_ptr(), ptr::null(), &mut out); - assert!(expect_ffi_error(e, "wire").starts_with("prompt_json: invalid JSON:")); - // Well-formed JSON of the wrong shape (a number is not a prompt). - let e = aimux_generate_text(h, c("42").as_ptr(), ptr::null(), &mut out); - let (code, m) = expect_aimux_error(e, "schema"); - assert_eq!(code, AIMUX_E_INVALID_ARGUMENT); - assert!(m.contains("prompt_json:"), "{m}"); - assert!(out.is_null()); - aimux_drop_handle(h); -} - -/// RFC-0042 §7.2 at the C boundary: a prompt part with an unknown `type` is -/// InvalidArgument naming the type, not "data did not match any variant of -/// untagged enum ModelPrompt". -#[test] -fn generate_text_unknown_part_type_is_named() { - let h = valid_handle(); - let mut out: *mut c_char = ptr::null_mut(); - for prompt in [ - r#"[{"role":"user","content":[{"type":"no-such-part","text":"hi"}]}]"#, - r#"{"prompt":[{"role":"user","content":[{"type":"no-such-part","text":"hi"}]}]}"#, - ] { - let e = aimux_generate_text(h, c(prompt).as_ptr(), ptr::null(), &mut out); - let (code, m) = expect_aimux_error(e, prompt); - assert_eq!(code, AIMUX_E_INVALID_ARGUMENT); - assert!( - m.contains("prompt_json:") && m.contains("unknown variant `no-such-part`"), - "{m}" - ); - assert!(out.is_null()); - } - aimux_drop_handle(h); -} - -#[test] -fn generate_text_null_prompt_json_fails() { - let h = valid_handle(); - let mut out: *mut c_char = ptr::null_mut(); - let e = aimux_generate_text(h, ptr::null(), ptr::null(), &mut out); - assert_eq!( - expect_ffi_error(e, "generate_text"), - "prompt_json: must not be NULL" - ); - aimux_drop_handle(h); -} - -/// Options are camelCase JSON; a key this version does not read (aimux 0.5's -/// `max_retries`, a nested `total_ms`, a typo) fails the call with -/// InvalidArgument naming it, before any request. -#[test] -fn generate_text_rejects_unknown_option_keys() { - let h = valid_handle(); - let mut out: *mut c_char = ptr::null_mut(); - for (opts, key) in [ - (r#"{"max_retries":0}"#, "max_retries"), - (r#"{"timeout":{"total_ms":1000}}"#, "total_ms"), - (r#"{"maxRetries":0,"temprature":0.2}"#, "temprature"), - ] { - let e = aimux_generate_text(h, c("\"hi\"").as_ptr(), c(opts).as_ptr(), &mut out); - let (code, m) = expect_aimux_error(e, opts); - assert_eq!(code, AIMUX_E_INVALID_ARGUMENT, "{opts}"); - assert!( - m.contains("opts_json:") && m.contains(&format!("unknown field `{key}`")), - "{opts}: {m}" - ); - assert!(out.is_null()); - } - aimux_drop_handle(h); -} - -/// A NULL out-parameter is reported before anything else runs. -#[test] -fn null_out_json_is_an_ffi_error() { - let h = valid_handle(); - let e = aimux_generate_text(h, c("\"hi\"").as_ptr(), ptr::null(), ptr::null_mut()); - assert_eq!( - expect_ffi_error(e, "generate_text"), - "out_json: must not be NULL" - ); - let e = aimux_openai_new(c("k").as_ptr(), c("m").as_ptr(), ptr::null_mut()); - assert_eq!( - expect_ffi_error(e, "openai_new"), - "out_handle: must not be NULL" - ); - aimux_drop_handle(h); -} - -// ── Stream polarity + abort ───────────────────────────────────────────────── - -static PART_COUNT: AtomicUsize = AtomicUsize::new(0); - -extern "C-unwind" fn on_part(_json: *const c_char, _ctx: *mut std::ffi::c_void) { - PART_COUNT.fetch_add(1, Ordering::SeqCst); -} -extern "C-unwind" fn on_done(_ctx: *mut std::ffi::c_void) { - // not used in failure tests -} - -#[test] -fn stream_text_invalid_prompt_returns_error() { - let h = valid_handle(); - let e = aimux_stream_text( - h, - c("not-json").as_ptr(), - ptr::null(), - Some(on_part), - Some(on_done), - ptr::null_mut(), - ); - assert!(expect_ffi_error(e, "stream_text").starts_with("prompt_json: invalid JSON:")); - aimux_drop_handle(h); -} - -// ── Constructors ──────────────────────────────────────────────────────────── - -#[test] -fn unknown_provider_fills_unknown_provider_code() { - let mut h = 0; - let e = aimux_provider_new( - c("not-a-real-provider-xyz").as_ptr(), - c("sk-x").as_ptr(), - c("model").as_ptr(), - ptr::null(), - &mut h, - ); - assert_eq!(h, 0); - assert_eq!( - expect_aimux_error(e, "provider_new").0, - AIMUX_E_NO_SUCH_PROVIDER - ); -} - -#[test] -fn null_openai_args_are_ffi_errors() { - let mut h = 0; - let e = aimux_openai_new(ptr::null(), ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e, "openai_new"), - "api_key: must not be NULL" - ); -} - -#[test] -fn invalid_config_json_reports_error() { - let mut h = 0; - let e = aimux_provider_new( - c("openai").as_ptr(), - c("sk-x").as_ptr(), - c("gpt-4o").as_ptr(), - c("{not json").as_ptr(), - &mut h, - ); - assert_eq!(h, 0); - assert!(expect_ffi_error(e, "provider_new").starts_with("config_json: invalid JSON:")); -} - -#[test] -fn azure_null_deployment_is_an_ffi_error() { - let mut h = 0; - let e = aimux_azure_new( - c("sk").as_ptr(), - c("res").as_ptr(), - ptr::null(), - ptr::null(), - &mut h, - ); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e, "azure_new"), - "deployment: must not be NULL" - ); -} - -/// `register_providers`: malformed text is a C ABI failure; a well-formed -/// document the registry rejects is AiMuxError::InvalidArgument. -#[test] -fn register_providers_wire_vs_schema() { - let e = aimux_register_providers(c("{not json").as_ptr()); - assert!( - expect_ffi_error(e, "register_providers (wire)").starts_with("config_json: invalid JSON:") - ); - let e = aimux_register_providers(c(r#"{"providers": 42}"#).as_ptr()); - let (code, m) = expect_aimux_error(e, "register_providers (schema)"); - assert_eq!(code, AIMUX_E_INVALID_ARGUMENT); - assert!(m.contains("config_json:"), "{m}"); -} - -// Keep abort helpers linked (smoke). -#[test] -fn abort_signal_new_drop_smoke() { - let a = aimux_abort_signal_new(); - assert_ne!(a, 0); - aimux_abort_signal_abort(a); - aimux_abort_signal_drop(a); - let _ = PART_COUNT.load(Ordering::SeqCst); -} - -// ── R4-1: multimodal constructors honor the error contract ───────────────── -// -// The multimodal provider construction methods (embedding / speech / image / -// transcription / files / reranking / video / search) return the model -// *directly* rather than `Result`: they only stash the model id + config and -// perform no validation or I/O, so construction is infallible (the type system -// enforces this — the FFI compiles, so none of these calls can yield `Err`). -// The only failure path in these FFI constructors is a null / invalid-UTF-8 -// argument — a C ABI failure. -// -// Note: an *empty* C string (`""`) is a valid non-null argument and does NOT -// fail here — it constructs a model with a blank key (the error surfaces later, -// at network time). Only null / invalid-UTF-8 pointers are failure triggers. - -type Ctor3 = extern "C" fn(*const c_char, *const c_char, *mut u64) -> *mut aimux_error_t; - -/// Model-id-bearing multimodal constructors: `(api_key, model_id, out) -> err`. -static MODEL_ID_CTORS: &[(&str, Ctor3)] = &[ - ("openai_embedding", aimux_openai_embedding_new), - ("openai_speech", aimux_openai_speech_new), - ("openai_image", aimux_openai_image_new), - ("google_image", aimux_google_image_new), - ("openai_transcription", aimux_openai_transcription_new), - ("cohere_reranking", aimux_cohere_reranking_new), - ("google_video", aimux_google_video_new), -]; - -#[test] -fn multimodal_null_api_key_is_an_ffi_error() { - let model = c("text-embedding-3-small"); - for &(name, ctor) in MODEL_ID_CTORS { - let mut h = 0; - let e = ctor(ptr::null(), model.as_ptr(), &mut h); - assert_eq!(h, 0, "{name}"); - assert_eq!(expect_ffi_error(e, name), "api_key: must not be NULL"); - } - // Single-key constructor (no model_id). - let mut h = 0; - let e = aimux_openai_files_new(ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e, "openai_files"), - "api_key: must not be NULL" - ); - // Tavily ignores model_id but still validates api_key. - let e = aimux_tavily_search_new(ptr::null(), ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e, "tavily_search"), - "api_key: must not be NULL" - ); -} - -#[test] -fn multimodal_null_model_id_is_an_ffi_error() { - let key = c("sk-test-fake-key"); - for &(name, ctor) in MODEL_ID_CTORS { - let mut h = 0; - let e = ctor(key.as_ptr(), ptr::null(), &mut h); - assert_eq!(h, 0, "{name}"); - assert_eq!(expect_ffi_error(e, name), "model_id: must not be NULL"); - } -} - -#[test] -fn multimodal_invalid_utf8_api_key_is_an_ffi_error() { - // 0xFF is invalid UTF-8. - let bad_key = CString::new(b"sk-\xff".to_vec()).unwrap(); - let model = c("text-embedding-3-small"); - for &(name, ctor) in MODEL_ID_CTORS { - let mut h = 0; - let e = ctor(bad_key.as_ptr(), model.as_ptr(), &mut h); - assert_eq!(h, 0, "{name}"); - assert_eq!(expect_ffi_error(e, name), "api_key: must be valid UTF-8"); - } -} - -#[test] -fn multimodal_empty_key_construction_is_infallible() { - // The R4-1 finding suspected construction could fail (e.g. on a blank key) - // and bypass the error contract. It cannot: the multimodal provider - // constructors return the model directly (not `Result`), so even an empty - // api_key — a valid non-null C string — yields a non-zero handle and NULL. - // The error would only surface later, at network time. This test pins - // that behavior so a future "fix" cannot silently turn a success path into - // a failure. - let mut h = 0; - ok( - aimux_openai_embedding_new(c("").as_ptr(), c("text-embedding-3-small").as_ptr(), &mut h), - "openai_embedding_new (empty key)", - ); - assert_ne!(h, 0, "empty api_key still constructs (infallible)"); - aimux_drop_handle(h); -} - -// ── Arity smoke tests for new FFI symbols (M11/M12/M6) ──────────────────────── - -#[test] -fn generate_object_bad_handle_fails() { - let mut out: *mut c_char = ptr::null_mut(); - let e = aimux_generate_object(99999, c("{}").as_ptr(), ptr::null(), &mut out); - assert!(out.is_null(), "expected NULL on bad handle"); - // A dead handle is this layer's finding, and it says which handle. - assert_eq!( - expect_ffi_error(e, "generate_object"), - "invalid or expired model handle" - ); -} - -#[test] -fn consume_stream_text_bad_handle_fails() { - let mut out: *mut c_char = ptr::null_mut(); - let e = aimux_consume_stream_text(99999, c("{}").as_ptr(), ptr::null(), &mut out); - assert!(out.is_null(), "expected NULL on bad handle"); - assert_eq!( - expect_ffi_error(e, "consume_stream_text"), - "invalid or expired model handle" - ); -} - -#[test] -fn init_proxy_valid_json_succeeds() { - ok( - aimux_init_proxy(c(r#"{"http_url": null}"#).as_ptr()), - "init_proxy (idempotent)", - ); -} - -#[test] -fn init_proxy_null_json_fails() { - assert_eq!( - expect_ffi_error(aimux_init_proxy(ptr::null()), "init_proxy"), - "config_json: must not be NULL" - ); -} - -/// A NULL callback is a null pointer like any other required argument: this -/// layer reports it (with the parameter name) instead of dereferencing it. -#[test] -fn stream_text_null_callback_is_an_ffi_error() { - let h = valid_handle(); - let e = aimux_stream_text( - h, - c("\"hi\"").as_ptr(), - ptr::null(), - None, - Some(on_done), - ptr::null_mut(), - ); - assert_eq!(expect_ffi_error(e, "on_part"), "on_part: must not be NULL"); - let e = aimux_stream_text( - h, - c("\"hi\"").as_ptr(), - ptr::null(), - Some(on_part), - None, - ptr::null_mut(), - ); - assert_eq!(expect_ffi_error(e, "on_done"), "on_done: must not be NULL"); - aimux_drop_handle(h); -} - -/// The router and MoA configs are camelCase, as the header documents, and -/// strict: aimux 0.5's `provider_name` / `fail_mode` fail naming the key -/// instead of leaving the default in place. -#[test] -fn composite_configs_reject_unknown_keys() { - let h = valid_handle(); - let children = [h]; - let mut router = 0; - ok( - aimux_router_new( - children.as_ptr(), - children.len(), - c(r#"{"router":"rule","fallback":"none","providerName":"r","modelId":"m"}"#).as_ptr(), - &mut router, - ), - "router_new", - ); - assert_ne!(router, 0); - aimux_drop_handle(router); - - for (config, key) in [ - (r#"{"provider_name":"r"}"#, "provider_name"), - (r#"{"modelId":"m","model_id":"m"}"#, "model_id"), - ] { - let mut router = 0; - let e = aimux_router_new( - children.as_ptr(), - children.len(), - c(config).as_ptr(), - &mut router, - ); - let (code, m) = expect_aimux_error(e, config); - assert_eq!(code, AIMUX_E_INVALID_ARGUMENT, "{config}"); - assert!( - m.contains("config_json:") && m.contains(&format!("unknown field `{key}`")), - "{config}: {m}" - ); - assert_eq!(router, 0); - } - - let mut moa = 0; - let e = aimux_moa_new( - ptr::null(), - 0, - h, - c(r#"{"fail_mode":"fail-fast"}"#).as_ptr(), - &mut moa, - ); - let (code, m) = expect_aimux_error(e, "moa_new"); - assert_eq!(code, AIMUX_E_INVALID_ARGUMENT); - assert!( - m.contains("config_json:") && m.contains("unknown field `fail_mode`"), - "{m}" - ); - assert_eq!(moa, 0); - aimux_drop_handle(h); -} diff --git a/aimux-ffi/tests/exports_smoke_test.rs b/aimux-ffi/tests/exports_smoke_test.rs index a31315ce8..c8d4be224 100644 --- a/aimux-ffi/tests/exports_smoke_test.rs +++ b/aimux-ffi/tests/exports_smoke_test.rs @@ -1,1310 +1,92 @@ -//! FFI export smoke harness (issue #119). -//! -//! A no-network smoke traversal of the C ABI surface in `aimux-ffi/src/lib.rs`. -//! It does NOT verify protocol behaviour — wiremock round-trips live in -//! `aimux-providers/tests/e2e_test.rs` — it verifies that every exported -//! symbol can be called from a C-style caller without panicking and honours -//! the ABI contract: -//! -//! - **Constructor class** (`*_new` / `*_new_with_base` / composites): fake -//! API key + `baseUrl` pointing at a loopback port nothing listens on -//! (`http://127.0.0.1:1`) → must return NULL and write a non-zero handle, -//! which is then released with [`aimux_ffi::aimux_drop_handle`]. -//! Constructors that cannot take a base URL (e.g. `aimux_provider_from_env`) -//! are only checked for a clean handle-or-error outcome. -//! - **Utility class**: called directly with minimal/empty arguments; asserts -//! a sane outcome, never a panic. -//! - **Session class** (generate / stream / embed / speech / image / -//! transcription / rerank / video / search / files): handle + minimal -//! options against the unreachable base URL → must return a returned error -//! with the AiMuxError view and leave the out-param NULL (a clean error path). -//! Text calls pass `{"maxRetries":0}` to skip the retry backoff; the -//! multimodal option structs expose no retry override, so those tests run -//! as separate `#[test]`s to parallelise the default backoff. -//! - Every returned error and every returned JSON string is released -//! (`aimux_error_free` / `aimux_free_string`). -//! -//! Note: multimodal session smoke calls use the default retry settings -//! (2 retries) against a refused port; under `--test-threads=1` this adds -//! roughly a minute of backoff sleep — run the default parallel profile. -//! -//! ## Coverage -//! -//! All 130 `#[unsafe(no_mangle)]` exports in `src/lib.rs` are exercised (the -//! constructor and utility classes in full; the session class one -//! representative call per export). [`header_and_exports_agree`] pins the -//! count against the two headers. Decision exports are exercised by -//! `decision_test.rs` and `provider_handle_test.rs`. - -mod common; - -use std::ffi::CStr; -use std::os::raw::{c_char, c_void}; -use std::ptr; -use std::sync::atomic::{AtomicUsize, Ordering}; - -use aimux_ffi::{ - AIMUX_E_FFI_INVALID_WIRE_JSON, AIMUX_E_INVALID_ARGUMENT, AIMUX_OK, aimux_abort_signal_abort, - aimux_abort_signal_drop, aimux_abort_signal_new, aimux_anthropic_aws_new, - aimux_anthropic_aws_new_with_base, aimux_anthropic_new, aimux_anthropic_new_with_base, - aimux_apply_tool_call_repair, aimux_apply_tool_call_repair_to_result, aimux_azure_new, - aimux_azure_new_with_base, aimux_bedrock_new, aimux_bedrock_new_with_base, aimux_codex_refresh, - aimux_cohere_embedding_new, aimux_cohere_embedding_new_with_base, aimux_cohere_new, - aimux_cohere_new_with_base, aimux_cohere_reranking_new, aimux_cohere_reranking_new_with_base, - aimux_consume_stream_text, aimux_drop_handle, aimux_drop_handle_ptr, aimux_embed, - aimux_error_available_tools, aimux_error_code, aimux_error_free, aimux_error_message, - aimux_error_model_id, aimux_error_model_type, aimux_error_original_error, - aimux_error_provider_code, aimux_error_provider_id, aimux_error_provider_message, - aimux_error_response_body, aimux_error_retry_ms, aimux_error_retryable, aimux_error_status, - aimux_error_t, aimux_error_tool_input, aimux_error_tool_name, aimux_file_upload, - aimux_free_string, aimux_generate_object, aimux_generate_text, aimux_generate_text_as_openai, - aimux_generate_text_result_as_openai, aimux_get_model_specs, aimux_google_embedding_new, - aimux_google_embedding_new_with_base, aimux_google_image_new, aimux_google_image_new_with_base, - aimux_google_video_new, aimux_google_video_new_with_base, aimux_image_generate, - aimux_init_logging, aimux_init_proxy, aimux_init_recording, aimux_init_recording_ring, - aimux_init_recording_ring_default, aimux_list_sessions, aimux_mistral_new, - aimux_mistral_new_with_base, aimux_moa_new, aimux_mock_replay_new, aimux_openai_embedding_new, - aimux_openai_embedding_new_with_base, aimux_openai_files_new, aimux_openai_files_new_with_base, - aimux_openai_image_new, aimux_openai_image_new_with_base, aimux_openai_new, - aimux_openai_new_with_base, aimux_openai_speech_new, aimux_openai_speech_new_with_base, - aimux_openai_transcription_new, aimux_openai_transcription_new_with_base, - aimux_provider_from_env, aimux_provider_handle_new, aimux_provider_list_models, - aimux_provider_model, aimux_provider_new, aimux_recording_flush, aimux_recording_stop, - aimux_recording_try_flush, aimux_register_providers, aimux_rerank, aimux_router_new, - aimux_search, aimux_session_calls, aimux_session_infer_init, aimux_session_store_init, - aimux_speech_generate, aimux_stream_text, aimux_stream_text_as_openai, - aimux_stream_text_as_openai_with_abort, aimux_stream_text_with_abort, aimux_tavily_search_new, - aimux_tavily_search_new_with_base, aimux_tool_call_repair_context, aimux_trace_aggregate, - aimux_trace_clear, aimux_trace_export_jsonl, aimux_trace_new, aimux_trace_new_audited, - aimux_trace_session_chain, aimux_trace_session_trajectory, aimux_transcription_generate, - aimux_transcription_input_done, aimux_transcription_next_part, aimux_transcription_push_audio, - aimux_transcription_session_drop, aimux_transcription_session_new, aimux_vertex_new, - aimux_vertex_new_with_base, aimux_video_generate, aimux_xai_new, aimux_xai_new_with_base, -}; -use common::{c, expect_aimux_error, expect_failure, expect_ffi_error, msg, ok, take}; - -/// Loopback port that nothing listens on: connections are refused (fast, -/// deterministic) on every platform. Through an HTTP proxy it surfaces as a -/// proxy error — either way a clean transport error, never a hang. -const UNREACHABLE: &str = "http://127.0.0.1:1"; - -const FAKE_KEY: &str = "sk-ffi-smoke-fake-key"; - -// ── harness helpers ───────────────────────────────────────────────────────── - -/// Success + non-zero handle, or panic with the message. -fn expect_handle(e: *mut aimux_error_t, h: u64, name: &str) -> u64 { - ok(e, name); - assert_ne!(h, 0, "{name}: expected a non-zero handle"); - h -} - -/// A string-result call failed cleanly: NULL out-param and AiMuxError code. -fn expect_ptr_aimux_failure(e: *mut aimux_error_t, out: *mut c_char, name: &str) { - assert!(out.is_null(), "{name}: expected NULL out-param on failure"); - expect_aimux_error(e, name); -} - -/// Read a returned JSON string, assert it is non-empty, and free it. -fn read_and_free_json(out: *mut c_char, name: &str) -> String { - assert!(!out.is_null(), "{name}: expected a non-NULL JSON string"); - let s = take(out); - assert!(!s.is_empty(), "{name}: expected a non-empty JSON payload"); - s -} - -/// An OpenAI model handle bound to the unreachable base URL (session-class -/// smoke input; `max_retries:0` is passed per-call via opts). -fn unreachable_model() -> u64 { - let mut h = 0; - let e = aimux_openai_new_with_base( - c(FAKE_KEY).as_ptr(), - c("gpt-4o-mini").as_ptr(), - c(UNREACHABLE).as_ptr(), - &mut h, - ); - expect_handle(e, h, "openai_new_with_base (unreachable)") -} - -// ── no-op stream callbacks (must never re-enter the FFI layer) ────────────── - -static PARTS_SEEN: AtomicUsize = AtomicUsize::new(0); -static DONE_SEEN: AtomicUsize = AtomicUsize::new(0); - -extern "C-unwind" fn smoke_on_part(_json: *const c_char, _ctx: *mut c_void) { - PARTS_SEEN.fetch_add(1, Ordering::Relaxed); -} - -extern "C-unwind" fn smoke_on_done(_ctx: *mut c_void) { - DONE_SEEN.fetch_add(1, Ordering::Relaxed); -} - -// ── header / export parity ────────────────────────────────────────────────── - -/// Every `#[unsafe(no_mangle)]` export in `src/lib.rs` has exactly one -/// prototype across the two headers, and vice versa. -#[test] -fn header_and_exports_agree() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - let lib = std::fs::read_to_string(root.join("src/lib.rs")).unwrap(); - let mut exports: Vec = lib - .split("#[unsafe(no_mangle)]") - .skip(1) - .map(|after| { - let after = &after[after.find("fn aimux_").expect("export fn") + 3..]; - after[..after.find('(').unwrap()].to_string() - }) - .collect(); - exports.sort(); - assert_eq!( - exports.len(), - 130, - "export count changed; update the headers" - ); - - let headers = std::fs::read_to_string(root.join("aimux-ffi.h")).unwrap() - + &std::fs::read_to_string(root.join("aimux-error.h")).unwrap(); - // A prototype: a line that starts with a return type and names an - // `aimux_*(` symbol (typedefs and comments start with other tokens). - let mut protos: Vec = headers - .lines() - .filter(|l| { - l.starts_with("aimux_error_t *aimux_") - || l.starts_with("uint64_t aimux_") - || l.starts_with("void aimux_") - || l.starts_with("int32_t aimux_") - || l.starts_with("int64_t aimux_") - || l.starts_with("char *aimux_") - || (l.starts_with("const aimux_") && l.contains("*aimux_")) - }) - .map(|l| { - let sig = &l[..l.find('(').unwrap()]; - sig.rsplit(' ') - .next() - .unwrap() - .trim_start_matches('*') - .to_string() - }) - .collect(); - protos.sort(); - assert_eq!(protos, exports, "header prototypes vs. no_mangle exports"); -} - -// ── constructor class (49 exports) ────────────────────────────────────────── - -#[test] -fn constructor_exports_build_and_release_handles() { - // Collect every created handle so the release path is exercised too. - let mut handles: Vec = Vec::new(); - let key = c(FAKE_KEY); - let secret = c("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - let region = c("us-east-1"); - let token = c("ya29.ffi-smoke-token"); - let project = c("ffi-smoke-project"); - let location = c("us-central1"); - let resource = c("ffi-smoke-resource"); - let base = c(UNREACHABLE); - let mut h: u64; - - macro_rules! ctor { - ($name:literal, $call:expr) => {{ - h = 0; - let e = $call; - handles.push(expect_handle(e, h, $name)); - }}; - } - - // Simple key constructors + with_base variants. - ctor!( - "openai_new", - aimux_openai_new(key.as_ptr(), c("gpt-4o-mini").as_ptr(), &mut h) - ); - ctor!( - "openai_new_with_base", - aimux_openai_new_with_base( - key.as_ptr(), - c("gpt-4o-mini").as_ptr(), - base.as_ptr(), - &mut h - ) - ); - ctor!( - "anthropic_new", - aimux_anthropic_new(key.as_ptr(), c("claude-3-5-sonnet-latest").as_ptr(), &mut h) - ); - ctor!( - "anthropic_new_with_base", - aimux_anthropic_new_with_base( - key.as_ptr(), - c("claude-3-5-sonnet-latest").as_ptr(), - base.as_ptr(), - &mut h - ) - ); - ctor!( - "cohere_new", - aimux_cohere_new(key.as_ptr(), c("command-r-plus").as_ptr(), &mut h) - ); - ctor!( - "cohere_new_with_base", - aimux_cohere_new_with_base( - key.as_ptr(), - c("command-r-plus").as_ptr(), - base.as_ptr(), - &mut h - ) - ); - ctor!( - "mistral_new", - aimux_mistral_new(key.as_ptr(), c("mistral-small-latest").as_ptr(), &mut h) - ); - ctor!( - "mistral_new_with_base", - aimux_mistral_new_with_base( - key.as_ptr(), - c("mistral-small-latest").as_ptr(), - base.as_ptr(), - &mut h - ) - ); - ctor!( - "xai_new", - aimux_xai_new(key.as_ptr(), c("grok-3").as_ptr(), &mut h) - ); - ctor!( - "xai_new_with_base", - aimux_xai_new_with_base(key.as_ptr(), c("grok-3").as_ptr(), base.as_ptr(), &mut h) - ); - - // Credential constructors. - ctor!( - "anthropic_aws_new", - aimux_anthropic_aws_new( - key.as_ptr(), - region.as_ptr(), - c("anthropic.claude-3-5-sonnet-20240620-v1:0").as_ptr(), - &mut h - ) - ); - ctor!( - "anthropic_aws_new_with_base", - aimux_anthropic_aws_new_with_base( - key.as_ptr(), - region.as_ptr(), - c("anthropic.claude-3-5-sonnet-20240620-v1:0").as_ptr(), - base.as_ptr(), - &mut h - ) - ); - ctor!( - "azure_new", - aimux_azure_new( - key.as_ptr(), - resource.as_ptr(), - c("gpt-4o").as_ptr(), - ptr::null(), - &mut h - ) - ); - ctor!( - "azure_new_with_base", - aimux_azure_new_with_base( - key.as_ptr(), - base.as_ptr(), - c("gpt-4o").as_ptr(), - ptr::null(), - &mut h - ) - ); - ctor!( - "bedrock_new", - aimux_bedrock_new( - key.as_ptr(), - secret.as_ptr(), - region.as_ptr(), - c("anthropic.claude-3-5-sonnet-20240620-v1:0").as_ptr(), - &mut h - ) - ); - ctor!( - "bedrock_new_with_base", - aimux_bedrock_new_with_base( - key.as_ptr(), - secret.as_ptr(), - region.as_ptr(), - c("anthropic.claude-3-5-sonnet-20240620-v1:0").as_ptr(), - base.as_ptr(), - &mut h - ) - ); - ctor!( - "vertex_new", - aimux_vertex_new( - token.as_ptr(), - project.as_ptr(), - location.as_ptr(), - c("gemini-2.0-flash").as_ptr(), - &mut h - ) - ); - ctor!( - "vertex_new_with_base", - aimux_vertex_new_with_base( - token.as_ptr(), - project.as_ptr(), - location.as_ptr(), - c("gemini-2.0-flash").as_ptr(), - base.as_ptr(), - &mut h - ) - ); - - // Multimodal constructors. - ctor!( - "openai_embedding_new", - aimux_openai_embedding_new(key.as_ptr(), c("text-embedding-3-small").as_ptr(), &mut h) - ); - ctor!( - "openai_embedding_new_with_base", - aimux_openai_embedding_new_with_base( - key.as_ptr(), - c("text-embedding-3-small").as_ptr(), - base.as_ptr(), - &mut h - ) - ); - ctor!( - "cohere_embedding_new", - aimux_cohere_embedding_new(key.as_ptr(), c("embed-english-v3.0").as_ptr(), &mut h) - ); - ctor!( - "cohere_embedding_new_with_base", - aimux_cohere_embedding_new_with_base( - key.as_ptr(), - c("embed-english-v3.0").as_ptr(), - base.as_ptr(), - &mut h - ) - ); - ctor!( - "google_embedding_new", - aimux_google_embedding_new(key.as_ptr(), c("text-embedding-004").as_ptr(), &mut h) - ); - ctor!( - "google_embedding_new_with_base", - aimux_google_embedding_new_with_base( - key.as_ptr(), - c("text-embedding-004").as_ptr(), - base.as_ptr(), - &mut h - ) - ); - ctor!( - "openai_speech_new", - aimux_openai_speech_new(key.as_ptr(), c("tts-1").as_ptr(), &mut h) - ); - ctor!( - "openai_speech_new_with_base", - aimux_openai_speech_new_with_base(key.as_ptr(), c("tts-1").as_ptr(), base.as_ptr(), &mut h) - ); - ctor!( - "openai_image_new", - aimux_openai_image_new(key.as_ptr(), c("gpt-image-1").as_ptr(), &mut h) - ); - ctor!( - "openai_image_new_with_base", - aimux_openai_image_new_with_base( - key.as_ptr(), - c("gpt-image-1").as_ptr(), - base.as_ptr(), - &mut h - ) - ); - ctor!( - "google_image_new", - aimux_google_image_new(key.as_ptr(), c("imagen-4.0-generate-001").as_ptr(), &mut h) - ); - ctor!( - "google_image_new_with_base", - aimux_google_image_new_with_base( - key.as_ptr(), - c("imagen-4.0-generate-001").as_ptr(), - base.as_ptr(), - &mut h - ) - ); - ctor!( - "openai_transcription_new", - aimux_openai_transcription_new(key.as_ptr(), c("whisper-1").as_ptr(), &mut h) - ); - ctor!( - "openai_transcription_new_with_base", - aimux_openai_transcription_new_with_base( - key.as_ptr(), - c("whisper-1").as_ptr(), - base.as_ptr(), - &mut h - ) - ); - ctor!( - "openai_files_new", - aimux_openai_files_new(key.as_ptr(), &mut h) - ); - ctor!( - "openai_files_new_with_base", - aimux_openai_files_new_with_base(key.as_ptr(), base.as_ptr(), &mut h) - ); - ctor!( - "cohere_reranking_new", - aimux_cohere_reranking_new(key.as_ptr(), c("rerank-v3.5").as_ptr(), &mut h) - ); - ctor!( - "cohere_reranking_new_with_base", - aimux_cohere_reranking_new_with_base( - key.as_ptr(), - c("rerank-v3.5").as_ptr(), - base.as_ptr(), - &mut h - ) - ); - ctor!( - "google_video_new", - aimux_google_video_new(key.as_ptr(), c("veo-3.0-generate-001").as_ptr(), &mut h) - ); - ctor!( - "google_video_new_with_base", - aimux_google_video_new_with_base( - key.as_ptr(), - c("veo-3.0-generate-001").as_ptr(), - base.as_ptr(), - &mut h - ) - ); - ctor!( - "tavily_search_new", - aimux_tavily_search_new(key.as_ptr(), c("tavily").as_ptr(), &mut h) - ); - ctor!( - "tavily_search_new_with_base", - aimux_tavily_search_new_with_base( - key.as_ptr(), - c("tavily").as_ptr(), - base.as_ptr(), - &mut h - ) - ); - - // Registry-backed constructors (RFC-0017 / RFC-0027). - let provider_opts = c(r#"{"baseUrl":"http://127.0.0.1:1"}"#); - ctor!( - "provider_new", - aimux_provider_new( - c("groq").as_ptr(), - key.as_ptr(), - c("llama-3.3-70b-versatile").as_ptr(), - provider_opts.as_ptr(), - &mut h - ) - ); - ctor!( - "provider_handle_new", - aimux_provider_handle_new( - c("groq").as_ptr(), - key.as_ptr(), - provider_opts.as_ptr(), - &mut h - ) - ); - - // Composite wrappers over an unreachable model handle. - let model = unreachable_model(); - handles.push(model); - let mut traced = 0; - expect_handle(aimux_trace_new(model, &mut traced), traced, "trace_new"); - let mut audited = 0; - expect_handle( - aimux_trace_new_audited(model, 0, &mut audited), - audited, - "trace_new_audited", - ); - let children = [traced, audited]; - let mut router = 0; - expect_handle( - aimux_router_new(children.as_ptr(), children.len(), ptr::null(), &mut router), - router, - "router_new", - ); - let mut moa = 0; - expect_handle( - aimux_moa_new( - children.as_ptr(), - children.len(), - model, - ptr::null(), - &mut moa, - ), - moa, - "moa_new", - ); - handles.push(aimux_abort_signal_new()); - assert_ne!(*handles.last().unwrap(), 0, "abort_signal_new"); - - // Release every handle created above (aimux_drop_handle is safe with 0). - // The pointer-sized entry point (a finalizer's callback) releases one. - for h in [traced, audited, router] { - aimux_drop_handle(h); +//! C export and retired-file guards. Build the cdylib before running this test; set +//! AIMUX_FFI_LIBRARY to audit a specific artifact. New primitive behavior is +//! covered by the ABI primitive tests. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const PRIMITIVES: &[&str] = &[ + "aimux_abi_version", + "aimux_manifest_json", + "aimux_call", + "aimux_stream", + "aimux_operation_push", + "aimux_operation_next_part", + "aimux_abort", + "aimux_drop", + "aimux_free_string", + "aimux_error_free", + "aimux_error_code", + "aimux_error_message", + "aimux_error_json", + "aimux_handle_finalizer", +]; + +fn library() -> PathBuf { + if let Some(path) = std::env::var_os("AIMUX_FFI_LIBRARY") { + return path.into(); } - aimux_drop_handle_ptr(std::ptr::without_provenance_mut(moa as usize)); - - // mock_replay_new: empty input must fail cleanly (no recordings) — the - // success path needs a real Recording, covered by replay tests elsewhere. - let mut replay = 7; - let e = aimux_mock_replay_new(c("").as_ptr(), &mut replay); - assert_eq!(replay, 0, "failure writes the sentinel"); - assert_eq!( - expect_aimux_error(e, "mock_replay_new (empty jsonl)").0, - AIMUX_E_INVALID_ARGUMENT - ); - - for h in handles { - aimux_drop_handle(h); + let executable = std::env::current_exe().expect("test executable path"); + let profile = executable + .parent() + .and_then(Path::parent) + .expect("test executable must be in the profile's deps directory"); + let name = format!( + "{}aimux_ffi{}", + std::env::consts::DLL_PREFIX, + std::env::consts::DLL_SUFFIX + ); + let dependency = executable.parent().unwrap().join(&name); + if dependency.exists() { + dependency + } else { + profile.join(name) } } -/// `provider_from_env` defers reading the key until request time. -#[test] -fn provider_from_env_handle() { - let mut h = 0; - let e = aimux_provider_from_env( - c("groq").as_ptr(), - c("llama-3.3-70b-versatile").as_ptr(), - &mut h, - ); - expect_handle(e, h, "provider_from_env"); - aimux_drop_handle(h); -} - -// ── session class: text generation (4 exports) ────────────────────────────── - #[test] -fn text_generation_exports_fail_cleanly_on_unreachable_host() { - let h = unreachable_model(); - let prompt = c("\"ffi smoke\""); - let opts = c(r#"{"maxRetries":0}"#); - let mut out: *mut c_char = ptr::null_mut(); - - let e = aimux_generate_text(h, prompt.as_ptr(), opts.as_ptr(), &mut out); - expect_ptr_aimux_failure(e, out, "generate_text"); - - let e = aimux_generate_object(h, prompt.as_ptr(), opts.as_ptr(), &mut out); - expect_ptr_aimux_failure(e, out, "generate_object"); - - let e = aimux_consume_stream_text(h, prompt.as_ptr(), opts.as_ptr(), &mut out); - expect_ptr_aimux_failure(e, out, "consume_stream_text"); - - let e = aimux_generate_text_as_openai(h, prompt.as_ptr(), opts.as_ptr(), &mut out); - expect_ptr_aimux_failure(e, out, "generate_text_as_openai"); - - aimux_drop_handle(h); -} - -// ── session class: streaming with callbacks (4 exports) ───────────────────── - -#[test] -fn streaming_exports_fail_cleanly_on_unreachable_host() { - let h = unreachable_model(); - let prompt = c("\"ffi smoke\""); - let opts = c(r#"{"maxRetries":0}"#); - let done_before = DONE_SEEN.load(Ordering::Relaxed); - - let e = aimux_stream_text( - h, - prompt.as_ptr(), - opts.as_ptr(), - Some(smoke_on_part), - Some(smoke_on_done), - ptr::null_mut(), - ); - expect_aimux_error(e, "stream_text"); - - let abort = aimux_abort_signal_new(); - let e = aimux_stream_text_with_abort( - h, - abort, - prompt.as_ptr(), - opts.as_ptr(), - Some(smoke_on_part), - Some(smoke_on_done), - ptr::null_mut(), - ); - expect_aimux_error(e, "stream_text_with_abort"); - - let e = aimux_stream_text_as_openai( - h, - prompt.as_ptr(), - opts.as_ptr(), - Some(smoke_on_part), - Some(smoke_on_done), - ptr::null_mut(), - ); - expect_aimux_error(e, "stream_text_as_openai"); - - let e = aimux_stream_text_as_openai_with_abort( - h, - abort, - prompt.as_ptr(), - opts.as_ptr(), - Some(smoke_on_part), - Some(smoke_on_done), - ptr::null_mut(), - ); - expect_aimux_error(e, "stream_text_as_openai_with_abort"); - - // Failure never calls on_done. - assert_eq!(DONE_SEEN.load(Ordering::Relaxed), done_before); - aimux_abort_signal_drop(abort); - aimux_drop_handle(h); -} - -// ── session class: multimodal generation (8 exports, one per test to -// parallelise the default retry backoff — their option structs expose no -// max_retries override) ─────────────────────────────────────────────────────── - -#[test] -fn embed_fails_cleanly_on_unreachable_host() { - let mut h = 0; - let e = aimux_openai_embedding_new_with_base( - c(FAKE_KEY).as_ptr(), - c("text-embedding-3-small").as_ptr(), - c(UNREACHABLE).as_ptr(), - &mut h, - ); - expect_handle(e, h, "embedding handle"); - let mut out: *mut c_char = ptr::null_mut(); - let e = aimux_embed( - h, - c(r#"["hello"]"#).as_ptr(), - c(r#"{"values":[]}"#).as_ptr(), - &mut out, - ); - expect_ptr_aimux_failure(e, out, "embed"); - aimux_drop_handle(h); -} - -#[test] -fn speech_generate_fails_cleanly_on_unreachable_host() { - let mut h = 0; - let e = aimux_openai_speech_new_with_base( - c(FAKE_KEY).as_ptr(), - c("tts-1").as_ptr(), - c(UNREACHABLE).as_ptr(), - &mut h, - ); - expect_handle(e, h, "speech handle"); - let mut out: *mut c_char = ptr::null_mut(); - let e = aimux_speech_generate(h, c(r#"{"text":"ffi smoke"}"#).as_ptr(), &mut out); - expect_ptr_aimux_failure(e, out, "speech_generate"); - aimux_drop_handle(h); -} - -#[test] -fn image_generate_fails_cleanly_on_unreachable_host() { - let mut h = 0; - let e = aimux_openai_image_new_with_base( - c(FAKE_KEY).as_ptr(), - c("gpt-image-1").as_ptr(), - c(UNREACHABLE).as_ptr(), - &mut h, - ); - expect_handle(e, h, "image handle"); - let opts = c(r#"{"prompt":"a rust crab","n":1,"providerOptions":{}}"#); - let mut out: *mut c_char = ptr::null_mut(); - let e = aimux_image_generate(h, opts.as_ptr(), &mut out); - expect_ptr_aimux_failure(e, out, "image_generate"); - aimux_drop_handle(h); -} - -#[test] -fn transcription_generate_fails_cleanly_on_unreachable_host() { - let mut h = 0; - let e = aimux_openai_transcription_new_with_base( - c(FAKE_KEY).as_ptr(), - c("whisper-1").as_ptr(), - c(UNREACHABLE).as_ptr(), - &mut h, - ); - expect_handle(e, h, "transcription handle"); - let mut out: *mut c_char = ptr::null_mut(); - let e = aimux_transcription_generate( - h, - c("ZmFrZS1hdWRpbw==").as_ptr(), - c("audio/wav").as_ptr(), - ptr::null(), - &mut out, - ); - expect_ptr_aimux_failure(e, out, "transcription_generate"); - aimux_drop_handle(h); -} - -#[test] -fn file_upload_fails_cleanly_on_unreachable_host() { - let mut h = 0; - let e = aimux_openai_files_new_with_base(c(FAKE_KEY).as_ptr(), c(UNREACHABLE).as_ptr(), &mut h); - expect_handle(e, h, "files handle"); - let mut out: *mut c_char = ptr::null_mut(); - let e = aimux_file_upload( - h, - c("ZmFrZS1maWxl").as_ptr(), - c("text/plain").as_ptr(), - ptr::null(), - &mut out, - ); - expect_ptr_aimux_failure(e, out, "file_upload"); - aimux_drop_handle(h); -} - -#[test] -fn rerank_fails_cleanly_on_unreachable_host() { - let mut h = 0; - let e = aimux_cohere_reranking_new_with_base( - c(FAKE_KEY).as_ptr(), - c("rerank-v3.5").as_ptr(), - c(UNREACHABLE).as_ptr(), - &mut h, - ); - expect_handle(e, h, "reranking handle"); - let opts = c(r#"{"query":"rust","documents":{"type":"text","values":["a","b"]}}"#); - let mut out: *mut c_char = ptr::null_mut(); - let e = aimux_rerank(h, opts.as_ptr(), &mut out); - expect_ptr_aimux_failure(e, out, "rerank"); - aimux_drop_handle(h); -} - -#[test] -fn video_generate_fails_cleanly_on_unreachable_host() { - let mut h = 0; - let e = aimux_google_video_new_with_base( - c(FAKE_KEY).as_ptr(), - c("veo-3.0-generate-001").as_ptr(), - c(UNREACHABLE).as_ptr(), - &mut h, - ); - expect_handle(e, h, "video handle"); - // `n` and `provider_options` are omitted and `poll` is supplied: this - // pins the serde defaults and the poll wire keys at the ABI boundary — - // typed binding structs omit unset fields, so a strict parse here would - // regress every C-ABI language at once. - let opts = c(r#"{"prompt":"waves","poll":{"intervalMs":1,"timeoutMs":1}}"#); - let mut out: *mut c_char = ptr::null_mut(); - let e = aimux_video_generate(h, opts.as_ptr(), &mut out); - assert!(out.is_null(), "video_generate: expected NULL out-param"); - let (code, message) = expect_aimux_error(e, "video_generate"); - assert_ne!( - code, AIMUX_E_INVALID_ARGUMENT, - "options with defaults omitted must parse; got: {message}" - ); - aimux_drop_handle(h); -} - -#[test] -fn search_fails_cleanly_on_unreachable_host() { - let mut h = 0; - let e = aimux_tavily_search_new_with_base( - c(FAKE_KEY).as_ptr(), - c("tavily").as_ptr(), - c(UNREACHABLE).as_ptr(), - &mut h, - ); - expect_handle(e, h, "search handle"); - let mut out: *mut c_char = ptr::null_mut(); - let e = aimux_search(h, c(r#"{"query":"ffi smoke"}"#).as_ptr(), &mut out); - expect_ptr_aimux_failure(e, out, "search"); - aimux_drop_handle(h); -} - -// ── session class: transcription streaming session (5 exports) ────────────── - -#[test] -fn transcription_session_reaches_clean_terminal_error() { - let mut model = 0; - let e = aimux_openai_transcription_new_with_base( - c(FAKE_KEY).as_ptr(), - c("whisper-1").as_ptr(), - c(UNREACHABLE).as_ptr(), - &mut model, - ); - expect_handle(e, model, "transcription handle"); - - let mut session = 0; - let e = aimux_transcription_session_new(model, 0, ptr::null(), &mut session); - expect_handle(e, session, "transcription_session_new"); - - // The driver fails on the unreachable host; pull parts until the stream - // reaches its terminal state (error or ENDED) within a bounded number of - // timed pulls. This is exactly the smoke point: a clean error path. - let mut reached_terminal = false; - for _ in 0..3 { - let mut part: *mut c_char = ptr::null_mut(); - let mut state: i32 = 0; - let e = aimux_transcription_next_part(session, 3_000, &mut part, &mut state); - if !e.is_null() { - assert!(part.is_null(), "failure leaves *out_part NULL"); - expect_failure(e, "next_part (terminal)"); - reached_terminal = true; - break; - } - match state { - aimux_ffi::AIMUX_TRANSCRIPTION_NEXT_PART_PART => { - read_and_free_json(part, "transcription part"); - } - aimux_ffi::AIMUX_TRANSCRIPTION_NEXT_PART_ENDED => { - assert!(part.is_null()); - reached_terminal = true; - break; - } - aimux_ffi::AIMUX_TRANSCRIPTION_NEXT_PART_TIMEOUT => assert!(part.is_null()), - other => panic!("unknown next_part state {other}"), - } - } +fn cdylib_exports_only_abi_primitives() { + let library = library(); assert!( - reached_terminal, - "session must reach a terminal state within a few pulls" - ); - - // After the terminal state a push is either rejected (error) or still - // buffered (NULL) — both clean, non-panicking outcomes. - let chunk = [0u8, 1, 2, 3]; - let e = aimux_transcription_push_audio(session, chunk.as_ptr(), chunk.len()); - aimux_error_free(e); - - ok( - aimux_transcription_input_done(session), - "input_done on a live session handle", - ); - - // Drop, then verify the handle is gone (clean failure, no panic). - aimux_transcription_session_drop(session); - let e = aimux_transcription_push_audio(session, chunk.as_ptr(), chunk.len()); - assert_eq!( - expect_ffi_error(e, "push after drop"), - "invalid or expired transcription session handle" - ); - aimux_drop_handle(model); -} - -// ── session class: provider discovery + catalogue + codex (4 exports) ─────── - -#[test] -fn provider_discovery_and_catalogue_exports_fail_cleanly() { - // Provider handle bound to the unreachable host. - let mut provider = 0; - let e = aimux_provider_handle_new( - c("groq").as_ptr(), - c(FAKE_KEY).as_ptr(), - c(r#"{"baseUrl":"http://127.0.0.1:1"}"#).as_ptr(), - &mut provider, - ); - expect_handle(e, provider, "provider_handle_new"); - - let mut out: *mut c_char = ptr::null_mut(); - let e = aimux_provider_list_models(provider, &mut out); - expect_ptr_aimux_failure(e, out, "provider_list_models"); - - let mut model = 0; - let e = aimux_provider_model(provider, c("llama-3.3-70b-versatile").as_ptr(), &mut model); - expect_handle(e, model, "provider_model"); - aimux_drop_handle(model); - aimux_drop_handle(provider); - - // Catalogue fetch pointed at the unreachable host. - let e = aimux_get_model_specs(c(UNREACHABLE).as_ptr(), &mut out); - expect_ptr_aimux_failure(e, out, "get_model_specs"); - - // Codex OAuth refresh: NULL args fail cleanly in the C ABI without - // touching the network. - let e = aimux_codex_refresh(ptr::null(), ptr::null(), &mut out); - assert!(out.is_null()); - assert_eq!( - expect_ffi_error(e, "codex_refresh (null args)"), - "refresh_token: must not be NULL" - ); -} - -// ── utility class: lifecycle, sessions, config ────────────────────────────── - -#[test] -fn utility_exports_return_clean_values() { - ok(aimux_init_logging(c("warn").as_ptr()), "init_logging"); - ok( - aimux_init_logging(ptr::null()), - "init_logging (NULL = default)", - ); - aimux_session_store_init(); - aimux_session_infer_init(0); - - let mut out: *mut c_char = ptr::null_mut(); - ok( - aimux_session_calls(c("ffi-smoke-session").as_ptr(), &mut out), - "session_calls", - ); - let calls = read_and_free_json(out, "session_calls"); - assert!(calls.starts_with('['), "session_calls returns a JSON array"); - - ok(aimux_list_sessions(&mut out), "list_sessions"); - let sessions = read_and_free_json(out, "list_sessions"); - assert!( - sessions.starts_with('['), - "list_sessions returns a JSON array" - ); - - // Abort-signal lifecycle. - let abort = aimux_abort_signal_new(); - aimux_abort_signal_abort(abort); // idempotent no-op on valid handle - aimux_abort_signal_abort(abort); - aimux_abort_signal_drop(abort); - aimux_abort_signal_drop(0); // safe with 0 - - // aimux_drop_handle(0) is documented as a safe no-op. - aimux_drop_handle(0); - - // register_providers: valid overlay then a malformed config. - let valid = c( - r#"{"providers":[{"name":"ffi-smoke-provider","base_url":"http://127.0.0.1:1/v1","protocol":"openai_compat"}]}"#, - ); - ok( - aimux_register_providers(valid.as_ptr()), - "register_providers (valid)", - ); - let e = aimux_register_providers(c("{not json").as_ptr()); - // Malformed JSON text is this C layer's finding. AiMuxError-only payload - // getters answer their sentinels for its unified FFI code. - assert!(!e.is_null()); - assert_eq!(aimux_error_code(e), AIMUX_E_FFI_INVALID_WIRE_JSON); - assert_eq!(aimux_error_retryable(e), 0); - assert_eq!(aimux_error_status(e), -1); - assert_eq!(aimux_error_retry_ms(e), -1); - for get in [ - aimux_error_provider_code, - aimux_error_provider_message, - aimux_error_response_body, - aimux_error_model_id, - aimux_error_model_type, - aimux_error_provider_id, - aimux_error_tool_name, - aimux_error_available_tools, - aimux_error_tool_input, - aimux_error_original_error, - ] { - assert!(get(e).is_null(), "AiMuxError payload getter must be NULL"); + library.is_file(), + "build the cdylib first, or set AIMUX_FFI_LIBRARY: {}", + library.display() + ); + let mut command = Command::new("nm"); + if cfg!(target_os = "macos") { + command.arg("-gU"); + } else { + command.args(["-D", "-g", "--defined-only"]); } - let m = aimux_error_message(e); - assert!(!m.is_null()); + let output = command.arg(&library).output().expect("run nm on cdylib"); assert!( - unsafe { CStr::from_ptr(m) } - .to_str() - .unwrap() - .starts_with("config_json: invalid JSON:") - ); - unsafe { aimux_free_string(m) }; - aimux_error_free(e); - // NULL means success. - assert_eq!(aimux_error_code(ptr::null()), AIMUX_OK); - assert!(aimux_error_message(ptr::null()).is_null()); - // NULL owner: accessors are NULL-safe, free is a no-op. - assert!(aimux_error_message(ptr::null()).is_null()); - aimux_error_free(ptr::null_mut()); - - // Empty proxy config is the documented no-op-style success. - ok(aimux_init_proxy(c("{}").as_ptr()), "init_proxy"); -} - -// ── utility class: trace queries ──────────────────────────────────────────── - -#[test] -fn trace_query_exports_return_clean_values() { - let model = unreachable_model(); - let mut traced = 0; - expect_handle(aimux_trace_new(model, &mut traced), traced, "trace handle"); - - // Empty filter `{}` = all (TraceFilter is all-Option). - let mut out: *mut c_char = ptr::null_mut(); - ok( - aimux_trace_aggregate(traced, c("{}").as_ptr(), &mut out), - "trace_aggregate", + output.status.success(), + "nm failed: {}", + String::from_utf8_lossy(&output.stderr) ); - let stats = read_and_free_json(out, "trace_aggregate"); - assert!( - stats.starts_with('['), - "trace_aggregate returns a JSON array" - ); - - // Unknown session: documented clean AiMuxError. - let e = aimux_trace_session_chain(traced, c("ffi-smoke-unknown").as_ptr(), &mut out); - assert!(out.is_null()); - let (code, m) = expect_aimux_error(e, "trace_session_chain (unknown session)"); - assert_eq!(code, AIMUX_E_INVALID_ARGUMENT); - assert!(m.contains("unknown session"), "{m}"); - - ok( - aimux_trace_session_trajectory(traced, c("ffi-smoke-unknown").as_ptr(), &mut out), - "trace_session_trajectory", - ); - let trajectory = read_and_free_json(out, "trace_session_trajectory"); - assert!( - trajectory.starts_with('['), - "trace_session_trajectory returns a JSON array" - ); - - ok( - aimux_trace_export_jsonl(traced, &mut out), - "trace_export_jsonl", - ); - assert!(!out.is_null(), "trace_export_jsonl must return a string"); - let jsonl = take(out); - assert!( - jsonl.is_empty() || jsonl.lines().all(|l| !l.trim().is_empty()), - "trace_export_jsonl is empty-or-JSONL" - ); - - ok(aimux_trace_clear(traced), "trace_clear on live handle"); - assert_eq!( - expect_ffi_error(aimux_trace_clear(999_999), "trace_clear on unknown handle"), - "invalid or expired trace handle" - ); - - aimux_drop_handle(traced); - aimux_drop_handle(model); - // A released trace handle is gone from the trace registry too. - let e = aimux_trace_export_jsonl(traced, &mut out); - assert!(out.is_null()); - assert_eq!(msg(e), "invalid or expired trace handle"); -} - -// ── utility class: recording ──────────────────────────────────────────────── - -#[test] -fn recording_exports_lifecycle_cleanly() { - // cap == 0 is the documented failure. + let symbols = String::from_utf8(output.stdout).expect("nm output must be UTF-8"); + let actual: BTreeSet<_> = symbols + .lines() + .filter_map(|line| line.split_whitespace().next_back()) + .map(|symbol| symbol.strip_prefix('_').unwrap_or(symbol)) + .filter(|symbol| symbol.starts_with("aimux_")) + .collect(); assert_eq!( - expect_aimux_error(aimux_init_recording_ring(0), "init_recording_ring(0)").0, - AIMUX_E_INVALID_ARGUMENT - ); - - ok(aimux_init_recording_ring(8), "init_recording_ring"); - aimux_recording_flush(); - ok(aimux_recording_try_flush(), "recording_try_flush"); - - // File-backed recorder in a throwaway temp dir. - let dir = std::env::temp_dir().join(format!("aimux-ffi-smoke-{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - ok( - aimux_init_recording(c(dir.to_str().expect("utf8 temp dir")).as_ptr()), - "init_recording", - ); - - aimux_init_recording_ring_default(); - aimux_recording_stop(); - aimux_recording_flush(); - ok( - aimux_recording_try_flush(), - "recording_try_flush after stop", - ); - let _ = std::fs::remove_dir_all(&dir); -} - -// ── host-side tool-call repair (RFC-0035, 3 exports) ─────────────────────── - -const REPAIR_TOOLS: &str = r#"[{"type":"function","name":"weather","inputSchema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]"#; -const REPAIR_INVALID: &str = r#"{"toolCallId":"call-1","toolName":"weather","input":{"town":"SG"},"dynamic":true,"invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{\"town\":\"SG\"}","cause":"missing city"}}"#; -const REPAIR_REPLY: &str = r#"{"type":"repaired","toolCall":{"toolCallId":"call-1","toolName":"weather","input":"{\"city\":\"SG\"}"}}"#; - -/// A serialized one-step `GenerateTextResult` whose step finished with tool -/// calls and holds `content` (a JSON array of content parts). Only the steps -/// and the total usage are read back from a result. -fn tool_call_result(content: &str) -> String { - let usage = r#"{"inputTokens":1,"outputTokens":1,"totalTokens":2}"#; - format!( - r#"{{"steps":[{{"callId":"","stepNumber":0,"model":{{"provider":"openai","modelId":"gpt-4o-mini"}},"content":{content},"finishReason":"tool-calls","usage":{usage},"performance":{{"effectiveOutputTokensPerSecond":0.0,"effectiveTotalTokensPerSecond":0.0,"stepTimeMs":0.0,"responseTimeMs":0.0}},"response":{{"id":"","timestamp":"2024-01-01T00:00:00.000Z","modelId":""}}}}],"totalUsage":{usage}}}"# - ) -} - -/// The three repair exports are pure: they take JSON, return JSON, and never -/// touch a handle or the runtime. They also accept the very `prompt_json` / -/// `opts_json` a caller already passed to `aimux_generate_text`. -#[test] -fn tool_call_repair_exports_round_trip() { - let opts = format!(r#"{{"tools":{REPAIR_TOOLS},"instructions":"be terse"}}"#); - - let mut out: *mut c_char = ptr::null_mut(); - let e = aimux_tool_call_repair_context( - c(REPAIR_INVALID).as_ptr(), - c(r#"{"prompt":"weather in SG?"}"#).as_ptr(), - c(&opts).as_ptr(), - &mut out, - ); - ok(e, "tool_call_repair_context"); - let context = read_and_free_json(out, "tool_call_repair_context"); - assert!( - context.contains("\"inputSchema\"") && context.contains("{\\\"town\\\":\\\"SG\\\"}"), - "context should carry the schema and the raw argument text: {context}" - ); - // The `{"prompt": …}` wrapper unwrapped into the one user message. - assert!( - context.contains(r#""messages":[{"role":"user","content":"weather in SG?"}]"#) - && context.contains(r#""instructions":"be terse""#), - "messages and instructions come from prompt_json / opts_json: {context}" - ); - // The repair function's `toolCall` is a tagged `LanguageModelV4ToolCall` - // (provider language-model-v4-tool-call.ts:7). - assert!( - context.contains(r#""toolCall":{"type":"tool-call","toolCallId":"call-1""#), - "{context}" - ); - - let e = aimux_apply_tool_call_repair( - c(REPAIR_INVALID).as_ptr(), - c(&opts).as_ptr(), - c(REPAIR_REPLY).as_ptr(), - &mut out, - ); - ok(e, "apply_tool_call_repair"); - let repaired = read_and_free_json(out, "apply_tool_call_repair"); - assert!( - repaired.contains(r#""input":{"city":"SG"}"#) && !repaired.contains("invalid"), - "repaired call should be valid: {repaired}" - ); - // A standalone call is a tagged `TypedToolCall` (ai generate-text/tool-call.ts:8-14). - assert!( - repaired.starts_with(r#"{"type":"tool-call","toolCallId":"call-1""#), - "{repaired}" - ); - - let result = tool_call_result(&format!( - r#"[{{"type":"tool-call",{}]"#, - &REPAIR_INVALID[1..] - )); - let e = aimux_apply_tool_call_repair_to_result( - c(&result).as_ptr(), - c(&opts).as_ptr(), - c("call-1").as_ptr(), - c(r#"{"type":"unchanged"}"#).as_ptr(), - &mut out, - ); - ok(e, "apply_tool_call_repair_to_result"); - let patched = read_and_free_json(out, "apply_tool_call_repair_to_result"); - assert!(patched.contains(r#""invalid":true"#), "{patched}"); - assert!( - patched.contains(r#""toolCalls":[{"type":"tool-call","toolCallId":"call-1""#), - "{patched}" - ); - - // A reply naming a tool call the document does not have is an AiMuxError. - let e = aimux_apply_tool_call_repair_to_result( - c(&result).as_ptr(), - c(&opts).as_ptr(), - c("call-9").as_ptr(), - c(r#"{"type":"unchanged"}"#).as_ptr(), - &mut out, - ); - let (code, _) = expect_aimux_error(e, "apply_tool_call_repair_to_result (unknown id)"); - assert_eq!(code, AIMUX_E_INVALID_ARGUMENT); - assert!(out.is_null()); -} - -/// A repaired native result converts to a ChatCompletion whose arguments are -/// the step's (repaired) tool-call input; a result keeps no raw provider -/// result to read them from (the OpenAI non-streaming path on a host). The -/// handle only supplies the fallback model id. -#[test] -fn a_repaired_result_converts_to_a_chat_completion() { - let result = tool_call_result( - r#"[{"type":"tool-call","toolCallId":"call-1","toolName":"weather","input":{"city":"SG"}}]"#, - ); - let h = unreachable_model(); - - let mut out: *mut c_char = ptr::null_mut(); - let e = aimux_generate_text_result_as_openai(h, c(&result).as_ptr(), &mut out); - ok(e, "generate_text_result_as_openai"); - let completion = read_and_free_json(out, "generate_text_result_as_openai"); - assert!( - completion.contains(r#""model":"gpt-4o-mini""#) - && completion.contains(r#""arguments":"{\"city\":\"SG\"}""#), - "completion should carry the repaired arguments: {completion}" + actual, + PRIMITIVES.iter().copied().collect(), + "C export allowlist mismatch" ); - - // Not a GenerateTextResult: a wire-JSON failure. - let e = aimux_generate_text_result_as_openai(h, c(r#"{"text":""}"#).as_ptr(), &mut out); - expect_failure(e, "generate_text_result_as_openai (bad result)"); - assert!(out.is_null()); - aimux_drop_handle(h); - - // A dropped handle is rejected like every other model export. - let e = aimux_generate_text_result_as_openai(h, c(&result).as_ptr(), &mut out); - expect_failure(e, "generate_text_result_as_openai (dropped handle)"); - assert!(out.is_null()); } -/// Options with no tool set: the context call writes the JSON literal `null` -/// (a success — the host skips the call), while applying a reply is an error. #[test] -fn tool_call_repair_without_a_tool_set_yields_null_then_rejects() { - let mut out: *mut c_char = ptr::null_mut(); - for opts in [ - ptr::null(), - c("").as_ptr(), - c(r#"{"temperature":0.0}"#).as_ptr(), +fn retired_headers_and_session_are_absent() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + for retired in [ + "aimux-ffi.h", + "aimux-error.h", + "src/transcription_session.rs", ] { - let e = aimux_tool_call_repair_context( - c(REPAIR_INVALID).as_ptr(), - c("\"weather in SG?\"").as_ptr(), - opts, - &mut out, - ); - ok(e, "tool_call_repair_context (no tools)"); - assert_eq!(read_and_free_json(out, "tool_call_repair_context"), "null"); + assert!(!root.join(retired).exists(), "retired file: {retired}"); } - - let e = aimux_apply_tool_call_repair( - c(REPAIR_INVALID).as_ptr(), - ptr::null(), - c(r#"{"type":"unchanged"}"#).as_ptr(), - &mut out, - ); - let (code, _) = expect_aimux_error(e, "apply_tool_call_repair (no tools)"); - assert_eq!(code, AIMUX_E_INVALID_ARGUMENT); - assert!(out.is_null()); -} - -#[test] -fn tool_call_repair_exports_reject_bad_arguments() { - let mut out: *mut c_char = ptr::null_mut(); - - let e = aimux_apply_tool_call_repair(ptr::null(), ptr::null(), c("{}").as_ptr(), &mut out); - assert_eq!( - expect_ffi_error(e, "apply_tool_call_repair (null tool_call_json)"), - "tool_call_json: must not be NULL" - ); - assert!(out.is_null()); - - let e = aimux_tool_call_repair_context( - c("{ not json").as_ptr(), - c("\"hi\"").as_ptr(), - ptr::null(), - &mut out, - ); - assert!( - expect_ffi_error(e, "tool_call_repair_context (malformed)") - .starts_with("tool_call_json: invalid JSON:") - ); - assert!(out.is_null()); - - // An unknown reply tag is a wire-JSON failure, not a silent no-op. - let e = aimux_apply_tool_call_repair( - c(REPAIR_INVALID).as_ptr(), - c(&format!(r#"{{"tools":{REPAIR_TOOLS}}}"#)).as_ptr(), - c(r#"{"type":"nonsense"}"#).as_ptr(), - &mut out, - ); - expect_failure(e, "apply_tool_call_repair (unknown reply tag)"); - assert!(out.is_null()); } diff --git a/aimux-ffi/tests/native_constructors_test.rs b/aimux-ffi/tests/native_constructors_test.rs deleted file mode 100644 index a4289e526..000000000 --- a/aimux-ffi/tests/native_constructors_test.rs +++ /dev/null @@ -1,214 +0,0 @@ -//! Smoke tests for the native-protocol C ABI constructors -//! (cohere / mistral / xai / bedrock / vertex / anthropic_aws / azure). -//! -//! Constructing a model only builds a config + provider — no network is -//! touched, so fake keys are fine. Constructors return NULL and write a -//! non-zero handle to `*out_handle`, or return an `aimux_error_t`. - -mod common; - -use std::ptr; - -use aimux_ffi::{ - aimux_anthropic_aws_new, aimux_anthropic_aws_new_with_base, aimux_azure_new, - aimux_azure_new_with_base, aimux_bedrock_new, aimux_bedrock_new_with_base, aimux_cohere_new, - aimux_cohere_new_with_base, aimux_drop_handle, aimux_error_t, aimux_mistral_new, - aimux_mistral_new_with_base, aimux_vertex_new, aimux_vertex_new_with_base, aimux_xai_new, - aimux_xai_new_with_base, -}; -use common::{c, expect_ffi_error, ok}; - -fn expect_handle(e: *mut aimux_error_t, h: u64, name: &str) { - ok(e, name); - assert_ne!(h, 0, "{name}: expected non-zero handle"); - aimux_drop_handle(h); -} - -#[test] -fn simple_key_constructors() { - let key = c("sk-test-fake-key"); - let model = c("command-r-plus"); - let base = c("https://example.com/v1"); - let mut h = 0; - expect_handle( - aimux_cohere_new(key.as_ptr(), model.as_ptr(), &mut h), - h, - "cohere_new", - ); - expect_handle( - aimux_cohere_new_with_base(key.as_ptr(), model.as_ptr(), base.as_ptr(), &mut h), - h, - "cohere_new_with_base", - ); - - let model = c("mistral-large-latest"); - expect_handle( - aimux_mistral_new(key.as_ptr(), model.as_ptr(), &mut h), - h, - "mistral_new", - ); - expect_handle( - aimux_mistral_new_with_base(key.as_ptr(), model.as_ptr(), base.as_ptr(), &mut h), - h, - "mistral_new_with_base", - ); - - let model = c("grok-3"); - expect_handle( - aimux_xai_new(key.as_ptr(), model.as_ptr(), &mut h), - h, - "xai_new", - ); - expect_handle( - aimux_xai_new_with_base(key.as_ptr(), model.as_ptr(), base.as_ptr(), &mut h), - h, - "xai_new_with_base", - ); -} - -#[test] -fn credential_constructors() { - let mut h = 0; - let access = c("AKIDEXAMPLE"); - let secret = c("wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"); - let region = c("us-east-1"); - let model = c("anthropic.claude-3-5-sonnet-20240620-v1:0"); - expect_handle( - aimux_bedrock_new( - access.as_ptr(), - secret.as_ptr(), - region.as_ptr(), - model.as_ptr(), - &mut h, - ), - h, - "bedrock_new", - ); - let base = c("https://example.com"); - expect_handle( - aimux_bedrock_new_with_base( - access.as_ptr(), - secret.as_ptr(), - region.as_ptr(), - model.as_ptr(), - base.as_ptr(), - &mut h, - ), - h, - "bedrock_new_with_base", - ); - - let token = c("ya29.fake-token"); - let project = c("my-gcp-project"); - let location = c("us-central1"); - let model = c("gemini-2.0-flash"); - expect_handle( - aimux_vertex_new( - token.as_ptr(), - project.as_ptr(), - location.as_ptr(), - model.as_ptr(), - &mut h, - ), - h, - "vertex_new", - ); - expect_handle( - aimux_vertex_new_with_base( - token.as_ptr(), - project.as_ptr(), - location.as_ptr(), - model.as_ptr(), - base.as_ptr(), - &mut h, - ), - h, - "vertex_new_with_base", - ); - - let key = c("sk-ant-fake"); - let model = c("claude-3-5-sonnet-20240620-v1:0"); - expect_handle( - aimux_anthropic_aws_new(key.as_ptr(), region.as_ptr(), model.as_ptr(), &mut h), - h, - "anthropic_aws_new", - ); - expect_handle( - aimux_anthropic_aws_new_with_base( - key.as_ptr(), - region.as_ptr(), - model.as_ptr(), - base.as_ptr(), - &mut h, - ), - h, - "anthropic_aws_new_with_base", - ); -} - -#[test] -fn azure_constructors() { - let mut h = 0; - let key = c("sk-azure-fake"); - let resource = c("my-resource"); - let deployment = c("gpt-4o"); - expect_handle( - aimux_azure_new( - key.as_ptr(), - resource.as_ptr(), - deployment.as_ptr(), - ptr::null(), - &mut h, - ), - h, - "azure_new (default api_version)", - ); - let version = c("2024-06-01"); - expect_handle( - aimux_azure_new( - key.as_ptr(), - resource.as_ptr(), - deployment.as_ptr(), - version.as_ptr(), - &mut h, - ), - h, - "azure_new (explicit api_version)", - ); - let base = c("https://example.openai.azure.com"); - expect_handle( - aimux_azure_new_with_base( - key.as_ptr(), - base.as_ptr(), - deployment.as_ptr(), - ptr::null(), - &mut h, - ), - h, - "azure_new_with_base", - ); -} - -#[test] -fn invalid_args_return_error() { - let mut h = 7; - let key = c("sk-test-fake-key"); - let e = aimux_cohere_new(key.as_ptr(), ptr::null(), &mut h); - assert_eq!(h, 0, "failure writes the sentinel"); - assert_eq!( - expect_ffi_error(e, "cohere_new(null model_id)"), - "model_id: must not be NULL" - ); - let e = aimux_bedrock_new( - key.as_ptr(), - key.as_ptr(), - key.as_ptr(), - ptr::null(), - &mut h, - ); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e, "bedrock_new(null model_id)"), - "model_id: must not be NULL" - ); -} diff --git a/aimux-ffi/tests/provider_handle_test.rs b/aimux-ffi/tests/provider_handle_test.rs deleted file mode 100644 index 2750c6132..000000000 --- a/aimux-ffi/tests/provider_handle_test.rs +++ /dev/null @@ -1,235 +0,0 @@ -//! Tests for the RFC-0027 provider handle FFI functions: -//! `aimux_provider_handle_new`, `aimux_provider_list_models`, -//! `aimux_provider_model`. -//! -//! Handle lifecycle (create → use → drop) and failures (null args, invalid -//! handle) without touching the network. - -mod common; - -use std::os::raw::c_char; -use std::ptr; - -use aimux_ffi::{ - AIMUX_E_NO_SUCH_PROVIDER, aimux_drop_handle, aimux_error_t, aimux_provider_handle_new, - aimux_provider_list_models, aimux_provider_model, -}; -use common::{c, expect_aimux_error, expect_ffi_error, ok}; - -#[test] -fn provider_decision_factory_keeps_model_alive_after_provider_drop() { - use aimux_ffi::{ - aimux_decision_capabilities, aimux_free_string, aimux_provider_decision_model, - }; - let mut provider = 0; - ok( - aimux_provider_handle_new( - c("openai").as_ptr(), - c("key").as_ptr(), - ptr::null(), - &mut provider, - ), - "openai provider", - ); - let mut model = 0; - ok( - aimux_provider_decision_model(provider, c("gpt-6-luna").as_ptr(), &mut model), - "decision factory", - ); - aimux_drop_handle(provider); - let mut capabilities = ptr::null_mut(); - ok( - aimux_decision_capabilities(model, &mut capabilities), - "decision capabilities", - ); - let json: serde_json::Value = unsafe { - serde_json::from_str(std::ffi::CStr::from_ptr(capabilities).to_str().unwrap()).unwrap() - }; - assert_eq!( - json, - serde_json::json!({ - "specificationVersion": "v4", - "provider": "openai", - "modelId": "gpt-6-luna", - "supportedQuestionTypes": ["choice", "score", "boolean"] - }) - ); - // SAFETY: capabilities was allocated by aimux_decision_capabilities above. - unsafe { aimux_free_string(capabilities) }; - let mut invalid = 0; - assert!( - expect_ffi_error( - aimux_provider_decision_model(model, c("x").as_ptr(), &mut invalid), - "wrong handle" - ) - .contains("provider") - ); - assert_eq!(invalid, 0); - aimux_drop_handle(model); - let other = deepseek(); - // A provider without decision models offers none (`NoSuchModel`, as the - // registry reports, ai@7.0.128 src/registry/provider-registry.ts:457-459). - let error = expect_aimux_error( - aimux_provider_decision_model(other, c("x").as_ptr(), &mut invalid), - "provider without decision models", - ); - assert_eq!(error.0, aimux_ffi::AIMUX_E_NO_SUCH_MODEL); - aimux_drop_handle(other); -} - -fn expect_handle(e: *mut aimux_error_t, h: u64, name: &str) -> u64 { - ok(e, name); - assert_ne!(h, 0, "{name}: expected non-zero handle"); - h -} - -fn deepseek() -> u64 { - let mut h = 0; - let e = aimux_provider_handle_new( - c("deepseek").as_ptr(), - c("sk-test-fake").as_ptr(), - ptr::null(), - &mut h, - ); - expect_handle(e, h, "provider_handle_new(deepseek)") -} - -// ── aimux_provider_handle_new ──────────────────────────────────────────────── - -#[test] -fn provider_handle_new_success() { - aimux_drop_handle(deepseek()); -} - -#[test] -fn provider_handle_new_null_name() { - let mut h = 0; - let e = aimux_provider_handle_new(ptr::null(), c("sk").as_ptr(), ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e, "provider_handle_new(null name)"), - "name: must not be NULL" - ); -} - -#[test] -fn provider_handle_new_unknown_provider() { - let mut h = 0; - let e = aimux_provider_handle_new( - c("no-such-provider").as_ptr(), - c("sk").as_ptr(), - ptr::null(), - &mut h, - ); - assert_eq!(h, 0); - assert_eq!( - expect_aimux_error(e, "provider_handle_new(unknown provider)").0, - AIMUX_E_NO_SUCH_PROVIDER - ); -} - -#[test] -fn provider_handle_new_with_config_json() { - let mut h = 0; - let e = aimux_provider_handle_new( - c("deepseek").as_ptr(), - c("sk-test-fake").as_ptr(), - c(r#"{"baseUrl":"https://example.com/v1"}"#).as_ptr(), - &mut h, - ); - aimux_drop_handle(expect_handle(e, h, "provider_handle_new(deepseek, config)")); -} - -#[test] -fn provider_handle_new_bad_config_json() { - let mut h = 0; - let e = aimux_provider_handle_new( - c("deepseek").as_ptr(), - c("sk-test-fake").as_ptr(), - c("not valid json {{{").as_ptr(), - &mut h, - ); - assert_eq!(h, 0); - assert!( - expect_ffi_error(e, "provider_handle_new(bad config)") - .starts_with("config_json: invalid JSON:") - ); -} - -// ── aimux_provider_model ───────────────────────────────────────────────────── - -#[test] -fn provider_model_success() { - let provider_handle = deepseek(); - let mut model_handle = 0; - let e = aimux_provider_model( - provider_handle, - c("deepseek-chat").as_ptr(), - &mut model_handle, - ); - expect_handle(e, model_handle, "provider_model"); - assert_ne!(provider_handle, model_handle); - aimux_drop_handle(model_handle); - aimux_drop_handle(provider_handle); -} - -#[test] -fn provider_model_null_model_id() { - let provider_handle = deepseek(); - let mut h = 0; - let e = aimux_provider_model(provider_handle, ptr::null(), &mut h); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e, "provider_model(null model_id)"), - "model_id: must not be NULL" - ); - aimux_drop_handle(provider_handle); -} - -#[test] -fn provider_model_invalid_handle() { - let mut h = 0; - let e = aimux_provider_model(999999, c("deepseek-chat").as_ptr(), &mut h); - assert_eq!(h, 0); - assert_eq!( - expect_ffi_error(e, "provider_model(invalid handle)"), - "invalid or expired provider handle" - ); -} - -// ── aimux_provider_list_models ─────────────────────────────────────────────── - -#[test] -fn provider_list_models_invalid_handle() { - let mut out: *mut c_char = ptr::null_mut(); - let e = aimux_provider_list_models(999999, &mut out); - assert!(out.is_null()); - assert_eq!( - expect_ffi_error(e, "list_models(invalid handle)"), - "invalid or expired provider handle" - ); -} - -#[test] -fn provider_list_models_dropped_handle() { - let provider_handle = deepseek(); - aimux_drop_handle(provider_handle); - let mut out: *mut c_char = ptr::null_mut(); - let e = aimux_provider_list_models(provider_handle, &mut out); - assert!(out.is_null()); - assert_eq!( - expect_ffi_error(e, "list_models(dropped handle)"), - "invalid or expired provider handle" - ); -} - -#[test] -fn provider_list_models_handle_zero() { - let mut out: *mut c_char = ptr::null_mut(); - let e = aimux_provider_list_models(0, &mut out); - assert!(out.is_null()); - assert_eq!( - expect_ffi_error(e, "list_models(handle=0)"), - "invalid or expired provider handle" - ); -} diff --git a/aimux-ffi/tests/recording_flush_test.rs b/aimux-ffi/tests/recording_flush_test.rs deleted file mode 100644 index d21429642..000000000 --- a/aimux-ffi/tests/recording_flush_test.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! FFI tests for `aimux_recording_try_flush` (write-failure observability, -//! see #136). -//! -//! Everything lives in one `#[test]` on purpose: the global recorder is a -//! process-level singleton, and the outcomes must be observed in a -//! controlled sequence. The sticky `AIMUX_E_RECORDING_WRITE` and the -//! `WRITER_GONE` paths are not portable to inject from the FFI layer (it -//! needs a post-open ENOSPC); they are covered by the core `FailingWriter` -//! test from #133. - -mod common; - -use std::ffi::CString; -use std::sync::atomic::{AtomicU32, Ordering}; - -use aimux_ffi::{ - AIMUX_E_RECORDING_INIT, aimux_init_recording, aimux_recording_stop, aimux_recording_try_flush, -}; -use common::{expect_ffi_error, expect_recording_error, ok}; - -static SEQ: AtomicU32 = AtomicU32::new(0); - -fn unique_dir(tag: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!( - "aimux-ffi-flush-{tag}-{}-{}", - std::process::id(), - SEQ.fetch_add(1, Ordering::Relaxed) - )); - let _ = std::fs::remove_dir_all(&dir); - dir -} - -#[test] -fn recording_try_flush_reports_outcomes() { - // Reset so the test is independent of ordering. - aimux_recording_stop(); - - // No recorder initialized: nothing to flush is a success. - ok(aimux_recording_try_flush(), "try_flush (no recorder)"); - - // Valid directory: flush confirms the (empty) JSONL on disk. - let dir = unique_dir("ok"); - let c_dir = CString::new(dir.to_str().unwrap()).unwrap(); - ok(aimux_init_recording(c_dir.as_ptr()), "init_recording"); - ok(aimux_recording_try_flush(), "try_flush"); - assert!( - dir.join("recordings.jsonl").exists(), - "flush should have created the JSONL file" - ); - aimux_recording_stop(); - let _ = std::fs::remove_dir_all(&dir); - - // Unwritable directory (parent path is a regular file): init itself fails - // with INIT — the recorder is no longer silently degraded to a no-op and - // discovered only at the first flush (the gap #136 was about). - let blocker = unique_dir("blocker"); - std::fs::create_dir_all(&blocker).unwrap(); - let file = blocker.join("occupied"); - std::fs::write(&file, b"x").unwrap(); - let bad_dir = file.join("sub"); - let c_bad = CString::new(bad_dir.to_str().unwrap()).unwrap(); - let (code, text) = - expect_recording_error(aimux_init_recording(c_bad.as_ptr()), "init (bad dir)"); - assert_eq!(code, AIMUX_E_RECORDING_INIT); - assert!(text.contains("recording init failed"), "{text}"); - // The failed init left no recorder behind: flushing is still a success. - ok(aimux_recording_try_flush(), "try_flush after failed init"); - - // The operation is recording-related, but malformed C input is still a - // C ABI failure — no recording view. - assert_eq!( - expect_ffi_error(aimux_init_recording(std::ptr::null()), "init (NULL dir)"), - "dir: must not be NULL" - ); - aimux_recording_stop(); - let _ = std::fs::remove_dir_all(&blocker); -} diff --git a/aimux-ffi/tests/recording_ring_test.rs b/aimux-ffi/tests/recording_ring_test.rs deleted file mode 100644 index 7f55fa732..000000000 --- a/aimux-ffi/tests/recording_ring_test.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Smoke tests for the recording-ring FFI entry points. -//! -//! The global recorder is a process-level singleton shared across tests in this -//! binary. `default_ring_succeeds` resets it via `aimux_recording_stop` -//! before and after, so it does not depend on ordering; `cap == 0` fails -//! before touching global state, so it is order-independent as well. - -mod common; - -use aimux_ffi::{ - AIMUX_E_INVALID_ARGUMENT, aimux_init_recording_ring, aimux_init_recording_ring_default, - aimux_recording_stop, -}; -use common::{expect_aimux_error, ok}; - -#[test] -fn default_ring_succeeds() { - // Reset the global recorder so this test is independent of ordering. - aimux_recording_stop(); - // No-argument entry point initializes a default-capacity (2048) ring. - aimux_init_recording_ring_default(); - ok(aimux_init_recording_ring(8), "init_recording_ring(8)"); - // Leave the global recorder unset for any downstream tests. - aimux_recording_stop(); -} - -#[test] -fn explicit_zero_capacity_still_errors() { - // The default entry point does not relax the "cap == 0 fails" contract: - // passing an explicit 0 must keep failing as AiMuxError::InvalidArgument. - let (code, m) = expect_aimux_error(aimux_init_recording_ring(0), "init_recording_ring(0)"); - assert_eq!(code, AIMUX_E_INVALID_ARGUMENT); - assert!(m.contains("cap"), "{m}"); -} diff --git a/aimux-ops/Cargo.toml b/aimux-ops/Cargo.toml new file mode 100644 index 000000000..e54a0a62b --- /dev/null +++ b/aimux-ops/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "aimux-ops" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Transport-independent operation dispatch for aimux bindings" + +[dependencies] +aimux-core = { workspace = true } +aimux-providers = { workspace = true } +aimux-provider-utils = { workspace = true, features = ["ws"] } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +futures = { workspace = true } +sha2 = "0.10" + +[lints] +workspace = true + +[dev-dependencies] +jsonschema = { workspace = true } diff --git a/aimux-ops/README.md b/aimux-ops/README.md new file mode 100644 index 000000000..dae40766a --- /dev/null +++ b/aimux-ops/README.md @@ -0,0 +1,118 @@ +# aimux-ops + +Transport-independent operation dispatch for aimux's C and language adapters. +The crate owns operation registration, control decoding, runtime handle lifting, +resource ownership, error projection and stream framing. Generation, retries, +provider protocols, trace recording and replay algorithms remain in the existing +Rust engine crates. + +Dependencies flow from adapters into `aimux-ops`, then into `aimux-core`, +`aimux-providers` and `aimux-provider-utils`. Canonical business DTOs contain no +binding handle IDs or adapter types. See the [operation table](../docs/api/ops.md) +for registered names and the runtime manifest for authoritative modes, handle +kinds, type paths, cancellation, capabilities and byte policies. + +## Dispatch and ownership + +`dispatch::call` and `dispatch::stream` use the shared registry and operation +table. `Dispatcher::new` supports an isolated registry and registration table. +Handlers receive `Target::Global` or `Target::Handle(Entry)`; handle `0` denotes +global scope. Global scope is never a resource kind. + +Handles are decimal strings on JSON boundaries and never reused. Node/Python +adapters allocate owner tokens in trusted code with `Owner::new_node()` and +`Owner::new_python()`; C callers share `Owner::CProcess`. Primary and secondary +handles are checked against the same owner and declared kinds. Foreign handles +produce the same error as unknown IDs, without disclosing their kinds. +`handle.drop` succeeds for zero, unknown, dropped and foreign handles. Resources +are removed before cancellation and cleanup; cloned dependencies survive parent +handle removal. Owner release removes all owned entries before disposal. + +Errors cross dispatch once, capturing one clock snapshot and a cached +`ErrorEnvelope`. Adapters and operation status reuse that projection. Retry hints +use `aimux_core::error::retry_after_ms_from_headers`; the ops re-export adds no +parser or retry policy. Core owns generation retries, while uploads and provider +control calls do not add retries. + +## Runtime configuration and tracing + +`runtime.configure` accepts strict `logging` and `proxy` objects; `{}` inspects +state without freezing proxy configuration. Logger initialization uses the same +process guard as native and environment initialization and honors existing +host/environment precedence. A different requested level after initialization +fails with InvalidState; an identical programmatic request is idempotent. +The summary reports the requested level, configured flags and freeze flags, +without returning proxy URLs, credentials or environment directives. + +The startup proxy freezes when the default transport is first acquired by HTTP, +WebSocket or guarded download code. Repeating its active configuration is +idempotent. The explicit native `set_default_transport` replacement API retains +its existing behavior; replacing a transport does not unfreeze startup settings. +Logging and proxy updates are separate process settings, not a transactional pair. + +`trace.create` retains `auditMode: off|shared|strict`. `middleware.create` with +`kind: trace` retains its TraceStore and uses canonical `TraceLayer` for language +models reached through `model.wrap`, `provider.wrap` and `provider.custom`. +Omitting middleware auditMode inherits the store policy; explicit +`disabled|shared|strict` overrides it. `scopeName` isolates fingerprint history. +Shared and strict use the existing rules auditor. Trace storage retains hashes +and telemetry. Recording is separate: `recorder.create` supports only explicit +`capturePolicy: full` with the existing redaction policy; audit-only recordings +are not an exposed capability. + +## Streams and limits + +`transcription.start` returns an Operation handle. Native +`dispatch::operation_push` admits binary bytes and `dispatch::operation_next` +returns Part, Ended or Timeout. JSON `operation.next` uses the same polling +service; generic JSON calls to `operation.push` are InvalidOp. Input closure, +cancellation, consumer closure and the terminal result share one operation state. + +Stream events and binary chunks are limited to 1 MiB. Default operation queues +hold 64 input and 256 output items, with 8 MiB budgets each; terminal delivery +has a separate first-writer slot. Admission reserves capacity before copying +binary payloads. These limits cover wire bytes and admitted queue payloads, +not every upstream provider allocation. One-shot JSON requests have no byte cap +in this layer. The existing core text pump uses an unbounded internal channel. +Realtime cleanup has a five-second bound and reports cleanup failure separately +from the terminal outcome. Blocking recorder I/O uses `spawn_blocking`; adapter +finalizers enqueue cleanup. + +## Manifest and registered differences + +`manifest::manifest()` exports all 72 operations, including two Decision +operations beyond RFC-0039's original 70, 23 handle kinds, error codes and a +`types` catalogue. Ops controls and result wrappers carry schemas; canonical +business fields retain explicit type references. Codegen publishes the C header, +language constants and schemas. Its `--check` detects missing, stale and changed +outputs. The manifest's `digest` field equals `manifest_digest()`; hashing sorts +object keys and operation names and excludes the `digest` field itself. + +Registered differences from RFC-0039 include the Decision kind and operations, +BinaryPush-only JSON rejection for operation.push, and the native finalizer +primitive `aimux_handle_finalizer`. Error envelopes use the canonical error name +and payload with no outer retryable field. Existing numeric code aliases remain: +code 3 covers three variants, code 6 covers two, and code 4 is reserved. Protocol +codes use 300+. Stdio, host control-flow callbacks and skills/Batch/Evaluation +operations are outside the exposed inventory. A parsed negative, non-finite +or out-of-range preferred retry +value blocks fallback; malformed nonnumeric text allows fallback. + +Registered gaps: + +- **G-a:** recorder, session store and inference capture stay process-wide. + Handle ownership does not provide per-owner capture isolation. +- **G-b:** recording format migration and removal of schema-1 reading belong to + RFC-0041. Current canonical readers retain their existing schema compatibility. +- **G-c:** credential resolver creation and replay transport capabilities return + UnsupportedFunctionality and have Future scope. Replay uses existing model-level + language/Decision replay. Provider WebSocket/credential settings unsupported by + its native factory also fail explicitly. +- **G-d:** canonical business schemas and language DTO generation belong to + RFC-0042. `scripts/gen_ts_types.py` remains until its full outputs are covered. +- **G-e:** release ordering/header packaging does not establish installation of + every published asset on every platform. + +Model source summaries are object-level owner-scoped metadata; complete +per-handle/package provenance and authoritative defaults for every provider +are not exposed. diff --git a/aimux-ops/src/dispatch.rs b/aimux-ops/src/dispatch.rs new file mode 100644 index 000000000..3e1b15672 --- /dev/null +++ b/aimux-ops/src/dispatch.rs @@ -0,0 +1,387 @@ +//! Shared dispatch, validation and error projection for native adapters. +use std::future::Future; +use std::pin::Pin; +use std::sync::OnceLock; +use std::time::{Duration, SystemTime}; + +use futures::{Stream, StreamExt}; +use serde_json::Value; + +use crate::envelope::{OpsError, ProtocolError}; +pub use crate::frame::FrameStream; +use crate::handle::{Entry, HandleId, HandleKind, HandleRegistry, Owner}; +use crate::op::{Cancellation, OpDescriptor, OpMode}; + +#[derive(Clone)] +pub struct Ctx { + pub owner: Owner, + pub abort: Option, + pub now: fn() -> SystemTime, +} + +#[derive(Clone)] +pub enum Target { + Global, + Handle(Entry), +} + +pub type CallFuture = Pin> + Send>>; +pub type EventStream = Pin> + Send>>; + +#[derive(Clone, Copy)] +pub enum Handler { + Call(fn(Ctx, Target, Value) -> CallFuture), + Stream(fn(Ctx, Target, Value) -> Result), +} + +/// Owner-aware resolution seam. Production uses HandleRegistry; gates use an isolated registry. +pub trait ResourceRegistry: Sync { + /// # Errors + /// Returns InvalidHandle for missing, foreign or incorrectly typed handles. + fn get(&self, owner: Owner, id: HandleId, allowed: &[HandleKind]) -> Result; + fn remove(&self, owner: Owner, id: HandleId) -> Option; +} +impl ResourceRegistry for HandleRegistry { + fn get(&self, owner: Owner, id: HandleId, allowed: &[HandleKind]) -> Result { + self.get(owner, id, allowed) + } + fn remove(&self, owner: Owner, id: HandleId) -> Option { + self.remove(owner, id) + } +} + +pub(crate) fn registrations() -> &'static [(&'static OpDescriptor, Handler)] { + static OPS: OnceLock> = OnceLock::new(); + OPS.get_or_init(|| { + let mut r = Vec::new(); + crate::ops::runtime::register(&mut r); + crate::ops::provider::register(&mut r); + crate::ops::registry::register(&mut r); + crate::ops::composite::register(&mut r); + crate::ops::text::register(&mut r); + crate::ops::modalities::register(&mut r); + crate::ops::files::register(&mut r); + crate::ops::handles::register(&mut r); + crate::ops::trace::register(&mut r); + crate::ops::recording::register(&mut r); + crate::ops::session::register(&mut r); + crate::ops::replay::register(&mut r); + crate::ops::misc::register(&mut r); + crate::ops::decision::register(&mut r); + crate::ops::operation::register(&mut r); + r.sort_unstable_by_key(|(op, _)| op.name); + r + }) +} + +/// Dispatch routes this op itself, before ordinary handle lookup. +pub const HANDLE_DROP: &str = "handle.drop"; + +#[must_use] +pub fn descriptor(op: &str) -> Option<&'static OpDescriptor> { + registrations() + .iter() + .find(|(descriptor, _)| descriptor.name == op) + .map(|(descriptor, _)| *descriptor) +} + +/// A dispatcher with explicit registry and registration dependencies. +pub struct Dispatcher<'a> { + registry: &'a dyn ResourceRegistry, + registrations: &'a [(&'static OpDescriptor, Handler)], +} +impl<'a> Dispatcher<'a> { + #[must_use] + pub fn new( + registry: &'a dyn ResourceRegistry, + registrations: &'a [(&'static OpDescriptor, Handler)], + ) -> Self { + Self { + registry, + registrations, + } + } + + fn registration(&self, name: &str) -> Result<(&'static OpDescriptor, Handler), OpsError> { + self.registrations + .iter() + .find(|(op, _)| op.name == name) + .copied() + .ok_or_else(|| invalid_op(name)) + } + + /// # Errors + /// Returns a cached projection for validation, ownership, mode or runtime failure. + pub async fn call( + &self, + ctx: Ctx, + handle: HandleId, + name: &str, + params: Value, + ) -> Result { + let now = ctx.now; + let result = async { + let (descriptor, handler) = self.registration(name)?; + self.call_registered(ctx, handle, descriptor, handler, params) + .await + } + .await; + result.map_err(|error| error.project_with_clock(now)) + } + + async fn call_registered( + &self, + mut ctx: Ctx, + handle: HandleId, + descriptor: &OpDescriptor, + handler: Handler, + params: Value, + ) -> Result { + if !matches!(descriptor.mode, OpMode::Call | OpMode::Poll) { + return Err(invalid_op(descriptor.name)); + } + let Handler::Call(handler) = handler else { + return Err(invalid_op(descriptor.name)); + }; + validate_object(¶ms)?; + if descriptor.name == HANDLE_DROP { + decode::(params)?; + if let Some(Entry::Operation(operation)) = self.registry.remove(ctx.owner, handle) { + operation.consumer_close(); + } + return Ok(Value::Null); + } + let target = resolve(self.registry, &ctx, handle, descriptor)?; + validate_secondary(self.registry, &mut ctx, descriptor, ¶ms)?; + let abort = ctx + .abort + .clone() + .filter(|_| descriptor.cancellation == Cancellation::Abort); + if let Some(abort) = abort { + tokio::select! { + biased; + () = abort.cancelled() => Err(aborted()), + result = handler(ctx, target, params) => result, + } + } else { + handler(ctx, target, params).await + } + } + + /// # Errors + /// Returns errors before acceptance. Accepted failures become one terminal frame. + pub fn stream( + &self, + mut ctx: Ctx, + handle: HandleId, + name: &str, + params: Value, + ) -> Result { + let now = ctx.now; + let result = (|| { + let (descriptor, handler) = self.registration(name)?; + if descriptor.mode != OpMode::Stream { + return Err(invalid_op(name)); + } + let Handler::Stream(handler) = handler else { + return Err(invalid_op(name)); + }; + validate_object(¶ms)?; + let target = resolve(self.registry, &ctx, handle, descriptor)?; + validate_secondary(self.registry, &mut ctx, descriptor, ¶ms)?; + let abort = ctx + .abort + .get_or_insert_with(aimux_core::AbortSignal::new) + .clone(); + let events = handler(ctx, target, params)?; + Ok(FrameStream::new(events, Some(abort), now)) + })(); + result.map_err(|error: OpsError| error.project_with_clock(now)) + } +} + +fn invalid_op(name: &str) -> OpsError { + OpsError::Protocol(ProtocolError::InvalidOp(name.into())) +} +fn aborted() -> OpsError { + OpsError::AiMux(aimux_core::AiMuxError::Aborted("request aborted".into())) +} +fn validate_object(params: &Value) -> Result<(), OpsError> { + if params.is_object() { + Ok(()) + } else { + Err(OpsError::Protocol(ProtocolError::InvalidParams( + "operation params must be an object".into(), + ))) + } +} + +pub(crate) fn decode(params: Value) -> Result { + serde_json::from_value(params) + .map_err(|error| OpsError::Protocol(ProtocolError::InvalidParams(error.to_string()))) +} + +fn resolve( + registry: &dyn ResourceRegistry, + ctx: &Ctx, + handle: HandleId, + descriptor: &OpDescriptor, +) -> Result { + if handle.0 == 0 && descriptor.allowed_handle_kinds.is_empty() { + return Ok(Target::Global); + } + registry + .get(ctx.owner, handle, descriptor.allowed_handle_kinds) + .map(Target::Handle) +} + +/// Resolve a secondary handle with exactly the same owner and kind policy as the primary. +/// # Errors +/// Returns InvalidParams for invalid wire IDs or InvalidHandle for resolution failure. +pub fn resolve_secondary( + registry: &dyn ResourceRegistry, + ctx: &Ctx, + value: &Value, + allowed: &[HandleKind], +) -> Result { + let handle: HandleId = decode(value.clone())?; + registry.get(ctx.owner, handle, allowed) +} + +fn field_values<'a>(value: &'a Value, path: &[&str], result: &mut Vec<&'a Value>) { + if path.is_empty() { + if !value.is_null() { + result.push(value); + } + return; + } + if path[0] == "*" { + match value { + Value::Array(values) => { + for value in values { + field_values(value, &path[1..], result); + } + } + Value::Object(values) => { + for value in values.values() { + field_values(value, &path[1..], result); + } + } + _ => {} + } + } else if let Some(value) = value.get(path[0]) { + field_values(value, &path[1..], result); + } +} + +fn validate_secondary( + registry: &dyn ResourceRegistry, + ctx: &mut Ctx, + descriptor: &OpDescriptor, + params: &Value, +) -> Result<(), OpsError> { + for (path, allowed) in descriptor.runtime_handle_fields { + let mut values = Vec::new(); + field_values(params, &path.split('.').collect::>(), &mut values); + for value in values { + let entry = resolve_secondary(registry, ctx, value, allowed)?; + if let ("abort", Entry::Abort(signal)) = (*path, entry) { + ctx.abort = Some((*signal).clone()); + } + } + } + Ok(()) +} + +/// # Errors +/// Returns a projected error for failed call preflight or execution. +pub async fn call( + ctx: Ctx, + handle: HandleId, + name: &str, + params: Value, +) -> Result { + Dispatcher::new(HandleRegistry::global(), registrations()) + .call(ctx, handle, name, params) + .await +} +/// # Errors +/// Returns a projected error before acceptance. +pub fn stream( + ctx: Ctx, + handle: HandleId, + name: &str, + params: Value, +) -> Result { + Dispatcher::new(HandleRegistry::global(), registrations()).stream(ctx, handle, name, params) +} + +#[derive(Debug)] +pub enum PollResult { + Part(Value), + Ended, + Timeout, +} + +/// Admit raw input without a JSON/base64 round trip or an allocation on rejection. +/// # Errors +/// Returns InvalidHandle, InvalidState or ResourceLimit, projected once. +pub fn operation_push(ctx: &Ctx, op: HandleId, data: &[u8]) -> Result<(), OpsError> { + let result = (|| { + let Entry::Operation(operation) = + HandleRegistry::global().get(ctx.owner, op, &[HandleKind::Operation])? + else { + return Err(invalid_op("operation.push")); + }; + operation.try_push(data) + })(); + result.map_err(|error| error.project_with_clock(ctx.now)) +} + +/// Poll the same service used by JSON operation.next. +/// # Errors +/// Returns InvalidHandle, ConcurrentConsumer or the saved terminal error. +pub async fn operation_next( + ctx: &Ctx, + op: HandleId, + timeout: Duration, +) -> Result { + let result = async { + let Entry::Operation(operation) = + HandleRegistry::global().get(ctx.owner, op, &[HandleKind::Operation])? + else { + return Err(invalid_op("operation.next")); + }; + next_operation(&operation, timeout).await + } + .await; + result.map_err(|error| error.project_with_clock(ctx.now)) +} +pub(crate) async fn next_operation( + operation: &crate::operation::Operation, + timeout: Duration, +) -> Result { + match operation.next(Some(timeout)).await? { + crate::operation::PollResult::Part(part) => serde_json::to_value(part) + .map(PollResult::Part) + .map_err(|error| { + crate::envelope::BindingError::ResultSerialization(error.to_string()).into() + }), + crate::operation::PollResult::Ended => Ok(PollResult::Ended), + crate::operation::PollResult::Timeout => Ok(PollResult::Timeout), + } +} + +/// Lift typed serializable events at the native framing boundary. +/// Serializer failures become terminal ResultSerialization errors. +pub fn serialize_events(source: S) -> EventStream +where + S: Stream> + Send + 'static, + T: serde::Serialize + Send + 'static, +{ + Box::pin(source.map(|event| { + serde_json::to_value(event?).map_err(|error| { + crate::envelope::BindingError::ResultSerialization(error.to_string()).into() + }) + })) +} diff --git a/aimux-ops/src/envelope.rs b/aimux-ops/src/envelope.rs new file mode 100644 index 000000000..eb2230a94 --- /dev/null +++ b/aimux-ops/src/envelope.rs @@ -0,0 +1,348 @@ +//! Shared, lossless error projection for every ops adapter. + +use std::fmt; +use std::time::SystemTime; + +use aimux_core::error::AiMuxError; +use aimux_core::recording::RecordingError; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::handle::HandleKind; + +#[derive(Debug)] +pub enum OpsError { + Projected(Box), + AiMux(AiMuxError), + Recording(RecordingError), + Binding(BindingError), + Protocol(ProtocolError), +} + +#[derive(Debug, Clone)] +pub enum BindingError { + NullPointer { + argument: &'static str, + }, + InvalidUtf8 { + argument: &'static str, + }, + InvalidWireJson { + argument: String, + message: String, + }, + InvalidHandle { + expected: Vec, + actual: Option, + }, + ReentrantCall, + ResultSerialization(String), + CallbackFailure(String), +} + +#[derive(Debug, Clone)] +pub enum ProtocolError { + InvalidOp(String), + InvalidParams(String), + UnsupportedOpsVersion(String), + ConcurrentConsumer, + InvalidState(String), + ResourceLimit(String), + HandleExhausted, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ErrorDomain { + AiMux, + Recording, + Binding, + Protocol, +} + +/// Keep the payload open so newer error variants, provider data, and explicit +/// JSON nulls survive decoding and re-encoding by an older adapter. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ErrorEnvelope { + pub kind: ErrorDomain, + pub code: i32, + pub message: String, + pub error: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_after_ms: Option, +} + +#[derive(Debug)] +pub struct ProjectedError { + pub envelope: ErrorEnvelope, + pub source: OpsError, +} + +impl OpsError { + /// Capture a boundary clock once. Reprojection preserves the original envelope. + #[must_use] + pub fn project(self, now: SystemTime) -> Self { + if matches!(self, Self::Projected(_)) { + self + } else { + let envelope = self.envelope(now); + Self::Projected(Box::new(ProjectedError { + envelope, + source: self, + })) + } + } + pub(crate) fn project_with_clock(self, now: fn() -> SystemTime) -> Self { + if matches!(self, Self::Projected(_)) { + self + } else { + self.project(now()) + } + } + #[must_use] + pub fn unprojected(&self) -> &Self { + match self { + Self::Projected(error) => error.source.unprojected(), + error => error, + } + } + + /// Project once using the clock captured at the dispatch boundary. Adapters + /// should retain this envelope rather than recompute date-based hints. + #[must_use] + pub fn envelope(&self, now: SystemTime) -> ErrorEnvelope { + let (kind, error) = match self { + Self::Projected(error) => return error.envelope.clone(), + Self::AiMux(error) => match serde_json::to_value(error) { + Ok(payload) => (ErrorDomain::AiMux, payload), + Err(error) => { + return Self::Binding(BindingError::ResultSerialization(error.to_string())) + .envelope(now); + } + }, + Self::Recording(error) => (ErrorDomain::Recording, recording_payload(error)), + Self::Binding(error) => (ErrorDomain::Binding, error.payload()), + Self::Protocol(error) => (ErrorDomain::Protocol, error.payload()), + }; + let retry_after_ms = match self { + Self::AiMux(AiMuxError::ApiCall(api)) => api + .response_headers + .as_ref() + .and_then(|headers| crate::retry_hint::retry_after_ms(headers, now)), + _ => None, + }; + ErrorEnvelope { + kind, + code: self.code(), + message: self.to_string(), + error, + retry_after_ms, + } + } + + #[must_use] + pub fn code(&self) -> i32 { + match self { + Self::Projected(error) => error.envelope.code, + Self::AiMux(error) => match error { + AiMuxError::Other(_) => 1, + AiMuxError::JsonParse(_) => 2, + AiMuxError::InvalidResponseData(_) + | AiMuxError::ToolCallNotFoundForApproval { .. } + | AiMuxError::ToolChoiceViolation(_) => 3, + // Code 4 is retired. Preserve the existing FFI groupings. + AiMuxError::InvalidArgument(_) => 5, + AiMuxError::InvalidPrompt(_) | AiMuxError::MissingToolResults { .. } => 6, + AiMuxError::TokenExpired(_) => 7, + AiMuxError::UnsupportedFunctionality(_) => 8, + AiMuxError::NoSuchModel { .. } => 9, + AiMuxError::NoSuchProvider { .. } => 10, + AiMuxError::ApiCall(_) => 11, + AiMuxError::Timeout(_) => 12, + AiMuxError::Aborted(_) => 13, + AiMuxError::Retry(_) => 14, + AiMuxError::NoSuchTool { .. } => 15, + AiMuxError::InvalidToolInput { .. } => 16, + AiMuxError::ToolCallRepair { .. } => 17, + AiMuxError::NoOutputGenerated(_) => 18, + AiMuxError::LoadApiKey { .. } => 19, + AiMuxError::LoadSetting { .. } => 20, + }, + Self::Recording(error) => match error { + RecordingError::Init { .. } => 100, + RecordingError::OpenFile { .. } => 101, + RecordingError::Spawn { .. } => 102, + RecordingError::WriterGone => 103, + RecordingError::FlushTimeout => 104, + RecordingError::Write(_) => 105, + }, + Self::Binding(error) => match error { + BindingError::NullPointer { .. } => 200, + BindingError::InvalidUtf8 { .. } => 201, + BindingError::InvalidWireJson { .. } => 202, + BindingError::InvalidHandle { .. } => 203, + BindingError::ReentrantCall => 204, + BindingError::ResultSerialization(_) => 205, + BindingError::CallbackFailure(_) => 206, + }, + Self::Protocol(error) => match error { + ProtocolError::InvalidOp(_) => 300, + ProtocolError::InvalidParams(_) => 301, + ProtocolError::UnsupportedOpsVersion(_) => 302, + ProtocolError::ConcurrentConsumer => 303, + ProtocolError::InvalidState(_) => 304, + ProtocolError::ResourceLimit(_) => 305, + ProtocolError::HandleExhausted => 306, + }, + } + } +} + +fn recording_payload(error: &RecordingError) -> Value { + match error { + RecordingError::Init { path, source } => { + json!({"name": "Init", "path": path.to_string_lossy(), "source": source.to_string()}) + } + RecordingError::OpenFile { path, source } => { + json!({"name": "OpenFile", "path": path.to_string_lossy(), "source": source.to_string()}) + } + RecordingError::Spawn { source } => { + json!({"name": "Spawn", "source": source.to_string()}) + } + RecordingError::WriterGone => json!({"name": "WriterGone"}), + RecordingError::FlushTimeout => json!({"name": "FlushTimeout"}), + RecordingError::Write(message) => json!({"name": "Write", "message": message}), + } +} + +impl BindingError { + fn payload(&self) -> Value { + match self { + Self::NullPointer { argument } => json!({"name": "NullPointer", "argument": argument}), + Self::InvalidUtf8 { argument } => json!({"name": "InvalidUtf8", "argument": argument}), + Self::InvalidWireJson { argument, message } => { + json!({"name": "InvalidWireJson", "argument": argument, "message": message}) + } + Self::InvalidHandle { expected, actual } => { + let mut payload = json!({"name": "InvalidHandle", "expected": expected}); + if let Some(actual) = actual { + payload["actual"] = json!(actual); + } + payload + } + Self::ReentrantCall => json!({"name": "ReentrantCall"}), + Self::ResultSerialization(message) => { + json!({"name": "ResultSerialization", "message": message}) + } + Self::CallbackFailure(message) => { + json!({"name": "CallbackFailure", "message": message}) + } + } + } +} + +impl ProtocolError { + fn payload(&self) -> Value { + match self { + Self::InvalidOp(message) => json!({"name": "InvalidOp", "message": message}), + Self::InvalidParams(message) => json!({"name": "InvalidParams", "message": message}), + Self::UnsupportedOpsVersion(message) => { + json!({"name": "UnsupportedOpsVersion", "message": message}) + } + Self::ConcurrentConsumer => json!({"name": "ConcurrentConsumer"}), + Self::InvalidState(message) => json!({"name": "InvalidState", "message": message}), + Self::ResourceLimit(message) => json!({"name": "ResourceLimit", "message": message}), + Self::HandleExhausted => json!({"name": "HandleExhausted"}), + } + } +} + +impl fmt::Display for BindingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NullPointer { argument } => write!(f, "{argument}: must not be NULL"), + Self::InvalidUtf8 { argument } => write!(f, "{argument}: must be valid UTF-8"), + Self::InvalidWireJson { argument, message } => { + write!(f, "{argument}: invalid JSON: {message}") + } + Self::InvalidHandle { expected, actual } => { + write!( + f, + "invalid or expired handle: expected {expected:?}, actual {actual:?}" + ) + } + Self::ReentrantCall => { + f.write_str("re-entrant FFI call from within a callback is not allowed") + } + Self::ResultSerialization(message) => { + write!(f, "could not serialize result: {message}") + } + Self::CallbackFailure(message) => write!(f, "host callback failed: {message}"), + } + } +} + +impl fmt::Display for ProtocolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidOp(message) => write!(f, "invalid op: {message}"), + Self::InvalidParams(message) => write!(f, "invalid params: {message}"), + Self::UnsupportedOpsVersion(message) => write!(f, "unsupported ops version: {message}"), + Self::ConcurrentConsumer => f.write_str("operation already has a consumer"), + Self::InvalidState(message) => write!(f, "invalid state: {message}"), + Self::ResourceLimit(message) => write!(f, "resource limit: {message}"), + Self::HandleExhausted => f.write_str("handle IDs exhausted"), + } + } +} + +impl fmt::Display for OpsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Projected(error) => f.write_str(&error.envelope.message), + Self::AiMux(error) => error.fmt(f), + Self::Recording(error) => error.fmt(f), + Self::Binding(error) => error.fmt(f), + Self::Protocol(error) => error.fmt(f), + } + } +} + +impl std::error::Error for BindingError {} +impl std::error::Error for ProtocolError {} +impl std::error::Error for OpsError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Projected(error) => Some(&error.source), + Self::AiMux(error) => Some(error), + Self::Recording(error) => Some(error), + Self::Binding(error) => Some(error), + Self::Protocol(error) => Some(error), + } + } +} + +impl From for OpsError { + fn from(error: AiMuxError) -> Self { + Self::AiMux(error) + } +} + +impl From for OpsError { + fn from(error: RecordingError) -> Self { + Self::Recording(error) + } +} + +impl From for OpsError { + fn from(error: BindingError) -> Self { + Self::Binding(error) + } +} + +impl From for OpsError { + fn from(error: ProtocolError) -> Self { + Self::Protocol(error) + } +} diff --git a/aimux-ops/src/frame.rs b/aimux-ops/src/frame.rs new file mode 100644 index 000000000..f181aa07f --- /dev/null +++ b/aimux-ops/src/frame.rs @@ -0,0 +1,143 @@ +use std::future::Future; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::time::SystemTime; + +use aimux_core::{AbortSignal, AiMuxError}; +use futures::Stream; +use serde::Serialize; + +use crate::dispatch::EventStream; +use crate::envelope::{ErrorEnvelope, OpsError, ProtocolError}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Outcome { + Success, + Error, + Cancelled, +} + +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum StreamFrame { + Event { + seq: String, + event: serde_json::Value, + }, + End { + seq: String, + outcome: Outcome, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + }, +} + +/// Pull-driven framing: at most one upstream item is read for each output item. +/// The terminal transition drops upstream immediately and is never rewritten. +pub struct FrameStream { + events: Option, + cancelled: Option + Send>>>, + abort: Option, + now: fn() -> SystemTime, + seq: u64, +} + +impl FrameStream { + #[must_use] + pub fn new(events: EventStream, abort: Option, now: fn() -> SystemTime) -> Self { + Self { + events: Some(events), + cancelled: abort.as_ref().map(|abort| Box::pin(abort.cancelled()) as _), + abort, + now, + seq: 0, + } + } + + fn end(&mut self, outcome: Outcome, error: Option) -> Poll> { + self.events = None; + self.cancelled = None; + Poll::Ready(Some(StreamFrame::End { + seq: self.seq.to_string(), + outcome, + error: error.map(|error| { + error + .project_with_clock(self.now) + .envelope(SystemTime::UNIX_EPOCH) + }), + })) + } +} + +impl Drop for FrameStream { + fn drop(&mut self) { + if let (Some(_), Some(abort)) = (&self.events, &self.abort) { + abort.abort(); + } + } +} + +impl Stream for FrameStream { + type Item = StreamFrame; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + if this + .cancelled + .as_mut() + .is_some_and(|cancelled| cancelled.as_mut().poll(cx).is_ready()) + { + return this.end( + Outcome::Cancelled, + Some(OpsError::AiMux(AiMuxError::Aborted( + "request aborted".into(), + ))), + ); + } + let next = match this.events.as_mut() { + Some(events) => events.as_mut().poll_next(cx), + None => return Poll::Ready(None), + }; + match next { + Poll::Pending => Poll::Pending, + Poll::Ready(None) => this.end(Outcome::Success, None), + Poll::Ready(Some(Err(error))) => { + let outcome = + if matches!(error.unprojected(), OpsError::AiMux(AiMuxError::Aborted(_))) { + Outcome::Cancelled + } else { + Outcome::Error + }; + this.end(outcome, Some(error)) + } + Poll::Ready(Some(Ok(event))) => { + let Some(next_seq) = this.seq.checked_add(1) else { + return this.end( + Outcome::Error, + Some(OpsError::Protocol(ProtocolError::ResourceLimit( + "stream sequence exhausted".into(), + ))), + ); + }; + let frame = StreamFrame::Event { + seq: this.seq.to_string(), + event, + }; + if let Err(error) = + crate::limits::check_wire_item(&frame, crate::limits::ITEM_BYTES) + { + return this.end(Outcome::Error, Some(error)); + } + this.seq = next_seq; + Poll::Ready(Some(frame)) + } + } + } +} + +impl futures::stream::FusedStream for FrameStream { + fn is_terminated(&self) -> bool { + self.events.is_none() + } +} diff --git a/aimux-ops/src/handle.rs b/aimux-ops/src/handle.rs new file mode 100644 index 000000000..380aa90e5 --- /dev/null +++ b/aimux-ops/src/handle.rs @@ -0,0 +1,418 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::envelope::{BindingError, OpsError, ProtocolError}; + +/// Global scope is 0; live resource identifiers are never reused. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct HandleId(pub u64); + +impl Serialize for HandleId { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0.to_string()) + } +} + +impl std::str::FromStr for HandleId { + type Err = String; + + /// The one handle-id rule: a decimal string that fits u64. + fn from_str(value: &str) -> Result { + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err("expected a decimal handle string".into()); + } + value.parse().map(Self).map_err(|error| format!("{error}")) + } +} + +impl<'de> Deserialize<'de> for HandleId { + fn deserialize>(deserializer: D) -> Result { + String::deserialize(deserializer)? + .parse() + .map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Owner { + CProcess, + Node(u64), + Python(u64), +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize)] +pub enum HandleKind { + Provider, + Registry, + Language, + Embedding, + Image, + Speech, + Transcription, + Reranking, + Video, + Search, + Decision, + Files, + Operation, + Abort, + Fetch, + WebSocket, + Download, + CredentialResolver, + Middleware, + TraceStore, + Recorder, + SessionStore, + ReplaySession, +} + +impl HandleKind { + pub const ALL: &'static [Self] = &[ + Self::Provider, + Self::Registry, + Self::Language, + Self::Embedding, + Self::Image, + Self::Speech, + Self::Transcription, + Self::Reranking, + Self::Video, + Self::Search, + Self::Decision, + Self::Files, + Self::Operation, + Self::Abort, + Self::Fetch, + Self::WebSocket, + Self::Download, + Self::CredentialResolver, + Self::Middleware, + Self::TraceStore, + Self::Recorder, + Self::SessionStore, + Self::ReplaySession, + ]; +} + +/// Strong references are cloned under the registry lock and used outside it. +#[derive(Clone)] +pub enum Entry { + Provider(Arc), + Registry(Arc), + Language(Arc), + Embedding(Arc), + Image(Arc), + Speech(Arc), + Transcription(Arc), + Reranking(Arc), + Video(Arc), + Search(Arc), + Decision(Arc), + Files(Arc), + Operation(Arc), + Abort(Arc), + Fetch(Arc), + WebSocket(Arc), + Download(Arc), + CredentialResolver(Arc>), + Middleware(Arc), + TraceStore(Arc), + Recorder(Arc), + SessionStore(Arc), + ReplaySession(Arc), +} + +impl Entry { + #[must_use] + pub fn kind(&self) -> HandleKind { + match self { + Self::Provider(_) => HandleKind::Provider, + Self::Registry(_) => HandleKind::Registry, + Self::Language(_) => HandleKind::Language, + Self::Embedding(_) => HandleKind::Embedding, + Self::Image(_) => HandleKind::Image, + Self::Speech(_) => HandleKind::Speech, + Self::Transcription(_) => HandleKind::Transcription, + Self::Reranking(_) => HandleKind::Reranking, + Self::Video(_) => HandleKind::Video, + Self::Search(_) => HandleKind::Search, + Self::Decision(_) => HandleKind::Decision, + Self::Files(_) => HandleKind::Files, + Self::Operation(_) => HandleKind::Operation, + Self::Abort(_) => HandleKind::Abort, + Self::Fetch(_) => HandleKind::Fetch, + Self::WebSocket(_) => HandleKind::WebSocket, + Self::Download(_) => HandleKind::Download, + Self::CredentialResolver(_) => HandleKind::CredentialResolver, + Self::Middleware(_) => HandleKind::Middleware, + Self::TraceStore(_) => HandleKind::TraceStore, + Self::Recorder(_) => HandleKind::Recorder, + Self::SessionStore(_) => HandleKind::SessionStore, + Self::ReplaySession(_) => HandleKind::ReplaySession, + } + } +} + +#[derive(Default)] +pub struct HandleRegistry { + entries: Mutex>, +} + +// Even separate registry instances allocate from the same process-wide sequence. +static NEXT_ID: AtomicU64 = AtomicU64::new(1); + +impl Owner { + /// Allocate an owner token in trusted adapter code, never from user input. + /// # Errors + /// Returns HandleExhausted when the process owner allocator is exhausted. + pub fn new_node() -> Result { + allocate(&NEXT_OWNER).map(Self::Node) + } + /// Allocate a Python interpreter owner token. + /// # Errors + /// Returns HandleExhausted when the process owner allocator is exhausted. + pub fn new_python() -> Result { + allocate(&NEXT_OWNER).map(Self::Python) + } +} +static NEXT_OWNER: AtomicU64 = AtomicU64::new(1); + +impl HandleRegistry { + /// Remove all owned entries before signalling operations or dropping resources. + pub fn release_owner(&self, owner: Owner) -> usize { + let removed = { + let mut entries = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let ids: Vec<_> = entries + .iter() + .filter_map(|(&id, (found, _))| (*found == owner).then_some(id)) + .collect(); + ids.into_iter() + .filter_map(|id| entries.remove(&id).map(|(_, entry)| entry)) + .collect::>() + }; + let count = removed.len(); + for entry in &removed { + if let Entry::Operation(operation) = entry { + operation.consumer_close(); + } + } + drop(removed); + count + } + + #[must_use] + pub fn global() -> &'static Self { + static REGISTRY: OnceLock = OnceLock::new(); + REGISTRY.get_or_init(Self::default) + } + + /// Insert a resource, failing permanently rather than wrapping the allocator. + /// + /// # Errors + /// Returns HandleExhausted on overflow. + pub fn insert(&self, owner: Owner, entry: Entry) -> Result { + let id = allocate(&NEXT_ID)?; + self.entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(id, (owner, entry)); + Ok(HandleId(id)) + } + + /// Clone a live resource after checking its owner and kind. + /// + /// # Errors + /// Returns InvalidHandle without revealing a foreign owner's resource kind. + pub fn get( + &self, + owner: Owner, + id: HandleId, + allowed: &[HandleKind], + ) -> Result { + let entries = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let entry = entries + .get(&id.0) + .filter(|(entry_owner, _)| *entry_owner == owner); + match entry { + Some((_, entry)) if allowed.contains(&entry.kind()) => Ok(entry.clone()), + entry => Err(OpsError::Binding(BindingError::InvalidHandle { + expected: allowed.to_vec(), + actual: entry.map(|(_, entry)| entry.kind()), + })), + } + } + + /// Remove before performing any cancellation, I/O or resource cleanup. + #[must_use] + pub fn remove(&self, owner: Owner, id: HandleId) -> Option { + let mut entries = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if entries + .get(&id.0) + .is_some_and(|(entry_owner, _)| *entry_owner == owner) + { + entries.remove(&id.0).map(|(_, entry)| entry) + } else { + None + } + } +} + +/// Take the next nonzero id from a counter that starts at 1, failing once it is exhausted. +/// +/// # Errors +/// Returns HandleExhausted after u64::MAX has been allocated. +pub fn allocate(next: &AtomicU64) -> Result { + // 0 is an exhausted sentinel, reached after allocating u64::MAX once. + let mut id = next.load(Ordering::Relaxed); + loop { + if id == 0 { + return Err(OpsError::Protocol(ProtocolError::HandleExhausted)); + } + let following = id.checked_add(1).unwrap_or(0); + match next.compare_exchange_weak(id, following, Ordering::Relaxed, Ordering::Relaxed) { + Ok(allocated) => return Ok(allocated), + Err(current) => id = current, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn owner_kind_and_drop_preserve_strong_reference() { + let registry = HandleRegistry::default(); + let signal = Arc::new(aimux_core::AbortSignal::new()); + let id = registry + .insert(Owner::Node(1), Entry::Abort(signal.clone())) + .unwrap(); + let retained = registry + .get(Owner::Node(1), id, &[HandleKind::Abort]) + .unwrap(); + assert!(matches!( + registry.get(Owner::Node(2), id, &[HandleKind::Abort]), + Err(OpsError::Binding(BindingError::InvalidHandle { + actual: None, + .. + })) + )); + assert!( + matches!(registry.get(Owner::Node(1), id, &[HandleKind::Language]), + Err(OpsError::Binding(BindingError::InvalidHandle { expected, actual: Some(HandleKind::Abort) })) + if expected == vec![HandleKind::Language]) + ); + assert!(registry.remove(Owner::Python(1), id).is_none()); + assert!(registry.remove(Owner::Node(1), id).is_some()); + assert!(registry.remove(Owner::Node(1), id).is_none()); + assert!( + registry + .get(Owner::Node(1), id, &[HandleKind::Abort]) + .is_err() + ); + let Entry::Abort(retained) = retained else { + unreachable!() + }; + retained.abort(); + assert!(signal.is_aborted()); + let next = registry + .insert(Owner::CProcess, Entry::Abort(signal)) + .unwrap(); + assert!(next.0 > id.0); + } + + #[test] + fn global_and_unknown_handles_are_not_resources() { + let registry = HandleRegistry::default(); + for id in [HandleId(0), HandleId(u64::MAX)] { + assert!(registry.get(Owner::CProcess, id, HandleKind::ALL).is_err()); + assert!(registry.remove(Owner::CProcess, id).is_none()); + } + } + + #[test] + fn allocator_exhaustion_does_not_wrap() { + let next = AtomicU64::new(u64::MAX); + assert_eq!(allocate(&next).unwrap(), u64::MAX); + assert!(matches!( + allocate(&next), + Err(OpsError::Protocol(ProtocolError::HandleExhausted)) + )); + assert!(allocate(&next).is_err()); + } + + #[test] + fn independent_registries_share_unique_process_ids_under_concurrent_insertion() { + let workers: Vec<_> = (0..4) + .map(|owner| { + std::thread::spawn(move || { + let registry = HandleRegistry::default(); + let ids: Vec<_> = (0..16) + .map(|_| { + registry + .insert( + Owner::Node(owner), + Entry::Abort(Arc::new(aimux_core::AbortSignal::new())), + ) + .unwrap() + .0 + }) + .collect(); + assert!(ids.windows(2).all(|pair| pair[0] < pair[1])); + ids + }) + }) + .collect(); + let mut ids: Vec<_> = workers + .into_iter() + .flat_map(|worker| worker.join().unwrap()) + .collect(); + assert!(ids.iter().all(|id| *id != 0)); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), 64); + } + + #[test] + fn handles_are_decimal_strings_including_above_js_integer_range() { + let id = HandleId(u64::MAX); + assert_eq!( + serde_json::to_string(&id).unwrap(), + "\"18446744073709551615\"" + ); + assert_eq!( + serde_json::from_str::("\"18446744073709551615\"").unwrap(), + id + ); + for invalid in [ + "7", + "\"-1\"", + "\"+1\"", + "\"1e3\"", + "\"\"", + "\"18446744073709551616\"", + ] { + assert!(serde_json::from_str::(invalid).is_err()); + } + } +} + +/// Built-in modality-specific middleware, with no host callback bridge. +#[derive(Clone)] +pub enum Middleware { + Language(Arc), + Image(Arc), +} diff --git a/aimux-ops/src/lib.rs b/aimux-ops/src/lib.rs new file mode 100644 index 000000000..6880ae739 --- /dev/null +++ b/aimux-ops/src/lib.rs @@ -0,0 +1,35 @@ +//! Shared asynchronous dispatch, resource ownership and wire framing for bindings. + +pub mod dispatch; +pub mod envelope; +pub mod frame; +pub mod handle; +pub mod limits; +pub mod manifest; +pub mod op; +pub mod operation; +pub mod retry_hint; + +pub mod ops { + pub mod composite; + pub mod decision; + pub mod files; + pub mod handles; + pub mod misc; + pub mod modalities; + pub mod operation; + pub mod provider; + pub mod recording; + pub mod registry; + pub mod replay; + pub mod runtime; + pub mod session; + pub mod text; + pub mod trace; +} + +pub use dispatch::{Ctx, Dispatcher, Handler, Target}; +pub use envelope::{BindingError, ErrorEnvelope, OpsError, ProjectedError, ProtocolError}; +pub use frame::{FrameStream, Outcome, StreamFrame}; +pub use handle::{Entry, HandleId, HandleKind, HandleRegistry, Owner}; +pub use op::{ABI_VERSION, OPS_VERSION, OpDescriptor, OpMode}; diff --git a/aimux-ops/src/limits.rs b/aimux-ops/src/limits.rs new file mode 100644 index 000000000..949176c09 --- /dev/null +++ b/aimux-ops/src/limits.rs @@ -0,0 +1,468 @@ +//! Byte- and item-bounded queues for operation input and output. +//! +//! Reserve capacity before allocating a payload. A reservation counts against +//! both budgets until it is dropped or its payload is consumed. Terminal state +//! has a separate slot and never waits for data capacity. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex, MutexGuard}; + +use aimux_core::AbortSignal; +use tokio::sync::Notify; + +use crate::envelope::{OpsError, ProtocolError}; + +pub const AUDIO_INPUT_ITEMS: usize = 64; +pub const OUTPUT_ITEMS: usize = 256; +pub const QUEUE_BYTES: usize = 8 * 1024 * 1024; +pub const ITEM_BYTES: usize = 1024 * 1024; + +/// Finite queue budgets. Construct a default, then explicitly lower it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct QueueLimits { + max_items: usize, + max_bytes: usize, + max_item_bytes: usize, +} + +impl QueueLimits { + #[must_use] + pub const fn audio_input() -> Self { + Self { + max_items: AUDIO_INPUT_ITEMS, + max_bytes: QUEUE_BYTES, + max_item_bytes: ITEM_BYTES, + } + } + + #[must_use] + pub const fn output() -> Self { + Self { + max_items: OUTPUT_ITEMS, + ..Self::audio_input() + } + } + + /// Lower any budget; zero and increases are rejected. + /// + /// # Errors + /// Returns ResourceLimit for invalid or unbounded budgets. + pub fn lowered( + self, + max_items: usize, + max_bytes: usize, + max_item_bytes: usize, + ) -> Result { + if max_items == 0 + || max_bytes == 0 + || max_item_bytes == 0 + || max_items > self.max_items + || max_bytes > self.max_bytes + || max_item_bytes > self.max_item_bytes + { + return Err(resource_limit( + "queue limits must be positive and may only be lowered", + )); + } + Ok(Self { + max_items, + max_bytes, + max_item_bytes, + }) + } + + #[must_use] + pub const fn max_items(self) -> usize { + self.max_items + } + + #[must_use] + pub const fn max_bytes(self) -> usize { + self.max_bytes + } + + #[must_use] + pub const fn max_item_bytes(self) -> usize { + self.max_item_bytes + } + + /// Validate a wire payload length before allocating it. + /// + /// # Errors + /// Returns ResourceLimit when the item cannot fit either byte budget. + pub fn check_item(self, bytes: usize) -> Result<(), OpsError> { + if bytes > self.max_item_bytes || bytes > self.max_bytes { + return Err(resource_limit( + "payload exceeds the single-item or queue byte limit", + )); + } + Ok(()) + } +} + +/// Size of the actual queued payload, including any wire encoding overhead. +/// Implementations must report the full retained payload size. +pub trait QueueItem { + fn byte_len(&self) -> usize; +} + +impl QueueItem for Vec { + fn byte_len(&self) -> usize { + self.capacity() + } +} + +impl QueueItem for Box<[u8]> { + fn byte_len(&self) -> usize { + self.len() + } +} + +impl QueueItem for String { + fn byte_len(&self) -> usize { + self.capacity() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum QueueStatus { + Open, + Finished, + ConsumerClosed, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum QueueEvent { + Data(T), + Terminal(E), +} + +enum Terminal { + Open, + Ready(E), + Delivered, +} + +struct State { + data: VecDeque<(T, usize)>, + used_items: usize, + used_bytes: usize, + terminal: Terminal, + consumer_closed: bool, +} + +struct Shared { + limits: QueueLimits, + state: Mutex>, + readable: Notify, + writable: Notify, +} + +impl Shared { + fn lock(&self) -> MutexGuard<'_, State> { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +/// Cloneable producer. The operation must explicitly publish its true terminal +/// outcome with `finish`; dropping a producer does not imply success. +pub struct QueueSender { + shared: Arc>, +} + +impl Clone for QueueSender { + fn clone(&self) -> Self { + Self { + shared: Arc::clone(&self.shared), + } + } +} + +/// Single consumer; dropping it explicitly closes the consumer side. +pub struct QueueReceiver { + shared: Arc>, +} + +/// Capacity reserved before allocation. Dropping it releases both budgets. +#[must_use = "dropping the permit releases its reserved capacity"] +pub struct QueuePermit { + shared: Arc>, + bytes: Option, +} + +#[must_use] +pub fn bounded_queue( + limits: QueueLimits, +) -> (QueueSender, QueueReceiver) { + let shared = Arc::new(Shared { + limits, + state: Mutex::new(State { + data: VecDeque::new(), + used_items: 0, + used_bytes: 0, + terminal: Terminal::Open, + consumer_closed: false, + }), + readable: Notify::new(), + writable: Notify::new(), + }); + ( + QueueSender { + shared: Arc::clone(&shared), + }, + QueueReceiver { shared }, + ) +} + +impl QueueSender { + /// Wait for both budgets before the caller allocates the payload. + /// + /// # Errors + /// Returns ResourceLimit for oversized items, Aborted on abort, or + /// InvalidState after terminal publication or consumer closure. + pub async fn reserve( + &self, + bytes: usize, + abort: Option<&AbortSignal>, + ) -> Result, OpsError> { + self.shared.limits.check_item(bytes)?; + loop { + let notified = self.shared.writable.notified(); + tokio::pin!(notified); + // Register before checking state so release/close cannot be missed. + notified.as_mut().enable(); + { + let mut state = self.shared.lock(); + if abort.is_some_and(AbortSignal::is_aborted) { + return Err(OpsError::AiMux(aimux_core::error::AiMuxError::Aborted( + "queue send aborted".into(), + ))); + } + ensure_open(&state)?; + if state.used_items < self.shared.limits.max_items + && bytes <= self.shared.limits.max_bytes - state.used_bytes + { + state.used_items += 1; + state.used_bytes += bytes; + return Ok(QueuePermit { + shared: Arc::clone(&self.shared), + bytes: Some(bytes), + }); + } + } + if let Some(abort) = abort { + tokio::select! { + biased; + () = abort.cancelled() => { + return Err(OpsError::AiMux(aimux_core::error::AiMuxError::Aborted( + "queue send aborted".into(), + ))); + } + () = notified => {} + } + } else { + notified.await; + } + } + } + + /// Send an already allocated payload. Use `reserve` before allocation when + /// accepting a wire length from an untrusted caller. + /// + /// # Errors + /// Returns the same errors as `reserve`. + pub async fn send(&self, item: T, abort: Option<&AbortSignal>) -> Result<(), OpsError> { + self.reserve(item.byte_len(), abort).await?.send(item) + } + + /// Publish the first terminal outcome without acquiring data capacity. + /// Already accepted data is delivered before this terminal. Pending + /// reservations can no longer commit. Later outcomes cannot replace it. + pub fn finish(&self, terminal: E) -> bool { + { + let mut state = self.shared.lock(); + if state.consumer_closed || !matches!(state.terminal, Terminal::Open) { + return false; + } + state.terminal = Terminal::Ready(terminal); + } + self.shared.readable.notify_waiters(); + self.shared.writable.notify_waiters(); + true + } + + /// Admitted and reserved item/byte counts, for backpressure instrumentation. + #[must_use] + pub fn usage(&self) -> (usize, usize) { + let state = self.shared.lock(); + (state.used_items, state.used_bytes) + } + + #[must_use] + pub fn status(&self) -> QueueStatus { + let state = self.shared.lock(); + if state.consumer_closed { + QueueStatus::ConsumerClosed + } else if matches!(state.terminal, Terminal::Open) { + QueueStatus::Open + } else { + QueueStatus::Finished + } + } +} + +impl QueuePermit { + /// Commit a payload whose actual size must not exceed its reservation. + /// + /// # Errors + /// Returns ResourceLimit for an undersized reservation, or InvalidState if + /// terminal publication/consumer closure occurred before this commit. + pub fn send(mut self, item: T) -> Result<(), OpsError> { + let actual = item.byte_len(); + let reserved = self.bytes.unwrap_or_default(); + if actual > reserved { + return Err(resource_limit("payload exceeds its reserved byte budget")); + } + { + let mut state = self.shared.lock(); + ensure_open(&state)?; + state.used_bytes -= reserved - actual; + state.data.push_back((item, actual)); + self.bytes = None; + } + self.shared.readable.notify_one(); + self.shared.writable.notify_waiters(); + Ok(()) + } +} + +impl Drop for QueuePermit { + fn drop(&mut self) { + if let Some(bytes) = self.bytes.take() { + { + let mut state = self.shared.lock(); + state.used_items -= 1; + state.used_bytes -= bytes; + } + self.shared.writable.notify_waiters(); + } + } +} + +impl QueueReceiver { + /// Drain accepted data, then deliver the independent terminal exactly once. + /// Returns None after terminal delivery or consumer closure. + pub async fn recv(&mut self) -> Option> { + loop { + let notified = self.shared.readable.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + { + let mut state = self.shared.lock(); + if state.consumer_closed { + return None; + } + if let Some((item, bytes)) = state.data.pop_front() { + state.used_items -= 1; + state.used_bytes -= bytes; + drop(state); + self.shared.writable.notify_waiters(); + return Some(QueueEvent::Data(item)); + } + match std::mem::replace(&mut state.terminal, Terminal::Delivered) { + Terminal::Ready(terminal) => return Some(QueueEvent::Terminal(terminal)), + Terminal::Delivered => return None, + Terminal::Open => state.terminal = Terminal::Open, + } + } + notified.await; + } + } + + /// Explicitly abandon queued data and wake blocked producers. The caller + /// must abort the associated operation and report cancelled/consumerClosed. + pub fn close(&mut self) { + let abandoned = { + let mut state = self.shared.lock(); + if state.consumer_closed || matches!(state.terminal, Terminal::Delivered) { + return; + } + state.consumer_closed = true; + let abandoned = std::mem::take(&mut state.data); + state.used_items -= abandoned.len(); + state.used_bytes -= abandoned.iter().map(|(_, bytes)| bytes).sum::(); + abandoned + }; + // Payload destructors run outside the mutex. + drop(abandoned); + self.shared.writable.notify_waiters(); + self.shared.readable.notify_waiters(); + } +} + +impl Drop for QueueReceiver { + fn drop(&mut self) { + self.close(); + } +} + +fn resource_limit(message: &str) -> OpsError { + OpsError::Protocol(ProtocolError::ResourceLimit(message.into())) +} + +fn ensure_open(state: &State) -> Result<(), OpsError> { + if state.consumer_closed { + Err(OpsError::Protocol(ProtocolError::InvalidState( + "consumerClosed".into(), + ))) + } else if !matches!(state.terminal, Terminal::Open) { + Err(OpsError::Protocol(ProtocolError::InvalidState( + "queue already terminal".into(), + ))) + } else { + Ok(()) + } +} + +/// Measure serialized wire bytes without allocating an output buffer. +/// # Errors +/// Returns ResourceLimit above the item bound or ResultSerialization for a serializer error. +pub fn check_wire_item(value: &impl serde::Serialize, maximum: usize) -> Result { + struct Counter { + bytes: usize, + maximum: usize, + exceeded: bool, + } + impl std::io::Write for Counter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + match self.bytes.checked_add(bytes.len()) { + Some(total) if total <= self.maximum => { + self.bytes = total; + Ok(bytes.len()) + } + _ => { + self.exceeded = true; + Err(std::io::Error::other("wire item exceeds byte budget")) + } + } + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut counter = Counter { + bytes: 0, + maximum, + exceeded: false, + }; + if let Err(error) = serde_json::to_writer(&mut counter, value) { + return if counter.exceeded { + Err(resource_limit("wire item exceeds byte budget")) + } else { + Err(crate::envelope::BindingError::ResultSerialization(error.to_string()).into()) + }; + } + Ok(counter.bytes) +} diff --git a/aimux-ops/src/manifest.rs b/aimux-ops/src/manifest.rs new file mode 100644 index 000000000..227001592 --- /dev/null +++ b/aimux-ops/src/manifest.rs @@ -0,0 +1,259 @@ +mod controls; + +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +use crate::{ABI_VERSION, HandleKind, OPS_VERSION}; + +/// Describe all registered operations and their wire metadata. +#[must_use] +pub fn manifest() -> Value { + let ops: Vec<_> = crate::dispatch::registrations() + .iter() + .map(|(descriptor, _)| json!(descriptor)) + .collect(); + let mut error_codes = Vec::new(); + for (kind, entries) in ERROR_CODES { + for (code, name) in *entries { + error_codes.push( + json!({ "kind": kind, "code": code, "name": name, "aliases": aliases(*code) }), + ); + } + } + let mut value = json!({ + "abiVersion": ABI_VERSION, + "opsVersion": OPS_VERSION, + "manifestRevision": "1", + "ops": ops, + "handleKinds": HandleKind::ALL, + "packages": crate::ops::provider::packages(), + "errorCodes": error_codes, + "types": types(), + "registeredGaps": [ + "G-a: recording/session/infer state remains process-wide", + "G-b: recording schema migration belongs to RFC-0041", + "G-c: replay transport capabilities and credential creation are future", + "G-d: business JSON schemas and language DTO generation belong to RFC-0042", + "G-e: per-asset platform install verification is not claimed" + ], + }); + value["digest"] = json!(digest(value.clone())); + value +} + +/// SHA-256 of recursively key-sorted compact JSON, independent of map insertion order. +#[must_use] +pub fn manifest_digest() -> String { + digest(manifest()) +} + +fn canonicalize(value: &mut Value) { + match value { + Value::Object(object) => { + object.sort_keys(); + for value in object.values_mut() { + canonicalize(value); + } + } + Value::Array(values) => { + for value in values { + canonicalize(value); + } + } + _ => {} + } +} + +fn digest(mut value: Value) -> String { + if let Some(object) = value.as_object_mut() { + object.remove("digest"); + } + canonicalize(&mut value); + format!("{:x}", Sha256::digest(value.to_string().as_bytes())) +} + +// Slot 4 remains retired; protocol codes occupy a separate range. +const ERROR_CODES: &[(&str, &[(i32, &str)])] = &[ + ( + "aimux", + &[ + (1, "Other"), + (2, "JsonParse"), + (3, "InvalidResponseData"), + (5, "InvalidArgument"), + (6, "InvalidPrompt"), + (7, "TokenExpired"), + (8, "UnsupportedFunctionality"), + (9, "NoSuchModel"), + (10, "NoSuchProvider"), + (11, "ApiCall"), + (12, "Timeout"), + (13, "Aborted"), + (14, "Retry"), + (15, "NoSuchTool"), + (16, "InvalidToolInput"), + (17, "ToolCallRepair"), + (18, "NoOutputGenerated"), + (19, "LoadApiKey"), + (20, "LoadSetting"), + ], + ), + ( + "recording", + &[ + (100, "Init"), + (101, "OpenFile"), + (102, "Spawn"), + (103, "WriterGone"), + (104, "FlushTimeout"), + (105, "Write"), + ], + ), + ( + "binding", + &[ + (200, "NullPointer"), + (201, "InvalidUtf8"), + (202, "InvalidWireJson"), + (203, "InvalidHandle"), + (204, "ReentrantCall"), + (205, "ResultSerialization"), + (206, "CallbackFailure"), + ], + ), + ( + "protocol", + &[ + (300, "InvalidOp"), + (301, "InvalidParams"), + (302, "UnsupportedOpsVersion"), + (303, "ConcurrentConsumer"), + (304, "InvalidState"), + (305, "ResourceLimit"), + (306, "HandleExhausted"), + ], + ), +]; + +fn aliases(code: i32) -> &'static [&'static str] { + match code { + 3 => &[ + "InvalidResponseData", + "ToolCallNotFoundForApproval", + "ToolChoiceViolation", + ], + 6 => &["InvalidPrompt", "MissingToolResults"], + _ => &[], + } +} + +fn types() -> Vec { + let mut ids = std::collections::BTreeSet::from(["ErrorEnvelope"]); + for (descriptor, _) in crate::dispatch::registrations() { + ids.insert(descriptor.request_type); + ids.insert(descriptor.result_type); + ids.extend(descriptor.event_type); + ids.extend(descriptor.terminal_type); + } + ids.into_iter().map(|id| { + let path = match id { + "EmptyParams" | "OperationNextParams" | "TranscriptionStartParams" | "BinaryInput" | "OperationPollResult" => format!("aimux_ops::ops::operation::{id}"), + "OperationStatus" | "CancelResult" => format!("aimux_ops::operation::{id}"), + "PollResult" => "aimux_ops::dispatch::PollResult".into(), + "HandleId" => "aimux_ops::handle::HandleId".into(), + "StreamFrame" => "aimux_ops::frame::StreamFrame".into(), + "ErrorEnvelope" => "aimux_ops::envelope::ErrorEnvelope".into(), + _ => id.to_owned(), + }; + let mut entry = json!({"id": id, "rustPath": path}); + let schema = match id { + "HandleId" => Some(controls::handle()), + "BinaryInput" => Some(json!({"type":"array","items":{"type":"integer","minimum":0,"maximum":255},"description":"Native binary push bytes; generic JSON dispatch is not admitted."})), + "ErrorEnvelope" => Some(error_envelope_schema()), + "StreamFrame" => Some(json!({"$defs":{"ErrorEnvelope":error_envelope_schema()},"oneOf":[ + {"type":"object","properties":{"type":{"const":"event"},"seq":{"type":"string","pattern":"^[0-9]+$"},"event":{}},"required":["type","seq","event"],"additionalProperties":false}, + {"type":"object","properties":{"type":{"const":"end"},"seq":{"type":"string","pattern":"^[0-9]+$"},"outcome":{"enum":["success","error","cancelled"]},"error":{"$ref":"#/$defs/ErrorEnvelope"}},"required":["type","seq","outcome"],"additionalProperties":false,"allOf":[ + {"if":{"properties":{"outcome":{"const":"error"}}},"then":{"required":["error"]}}, + {"if":{"properties":{"outcome":{"const":"success"}}},"then":{"not":{"required":["error"]}}} + ]} + ]})), + "EmptyParams" => Some(json!({"type":"object", "properties":{}, "additionalProperties":false})), + "OperationNextParams" => Some(json!({"type":"object", "properties":{"timeoutMs":{"type":"integer","minimum":0,"maximum":u64::MAX}}, "additionalProperties":false})), + "TranscriptionStartParams" => Some(json!({"type":"object", "properties":{ + "inputAudioFormat":{"type":["object","null"],"properties":{"formatType":{"type":"string"},"rate":{"type":["integer","null"],"minimum":0,"maximum":u32::MAX}},"required":["formatType"],"additionalProperties":false}, + "providerOptions":{"type":["object","null"]}, "headers":{"type":["object","null"]}, + "includeRawChunks":{"type":"boolean"}, "timeout":{"type":["object","null"],"properties":{ + "totalMs":{"type":["integer","null"],"minimum":0,"maximum":u64::MAX}, + "stepMs":{"type":["integer","null"],"minimum":0,"maximum":u64::MAX}, + "firstChunkMs":{"type":["integer","null"],"minimum":0,"maximum":u64::MAX}, + "chunkMs":{"type":["integer","null"],"minimum":0,"maximum":u64::MAX} + },"additionalProperties":false}, "abort":{"type":["string","null"],"pattern":"^[0-9]+$"} + },"additionalProperties":false})), + "CancelResult" => Some(json!({"type":"string","enum":["first","alreadyRequested","alreadyTerminal"]})), + "OperationPollResult" => Some(json!({"oneOf":[ + {"type":"object","properties":{"type":{"const":"part"},"part":{}},"required":["type","part"],"additionalProperties":false}, + {"type":"object","properties":{"type":{"enum":["ended","timeout"]}},"required":["type"],"additionalProperties":false} + ]})), + "OperationStatus" => Some(json!({"type":"object","properties":{ + "subkind":{"const":"transcription"},"state":{"enum":["running","inputClosed","terminal"]}, + "capabilities":{"type":"object","properties":{"push":{"type":"boolean"},"inputDone":{"type":"boolean"},"next":{"type":"boolean"},"cancel":{"type":"boolean"}},"required":["push","inputDone","next","cancel"],"additionalProperties":false}, + "consumerClosed":{"type":"boolean"}, + "queuedInputItems":{"type":"integer","minimum":0},"queuedInputBytes":{"type":"integer","minimum":0}, + "queuedOutputItems":{"type":"integer","minimum":0},"queuedOutputBytes":{"type":"integer","minimum":0}, + "inputDoneSequence":{"type":"string","pattern":"^[0-9]+$"}, + "terminal":{"type":"object","properties":{"outcome":{"enum":["success","error","cancelled"]},"error":{"type":"object"}},"required":["outcome"],"additionalProperties":false}, + "cleanupFailure":{"type":"string"} + },"required":["subkind","state","capabilities","consumerClosed","queuedInputItems","queuedInputBytes","queuedOutputItems","queuedOutputBytes"],"additionalProperties":false})), + _ => controls::schema(id), + }; + if let Some(schema) = schema { entry["schema"] = schema; } + entry + }).collect() +} + +fn error_envelope_schema() -> Value { + json!({"type":"object","properties":{ + "kind":{"enum":["aimux","recording","binding","protocol"]}, + "code":{"type":"integer"},"message":{"type":"string"}, + "error":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}, + "retryAfterMs":{"type":"integer","minimum":0,"maximum":u64::MAX} + },"required":["kind","code","message","error"],"additionalProperties":false}) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn digest_sorts_objects_recursively_and_preserves_arrays() { + let first: Value = serde_json::from_str(r#"{"z":[{"b":2,"a":1}],"a":3}"#).unwrap(); + let second: Value = serde_json::from_str(r#"{"a":3,"z":[{"a":1,"b":2}]}"#).unwrap(); + assert_eq!(digest(first), digest(second)); + assert_ne!(digest(json!([1, 2])), digest(json!([2, 1]))); + assert_eq!( + digest(json!({})), + "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + ); + } + + #[test] + fn manifest_has_stable_versions_kinds_codes_and_registered_ops() { + let value = manifest(); + assert_eq!(value["abiVersion"], 65536); + assert_eq!(value["opsVersion"], "1.0"); + assert_eq!(value["handleKinds"].as_array().unwrap().len(), 23); + let codes = value["errorCodes"].as_array().unwrap(); + assert!( + codes + .iter() + .any(|code| code["code"] == 306 && code["name"] == "HandleExhausted") + ); + assert!(!codes.iter().any(|code| code["code"] == 4)); + let ops = value["ops"].as_array().unwrap(); + assert_eq!(ops.len(), crate::dispatch::registrations().len()); + assert!( + ops.windows(2) + .all(|pair| pair[0]["name"].as_str() < pair[1]["name"].as_str()) + ); + } +} diff --git a/aimux-ops/src/manifest/controls.rs b/aimux-ops/src/manifest/controls.rs new file mode 100644 index 000000000..3d9717689 --- /dev/null +++ b/aimux-ops/src/manifest/controls.rs @@ -0,0 +1,283 @@ +//! Schemas for operation controls. Canonical business fields retain type references +//! until their schemas are published by RFC-0042. + +use serde_json::{Value, json}; + +fn object(properties: Value, required: &[&str]) -> Value { + json!({"type":"object", "properties":properties, "required":required, "additionalProperties":false}) +} + +fn string() -> Value { + json!({"type":"string"}) +} +fn nullable(schema: Value) -> Value { + json!({"anyOf":[schema, {"type":"null"}]}) +} +fn array(items: Value) -> Value { + json!({"type":"array", "items":items}) +} +fn business(path: &str) -> Value { + json!({"x-aimux-type":path,"$comment":"Canonical business schema is deferred to RFC-0042 (G-d)."}) +} +fn boolean() -> Value { + json!({"type":"boolean"}) +} +fn integer() -> Value { + json!({"type":"integer","minimum":0,"maximum":u64::MAX}) +} +fn methods() -> Value { + json!({"enum":["languageModel","embeddingModel","imageModel","speechModel","transcriptionModel","rerankingModel","videoModel","searchModel","decision"]}) +} +fn kind() -> Value { + json!({"enum":crate::HandleKind::ALL}) +} + +pub(super) fn handle() -> Value { + // DTO decoding permits leading zeroes but checks unsigned u64 overflow. + // Bound the numeric suffix instead of rounding a JSON number. + let maximum = u64::MAX.to_string(); + let mut choices = vec!["[0-9]{1,19}".to_owned()]; + for (index, digit) in maximum.bytes().enumerate() { + if digit > b'0' { + choices.push(format!( + "{}[0-{}][0-9]{{{}}}", + &maximum[..index], + char::from(digit - 1), + maximum.len() - index - 1 + )); + } + } + choices.push(maximum); + json!({"type":"string","pattern":format!("^0*({})$", choices.join("|")),"not":{"pattern":"[^0-9]"}}) +} + +fn proxy() -> Value { + object( + json!({"http_url":nullable(string()),"https_url":nullable(string()),"all_url":nullable(string()),"no_proxy":nullable(string())}), + &[], + ) +} + +fn handle_result(with_kind: bool) -> Value { + if with_kind { + object( + json!({"handle":handle(),"kind":kind()}), + &["handle", "kind"], + ) + } else { + object(json!({"handle":handle()}), &["handle"]) + } +} + +pub(super) fn schema(id: &str) -> Option { + let name = id.strip_prefix("aimux_ops::ops::")?; + Some(match name { + "runtime::RuntimeDescribeParams" + | "registry::ModelInfoParams" + | "recording::EmptyParams" => object(json!({}), &[]), + "runtime::RuntimeConfigureParams" => object( + json!({ + "logging":nullable(object(json!({"level":{"enum":["off","error","warn","info","debug","trace"]}}), &["level"])), + "proxy":nullable(proxy()) + }), + &[], + ), + "provider::ProviderCreateParams" => object( + json!({"package":string(),"settings":provider_settings()}), + &["package"], + ), + "provider::ProviderDefaultParams" => object(json!({"package":string()}), &["package"]), + "provider::ProviderModelParams" => object( + json!({"method":nullable(string()),"modelId":string()}), + &["modelId"], + ), + "provider::ProviderInvokeParams" => object( + json!({"method":string(),"abort":nullable(handle())}), + &["method"], + ), + "provider::ProviderWrapParams" => object( + json!({"languageModelMiddleware":array(handle()),"imageModelMiddleware":array(handle())}), + &[], + ), + "provider::ProviderCustomParams" => provider_custom(), + "provider::HandleResult" | "registry::HandleResult" | "handles::HandleResult" => { + handle_result(true) + } + "provider::ProviderInvokeResult" => { + array(business("aimux_core::model_catalogue::RuntimeModel")) + } + "registry::RegistryCreateParams" => object( + json!({"namespace":string(),"entries":array(object(json!({"key":string(),"provider":handle()}), &["key","provider"]))}), + &["entries"], + ), + "registry::RegistryProviderParams" => object(json!({"key":string()}), &["key"]), + "registry::ModelResolveParams" => object( + json!({"modelRef":object(json!({"namespace":string(),"key":string(),"method":methods(),"modelId":string()}), &["namespace","key","method","modelId"])}), + &["modelRef"], + ), + "registry::ModelWrapParams" => { + object(json!({"middleware":array(handle())}), &["middleware"]) + } + "registry::ModelInfo" => object( + json!({"provider":string(),"modelId":string(),"modality":string(),"capabilities":{},"source":{}}), + &["provider", "modelId", "modality", "capabilities", "source"], + ), + "composite::RouterParams" => { + let mut children = array(handle()); + children["minItems"] = json!(1); + object( + json!({"children":children,"strategy":{"oneOf":[ + object(json!({"type":{"const":"rule"}}), &["type"]), + object(json!({"type":{"const":"weighted"},"weights":array(json!({"type":"number"}))}), &["type","weights"]) + ]},"fallback":{"enum":["on-error","none"]},"providerName":nullable(string()),"modelId":nullable(string())}), + &["children", "strategy"], + ) + } + "composite::MoaParams" => object( + json!({"references":array(handle()),"aggregator":handle(),"providerName":string(),"modelId":string(),"aggregatorInstructions":nullable(string()),"stripReferenceTools":boolean(),"failMode":{"enum":["best-effort","fail-fast"]}}), + &["references", "aggregator"], + ), + "text::TextRequest" => text_request(false), + "text::OpenAiTextRequest" => text_request(true), + "decision::DecisionDecideRequest" => object( + json!({"options":business("aimux_core::decision_model::DecideOptions"),"abort":nullable(handle())}), + &["options"], + ), + "files::UploadFileParams" => object( + json!({"data":{"oneOf":[ + object(json!({"type":{"const":"data"},"data":{"type":"string","contentEncoding":"base64"}}), &["type","data"]), + object(json!({"type":{"const":"text"},"text":string()}), &["type","text"]) + ]},"mediaType":string(),"filename":nullable(string()),"providerOptions":nullable(json!({"type":"object"})),"abort":nullable(handle())}), + &["data", "mediaType"], + ), + "handles::TransportParams" => json!({"oneOf":[ + object(json!({"kind":{"const":"fetch"},"proxy":proxy()}), &["kind"]), + object(json!({"kind":{"const":"ws"},"proxy":proxy()}), &["kind"]), + object(json!({"kind":{"const":"download"},"maxBytes":nullable(integer())}), &["kind"]) + ]}), + "handles::CredentialParams" => object(json!({"kind":string()}), &["kind"]), + "handles::MiddlewareParams" => object( + json!({"kind":string(),"traceStore":nullable(handle()),"scopeName":nullable(string()),"auditMode":nullable(json!({"enum":["disabled","shared","strict"]}))}), + &["kind"], + ), + "trace::TraceCreateParams" => { + let mut capacity = integer(); + capacity["minimum"] = json!(1); + object( + json!({"capacity":capacity,"perScopeCapacity":capacity,"auditMode":{"enum":["off","shared","strict"]}}), + &[], + ) + } + "trace::TraceAggregateParams" => object( + json!({"filter":object(json!({"provider":nullable(string()),"model":nullable(string()),"sessionId":nullable(string()),"sinceUnixMs":nullable(json!({"type":"integer","minimum":i64::MIN,"maximum":i64::MAX}))}), &[])}), + &[], + ), + "trace::TraceSessionParams" | "session::SessionStoreCallsParams" => { + object(json!({"sessionId":string()}), &["sessionId"]) + } + "trace::TraceAggregateResult" => array(business("aimux_core::trace::TraceStats")), + "trace::TraceTrajectoryResult" => array(business("aimux_core::trace::SessionStepStat")), + "session::SessionStoreCreateParams" => object(json!({"infer":boolean()}), &["infer"]), + "session::SessionStoreHandle" + | "recording::RecorderHandleResult" + | "replay::ReplaySessionHandle" => handle_result(false), + "session::SessionViews" => array(business("aimux_core::session::SessionView")), + "session::SessionCalls" => array(business("aimux_core::session::SessionCall")), + "recording::CreateRecorderParams" => object( + json!({"backend":{"oneOf":[ + object(json!({"type":{"const":"ring"},"capacity":nullable(integer())}), &["type"]), + object(json!({"type":{"const":"jsonl"},"directory":string()}), &["type","directory"]) + ]},"capturePolicy":{"const":"full"}}), + &["backend", "capturePolicy"], + ), + "recording::RecorderExport" => object(json!({"jsonl":string()}), &["jsonl"]), + "recording::RecorderStats" => recorder_stats(), + "replay::ReplayCreateParams" => { + object(json!({"recordingsJsonl":string()}), &["recordingsJsonl"]) + } + "replay::ReplayLiveParams" => object( + json!({"callId":string(),"target":object(json!({"kind":{"enum":["model","registry"]},"handle":handle()}), &["kind","handle"]),"abort":nullable(handle())}), + &["callId", "target"], + ), + "replay::ReplayInspection" => object( + json!({"recordings":array(object(json!({"callId":string(),"provider":business("aimux_core::recording::ProviderRecord"),"operation":business("aimux_core::recording::RecordingOperation"),"complete":boolean(),"transportClosed":boolean(),"exchangeCount":integer()}), &["callId","provider","operation","complete","transportClosed","exchangeCount"]))}), + &["recordings"], + ), + "replay::ReplayPlan" => object( + json!({"calls":array(object(json!({"callId":string(),"provider":business("aimux_core::recording::ProviderRecord"),"operation":business("aimux_core::recording::RecordingOperation"),"liveSupported":boolean(),"requiresTarget":boolean()}), &["callId","provider","operation","liveSupported","requiresTarget"]))}), + &["calls"], + ), + "replay::ReplayLiveResult" => business( + "aimux_core::generate::GenerateTextResult | aimux_core::decision_model::DecideResult", + ), + "misc::CatalogueFetchRequest" => object( + json!({"sourceUrl":nullable(string()),"abort":nullable(handle())}), + &[], + ), + "misc::ToolRepairContextRequest" => object( + json!({"toolCall":business("aimux_core::ToolCall"),"prompt":business("aimux_core::ModelPrompt"),"options":business("aimux_core::GenerateTextOptions")}), + &["toolCall", "prompt"], + ), + "misc::ToolRepairApplyRequest" => object( + json!({"toolCall":business("aimux_core::ToolCall"),"reply":business("aimux_core::ToolCallRepairReply"),"options":business("aimux_core::GenerateTextOptions")}), + &["toolCall", "reply"], + ), + "misc::ToolRepairApplyResultRequest" => object( + json!({"result":{},"toolCallId":string(),"reply":business("aimux_core::ToolCallRepairReply"),"options":business("aimux_core::GenerateTextOptions")}), + &["result", "toolCallId", "reply"], + ), + "misc::CodexRefreshRequest" => object( + json!({"refreshToken":string(),"clientId":string()}), + &["refreshToken", "clientId"], + ), + "misc::OpenAiOutputRequest" => object( + json!({"result":business("aimux_core::generate::GenerateTextResult"),"metadataNamespace":{"type":"string","minLength":1}}), + &["result", "metadataNamespace"], + ), + "misc::ToolRepairContextResult" => nullable(business("serde_json::Value")), + _ => return None, + }) +} + +fn provider_settings() -> Value { + object( + json!({"apiKey":nullable(string()),"baseUrl":nullable(string()),"headers":{"type":"object","additionalProperties":nullable(string())},"organization":nullable(string()),"project":nullable(string()),"params":{"type":"object","additionalProperties":string()},"fetch":nullable(handle()),"webSocket":nullable(handle()),"credentialResolver":nullable(handle()),"endpoint":nullable(string()),"probabilitySource":nullable(string())}), + &[], + ) +} + +fn provider_custom() -> Value { + let mut properties = json!({"files":nullable(handle()),"fallbackProvider":nullable(handle()),"defaultMethod":nullable(string()),"languageModelMiddleware":array(handle()),"imageModelMiddleware":array(handle())}); + for field in [ + "languageModels", + "embeddingModels", + "imageModels", + "speechModels", + "transcriptionModels", + "rerankingModels", + "videoModels", + "searchModels", + "decisionModels", + ] { + properties[field] = json!({"type":"object","additionalProperties":handle()}); + } + object(properties, &[]) +} + +fn text_request(openai: bool) -> Value { + let mut properties = json!({"prompt":business("aimux_core::ModelPrompt"),"options":business("aimux_core::GenerateTextOptions"),"abort":nullable(handle())}); + if openai { + properties["streamOptions"] = object( + json!({"includeUsage":boolean(),"includeReasoning":boolean()}), + &[], + ); + } + object(properties, &["prompt"]) +} + +fn recorder_stats() -> Value { + json!({"oneOf":[ + object(json!({"type":{"const":"ring"},"completed":integer(),"pending":integer(),"dropped":integer(),"pendingEvicted":integer(),"inconsistentCallIds":array(string())}), &["type","completed","pending","dropped","pendingEvicted","inconsistentCallIds"]), + object(json!({"type":{"const":"jsonl"},"path":string(),"dropped":integer(),"inconsistentCallIds":array(string()),"lastWriteError":nullable(string())}), &["type","path","dropped","inconsistentCallIds","lastWriteError"]) + ]}) +} diff --git a/aimux-ops/src/op.rs b/aimux-ops/src/op.rs new file mode 100644 index 000000000..5a6155864 --- /dev/null +++ b/aimux-ops/src/op.rs @@ -0,0 +1,62 @@ +use serde::Serialize; + +use crate::handle::HandleKind; + +pub const OPS_VERSION: &str = "1.0"; +pub const ABI_VERSION: u32 = 1 << 16; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum OpMode { + Call, + Stream, + BinaryPush, + Poll, +} + +/// Metadata and stable wire type references for one operation. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpDescriptor { + pub name: &'static str, + pub since_ops_version: &'static str, + pub mode: OpMode, + /// An empty list requires global handle 0. + pub allowed_handle_kinds: &'static [HandleKind], + pub request_type: &'static str, + pub result_type: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_type: Option<&'static str>, + pub scope: Scope, + pub terminal_type: Option<&'static str>, + pub runtime_handle_fields: &'static [(&'static str, &'static [HandleKind])], + pub required_capabilities: &'static [&'static str], + pub cancellation: Cancellation, + pub result_ownership: &'static str, + pub request_byte_policy: RequestBytePolicy, + pub side_effects: bool, + pub retry_owner: &'static str, + #[serde(rename = "sourceSymbolInventory")] + pub source_symbols: &'static [&'static str], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Scope { + Current, + Future, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Cancellation { + None, + Abort, + Operation, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum RequestBytePolicy { + Json, + JsonWithBase64, + Binary, +} diff --git a/aimux-ops/src/operation.rs b/aimux-ops/src/operation.rs new file mode 100644 index 000000000..f6b349560 --- /dev/null +++ b/aimux-ops/src/operation.rs @@ -0,0 +1,811 @@ +//! Bidirectional transcription with ordered input admission and a separate terminal slot. + +use std::collections::{BTreeSet, VecDeque}; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::task::{Context, Poll}; +use std::time::{Duration, SystemTime}; + +use aimux_core::AbortSignal; +use aimux_core::error::AiMuxError; +use aimux_core::transcription_model::{ + AudioChunk, InputAudioFormat, TranscriptionModel, TranscriptionStreamOptions, + TranscriptionStreamPart, +}; +use futures::task::AtomicWaker; +use futures::{FutureExt, Stream, StreamExt}; +use serde::Serialize; +use tokio::sync::{Notify, oneshot}; +use tokio::task::JoinHandle; + +use crate::envelope::{BindingError, ErrorEnvelope, OpsError, ProtocolError}; + +const CLEANUP_TIMEOUT: Duration = Duration::from_secs(5); + +use crate::limits::{AUDIO_INPUT_ITEMS as INPUT_ITEMS, ITEM_BYTES, OUTPUT_ITEMS, QUEUE_BYTES}; + +/// Resource limits may only lower the protocol defaults. +#[derive(Clone, Copy, Debug)] +pub struct OperationLimits { + pub input_items: usize, + pub input_bytes: usize, + pub output_items: usize, + pub output_bytes: usize, + pub item_bytes: usize, +} + +impl Default for OperationLimits { + fn default() -> Self { + Self { + input_items: INPUT_ITEMS, + input_bytes: QUEUE_BYTES, + output_items: OUTPUT_ITEMS, + output_bytes: QUEUE_BYTES, + item_bytes: ITEM_BYTES, + } + } +} + +impl OperationLimits { + fn validate(self) -> Result { + for (value, maximum) in [ + (self.input_items, INPUT_ITEMS), + (self.input_bytes, QUEUE_BYTES), + (self.output_items, OUTPUT_ITEMS), + (self.output_bytes, QUEUE_BYTES), + (self.item_bytes, ITEM_BYTES), + ] { + if value == 0 || value > maximum { + return Err(resource_limit( + "operation limits must be positive and cannot exceed defaults", + )); + } + } + Ok(self) + } +} + +/// Plain options for `transcription.start`; audio comes from subsequent pushes. +#[derive(Default)] +pub struct OperationOptions { + pub input_audio_format: Option, + pub provider_options: Option, + pub headers: Option, + pub include_raw_chunks: bool, + pub timeout: Option, + pub limits: OperationLimits, +} + +/// Poll timeouts do not terminate an operation. +#[derive(Debug)] +pub enum PollResult { + Part(TranscriptionStreamPart), + Ended, + Timeout, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum CancelResult { + First, + AlreadyRequested, + AlreadyTerminal, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum OperationOutcome { + Success, + Error, + Cancelled, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum OperationState { + Running, + InputClosed, + Terminal, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalSummary { + pub outcome: OperationOutcome, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OperationCapabilities { + pub push: bool, + pub input_done: bool, + pub next: bool, + pub cancel: bool, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OperationStatus { + pub subkind: &'static str, + pub state: OperationState, + pub capabilities: OperationCapabilities, + pub consumer_closed: bool, + pub queued_input_items: usize, + pub queued_input_bytes: usize, + pub queued_output_items: usize, + pub queued_output_bytes: usize, + /// The close boundary in the same admission sequence as pushes. + #[serde(skip_serializing_if = "Option::is_none")] + pub input_done_sequence: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub terminal: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cleanup_failure: Option, +} + +enum InputState { + Open, + Closed { sequence: u64 }, +} + +enum ConsumerState { + Open, + Closed, +} + +struct Terminal { + summary: TerminalSummary, + // Taken exactly once by next; the independently cloned summary survives. + error: Option, +} + +struct State { + input: InputState, + consumer: ConsumerState, + admission_sequence: u64, + send_sequence: u64, + completed_admissions: BTreeSet, + input_queue: VecDeque>, + input_bytes: usize, + output_queue: VecDeque<(TranscriptionStreamPart, usize)>, + output_bytes: usize, + terminal: Option, + cleanup_failure: Option, +} + +struct Shared { + state: Mutex, + limits: OperationLimits, + now: fn() -> SystemTime, + changed: Notify, + input_waker: AtomicWaker, + abort: AbortSignal, + reader: tokio::sync::Mutex<()>, +} + +impl Shared { + fn lock(&self) -> MutexGuard<'_, State> { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn wake(&self) { + self.changed.notify_waiters(); + self.input_waker.wake(); + } + + fn finish(&self, outcome: OperationOutcome, error: Option) { + let error = error.map(|error| error.project_with_clock(self.now)); + { + let mut state = self.lock(); + if state.terminal.is_some() { + return; + } + let summary = TerminalSummary { + outcome, + error: error + .as_ref() + .map(|error| error.envelope(SystemTime::UNIX_EPOCH)), + }; + state.terminal = Some(Terminal { summary, error }); + state.input_queue.clear(); + state.input_bytes = 0; + } + self.wake(); + } + + fn cancel(&self) -> CancelResult { + let error = OpsError::AiMux(AiMuxError::Aborted("operation cancelled".into())) + .project_with_clock(self.now); + let result = { + let mut state = self.lock(); + match &state.terminal { + Some(terminal) if terminal.summary.outcome == OperationOutcome::Cancelled => { + return CancelResult::AlreadyRequested; + } + Some(_) => return CancelResult::AlreadyTerminal, + None => { + state.terminal = Some(Terminal { + summary: TerminalSummary { + outcome: OperationOutcome::Cancelled, + error: Some(error.envelope(SystemTime::UNIX_EPOCH)), + }, + error: Some(error), + }); + state.input_queue.clear(); + state.input_bytes = 0; + CancelResult::First + } + } + }; + self.abort.abort(); + self.wake(); + result + } + + fn record_cleanup_failure(&self, message: String) { + self.lock().cleanup_failure = Some(message); + self.wake(); + } +} + +/// A realtime transcription operation. Dropping the last owner cancels its task. +/// The driver holds only `Shared`, so it cannot keep the operation alive itself. +pub struct Operation { + shared: Arc, + task: Mutex>>, + runtime: tokio::runtime::Handle, +} + +impl Operation { + /// Check streaming capability before accepting; connection failures are terminal results. + /// + /// # Errors + /// Returns invalid limits/runtime state or unsupported streaming capability. + pub async fn start( + model: Arc, + options: OperationOptions, + user_abort: Option, + ) -> Result, OpsError> { + Self::start_with_clock(model, options, user_abort, SystemTime::now).await + } + + /// Start using the clock captured from the dispatch context. + /// # Errors + /// Returns preflight errors before operation acceptance. + pub async fn start_with_clock( + model: Arc, + options: OperationOptions, + user_abort: Option, + now: fn() -> SystemTime, + ) -> Result, OpsError> { + let limits = options.limits.validate()?; + let runtime = tokio::runtime::Handle::try_current() + .map_err(|_| invalid_state("starting an operation requires a Tokio runtime"))?; + let shared = Arc::new(Shared { + state: Mutex::new(State { + input: InputState::Open, + consumer: ConsumerState::Open, + admission_sequence: 0, + send_sequence: 0, + completed_admissions: BTreeSet::new(), + input_queue: VecDeque::new(), + input_bytes: 0, + output_queue: VecDeque::new(), + output_bytes: 0, + terminal: None, + cleanup_failure: None, + }), + limits, + now, + changed: Notify::new(), + input_waker: AtomicWaker::new(), + abort: AbortSignal::new(), + reader: tokio::sync::Mutex::new(()), + }); + let (accepted_tx, accepted_rx) = oneshot::channel(); + let driver_shared = shared.clone(); + let task = runtime.spawn(async move { + let driver = run_driver( + driver_shared.clone(), + model, + options, + user_abort, + accepted_tx, + ); + if std::panic::AssertUnwindSafe(driver) + .catch_unwind() + .await + .is_err() + { + driver_shared.finish( + OperationOutcome::Error, + Some(invalid_state("transcription driver panicked")), + ); + } + }); + // Hold the operation during admission so cancellation of start also drops/cancels it. + let operation = Arc::new(Self { + shared, + task: Mutex::new(Some(task)), + runtime, + }); + accepted_rx + .await + .map_err(|_| invalid_state("transcription driver failed before acceptance"))??; + Ok(operation) + } + + /// Copy one audio chunk once capacity is available, preserving admission order. + /// + /// # Errors + /// Returns ResourceLimit for oversize chunks, or InvalidState after input closure/termination. + pub async fn push(&self, bytes: &[u8]) -> Result<(), OpsError> { + self.check_input_length(bytes.len())?; + let admission = { + let mut state = self.shared.lock(); + if !matches!(state.input, InputState::Open) || state.terminal.is_some() { + return Err(invalid_state("operation input is closed")); + } + let sequence = state.admission_sequence; + state.admission_sequence = sequence + .checked_add(1) + .ok_or_else(|| resource_limit("input admission sequence exhausted"))?; + Admission { + shared: &self.shared, + sequence, + } + }; + loop { + let changed = self.shared.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + { + let mut state = self.shared.lock(); + if state.terminal.is_some() { + return Err(invalid_state("operation has terminated")); + } + let byte_count = state + .input_bytes + .checked_add(bytes.len()) + .ok_or_else(|| resource_limit("input byte count overflow"))?; + if state.send_sequence == admission.sequence + && state.input_queue.len() < self.shared.limits.input_items + && byte_count <= self.shared.limits.input_bytes + { + state.input_queue.push_back(bytes.to_vec()); + state.input_bytes = byte_count; + break; + } + } + changed.await; + } + drop(admission); + self.shared.wake(); + Ok(()) + } + + /// Synchronous binary admission. Full queues reject before copying the buffer. + /// # Errors + /// Returns ResourceLimit for full/oversize input and InvalidState after closure. + pub fn try_push(&self, bytes: &[u8]) -> Result<(), OpsError> { + self.check_input_length(bytes.len())?; + { + let mut state = self.shared.lock(); + if !matches!(state.input, InputState::Open) || state.terminal.is_some() { + return Err(invalid_state("operation input is closed")); + } + let total = state + .input_bytes + .checked_add(bytes.len()) + .ok_or_else(|| resource_limit("input byte count overflow"))?; + if state.admission_sequence != state.send_sequence + || state.input_queue.len() >= self.shared.limits.input_items + || total > self.shared.limits.input_bytes + { + return Err(resource_limit("operation input queue is full")); + } + let sequence = state + .admission_sequence + .checked_add(1) + .ok_or_else(|| resource_limit("input admission sequence exhausted"))?; + state.input_queue.push_back(bytes.to_vec()); + state.input_bytes = total; + state.admission_sequence = sequence; + state.send_sequence = sequence; + } + self.shared.wake(); + Ok(()) + } + + fn check_input_length(&self, length: usize) -> Result<(), OpsError> { + if length > self.shared.limits.item_bytes || length > self.shared.limits.input_bytes { + return Err(resource_limit("audio chunk exceeds the input budget")); + } + Ok(()) + } + + /// Validate a C buffer before constructing its slice, then push it. + /// NULL with zero length is legal. No allocation occurs before length checks. + /// + /// # Safety + /// For nonzero length, `data` must address readable initialized bytes in one allocation + /// and remain valid until the returned future completes or is dropped. + /// + /// # Errors + /// Returns NullPointer/ResourceLimit before reading invalid buffer arguments. + pub unsafe fn push_raw( + &self, + data: *const u8, + length: usize, + ) -> Result> + Send + '_, OpsError> { + if data.is_null() && length != 0 { + return Err(OpsError::Binding(BindingError::NullPointer { + argument: "data", + })); + } + self.check_input_length(length)?; + if data.addr().checked_add(length).is_none() { + return Err(resource_limit("audio buffer address range overflow")); + } + let bytes = if length == 0 { + &[] + } else { + // SAFETY: the caller guarantees validity; size was checked above. + unsafe { std::slice::from_raw_parts(data, length) } + }; + Ok(self.push(bytes)) + } + + /// Close admission, leaving every previously admitted push eligible to complete. + pub fn input_done(&self) { + { + let mut state = self.shared.lock(); + if matches!(state.input, InputState::Open) { + state.input = InputState::Closed { + sequence: state.admission_sequence, + }; + } + } + self.shared.wake(); + } + + /// Receive data, the one terminal error, or Ended. A second concurrent reader fails. + /// + /// # Errors + /// Returns ConcurrentConsumer or the saved terminal failure (only once). + pub async fn next(&self, timeout: Option) -> Result { + let _reader = self + .shared + .reader + .try_lock() + .map_err(|_| OpsError::Protocol(ProtocolError::ConcurrentConsumer))?; + let receive = async { + loop { + let changed = self.shared.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + { + let mut state = self.shared.lock(); + if let Some((part, bytes)) = state.output_queue.pop_front() { + state.output_bytes -= bytes; + drop(state); + self.shared.wake(); + return Ok(PollResult::Part(part)); + } + if let Some(terminal) = &mut state.terminal { + return match terminal.error.take() { + Some(error) => Err(error), + None => Ok(PollResult::Ended), + }; + } + } + changed.await; + } + }; + match timeout { + None => receive.await, + Some(duration) => tokio::time::timeout(duration, receive) + .await + .unwrap_or(Ok(PollResult::Timeout)), + } + } + + /// Cancellation wins only if no terminal result has already been stored. + pub fn cancel(&self) -> CancelResult { + self.shared.cancel() + } + + /// Stop consuming, abandon undelivered events, and cancel unfinished work. + pub fn consumer_close(&self) { + self.cancel(); + { + let mut state = self.shared.lock(); + state.consumer = ConsumerState::Closed; + state.output_queue.clear(); + state.output_bytes = 0; + } + self.shared.wake(); + } + + /// Obtain a durable terminal summary, including after its error has been consumed. + #[must_use] + pub fn status(&self) -> OperationStatus { + let state = self.shared.lock(); + OperationStatus { + subkind: "transcription", + state: if state.terminal.is_some() { + OperationState::Terminal + } else if matches!(state.input, InputState::Closed { .. }) { + OperationState::InputClosed + } else { + OperationState::Running + }, + capabilities: OperationCapabilities { + push: true, + input_done: true, + next: true, + cancel: true, + }, + consumer_closed: matches!(state.consumer, ConsumerState::Closed), + queued_input_items: state.input_queue.len(), + queued_input_bytes: state.input_bytes, + queued_output_items: state.output_queue.len(), + queued_output_bytes: state.output_bytes, + input_done_sequence: match state.input { + InputState::Open => None, + InputState::Closed { sequence } => Some(sequence.to_string()), + }, + terminal: state + .terminal + .as_ref() + .map(|terminal| terminal.summary.clone()), + cleanup_failure: state.cleanup_failure.clone(), + } + } + + /// Cancel and join outside all operation locks, with a five-second cleanup cap. + pub async fn terminate(&self) { + self.cancel(); + self.input_done(); + let task = self + .task + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(task) = task { + // A completed driver needs no cleanup runtime or timer. Runtime + // shutdown also completes it by cancellation. + if task.is_finished() { + if let Err(error) = task.await + && !error.is_cancelled() + { + self.shared + .record_cleanup_failure(format!("operation driver join failed: {error}")); + } + return; + } + let shared = self.shared.clone(); + // The cleanup task keeps running even if this caller stops awaiting terminate. + let cleanup = self + .runtime + .spawn(async move { join_driver(&shared, task).await }); + // Shutdown can race with spawn; it already disposes the driver. + if let Err(error) = cleanup.await + && !error.is_cancelled() + { + self.shared + .record_cleanup_failure(format!("operation cleanup task failed: {error}")); + } + } + } +} + +impl Drop for Operation { + fn drop(&mut self) { + self.consumer_close(); + let task = self + .task + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(task) = task + && !task.is_finished() + { + let shared = self.shared.clone(); + self.runtime + .spawn(async move { join_driver(&shared, task).await }); + } + } +} + +/// Cancelling a waiting push must release its admission sequence as well as capacity. +struct Admission<'a> { + shared: &'a Shared, + sequence: u64, +} + +impl Drop for Admission<'_> { + fn drop(&mut self) { + { + let mut state = self.shared.lock(); + state.completed_admissions.insert(self.sequence); + loop { + let sequence = state.send_sequence; + if !state.completed_admissions.remove(&sequence) { + break; + } + // Every admitted sequence has already passed checked_add in push. + state.send_sequence = sequence.saturating_add(1); + } + } + self.shared.wake(); + } +} + +struct AudioStream { + shared: Arc, +} + +impl Stream for AudioStream { + type Item = AudioChunk; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.shared.input_waker.register(cx.waker()); + let mut state = self.shared.lock(); + if state.terminal.is_some() { + return Poll::Ready(None); + } + if let Some(bytes) = state.input_queue.pop_front() { + state.input_bytes -= bytes.len(); + drop(state); + self.shared.changed.notify_waiters(); + return Poll::Ready(Some(AudioChunk::Binary(bytes))); + } + match state.input { + InputState::Closed { sequence } if state.send_sequence == sequence => Poll::Ready(None), + _ => Poll::Pending, + } + } +} + +async fn run_driver( + shared: Arc, + model: Arc, + options: OperationOptions, + user_abort: Option, + accepted: oneshot::Sender>, +) { + let options = TranscriptionStreamOptions { + audio: Box::pin(AudioStream { + shared: shared.clone(), + }), + input_audio_format: options.input_audio_format.unwrap_or(InputAudioFormat { + format_type: "audio/pcm".into(), + rate: None, + }), + provider_options: options.provider_options, + abort_signal: Some(shared.abort.clone()), + headers: options.headers, + include_raw_chunks: options.include_raw_chunks, + timeout: options.timeout, + }; + let Some(connect) = model.do_stream(options) else { + let _ = accepted.send(Err(OpsError::AiMux(AiMuxError::UnsupportedFunctionality( + format!( + "The {} model \"{}\" does not support streaming transcription.", + model.provider(), + model.model_id() + ), + )))); + return; + }; + if accepted.send(Ok(())).is_err() { + shared.cancel(); + return; + } + let external_cancel = async { + match user_abort { + Some(signal) => signal.cancelled().await, + None => std::future::pending::<()>().await, + } + // Store cancellation before select drops a potentially slow provider future. + shared.cancel(); + }; + let forward = async { + let result = connect.await.map_err(OpsError::AiMux)?; + let mut stream = result.stream; + loop { + wait_output_capacity(&shared, 0).await?; + let Some(part) = stream.next().await else { + return Ok::<(), OpsError>(()); + }; + let part = part.map_err(OpsError::AiMux)?; + let bytes = part_size( + &part, + shared.limits.item_bytes.min(shared.limits.output_bytes), + )?; + wait_output_capacity(&shared, bytes).await?; + { + let mut state = shared.lock(); + if state.terminal.is_some() { + return Ok(()); + } + state.output_bytes = state + .output_bytes + .checked_add(bytes) + .ok_or_else(|| resource_limit("output byte count overflow"))?; + state.output_queue.push_back((part, bytes)); + } + shared.wake(); + } + }; + tokio::select! { + biased; + _ = shared.abort.cancelled() => { shared.cancel(); } + _ = external_cancel => {} + result = forward => match result { + Ok(()) => shared.finish(OperationOutcome::Success, None), + Err(error) => shared.finish(OperationOutcome::Error, Some(error)), + } + } +} + +async fn wait_output_capacity(shared: &Shared, bytes: usize) -> Result<(), OpsError> { + loop { + let changed = shared.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + { + let state = shared.lock(); + if state.terminal.is_some() { + return Err(invalid_state("operation has terminated")); + } + let count = state + .output_bytes + .checked_add(bytes) + .ok_or_else(|| resource_limit("output byte count overflow"))?; + if state.output_queue.len() < shared.limits.output_items + && count <= shared.limits.output_bytes + && state.output_bytes < shared.limits.output_bytes + { + return Ok(()); + } + } + changed.await; + } +} + +fn part_size(part: &TranscriptionStreamPart, maximum: usize) -> Result { + crate::limits::check_wire_item(part, maximum).map_err(|error| match error { + OpsError::Protocol(ProtocolError::ResourceLimit(_)) => { + resource_limit("transcription event exceeds the output budget") + } + error => error, + }) +} + +async fn join_driver(shared: &Shared, mut task: JoinHandle<()>) { + match tokio::time::timeout(CLEANUP_TIMEOUT, &mut task).await { + Ok(Ok(())) => {} + Ok(Err(error)) => { + shared.record_cleanup_failure(format!("operation driver join failed: {error}")) + } + Err(_) => { + task.abort(); + shared.record_cleanup_failure("operation driver cleanup exceeded 5 seconds".into()); + } + } +} + +fn invalid_state(message: &str) -> OpsError { + OpsError::Protocol(ProtocolError::InvalidState(message.into())) +} + +fn resource_limit(message: &str) -> OpsError { + OpsError::Protocol(ProtocolError::ResourceLimit(message.into())) +} diff --git a/aimux-ops/src/ops/composite.rs b/aimux-ops/src/ops/composite.rs new file mode 100644 index 000000000..0eddfe14c --- /dev/null +++ b/aimux-ops/src/ops/composite.rs @@ -0,0 +1,168 @@ +//! Composite constructors retain language models, never their registry IDs. + +use std::sync::Arc; + +use aimux_core::LanguageModel; +use aimux_core::moa::{MoaConfig, MoaModel}; +use aimux_core::router::{ + FallbackPolicy, Router, RouterConfig, RouterModel, RuleRouter, WeightedRouter, +}; +use serde::Deserialize; +use serde_json::Value; + +use crate::dispatch::{CallFuture, Ctx, Handler, Target, decode, resolve_secondary}; +use crate::envelope::{OpsError, ProtocolError}; +use crate::handle::{Entry, HandleKind, HandleRegistry, Owner}; +use crate::op::{Cancellation, OPS_VERSION, OpDescriptor, OpMode, RequestBytePolicy, Scope}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RouterParams { + children: Vec, + strategy: Strategy, + #[serde(default)] + fallback: Fallback, + provider_name: Option, + model_id: Option, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)] +enum Strategy { + Rule {}, + Weighted { weights: Vec }, +} + +#[derive(Default, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum Fallback { + #[default] + OnError, + None, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct MoaParams { + references: Vec, + aggregator: String, + #[serde(flatten)] + config: MoaConfig, +} + +fn invalid(message: impl Into) -> OpsError { + OpsError::Protocol(ProtocolError::InvalidParams(message.into())) +} + +fn language(ctx: &Ctx, id: &str) -> Result, OpsError> { + match resolve_secondary( + HandleRegistry::global(), + ctx, + &Value::String(id.to_owned()), + &[HandleKind::Language], + )? { + Entry::Language(model) => Ok(model), + _ => Err(invalid("expected a Language entry")), + } +} + +fn languages(ctx: &Ctx, ids: &[String]) -> Result>, OpsError> { + ids.iter().map(|id| language(ctx, id)).collect() +} + +fn store(owner: Owner, model: Arc) -> Result { + let id = HandleRegistry::global().insert(owner, Entry::Language(model))?; + Ok(Value::String(id.0.to_string())) +} + +fn router(ctx: Ctx, _target: Target, params: Value) -> CallFuture { + Box::pin(async move { + let params: RouterParams = decode(params)?; + if params.children.is_empty() { + return Err(invalid("router children must not be empty")); + } + let models = languages(&ctx, ¶ms.children)?; + let strategy: Box = match params.strategy { + Strategy::Rule {} => Box::new(RuleRouter), + Strategy::Weighted { weights } => Box::new(WeightedRouter::new(weights)), + }; + let fallback = match params.fallback { + Fallback::OnError => FallbackPolicy::OnError, + Fallback::None => FallbackPolicy::None, + }; + let mut config = RouterConfig::default(); + if let Some(name) = params.provider_name { + config.provider_name = name; + } + if let Some(id) = params.model_id { + config.model_id = id; + } + // Core gives each child its own OperationSettings retry budget, including + // stream setup; it never retries a stream after setup succeeds. + store( + ctx.owner, + Arc::new(RouterModel::new(models, strategy, fallback, config)), + ) + }) +} + +fn moa(ctx: Ctx, _target: Target, params: Value) -> CallFuture { + Box::pin(async move { + let params: MoaParams = decode(params)?; + let references = languages(&ctx, ¶ms.references)?; + let aggregator = language(&ctx, ¶ms.aggregator)?; + store( + ctx.owner, + Arc::new(MoaModel::new(references, aggregator, params.config)), + ) + }) +} + +pub(crate) static OPS: &[OpDescriptor] = &[ + OpDescriptor { + name: "composite.router", + since_ops_version: OPS_VERSION, + mode: OpMode::Call, + allowed_handle_kinds: &[], + request_type: "aimux_ops::ops::composite::RouterParams", + result_type: "HandleId", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[("children.*", &[HandleKind::Language])], + required_capabilities: &[], + cancellation: Cancellation::None, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + side_effects: true, + retry_owner: "operation", + source_symbols: &["aimux_router_new", "RouterModel::new"], + }, + OpDescriptor { + name: "composite.moa", + since_ops_version: OPS_VERSION, + mode: OpMode::Call, + allowed_handle_kinds: &[], + request_type: "aimux_ops::ops::composite::MoaParams", + result_type: "HandleId", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[ + ("references.*", &[HandleKind::Language]), + ("aggregator", &[HandleKind::Language]), + ], + required_capabilities: &[], + cancellation: Cancellation::None, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + side_effects: true, + retry_owner: "operation", + source_symbols: &["aimux_moa_new", "MoaModel::new"], + }, +]; + +pub(crate) fn register(r: &mut Vec<(&'static OpDescriptor, Handler)>) { + r.push((&OPS[0], Handler::Call(router))); + r.push((&OPS[1], Handler::Call(moa))); +} diff --git a/aimux-ops/src/ops/decision.rs b/aimux-ops/src/ops/decision.rs new file mode 100644 index 000000000..74c90fdf1 --- /dev/null +++ b/aimux-ops/src/ops/decision.rs @@ -0,0 +1,106 @@ +//! Decision calls reuse the existing core operation and its model properties. +use aimux_core::decision_model::{DecideOptions, decide, decision_model_properties}; +use serde::Deserialize; +use serde_json::Value; + +use crate::{ + dispatch::{CallFuture, Ctx, Handler, Target, decode}, + envelope::{BindingError, OpsError, ProtocolError}, + handle::{Entry, HandleId, HandleKind}, + op::{Cancellation, OpDescriptor, OpMode, RequestBytePolicy, Scope}, + ops::operation::EmptyParams, +}; + +/// Runtime controls surround the canonical business options; only the control +/// object rejects unknown fields. Dispatch resolves `abort` with the caller's +/// owner before entering the handler and supplies its signal in `Ctx`. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DecisionDecideRequest { + pub options: DecideOptions, + pub abort: Option, +} + +pub(crate) static OPS: &[OpDescriptor] = &[ + OpDescriptor { + name: "decision.decide", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[HandleKind::Decision], + request_type: "aimux_ops::ops::decision::DecisionDecideRequest", + result_type: "aimux_core::decision_model::DecideResult", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[("abort", &[HandleKind::Abort])], + required_capabilities: &["doDecide"], + cancellation: Cancellation::Abort, + side_effects: true, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + retry_owner: "core", + source_symbols: &[ + "aimux_decide", + "aimux_decide_with_abort", + "aimux_core::decision_model::decide", + ], + }, + OpDescriptor { + name: "decision.capabilities", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[HandleKind::Decision], + request_type: "EmptyParams", + // The canonical property helper already returns this JSON value. + result_type: "serde_json::Value", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + side_effects: false, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + retry_owner: "none", + source_symbols: &[ + "aimux_decision_capabilities", + "aimux_core::decision_model::decision_model_properties", + ], + }, +]; + +pub(crate) fn register(r: &mut Vec<(&'static OpDescriptor, Handler)>) { + for (descriptor, handler) in OPS + .iter() + .zip([decision_decide, decision_capabilities] as [fn(Ctx, Target, Value) -> CallFuture; 2]) + { + r.push((descriptor, Handler::Call(handler))); + } +} + +fn model( + target: Target, +) -> Result, OpsError> { + let Target::Handle(Entry::Decision(model)) = target else { + return Err(ProtocolError::InvalidState("expected decision target".into()).into()); + }; + Ok(model) +} + +fn decision_capabilities(_: Ctx, target: Target, params: Value) -> CallFuture { + Box::pin(async move { + decode::(params)?; + Ok(decision_model_properties(model(target)?.as_ref())) + }) +} + +fn decision_decide(ctx: Ctx, target: Target, params: Value) -> CallFuture { + Box::pin(async move { + let mut request: DecisionDecideRequest = decode(params)?; + request.options.abort_signal = ctx.abort; + let result = decide(model(target)?.as_ref(), request.options).await?; + serde_json::to_value(result) + .map_err(|error| BindingError::ResultSerialization(error.to_string()).into()) + }) +} diff --git a/aimux-ops/src/ops/files.rs b/aimux-ops/src/ops/files.rs new file mode 100644 index 000000000..d87859387 --- /dev/null +++ b/aimux-ops/src/ops/files.rs @@ -0,0 +1,146 @@ +//! File access and uploads use the canonical core DTOs without upload retries. + +use aimux_core::files_model::UploadFileCallOptions; +use serde::Deserialize; +use serde_json::Value; + +use crate::dispatch::{CallFuture, Ctx, Handler, Target, decode}; +use crate::envelope::{BindingError, OpsError, ProtocolError}; +use crate::handle::{Entry, HandleId, HandleKind, HandleRegistry}; +use crate::op::{Cancellation, OpDescriptor, OpMode, RequestBytePolicy, Scope}; + +/// Runtime control is lifted separately from the canonical upload options. +#[derive(Deserialize)] +pub struct UploadFileParams { + #[serde(flatten)] + pub options: UploadFileCallOptions, + pub abort: Option, +} + +pub(crate) static OPS: &[OpDescriptor] = &[ + OpDescriptor { + name: "provider.files", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[HandleKind::Provider], + request_type: "EmptyParams", + result_type: "HandleId", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &["files"], + cancellation: Cancellation::None, + side_effects: false, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + retry_owner: "none", + source_symbols: &[ + "aimux_core::provider::Provider::files", + "aimux_ffi::aimux_openai_files_new", + "aimux_ffi::aimux_openai_files_new_with_base", + ], + }, + OpDescriptor { + name: "files.upload", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[HandleKind::Files], + request_type: "aimux_ops::ops::files::UploadFileParams", + result_type: "aimux_core::files_model::UploadFileResult", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[("abort", &[HandleKind::Abort])], + required_capabilities: &["uploadFile"], + cancellation: Cancellation::Abort, + side_effects: true, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::JsonWithBase64, + retry_owner: "none", + source_symbols: &[ + "aimux_core::files_model::Files::upload_file", + "aimux_ffi::aimux_file_upload", + ], + }, +]; + +pub(crate) fn register(r: &mut Vec<(&'static OpDescriptor, Handler)>) { + r.push((&OPS[0], Handler::Call(provider_files))); + r.push((&OPS[1], Handler::Call(upload))); +} + +fn provider_files(ctx: Ctx, target: Target, params: Value) -> CallFuture { + Box::pin(async move { + decode::(params)?; + let Target::Handle(Entry::Provider(provider)) = target else { + return Err(invalid_target(&target, HandleKind::Provider)); + }; + let files = provider.files().ok_or_else(|| { + aimux_core::AiMuxError::UnsupportedFunctionality( + "provider does not support files".into(), + ) + })?; + let id = HandleRegistry::global().insert(ctx.owner, Entry::Files(files))?; + serialize(id) + }) +} + +// Core business DTOs stay open; close their control keys at the ops boundary. +fn parse_options(params: Value) -> Result { + reject_unknown( + ¶ms, + &["data", "mediaType", "filename", "providerOptions", "abort"], + )?; + if let Some(data) = params.get("data") { + let fields: &[&str] = match data.get("type").and_then(Value::as_str) { + Some("data") => &["type", "data"], + Some("text") => &["type", "text"], + _ => &["type"], + }; + reject_unknown(data, fields)?; + } + decode(params) +} + +fn reject_unknown(value: &Value, allowed: &[&str]) -> Result<(), OpsError> { + if let Some(fields) = value.as_object() { + for field in fields.keys() { + if !allowed.contains(&field.as_str()) { + return Err( + ProtocolError::InvalidParams(format!("unknown field `{field}`")).into(), + ); + } + } + } + Ok(()) +} + +fn invalid_target(target: &Target, expected: HandleKind) -> OpsError { + BindingError::InvalidHandle { + expected: vec![expected], + actual: match target { + Target::Global => None, + Target::Handle(entry) => Some(entry.kind()), + }, + } + .into() +} + +fn serialize(value: impl serde::Serialize) -> Result { + serde_json::to_value(value) + .map_err(|error| BindingError::ResultSerialization(error.to_string()).into()) +} + +fn upload(ctx: Ctx, target: Target, params: Value) -> CallFuture { + Box::pin(async move { + let Target::Handle(Entry::Files(model)) = target else { + return Err(invalid_target(&target, HandleKind::Files)); + }; + let mut options = parse_options(params)?.options; + // Dispatch has resolved the optional abort handle with the caller's owner. + options.abort_signal = ctx.abort; + let result = model.upload_file(&options).await?; + serialize(result) + }) +} diff --git a/aimux-ops/src/ops/handles.rs b/aimux-ops/src/ops/handles.rs new file mode 100644 index 000000000..ee3f7a82c --- /dev/null +++ b/aimux-ops/src/ops/handles.rs @@ -0,0 +1,388 @@ +//! Built-in runtime capabilities and their owner-scoped lifetime. + +use std::sync::Arc; + +use aimux_core::download::{create_default_download_function, create_download}; +use aimux_core::{AbortSignal, AiMuxError}; +use aimux_provider_utils::fetch::{ProxyConfig, ReqwestFetch}; +use aimux_provider_utils::ws::TungsteniteConnector; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::dispatch::{CallFuture, Ctx, Handler, Target, resolve_secondary}; +use crate::envelope::{BindingError, OpsError, ProtocolError}; +use crate::handle::{Entry, HandleId, HandleKind, HandleRegistry}; +use crate::op::{Cancellation, OpDescriptor, OpMode, RequestBytePolicy, Scope}; + +#[derive(Clone, Copy, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AuditMode { + #[default] + Disabled, + Shared, + Strict, +} + +use crate::ops::operation::EmptyParams; + +#[derive(Serialize)] +struct HandleResult { + handle: HandleId, + kind: HandleKind, +} + +#[derive(Deserialize)] +#[serde(tag = "kind", deny_unknown_fields)] +enum TransportParams { + #[serde(rename = "fetch")] + Fetch { + #[serde(default, deserialize_with = "deserialize_proxy")] + proxy: ProxyConfig, + }, + #[serde(rename = "ws")] + WebSocket { + #[serde(default, deserialize_with = "deserialize_proxy")] + proxy: ProxyConfig, + }, + #[serde(rename = "download")] + Download { + #[serde(default, rename = "maxBytes")] + max_bytes: Option, + }, +} + +// ProxyConfig is the canonical settings type, but its serde reader is open. +// Close the control object without defining a second transport settings type. +fn deserialize_proxy<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = Value::deserialize(deserializer)?; + let object = value + .as_object() + .ok_or_else(|| ::custom("expected proxy settings"))?; + if object.keys().any(|key| { + !matches!( + key.as_str(), + "http_url" | "https_url" | "all_url" | "no_proxy" + ) + }) { + return Err(serde::de::Error::custom("unknown proxy setting")); + } + serde_json::from_value(value).map_err(serde::de::Error::custom) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CredentialParams { + kind: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct MiddlewareParams { + kind: String, + trace_store: Option, + scope_name: Option, + audit_mode: Option, +} + +fn params(value: Value) -> Result { + // Do not echo caller-supplied data (in particular credential material). + serde_json::from_value(value) + .map_err(|_| OpsError::Protocol(ProtocolError::InvalidParams("invalid parameters".into()))) +} + +fn unsupported(message: &'static str) -> OpsError { + OpsError::AiMux(AiMuxError::UnsupportedFunctionality(message.into())) +} + +fn insert(ctx: &Ctx, entry: Entry) -> Result { + let kind = entry.kind(); + let id = HandleRegistry::global().insert(ctx.owner, entry)?; + serde_json::to_value(HandleResult { handle: id, kind }) + .map_err(|error| BindingError::ResultSerialization(error.to_string()).into()) +} + +fn abort_create(ctx: Ctx, _target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let _: EmptyParams = params(value)?; + insert(&ctx, Entry::Abort(Arc::new(AbortSignal::new()))) + }) +} + +fn abort_signal(_ctx: Ctx, target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let _: EmptyParams = params(value)?; + let Target::Handle(Entry::Abort(signal)) = target else { + return Err(OpsError::Protocol(ProtocolError::InvalidState( + "abort.signal requires an Abort target".into(), + ))); + }; + signal.abort(); + Ok(Value::Null) + }) +} + +fn transport_create(ctx: Ctx, _target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let entry = match params::(value)? { + TransportParams::Fetch { proxy } => Entry::Fetch(Arc::new(ReqwestFetch::new(proxy))), + TransportParams::WebSocket { proxy } => { + Entry::WebSocket(Arc::new(TungsteniteConnector::new(proxy))) + } + TransportParams::Download { max_bytes } => { + Entry::Download(Arc::new(create_default_download_function( + Some(create_download(max_bytes)), + ctx.abort.clone(), + ))) + } + }; + insert(&ctx, entry) + }) +} + +fn credential_create(_ctx: Ctx, _target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let request: CredentialParams = params(value)?; + let _ = request.kind; + // Resolvable callbacks and explicit provider credentials are not built-in + // RFC-0040 resolvers. The baseline has no such resolver to construct. + Err(unsupported("built-in credential resolvers are unavailable")) + }) +} + +fn middleware_create(ctx: Ctx, _target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let request: MiddlewareParams = params(value)?; + if request.kind != "trace" { + return Err(unsupported("requested built-in middleware is unavailable")); + } + let id = request.trace_store.ok_or_else(|| { + OpsError::Protocol(ProtocolError::InvalidParams( + "traceStore must be a decimal handle string".into(), + )) + })?; + // Use the same owner-aware resolver as dispatch, including when the + // store is dropped between preflight and handler execution. + let Entry::TraceStore(store) = resolve_secondary( + HandleRegistry::global(), + &ctx, + &serde_json::to_value(id) + .map_err(|error| BindingError::ResultSerialization(error.to_string()))?, + &[HandleKind::TraceStore], + )? + else { + unreachable!("typed resolver returned a different kind") + }; + let mode = request + .audit_mode + .map(|mode| match mode { + AuditMode::Disabled => aimux_core::trace::store::TraceAuditMode::Off, + AuditMode::Shared => aimux_core::trace::store::TraceAuditMode::Shared, + AuditMode::Strict => aimux_core::trace::store::TraceAuditMode::Strict, + }) + .unwrap_or_else(|| store.audit_mode()); + insert( + &ctx, + Entry::Middleware(Arc::new(crate::handle::Middleware::Language(Arc::new( + TraceMiddleware { + store, + scope_name: request.scope_name, + audit_mode: mode, + }, + )))), + ) + }) +} + +struct TraceMiddleware { + store: Arc, + scope_name: Option, + audit_mode: aimux_core::trace::store::TraceAuditMode, +} + +impl aimux_core::language_model_middleware::LanguageModelMiddleware for TraceMiddleware { + fn wrap_model( + &self, + model: Arc, + ) -> Option> { + use aimux_core::trace::store::TraceAuditMode; + let mut layer = aimux_core::trace::TraceLayer::new(model, self.store.clone()); + if let Some(name) = &self.scope_name { + layer = layer.with_scope_name(name); + } + match self.audit_mode { + TraceAuditMode::Off => {} + TraceAuditMode::Shared => layer = layer.with_rules_auditor(false), + TraceAuditMode::Strict => layer = layer.with_rules_auditor(true), + } + Some(Arc::new(layer)) + } +} + +// Dispatch owns raw-ID admission and teardown before ordinary handle lookup. +fn drop_requires_id(_ctx: Ctx, _target: Target, _value: Value) -> CallFuture { + Box::pin(async { + Err(OpsError::Protocol(ProtocolError::InvalidState( + "dispatch must route handle.drop before handle lookup".into(), + ))) + }) +} + +pub(crate) static OPS: &[OpDescriptor] = &[ + OpDescriptor { + name: "abort.create", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[], + request_type: "EmptyParams", + result_type: "aimux_ops::ops::handles::HandleResult", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + side_effects: true, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + retry_owner: "none", + source_symbols: &["aimux_core::abort_signal::AbortSignal::new"], + }, + OpDescriptor { + name: "abort.signal", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[HandleKind::Abort], + request_type: "EmptyParams", + result_type: "()", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + side_effects: true, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + retry_owner: "none", + source_symbols: &["aimux_core::abort_signal::AbortSignal::abort"], + }, + OpDescriptor { + name: "transport.create", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[], + request_type: "aimux_ops::ops::handles::TransportParams", + result_type: "aimux_ops::ops::handles::HandleResult", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + side_effects: true, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + retry_owner: "none", + source_symbols: &[ + "aimux_provider_utils::fetch::ReqwestFetch::new", + "aimux_provider_utils::ws::TungsteniteConnector::new", + "aimux_core::download::create_download", + "aimux_core::download::create_default_download_function", + ], + }, + OpDescriptor { + name: "credential.create", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[], + request_type: "aimux_ops::ops::handles::CredentialParams", + result_type: "aimux_ops::ops::handles::HandleResult", + event_type: None, + scope: Scope::Future, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + side_effects: true, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + retry_owner: "none", + source_symbols: &[], + }, + OpDescriptor { + name: "middleware.create", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[], + request_type: "aimux_ops::ops::handles::MiddlewareParams", + result_type: "aimux_ops::ops::handles::HandleResult", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[("traceStore", &[HandleKind::TraceStore])], + required_capabilities: &[], + cancellation: Cancellation::None, + side_effects: true, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + retry_owner: "none", + source_symbols: &["aimux_core::trace::TraceLayer::new"], + }, + OpDescriptor { + name: crate::dispatch::HANDLE_DROP, + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[ + HandleKind::Provider, + HandleKind::Registry, + HandleKind::Language, + HandleKind::Embedding, + HandleKind::Image, + HandleKind::Speech, + HandleKind::Transcription, + HandleKind::Reranking, + HandleKind::Video, + HandleKind::Search, + HandleKind::Files, + HandleKind::Operation, + HandleKind::Abort, + HandleKind::Fetch, + HandleKind::WebSocket, + HandleKind::Download, + HandleKind::CredentialResolver, + HandleKind::Middleware, + HandleKind::TraceStore, + HandleKind::Recorder, + HandleKind::SessionStore, + HandleKind::ReplaySession, + HandleKind::Decision, + ], + request_type: "EmptyParams", + result_type: "()", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + side_effects: true, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + retry_owner: "none", + source_symbols: &["aimux_ops::handle::HandleRegistry::remove"], + }, +]; +pub(crate) fn register(registry: &mut Vec<(&'static OpDescriptor, Handler)>) { + registry.extend(OPS.iter().zip([ + Handler::Call(abort_create), + Handler::Call(abort_signal), + Handler::Call(transport_create), + Handler::Call(credential_create), + Handler::Call(middleware_create), + Handler::Call(drop_requires_id), + ])); +} diff --git a/aimux-ops/src/ops/misc.rs b/aimux-ops/src/ops/misc.rs new file mode 100644 index 000000000..4df353e34 --- /dev/null +++ b/aimux-ops/src/ops/misc.rs @@ -0,0 +1,565 @@ +//! Catalogue, host-side repair, subscription refresh and OpenAI output conversion. + +use std::time::Duration; + +use aimux_core::{ + AiMuxError, + generate::{GenerateTextOptions, GenerateTextResult}, + message::ModelPrompt, + parse_tool_call::{self, ToolCallRepairReply}, + tool::ToolCall, +}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::Value; + +use crate::{ + dispatch::{CallFuture, Ctx, Handler, Target}, + envelope::{BindingError, OpsError, ProtocolError}, + handle::{Entry, HandleId, HandleKind}, + op::{Cancellation, OpDescriptor, OpMode, RequestBytePolicy, Scope}, +}; + +pub(crate) static OPS: &[OpDescriptor] = &[ + OpDescriptor { + name: "catalogue.fetch", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[], + request_type: "aimux_ops::ops::misc::CatalogueFetchRequest", + result_type: "aimux_providers::catalogue::Catalogue", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[("abort", &[HandleKind::Abort])], + required_capabilities: &[], + cancellation: Cancellation::Abort, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + side_effects: false, + retry_owner: "none", + source_symbols: &["aimux_providers::get_model_specs"], + }, + OpDescriptor { + name: "toolRepair.context", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[], + request_type: "aimux_ops::ops::misc::ToolRepairContextRequest", + result_type: "aimux_ops::ops::misc::ToolRepairContextResult", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::JsonWithBase64, + side_effects: false, + retry_owner: "none", + source_symbols: &["aimux_core::parse_tool_call::tool_call_repair_context"], + }, + OpDescriptor { + name: "toolRepair.apply", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[], + request_type: "aimux_ops::ops::misc::ToolRepairApplyRequest", + result_type: "aimux_core::tool::ToolCall", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + side_effects: false, + retry_owner: "none", + source_symbols: &["aimux_core::parse_tool_call::apply_tool_call_repair"], + }, + OpDescriptor { + name: "toolRepair.applyResult", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[], + request_type: "aimux_ops::ops::misc::ToolRepairApplyResultRequest", + result_type: "aimux_core::generate::GenerateTextResult", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::JsonWithBase64, + side_effects: false, + retry_owner: "none", + source_symbols: &["aimux_core::parse_tool_call::apply_tool_call_repair_to_result"], + }, + OpDescriptor { + name: "auth.codexRefresh", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[], + request_type: "aimux_ops::ops::misc::CodexRefreshRequest", + result_type: "aimux_providers::codex::CodexTokens", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + side_effects: true, + retry_owner: "none", + source_symbols: &["aimux_providers::codex_refresh"], + }, + OpDescriptor { + name: "output.toOpenAI", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[HandleKind::Language], + request_type: "aimux_ops::ops::misc::OpenAiOutputRequest", + result_type: "aimux_core::openai_output::ChatCompletion", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::JsonWithBase64, + side_effects: false, + retry_owner: "none", + source_symbols: &["aimux_core::generate::generate_text_result_to_chat_completion"], + }, +]; + +pub(crate) fn register(r: &mut Vec<(&'static OpDescriptor, Handler)>) { + let handlers: [fn(Ctx, Target, Value) -> CallFuture; 6] = [ + catalogue_fetch, + repair_context, + repair_apply, + repair_apply_result, + codex_refresh, + to_openai, + ]; + r.extend( + OPS.iter() + .zip(handlers) + .map(|(op, handler)| (op, Handler::Call(handler))), + ); +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CatalogueFetchRequest { + source_url: Option, + #[serde(default, rename = "abort")] + _abort: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ToolRepairContextRequest { + tool_call: ToolCall, + prompt: ModelPrompt, + #[serde(default)] + options: GenerateTextOptions, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ToolRepairApplyRequest { + tool_call: ToolCall, + #[serde(default)] + options: GenerateTextOptions, + reply: ToolCallRepairReply, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ToolRepairApplyResultRequest { + result: Value, + #[serde(default)] + options: GenerateTextOptions, + tool_call_id: String, + reply: ToolCallRepairReply, +} + +// Deliberately no Debug implementation: token-bearing requests must not be logged. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CodexRefreshRequest { + refresh_token: String, + client_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct OpenAiOutputRequest { + result: GenerateTextResult, + metadata_namespace: String, +} + +fn params(value: Value) -> Result { + // Avoid including request values (especially refresh tokens) in error messages. + serde_json::from_value(value).map_err(|_| { + OpsError::Protocol(ProtocolError::InvalidParams( + "request does not match the operation schema".into(), + )) + }) +} + +fn output(value: &impl Serialize) -> Result { + serde_json::to_value(value) + .map_err(|e| OpsError::Binding(BindingError::ResultSerialization(e.to_string()))) +} + +fn catalogue_fetch(ctx: Ctx, _target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let request: CatalogueFetchRequest = params(value)?; + // Keep the existing source/default, parser and 60s exchange timeout while + // enabling the shared SSRF guard and bounded binary response handler. + let response = aimux_provider_utils::get_from_api( + aimux_provider_utils::HttpRequest { + url: request + .source_url + .unwrap_or_else(|| aimux_providers::catalogue::DEFAULT_ANYA2A_URL.into()), + validate_url: true, + response_timeout: Some(Duration::from_secs(60)), + abort_signal: ctx.abort, + ..Default::default() + }, + aimux_provider_utils::create_binary_response_handler(), + aimux_provider_utils::create_status_code_error_response_handler(), + ) + .await + .map_err(OpsError::AiMux)?; + let raw = serde_json::from_str(&String::from_utf8_lossy(&response.value)).map_err(|e| { + OpsError::AiMux(AiMuxError::JsonParse(format!( + "get_model_specs: invalid source JSON: {e}" + ))) + })?; + let catalogue = + aimux_providers::catalogue::parse_anya2a_all(&raw).map_err(OpsError::AiMux)?; + output(&catalogue) + }) +} + +type ToolRepairContextResult = Option; + +fn repair_context(_ctx: Ctx, _target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let request: ToolRepairContextRequest = params(value)?; + let (messages, instructions, tools) = + parse_tool_call::tool_call_repair_inputs(request.prompt, &request.options); + let context: ToolRepairContextResult = parse_tool_call::tool_call_repair_context( + &request.tool_call, + tools, + &messages, + instructions, + ) + .map_err(OpsError::AiMux)?; + output(&context) + }) +} + +fn repair_apply(_ctx: Ctx, _target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let request: ToolRepairApplyRequest = params(value)?; + let repaired = parse_tool_call::apply_tool_call_repair( + &request.tool_call, + request.options.tools.as_deref(), + request.reply, + ) + .map_err(OpsError::AiMux)?; + output(&aimux_core::wire::Tagged(&repaired)) + }) +} + +fn repair_apply_result(_ctx: Ctx, _target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let request: ToolRepairApplyResultRequest = params(value)?; + parse_tool_call::apply_tool_call_repair_to_result( + &request.result, + request.options.tools.as_deref(), + &request.tool_call_id, + request.reply, + ) + .map_err(OpsError::AiMux) + }) +} + +fn codex_refresh(_ctx: Ctx, _target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let request: CodexRefreshRequest = params(value)?; + // Exactly one refresh grant. The host owns token storage and retries. + output( + &aimux_providers::codex_refresh(&request.refresh_token, &request.client_id) + .await + .map_err(OpsError::AiMux)?, + ) + }) +} + +fn to_openai(_ctx: Ctx, target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let Target::Handle(Entry::Language(model)) = target else { + return Err(OpsError::Binding(BindingError::InvalidHandle { + expected: vec![HandleKind::Language], + actual: match target { + Target::Global => None, + Target::Handle(entry) => Some(entry.kind()), + }, + })); + }; + let request: OpenAiOutputRequest = params(value)?; + if request.metadata_namespace.is_empty() { + return Err(OpsError::Protocol(ProtocolError::InvalidParams( + "metadataNamespace must not be empty".into(), + ))); + } + // Core's result facts remain private. Restrict the canonical wire + // snapshot and lift it back before calling its existing converter. + let mut result = output(&request.result)?; + if let Some(steps) = result.get_mut("steps").and_then(Value::as_array_mut) { + for step in steps { + if let Some(metadata) = step + .get_mut("providerMetadata") + .and_then(Value::as_object_mut) + { + metadata.retain(|namespace, _| namespace == &request.metadata_namespace); + } + } + } + let result: GenerateTextResult = params(result)?; + output( + &aimux_core::generate::generate_text_result_to_chat_completion( + &result, + model.model_id(), + ), + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::handle::Owner; + use serde_json::json; + + fn ctx() -> Ctx { + Ctx { + owner: Owner::CProcess, + abort: None, + now: std::time::SystemTime::now, + } + } + + fn entry() -> Target { + Target::Global + } + + fn tools() -> Vec { + vec![ + aimux_core::tool::FunctionTool::new( + "weather", + json!({ + "type":"object", "properties":{"city":{"type":"string"}}, "required":["city"] + }), + ) + .into(), + ] + } + + async fn invalid_call() -> ToolCall { + parse_tool_call::parse_tool_call( + aimux_core::tool::RawToolCall { + tool_call_id: "call-1".into(), + tool_name: "weather".into(), + input: r#"{"city":7}"#.into(), + provider_executed: None, + dynamic: None, + provider_metadata: None, + }, + Some(&tools()), + None, + &[], + None, + ) + .await + } + + fn reply() -> Value { + json!({"type":"repaired", "toolCall":{ + "toolCallId":"call-1", "toolName":"weather", "input":r#"{"city":"Tokyo"}"# + }}) + } + + #[test] + fn registers_every_descriptor_once() { + let mut registered = Vec::new(); + register(&mut registered); + assert_eq!(registered.len(), 6); + for (descriptor, handler) in registered { + assert!(matches!(handler, Handler::Call(_))); + assert_eq!( + OPS.iter().filter(|op| op.name == descriptor.name).count(), + 1 + ); + assert_eq!( + descriptor.allowed_handle_kinds.is_empty(), + descriptor.name != "output.toOpenAI" + ); + } + assert_eq!(OPS[4].retry_owner, "none"); + assert!(OPS[4].side_effects); + } + + #[test] + fn rejects_unknown_control_fields_without_exposing_values() { + assert!(params::(json!({"sourceUrl":null,"extra":true})).is_err()); + for value in [ + json!({"refreshToken":"secret","clientId":"c","secret-field":true}), + json!({"refreshToken":{"secret":true},"clientId":"c"}), + ] { + match params::(value) { + Err(OpsError::Protocol(ProtocolError::InvalidParams(message))) => { + assert!(!message.contains("secret")) + } + _ => panic!("expected InvalidParams"), + } + } + assert!( + params::(json!({})) + .unwrap() + .source_url + .is_none() + ); + } + + #[tokio::test] + async fn context_matches_core_and_preserves_no_tools_null() { + let call = invalid_call().await; + let request = json!({"toolCall":call,"prompt":"hello", "options":{ + "tools":tools(), "instructions":"repair carefully" + }}); + let parsed: ToolRepairContextRequest = params(request.clone()).unwrap(); + let (messages, instructions, tools) = + parse_tool_call::tool_call_repair_inputs(parsed.prompt, &parsed.options); + let expected = + parse_tool_call::tool_call_repair_context(&call, tools, &messages, instructions) + .unwrap(); + assert_eq!( + repair_context(ctx(), entry(), request).await.unwrap(), + json!(expected) + ); + assert_eq!( + repair_context(ctx(), entry(), json!({"toolCall":call,"prompt":"hello"})) + .await + .unwrap(), + Value::Null + ); + } + + #[tokio::test] + async fn apply_matches_core_for_all_reply_variants() { + let call = invalid_call().await; + for reply in [ + reply(), + json!({"type":"unchanged"}), + json!({"type":"failed","message":"host failed"}), + ] { + let expected = parse_tool_call::apply_tool_call_repair( + &call, + Some(&tools()), + serde_json::from_value(reply.clone()).unwrap(), + ) + .unwrap(); + let actual = repair_apply( + ctx(), + entry(), + json!({"toolCall":call,"options":{"tools":tools()},"reply":reply}), + ) + .await + .unwrap(); + assert_eq!(actual, json!(aimux_core::wire::Tagged(&expected))); + assert_eq!(actual["type"], "tool-call"); + } + assert!(matches!( + repair_apply(ctx(), entry(), json!({"toolCall":call,"reply":reply()})).await, + Err(OpsError::AiMux(AiMuxError::InvalidArgument(_))) + )); + } + + #[tokio::test] + async fn apply_result_updates_calls_and_response_messages() { + let call = invalid_call().await; + let step = aimux_core::step_result::StepResult { + call_id: "call".into(), + step_number: 0, + model: aimux_core::step_result::StepModel { + provider: "test".into(), + model_id: "model".into(), + }, + content: vec![aimux_core::result::ContentPart::ToolCall(call.clone())], + finish_reason: aimux_core::types::FinishReasonUnified::ToolCalls, + raw_finish_reason: None, + usage: Default::default(), + performance: Default::default(), + warnings: vec![], + request: Default::default(), + response: aimux_core::step_result::StepResponse { + id: "response".into(), + timestamp: aimux_core::timestamp::Timestamp::now(), + model_id: "model".into(), + headers: None, + body: None, + messages: serde_json::from_value(json!([{"role":"assistant", "content":[{ + "type":"tool-call", "toolCallId":"call-1", "toolName":"weather", "input":{"city":7} + }]}])).unwrap(), + }, + provider_metadata: None, + }; + let result = + json!({"steps":[step],"totalUsage":aimux_core::types::LanguageModelUsage::default()}); + let expected = parse_tool_call::apply_tool_call_repair_to_result( + &result, + Some(&tools()), + "call-1", + serde_json::from_value(reply()).unwrap(), + ) + .unwrap(); + let actual = repair_apply_result( + ctx(), + entry(), + json!({"result":result,"options":{"tools":tools()}, + "toolCallId":"call-1","reply":reply()}), + ) + .await + .unwrap(); + assert_eq!(actual, expected); + assert_eq!(actual["toolCalls"][0]["input"], json!({"city":"Tokyo"})); + assert_eq!( + actual["responseMessages"][0]["content"][0]["input"], + json!({"city":"Tokyo"}) + ); + } + + #[tokio::test] + async fn catalogue_rejects_untrusted_sources_before_io() { + for url in [ + "http://127.0.0.1/catalogue", + "http://169.254.169.254/latest", + "file:///tmp/catalogue", + "http://localhost/catalogue", + ] { + assert!(matches!( + catalogue_fetch(ctx(), entry(), json!({"sourceUrl":url})).await, + Err(OpsError::AiMux(AiMuxError::InvalidArgument(_))) + )); + } + } +} diff --git a/aimux-ops/src/ops/modalities.rs b/aimux-ops/src/ops/modalities.rs new file mode 100644 index 000000000..f62e429e6 --- /dev/null +++ b/aimux-ops/src/ops/modalities.rs @@ -0,0 +1,798 @@ +//! Model operations reuse Core's wire types and retry/timeout orchestration. + +use aimux_core::{ + embedding_model, image_model, reranking_model, search_model, speech_model, transcription_model, + video_model, +}; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::Value; + +use crate::dispatch::{CallFuture, Ctx, Handler, Target, decode}; +use crate::envelope::{BindingError, OpsError, ProtocolError}; +use crate::handle::{Entry, HandleKind}; +use crate::op::{Cancellation, OpDescriptor, OpMode, RequestBytePolicy, Scope}; + +const fn operation( + name: &'static str, + kinds: &'static [HandleKind], + request_type: &'static str, + result_type: &'static str, + request_byte_policy: RequestBytePolicy, + source_symbols: &'static [&'static str], +) -> OpDescriptor { + OpDescriptor { + name, + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: kinds, + request_type, + result_type, + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[("abort", &[HandleKind::Abort])], + required_capabilities: &[], + cancellation: Cancellation::Abort, + result_ownership: "owned", + request_byte_policy, + side_effects: true, + retry_owner: "core", + source_symbols, + } +} + +pub(crate) static OPS: &[OpDescriptor] = &[ + operation( + "embedding.embed", + &[HandleKind::Embedding], + "aimux_core::embedding_model::EmbeddingCallOptions", + "aimux_core::embedding_model::EmbeddingResult", + RequestBytePolicy::Json, + &["aimux_embed", "aimux_core::embedding_model::embed"], + ), + operation( + "embedding.embedMany", + &[HandleKind::Embedding], + "aimux_core::embedding_model::EmbeddingCallOptions", + "aimux_core::embedding_model::EmbeddingResult", + RequestBytePolicy::Json, + &["aimux_embed", "aimux_core::embedding_model::embed"], + ), + operation( + "image.generate", + &[HandleKind::Image], + "aimux_core::image_model::ImageCallOptions", + "aimux_core::image_model::ImageResult", + RequestBytePolicy::JsonWithBase64, + &[ + "aimux_image_generate", + "aimux_core::image_model::generate_image", + ], + ), + operation( + "speech.generate", + &[HandleKind::Speech], + "aimux_core::speech_model::SpeechCallOptions", + "aimux_core::speech_model::SpeechResult", + RequestBytePolicy::Json, + &[ + "aimux_speech_generate", + "aimux_core::speech_model::generate_speech", + ], + ), + operation( + "transcription.generate", + &[HandleKind::Transcription], + "aimux_core::transcription_model::TranscriptionCallOptions", + "aimux_core::transcription_model::TranscriptionResult", + RequestBytePolicy::JsonWithBase64, + &[ + "aimux_transcription_generate", + "aimux_core::transcription_model::transcribe", + ], + ), + operation( + "reranking.rerank", + &[HandleKind::Reranking], + "aimux_core::reranking_model::RerankingCallOptions", + "aimux_core::reranking_model::RerankingResult", + RequestBytePolicy::Json, + &["aimux_rerank", "aimux_core::reranking_model::rerank"], + ), + operation( + "video.generate", + &[HandleKind::Video], + "aimux_core::video_model::VideoCallOptions", + "aimux_core::video_model::VideoResult", + RequestBytePolicy::JsonWithBase64, + &[ + "aimux_video_generate", + "aimux_core::video_model::generate_video", + ], + ), + operation( + "search.search", + &[HandleKind::Search], + "aimux_core::search_model::SearchCallOptions", + "aimux_core::search_model::SearchResult", + RequestBytePolicy::Json, + &["aimux_search", "aimux_core::search_model::search"], + ), +]; + +pub(crate) fn register(r: &mut Vec<(&'static OpDescriptor, Handler)>) { + r.extend([ + (&OPS[0], Handler::Call(embed)), + (&OPS[1], Handler::Call(embed)), + (&OPS[2], Handler::Call(generate_image)), + (&OPS[3], Handler::Call(generate_speech)), + (&OPS[4], Handler::Call(transcribe)), + (&OPS[5], Handler::Call(rerank)), + (&OPS[6], Handler::Call(generate_video)), + (&OPS[7], Handler::Call(search)), + ]); +} + +// Core options are canonical DTOs with open providerOptions/JSON fields. Keep +// their serde implementations, but reject unknown operation-level control keys. +fn check_fields(params: &Value, fields: &[&str]) -> Result<(), OpsError> { + let object = params.as_object().ok_or_else(|| { + OpsError::Protocol(ProtocolError::InvalidParams( + "expected an options object".into(), + )) + })?; + for key in object.keys() { + if !fields.contains(&key.as_str()) { + return Err(OpsError::Protocol(ProtocolError::InvalidParams(format!( + "unknown field `{key}`" + )))); + } + } + Ok(()) +} + +fn options(params: Value, fields: &[&str]) -> Result { + check_fields(¶ms, fields)?; + if let Some(timeout) = params.get("timeout").filter(|value| !value.is_null()) { + check_fields(timeout, &["totalMs", "stepMs", "firstChunkMs", "chunkMs"])?; + } + if let Some(poll) = params.get("poll").filter(|value| !value.is_null()) { + check_fields(poll, &["intervalMs", "timeoutMs"])?; + } + decode(params) +} + +fn result(value: impl Serialize) -> Result { + serde_json::to_value(value) + .map_err(|error| OpsError::Binding(BindingError::ResultSerialization(error.to_string()))) +} + +macro_rules! model_call { + ($name:ident, $kind:ident, $module:ident, $options:ident, $call:ident, [$($field:literal),* $(,)?]) => { + fn $name(ctx: Ctx, target: Target, mut params: Value) -> CallFuture { + Box::pin(async move { + let model = match target { + Target::Handle(Entry::$kind(model)) => model, + other => return Err(OpsError::Binding(BindingError::InvalidHandle { + expected: vec![HandleKind::$kind], + actual: match other { + Target::Handle(entry) => Some(entry.kind()), + Target::Global => None, + }, + })), + }; + // Dispatch has resolved this control through the owner-aware + // resolver and put its signal in Ctx. Core DTOs stay handle-free. + if let Some(object) = params.as_object_mut() { + object.remove("abort"); + } + let mut options: $module::$options = options(params, &[ + $($field,)* "maxRetries", "timeout", "providerOptions", "headers", + ])?; + options.abort_signal = ctx.abort; + let output = $module::$call(model.as_ref(), options).await.map_err(OpsError::AiMux)?; + result(output) + }) + } + }; +} + +model_call!( + embed, + Embedding, + embedding_model, + EmbeddingCallOptions, + embed, + ["values"] +); +model_call!( + generate_image, + Image, + image_model, + ImageCallOptions, + generate_image, + [ + "prompt", + "n", + "size", + "aspectRatio", + "seed", + "files", + "mask" + ] +); +model_call!( + generate_speech, + Speech, + speech_model, + SpeechCallOptions, + generate_speech, + [ + "text", + "voice", + "outputFormat", + "instructions", + "speed", + "language" + ] +); +model_call!( + transcribe, + Transcription, + transcription_model, + TranscriptionCallOptions, + transcribe, + ["audio", "mediaType"] +); +model_call!( + rerank, + Reranking, + reranking_model, + RerankingCallOptions, + rerank, + ["query", "documents", "topN"] +); +model_call!( + generate_video, + Video, + video_model, + VideoCallOptions, + generate_video, + [ + "prompt", + "n", + "aspectRatio", + "resolution", + "duration", + "fps", + "seed", + "image", + "frameImages", + "inputReferences", + "generateAudio", + "poll" + ] +); +model_call!( + search, + Search, + search_model, + SearchCallOptions, + search, + [ + "query", + "maxResults", + "includeRawContent", + "timeRange", + "includeDomains", + "excludeDomains" + ] +); + +#[cfg(test)] +mod tests { + use super::*; + use aimux_core::{AbortSignal, error::AiMuxError}; + use serde_json::json; + use std::{ + future::Future, + pin::Pin, + sync::{Arc, Mutex}, + time::SystemTime, + }; + + struct MockModel { + output: Value, + calls: Mutex>, + failure: Option, + } + + impl MockModel { + fn new(output: Value) -> Arc { + Arc::new(Self { + output, + calls: Mutex::new(Vec::new()), + failure: None, + }) + } + + fn respond( + &self, + options: impl Serialize, + abort: &Option, + ) -> Result { + assert!( + abort.is_some(), + "the context's abort signal must reach the model" + ); + self.calls + .lock() + .unwrap() + .push(serde_json::to_value(options).unwrap()); + if let Some(error) = &self.failure { + return Err(error.clone()); + } + Ok(serde_json::from_value(self.output.clone()).unwrap()) + } + } + + macro_rules! identity { + () => { + fn provider(&self) -> &str { + "mock" + } + fn model_id(&self) -> &str { + "mock-model" + } + }; + } + + // Spell out async-trait's boxed future signature so tests need no extra + // dependencies beyond the ops crate's contracted dependencies. + macro_rules! mock_method { + ($method:ident, $module:ident, $options:ident, $result:ident) => { + fn $method<'life0, 'life1, 'async_trait>( + &'life0 self, + options: &'life1 $module::$options, + ) -> Pin< + Box< + dyn Future> + Send + 'async_trait, + >, + > + where + 'life0: 'async_trait, + 'life1: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { self.respond(options, &options.abort_signal) }) + } + }; + } + + impl embedding_model::EmbeddingModel for MockModel { + identity!(); + fn max_embeddings_per_call(&self) -> Option { + None + } + fn supports_parallel_calls(&self) -> bool { + true + } + mock_method!( + do_embed, + embedding_model, + EmbeddingCallOptions, + EmbeddingResult + ); + } + impl image_model::ImageModel for MockModel { + identity!(); + fn max_images_per_call(&self) -> Option { + None + } + mock_method!(do_generate, image_model, ImageCallOptions, ImageResult); + } + impl speech_model::SpeechModel for MockModel { + identity!(); + mock_method!(do_generate, speech_model, SpeechCallOptions, SpeechResult); + } + impl transcription_model::TranscriptionModel for MockModel { + identity!(); + mock_method!( + do_generate, + transcription_model, + TranscriptionCallOptions, + TranscriptionResult + ); + } + impl reranking_model::RerankingModel for MockModel { + identity!(); + mock_method!( + do_rerank, + reranking_model, + RerankingCallOptions, + RerankingResult + ); + } + impl search_model::SearchModel for MockModel { + identity!(); + mock_method!(do_search, search_model, SearchCallOptions, SearchResult); + } + impl video_model::VideoModel for MockModel { + identity!(); + fn max_videos_per_call(&self) -> Option { + Some(1) + } + fn do_start<'life0, 'life1, 'async_trait>( + &'life0 self, + options: &'life1 video_model::VideoCallOptions, + ) -> Pin< + Box< + dyn Future> + + Send + + 'async_trait, + >, + > + where + 'life0: 'async_trait, + 'life1: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + assert!(options.abort_signal.is_some()); + self.calls + .lock() + .unwrap() + .push(serde_json::to_value(options).unwrap()); + Ok(video_model::VideoOperationStart { + operation: json!({"id": "task"}), + warnings: vec![], + provider_metadata: None, + response: Default::default(), + }) + }) + } + fn do_status<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + operation: &'life1 Value, + options: &'life2 video_model::VideoCallOptions, + ) -> Pin< + Box< + dyn Future> + + Send + + 'async_trait, + >, + > + where + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + assert_eq!(operation, &json!({"id": "task"})); + self.respond(options, &options.abort_signal) + .map(video_model::VideoOperationStatus::Completed) + }) + } + } + + fn context() -> Ctx { + Ctx { + owner: crate::handle::Owner::CProcess, + abort: Some(AbortSignal::new()), + now: SystemTime::now, + } + } + + async fn invoke(op: &str, entry: Entry, params: Value) -> Result { + let mut registered = vec![]; + register(&mut registered); + let registry = crate::handle::HandleRegistry::default(); + let ctx = context(); + let handle = registry.insert(ctx.owner, entry).unwrap(); + crate::dispatch::Dispatcher::new(®istry, ®istered) + .call(ctx, handle, op, params) + .await + } + + fn controls(mut params: Value) -> Value { + params["maxRetries"] = json!(0); + params["timeout"] = json!({"totalMs": 10000}); + params["headers"] = json!({"x-test": "preserved"}); + params["providerOptions"] = json!({"mock": {"arbitrary": {"nested": [null, 1, "值"]}}}); + params + } + + macro_rules! round_trip { + ($test:ident, $op:literal, $kind:ident, $params:expr, $output:expr) => { + #[tokio::test] + async fn $test() { + let params = controls($params); + let expected = $output; + let model = MockModel::new(expected.clone()); + let output = invoke($op, Entry::$kind(model.clone()), params.clone()) + .await + .unwrap(); + assert_eq!(output, expected); + assert_eq!(*model.calls.lock().unwrap(), vec![params]); + } + }; + } + + round_trip!( + embedding_single, + "embedding.embed", + Embedding, + json!({"values": ["hello"]}), + json!({"embeddings": [[1.0, 2.0]], "usage": {"tokens": 1}, "warnings": []}) + ); + round_trip!( + embedding_many_preserves_all_inputs, + "embedding.embedMany", + Embedding, + json!({"values": ["first", "第二", "🚀"]}), + json!({"embeddings": [[1.0], [2.0], [3.0]], "warnings": []}) + ); + round_trip!( + embedding_many_preserves_empty_input, + "embedding.embedMany", + Embedding, + json!({"values": []}), + json!({"embeddings": [], "warnings": []}) + ); + round_trip!( + image_generation, + "image.generate", + Image, + json!({"prompt": "scene", "n": 2, "size": "256x256", "aspectRatio": "1:1", "seed": 7, + "files": [{"type": "file", "mediaType": "image/png", "data": "AAH/"}], + "mask": {"type": "url", "url": "https://example.test/mask.png"}}), + json!({"images": ["AAH/", "AgM="], "warnings": [], "response": {"modelId": "image"}}) + ); + round_trip!( + speech_generation, + "speech.generate", + Speech, + json!({"text": "你好", "voice": "voice", "outputFormat": "wav", "speed": 1.5, "language": "zh", "instructions": "slowly"}), + json!({"audio": "AAH/", "warnings": [], "usage": null, "response": {"body": null}}) + ); + round_trip!( + transcription_base64_bytes, + "transcription.generate", + Transcription, + json!({"audio": "AAH/", "mediaType": "audio/wav"}), + json!({"text": "你好", "segments": [{"text": "你好", "startSecond": 0.0, "endSecond": 1.5}], + "language": "zh", "durationInSeconds": 1.5, "warnings": [], "response": {}}) + ); + round_trip!( + reranking_text_documents, + "reranking.rerank", + Reranking, + json!({"query": "q", "documents": {"type": "text", "values": ["a", "b"]}, "topN": 1}), + json!({"ranking": [{"index": 1, "relevanceScore": 0.75}], "warnings": []}) + ); + round_trip!( + reranking_object_documents, + "reranking.rerank", + Reranking, + json!({"query": "q", "documents": {"type": "object", "values": [{"arbitrary": [null, 1]}]}}), + json!({"ranking": [{"index": 0, "relevanceScore": 1.0}]}) + ); + round_trip!( + search_extension, + "search.search", + Search, + json!({"query": "q", "maxResults": 2, "includeRawContent": true, "timeRange": "week", + "includeDomains": ["example.test"], "excludeDomains": ["excluded.test"]}), + json!({"results": [{"title": "hit", "url": "https://example.test", "content": "snippet", "rawContent": "raw", "score": 0.5}], + "answer": "answer", "warnings": [], "response": {"body": null}, "providerMetadata": {"mock": {"extra": true}}}) + ); + + #[tokio::test] + async fn video_uses_core_batching_and_polling() { + let expected = json!({"videos": [{"type": "base64", "data": "AAH/", "mediaType": "video/mp4"}], + "warnings": [], "response": {}}); + let model = MockModel::new(expected.clone()); + let params = controls( + json!({"prompt": "scene", "n": 2, "poll": {"intervalMs": 0, "timeoutMs": 1000}, + "resolution": "1280x720", "duration": 3, "fps": 24, "generateAudio": true}), + ); + let output = invoke( + "video.generate", + Entry::Video(model.clone()), + params.clone(), + ) + .await + .unwrap(); + assert_eq!( + output["videos"], + json!([expected["videos"][0], expected["videos"][0]]) + ); + let calls = model.calls.lock().unwrap(); + assert_eq!( + calls.len(), + 4, + "one start and one status per provider-sized batch" + ); + for call in calls.iter() { + assert_eq!(call["n"], 1); + for field in [ + "prompt", + "poll", + "resolution", + "duration", + "fps", + "generateAudio", + "maxRetries", + "timeout", + "providerOptions", + ] { + assert_eq!(call[field], params[field]); + } + assert_eq!(call["headers"]["x-test"], "preserved"); + } + } + + #[tokio::test] + async fn rejects_unknown_controls_and_invalid_wire_without_calling_model() { + let model = MockModel::new(json!({})); + for (op, entry, params) in [ + ( + "embedding.embedMany", + Entry::Embedding(model.clone()), + json!({"values": ["a"], "unknown": true}), + ), + ( + "image.generate", + Entry::Image(model.clone()), + json!({"n": 1, "providerOptions": {}, "callback": {}}), + ), + ( + "speech.generate", + Entry::Speech(model.clone()), + json!({"text": "x", "abortSignal": "7"}), + ), + ( + "transcription.generate", + Entry::Transcription(model.clone()), + json!({"audio": [0, 1, 255], "mediaType": "audio/wav"}), + ), + ( + "transcription.generate", + Entry::Transcription(model.clone()), + json!({"audio": {"Binary": [0]}, "mediaType": "audio/wav"}), + ), + ( + "reranking.rerank", + Entry::Reranking(model.clone()), + json!({"query": "q", "documents": []}), + ), + ( + "video.generate", + Entry::Video(model.clone()), + json!({"n": 1, "providerOptions": {}, "download": {}}), + ), + ( + "search.search", + Entry::Search(model.clone()), + json!({"query": "q", "typo": 1}), + ), + ( + "search.search", + Entry::Search(model.clone()), + json!({"query": "q", "timeout": {"totalMs": 1000, "unknown": 1}}), + ), + ( + "video.generate", + Entry::Video(model.clone()), + json!({"n": 1, "providerOptions": {}, "poll": {"intervalMs": 0, "unknown": 1}}), + ), + ("search.search", Entry::Search(model.clone()), json!(null)), + ( + "embedding.embed", + Entry::Embedding(model.clone()), + json!({"values": "not an array"}), + ), + ] { + assert!( + matches!( + invoke(op, entry, params).await.unwrap_err().unprojected(), + OpsError::Protocol(ProtocolError::InvalidParams(_)) + ), + "{op}" + ); + } + assert!(model.calls.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn wrong_handle_retains_expected_and_actual_kinds() { + let model = MockModel::new(json!({})); + let error = invoke( + "embedding.embed", + Entry::Search(model), + json!({"values": ["a"]}), + ) + .await; + let error = error.unwrap_err(); + let OpsError::Binding(BindingError::InvalidHandle { expected, actual }) = + error.unprojected() + else { + panic!("expected InvalidHandle"); + }; + assert_eq!(expected, &[HandleKind::Embedding]); + assert_eq!(*actual, Some(HandleKind::Search)); + } + + #[tokio::test] + async fn core_error_is_preserved() { + let model = Arc::new(MockModel { + output: Value::Null, + calls: Mutex::new(vec![]), + failure: Some(AiMuxError::InvalidArgument( + "provider rejected query".into(), + )), + }); + let error = invoke( + "search.search", + Entry::Search(model.clone()), + controls(json!({"query": "q"})), + ) + .await; + assert!( + matches!(error.unwrap_err().unprojected(), OpsError::AiMux(AiMuxError::InvalidArgument(message)) if message == "provider rejected query") + ); + assert_eq!(model.calls.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn context_abort_is_observed_by_core() { + let model = MockModel::new(json!({})); + let ctx = context(); + ctx.abort.as_ref().unwrap().abort(); + let error = search( + ctx, + Target::Handle(Entry::Search(model.clone())), + json!({"query": "q"}), + ) + .await; + assert!(matches!( + error, + Err(OpsError::AiMux(AiMuxError::Aborted(_))) + )); + assert!(model.calls.lock().unwrap().is_empty()); + } + + #[test] + fn binary_results_use_v4_base64_wire() { + let speech = speech_model::SpeechResult { + audio: speech_model::AudioData::Binary(vec![0, 1, 255]), + warnings: vec![], + usage: None, + request: None, + response: Default::default(), + provider_metadata: None, + }; + assert_eq!(result(speech).unwrap()["audio"], "AAH/"); + let image = image_model::ImageResult { + images: image_model::ImageOutputs::Binary(vec![vec![0, 1, 255]]), + warnings: vec![], + response: Default::default(), + provider_metadata: None, + usage: None, + }; + assert_eq!(result(image).unwrap()["images"], json!(["AAH/"])); + let options: transcription_model::TranscriptionCallOptions = options( + json!({"audio": "AAH/", "mediaType": "audio/wav"}), + &["audio", "mediaType"], + ) + .unwrap(); + let transcription_model::AudioInput::Base64(audio) = options.audio else { + panic!("expected base64"); + }; + assert_eq!( + aimux_core::wire::decode_base64(&audio).unwrap(), + [0, 1, 255] + ); + } +} diff --git a/aimux-ops/src/ops/operation.rs b/aimux-ops/src/ops/operation.rs new file mode 100644 index 000000000..5810272de --- /dev/null +++ b/aimux-ops/src/ops/operation.rs @@ -0,0 +1,239 @@ +//! The six realtime operations share the native Operation service. +use crate::dispatch::{CallFuture, Ctx, Handler, PollResult, Target, decode, next_operation}; +use crate::envelope::{BindingError, OpsError, ProtocolError}; +use crate::handle::{Entry, HandleKind, HandleRegistry}; +use crate::op::{Cancellation, OpDescriptor, OpMode, RequestBytePolicy, Scope}; +use crate::operation::{Operation, OperationOptions}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::time::Duration; + +pub type BinaryInput = [u8]; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct EmptyParams {} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OperationNextParams { + #[serde(default)] + pub timeout_ms: u64, +} + +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TranscriptionStartParams { + pub input_audio_format: Option, + pub provider_options: Option, + pub headers: Option, + #[serde(default)] + pub include_raw_chunks: bool, + pub timeout: Option, + pub abort: Option, +} + +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum OperationPollResult { + Part { part: Value }, + Ended, + Timeout, +} + +const fn descriptor( + name: &'static str, + mode: OpMode, + kinds: &'static [HandleKind], + request_type: &'static str, + result_type: &'static str, + cancellation: Cancellation, + side_effects: bool, +) -> OpDescriptor { + OpDescriptor { + name, + since_ops_version: "1.0", + mode, + allowed_handle_kinds: kinds, + request_type, + result_type, + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: if matches!(cancellation, Cancellation::Abort) { + &[("abort", &[HandleKind::Abort])] + } else { + &[] + }, + required_capabilities: if matches!(cancellation, Cancellation::Abort) { + &["doStream"] + } else { + &[] + }, + cancellation, + side_effects, + result_ownership: "owned", + request_byte_policy: if matches!(mode, OpMode::BinaryPush) { + RequestBytePolicy::Binary + } else { + RequestBytePolicy::Json + }, + retry_owner: "none", + source_symbols: &["aimux_ops::operation::Operation"], + } +} +pub(crate) static OPS: &[OpDescriptor] = &[ + descriptor( + "transcription.start", + OpMode::Call, + &[HandleKind::Transcription], + "TranscriptionStartParams", + "HandleId", + Cancellation::Abort, + true, + ), + descriptor( + "operation.push", + OpMode::BinaryPush, + &[HandleKind::Operation], + "BinaryInput", + "()", + Cancellation::Operation, + true, + ), + descriptor( + "operation.inputDone", + OpMode::Call, + &[HandleKind::Operation], + "EmptyParams", + "()", + Cancellation::Operation, + true, + ), + descriptor( + "operation.next", + OpMode::Poll, + &[HandleKind::Operation], + "OperationNextParams", + "OperationPollResult", + Cancellation::Operation, + true, + ), + descriptor( + "operation.status", + OpMode::Call, + &[HandleKind::Operation], + "EmptyParams", + "OperationStatus", + Cancellation::None, + false, + ), + descriptor( + "operation.cancel", + OpMode::Call, + &[HandleKind::Operation], + "EmptyParams", + "CancelResult", + Cancellation::Operation, + true, + ), +]; +pub(crate) fn register(r: &mut Vec<(&'static OpDescriptor, Handler)>) { + for (descriptor, handler) in + OPS.iter() + .zip([start, binary_only, input_done, next, status, cancel] + as [fn(Ctx, Target, Value) -> CallFuture; 6]) + { + r.push((descriptor, Handler::Call(handler))); + } +} +fn operation(target: Target) -> Result, OpsError> { + match target { + Target::Handle(Entry::Operation(operation)) => Ok(operation), + _ => Err(OpsError::Protocol(ProtocolError::InvalidState( + "expected operation target".into(), + ))), + } +} +fn serialize(value: impl serde::Serialize) -> Result { + serde_json::to_value(value) + .map_err(|error| BindingError::ResultSerialization(error.to_string()).into()) +} +fn start(ctx: Ctx, target: Target, params: Value) -> CallFuture { + Box::pin(async move { + if params + .get("inputAudioFormat") + .and_then(Value::as_object) + .is_some_and(|format| { + format + .keys() + .any(|key| key != "formatType" && key != "rate") + }) + { + return Err( + ProtocolError::InvalidParams("unknown inputAudioFormat field".into()).into(), + ); + } + if params + .get("timeout") + .and_then(Value::as_object) + .is_some_and(|timeout| { + timeout.keys().any(|key| { + !["totalMs", "stepMs", "firstChunkMs", "chunkMs"].contains(&key.as_str()) + }) + }) + { + return Err(ProtocolError::InvalidParams("unknown timeout field".into()).into()); + } + let request: TranscriptionStartParams = decode(params)?; + let Target::Handle(Entry::Transcription(model)) = target else { + return Err(ProtocolError::InvalidState("expected transcription target".into()).into()); + }; + let options = OperationOptions { + input_audio_format: request.input_audio_format, + provider_options: request.provider_options, + headers: request.headers, + include_raw_chunks: request.include_raw_chunks, + timeout: request.timeout, + ..Default::default() + }; + let operation = Operation::start_with_clock(model, options, ctx.abort, ctx.now).await?; + let id = HandleRegistry::global().insert(ctx.owner, Entry::Operation(operation))?; + serialize(id) + }) +} +fn binary_only(_: Ctx, _: Target, _: Value) -> CallFuture { + Box::pin(async { + Err(ProtocolError::InvalidOp("operation.push requires binary dispatch".into()).into()) + }) +} +fn input_done(_: Ctx, target: Target, params: Value) -> CallFuture { + Box::pin(async move { + decode::(params)?; + operation(target)?.input_done(); + Ok(Value::Null) + }) +} +fn next(_: Ctx, target: Target, params: Value) -> CallFuture { + Box::pin(async move { + let request: OperationNextParams = decode(params)?; + let operation = operation(target)?; + match next_operation(&operation, Duration::from_millis(request.timeout_ms)).await? { + PollResult::Part(part) => serialize(OperationPollResult::Part { part }), + PollResult::Ended => serialize(OperationPollResult::Ended), + PollResult::Timeout => serialize(OperationPollResult::Timeout), + } + }) +} +fn status(_: Ctx, target: Target, params: Value) -> CallFuture { + Box::pin(async move { + decode::(params)?; + serialize(operation(target)?.status()) + }) +} +fn cancel(_: Ctx, target: Target, params: Value) -> CallFuture { + Box::pin(async move { + decode::(params)?; + serialize(operation(target)?.cancel()) + }) +} diff --git a/aimux-ops/src/ops/provider.rs b/aimux-ops/src/ops/provider.rs new file mode 100644 index 000000000..57d50dfef --- /dev/null +++ b/aimux-ops/src/ops/provider.rs @@ -0,0 +1,1500 @@ +//! Provider lifecycle and the existing model-discovery extension. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock, Weak}; + +use aimux_core::custom_provider::{CustomProviderOptions, custom_provider}; +use aimux_core::error::AiMuxError; +use aimux_core::image_model_middleware::ImageModelMiddleware; +use aimux_core::language_model_middleware::LanguageModelMiddleware; +use aimux_core::provider::Provider; +use aimux_core::wrap_provider::wrap_provider; +use aimux_provider_utils::Resolvable; +use aimux_providers::preset::PresetSettings; +use serde::Deserialize; +use serde_json::Value; + +use crate::dispatch::{CallFuture, Ctx, Handler, Target, decode}; +use crate::envelope::{BindingError, OpsError, ProtocolError}; +use crate::handle::{Entry, HandleId, HandleKind, HandleRegistry, Middleware, Owner}; +use crate::op::{Cancellation, OpDescriptor, OpMode, RequestBytePolicy, Scope}; + +const CREATE_HANDLES: &[(&str, &[HandleKind])] = &[ + ("settings.fetch", &[HandleKind::Fetch]), + ("settings.webSocket", &[HandleKind::WebSocket]), + ( + "settings.credentialResolver", + &[HandleKind::CredentialResolver], + ), +]; +const INVOKE_HANDLES: &[(&str, &[HandleKind])] = &[("abort", &[HandleKind::Abort])]; +const WRAP_HANDLES: &[(&str, &[HandleKind])] = &[ + ("languageModelMiddleware.*", &[HandleKind::Middleware]), + ("imageModelMiddleware.*", &[HandleKind::Middleware]), +]; +const CUSTOM_HANDLES: &[(&str, &[HandleKind])] = &[ + ("languageModels.*", &[HandleKind::Language]), + ("embeddingModels.*", &[HandleKind::Embedding]), + ("imageModels.*", &[HandleKind::Image]), + ("speechModels.*", &[HandleKind::Speech]), + ("transcriptionModels.*", &[HandleKind::Transcription]), + ("rerankingModels.*", &[HandleKind::Reranking]), + ("videoModels.*", &[HandleKind::Video]), + ("searchModels.*", &[HandleKind::Search]), + ("decisionModels.*", &[HandleKind::Decision]), + ("files", &[HandleKind::Files]), + ("fallbackProvider", &[HandleKind::Provider]), + ("languageModelMiddleware.*", &[HandleKind::Middleware]), + ("imageModelMiddleware.*", &[HandleKind::Middleware]), +]; + +#[allow(clippy::too_many_arguments)] +const fn descriptor( + name: &'static str, + kinds: &'static [HandleKind], + request_type: &'static str, + result_type: &'static str, + side_effects: bool, + cancellable: bool, + source_symbols: &'static [&'static str], + runtime_handle_fields: &'static [(&'static str, &'static [HandleKind])], +) -> OpDescriptor { + OpDescriptor { + name, + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: kinds, + request_type, + result_type, + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields, + required_capabilities: &[], + cancellation: if cancellable { + Cancellation::Abort + } else { + Cancellation::None + }, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + side_effects, + retry_owner: "none", + source_symbols, + } +} + +pub(crate) static OPS: &[OpDescriptor] = &[ + descriptor( + "provider.create", + &[], + "aimux_ops::ops::provider::ProviderCreateParams", + "aimux_ops::ops::provider::HandleResult", + true, + false, + &[ + "aimux_providers::create_provider", + "aimux_providers::JevProvider::new", + ], + CREATE_HANDLES, + ), + descriptor( + "provider.default", + &[], + "aimux_ops::ops::provider::ProviderDefaultParams", + "aimux_ops::ops::provider::HandleResult", + true, + false, + &[ + "aimux_providers::create_provider", + "aimux_providers::JevProvider::new", + ], + &[], + ), + descriptor( + "provider.model", + &[HandleKind::Provider], + "aimux_ops::ops::provider::ProviderModelParams", + "aimux_ops::ops::provider::HandleResult", + true, + false, + &["aimux_core::provider::Provider"], + &[], + ), + descriptor( + "provider.invoke", + &[HandleKind::Provider], + "aimux_ops::ops::provider::ProviderInvokeParams", + "aimux_ops::ops::provider::ProviderInvokeResult", + false, + true, + &["aimux_core::provider::ProviderDiscovery::list_models"], + INVOKE_HANDLES, + ), + descriptor( + "provider.wrap", + &[HandleKind::Provider], + "aimux_ops::ops::provider::ProviderWrapParams", + "aimux_ops::ops::provider::HandleResult", + true, + false, + &["aimux_core::wrap_provider::wrap_provider"], + WRAP_HANDLES, + ), + descriptor( + "provider.custom", + &[], + "aimux_ops::ops::provider::ProviderCustomParams", + "aimux_ops::ops::provider::HandleResult", + true, + false, + &["aimux_core::custom_provider::custom_provider"], + CUSTOM_HANDLES, + ), +]; + +pub(crate) fn register(r: &mut Vec<(&'static OpDescriptor, Handler)>) { + let handlers: [fn(Ctx, Target, Value) -> CallFuture; 6] = + [create, default_provider, model, invoke, wrap, custom]; + r.extend( + OPS.iter() + .zip(handlers) + .map(|(op, handler)| (op, Handler::Call(handler))), + ); +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProviderCreateParams { + package: String, + #[serde(default)] + settings: ProviderSettings, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProviderDefaultParams { + package: String, +} + +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProviderSettings { + api_key: Option, + base_url: Option, + #[serde(default)] + headers: HashMap>, + organization: Option, + project: Option, + #[serde(default)] + params: HashMap, + fetch: Option, + web_socket: Option, + credential_resolver: Option, + endpoint: Option, + probability_source: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProviderModelParams { + method: Option, + model_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProviderInvokeParams { + method: String, + #[serde(rename = "abort")] + _abort: Option, +} + +type ProviderInvokeResult = Vec; + +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProviderWrapParams { + #[serde(default)] + language_model_middleware: Vec, + #[serde(default)] + image_model_middleware: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProviderCustomParams { + #[serde(default)] + language_models: HashMap, + #[serde(default)] + embedding_models: HashMap, + #[serde(default)] + image_models: HashMap, + #[serde(default)] + speech_models: HashMap, + #[serde(default)] + transcription_models: HashMap, + #[serde(default)] + reranking_models: HashMap, + #[serde(default)] + video_models: HashMap, + #[serde(default)] + search_models: HashMap, + #[serde(default)] + decision_models: HashMap, + files: Option, + fallback_provider: Option, + default_method: Option, + #[serde(default)] + language_model_middleware: Vec, + #[serde(default)] + image_model_middleware: Vec, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct HandleResult { + handle: HandleId, + kind: HandleKind, +} + +#[derive(Clone, Copy)] +enum ModelMethod { + Language, + Embedding, + Image, + Speech, + Transcription, + Reranking, + Video, + Search, + Decision, +} + +impl ModelMethod { + fn parse(method: &str) -> Result { + match method { + "languageModel" => Ok(Self::Language), + "embeddingModel" => Ok(Self::Embedding), + "imageModel" => Ok(Self::Image), + "speechModel" => Ok(Self::Speech), + "transcriptionModel" => Ok(Self::Transcription), + "rerankingModel" => Ok(Self::Reranking), + "videoModel" => Ok(Self::Video), + "searchModel" => Ok(Self::Search), + "decision" => Ok(Self::Decision), + _ => Err(unsupported(method)), + } + } +} + +/// The Decision-only package provider.create builds itself rather than through the factory. +const JEV: &str = "jev"; + +/// Every package name provider.create accepts, sorted. +pub(crate) fn packages() -> Vec<&'static str> { + let mut names = aimux_providers::provider_names(); + names.push(JEV); + names.sort_unstable(); + names +} + +// Entry::Provider holds the canonical trait object. Keep only weak, per-object +// descriptor metadata here; package configuration stays in aimux-providers. +struct ProviderDescriptor { + provider: Weak, + default_method: Option, +} + +fn descriptors() -> &'static Mutex> { + static DESCRIPTORS: OnceLock>> = OnceLock::new(); + DESCRIPTORS.get_or_init(|| Mutex::new(Vec::new())) +} + +fn default_method(provider: &Arc) -> Result, OpsError> { + let descriptors = descriptors().lock().map_err(|_| invalid_state())?; + let weak = Arc::downgrade(provider); + Ok(descriptors + .iter() + .find(|d| Weak::ptr_eq(&d.provider, &weak)) + .and_then(|d| d.default_method)) +} + +fn insert_provider( + owner: Owner, + provider: Arc, + default_method: Option, +) -> Result { + { + let mut descriptors = descriptors().lock().map_err(|_| invalid_state())?; + descriptors.retain(|d| d.provider.strong_count() != 0); + descriptors.push(ProviderDescriptor { + provider: Arc::downgrade(&provider), + default_method, + }); + } + insert(owner, Entry::Provider(provider)) +} + +fn invalid_state() -> OpsError { + OpsError::Protocol(ProtocolError::InvalidState( + "provider descriptor lock poisoned".into(), + )) +} + +fn unsupported(method: &str) -> OpsError { + OpsError::AiMux(AiMuxError::UnsupportedFunctionality(format!( + "provider does not support {method}" + ))) +} + +fn invalid_handle(expected: &[HandleKind], actual: HandleKind) -> OpsError { + OpsError::Binding(BindingError::InvalidHandle { + expected: expected.to_vec(), + actual: Some(actual), + }) +} + +fn provider(target: Target) -> Result, OpsError> { + match target { + Target::Handle(Entry::Provider(provider)) => Ok(provider), + Target::Handle(entry) => Err(invalid_handle(&[HandleKind::Provider], entry.kind())), + Target::Global => Err(OpsError::Binding(BindingError::InvalidHandle { + expected: vec![HandleKind::Provider], + actual: None, + })), + } +} + +fn insert(owner: Owner, entry: Entry) -> Result { + let kind = entry.kind(); + let handle = HandleRegistry::global().insert(owner, entry)?; + serde_json::to_value(HandleResult { handle, kind }) + .map_err(|error| OpsError::Binding(BindingError::ResultSerialization(error.to_string()))) +} + +fn get(owner: Owner, handle: HandleId, kind: HandleKind) -> Result { + HandleRegistry::global().get(owner, handle, &[kind]) +} + +fn create(ctx: Ctx, _entry: Target, params: Value) -> CallFuture { + Box::pin(async move { + let params: ProviderCreateParams = decode(params)?; + create_package(ctx.owner, ¶ms.package, params.settings) + }) +} + +fn default_provider(ctx: Ctx, _entry: Target, params: Value) -> CallFuture { + Box::pin(async move { + let params: ProviderDefaultParams = decode(params)?; + create_package(ctx.owner, ¶ms.package, ProviderSettings::default()) + }) +} + +fn create_package( + owner: Owner, + package: &str, + settings: ProviderSettings, +) -> Result { + // Resolve every runtime slot before constructing anything. A valid but + // unavailable slot is a capability error, never silently ignored. + let fetch = match settings.fetch { + Some(handle) => match get(owner, handle, HandleKind::Fetch)? { + Entry::Fetch(fetch) => Some(fetch), + entry => return Err(invalid_handle(&[HandleKind::Fetch], entry.kind())), + }, + None => None, + }; + if let Some(handle) = settings.web_socket { + get(owner, handle, HandleKind::WebSocket)?; + return Err(unsupported("webSocket settings")); + } + if let Some(handle) = settings.credential_resolver { + get(owner, handle, HandleKind::CredentialResolver)?; + return Err(unsupported("credentialResolver settings")); + } + if package == JEV { + if fetch.is_some() + || settings.base_url.is_some() + || settings.organization.is_some() + || settings.project.is_some() + || !settings.params.is_empty() + { + return Err(OpsError::AiMux(AiMuxError::InvalidArgument( + "Jev accepts apiKey, endpoint, headers and probabilitySource settings".into(), + ))); + } + let mut config = match settings.api_key { + Some(key) => aimux_providers::JevConfig::new(key), + None => aimux_providers::JevConfig::from_env().map_err(OpsError::AiMux)?, + }; + if let Some(endpoint) = settings.endpoint.filter(|endpoint| !endpoint.is_empty()) { + config = config.with_endpoint(endpoint); + } + if let Some(source) = settings.probability_source { + config.probability_source = source.parse().map_err(OpsError::AiMux)?; + } + config.headers = settings + .headers + .into_iter() + .filter_map(|(key, value)| value.map(|value| (key, value))) + .collect(); + return insert_provider( + owner, + Arc::new(aimux_providers::JevProvider::new(config)), + Some(ModelMethod::Decision), + ); + } + if settings.endpoint.is_some() || settings.probability_source.is_some() { + return Err(OpsError::AiMux(AiMuxError::InvalidArgument( + "endpoint and probabilitySource settings require the Jev provider".into(), + ))); + } + let mut headers = settings.headers; + if let Some(org) = settings.organization { + headers + .entry("OpenAI-Organization".into()) + .or_insert(Some(org)); + } + if let Some(project) = settings.project { + headers + .entry("OpenAI-Project".into()) + .or_insert(Some(project)); + } + let provider = aimux_providers::create_provider( + package, + PresetSettings { + api_key: settings.api_key.map(Resolvable::Value), + base_url: settings.base_url, + headers: (!headers.is_empty()).then_some(headers), + fetch, + params: settings.params, + ..PresetSettings::default() + }, + ) + .map_err(OpsError::AiMux)?; + // Preset descriptors construct language models. Vendor factories expose + // no default-method metadata in this baseline; require an explicit method. + let method = aimux_providers::preset::lookup(package).map(|_| ModelMethod::Language); + insert_provider(owner, provider, method) +} + +fn model(ctx: Ctx, entry: Target, params: Value) -> CallFuture { + Box::pin(async move { + let params: ProviderModelParams = decode(params)?; + let provider = provider(entry)?; + let method = match params.method { + Some(method) => ModelMethod::parse(&method)?, + None => { + default_method(&provider)?.ok_or_else(|| unsupported("default model method"))? + } + }; + let id = ¶ms.model_id; + let model = match method { + ModelMethod::Language => { + Entry::Language(provider.language_model(id).map_err(OpsError::AiMux)?) + } + ModelMethod::Embedding => { + Entry::Embedding(provider.embedding_model(id).map_err(OpsError::AiMux)?) + } + ModelMethod::Image => Entry::Image(provider.image_model(id).map_err(OpsError::AiMux)?), + ModelMethod::Speech => Entry::Speech( + provider + .speech_model(id) + .ok_or_else(|| unsupported("speechModel"))? + .map_err(OpsError::AiMux)?, + ), + ModelMethod::Transcription => Entry::Transcription( + provider + .transcription_model(id) + .ok_or_else(|| unsupported("transcriptionModel"))? + .map_err(OpsError::AiMux)?, + ), + ModelMethod::Reranking => Entry::Reranking( + provider + .reranking_model(id) + .ok_or_else(|| unsupported("rerankingModel"))? + .map_err(OpsError::AiMux)?, + ), + ModelMethod::Video => Entry::Video( + provider + .video_model(id) + .ok_or_else(|| unsupported("videoModel"))? + .map_err(OpsError::AiMux)?, + ), + ModelMethod::Decision => Entry::Decision( + provider + .decision_model(id) + .ok_or_else(|| unsupported("decision"))? + .map_err(OpsError::AiMux)?, + ), + ModelMethod::Search => Entry::Search( + provider + .search_model(id) + .ok_or_else(|| unsupported("searchModel"))? + .map_err(OpsError::AiMux)?, + ), + }; + insert(ctx.owner, model) + }) +} + +fn invoke(ctx: Ctx, entry: Target, params: Value) -> CallFuture { + Box::pin(async move { + let params: ProviderInvokeParams = decode(params)?; + if params.method != "listModels" { + return Err(unsupported(¶ms.method)); + } + let provider = provider(entry)?; + let discovery = provider + .discovery() + .ok_or_else(|| unsupported("listModels"))?; + let models: ProviderInvokeResult = match ctx.abort { + Some(signal) => tokio::select! { + biased; + _ = signal.cancelled() => return Err(OpsError::AiMux(AiMuxError::from_abort_signal(&signal))), + result = discovery.list_models() => result, + }, + None => discovery.list_models().await, + }.map_err(OpsError::AiMux)?; + serde_json::to_value(models).map_err(|error| { + OpsError::Binding(BindingError::ResultSerialization(error.to_string())) + }) + }) +} + +type LanguageMiddleware = Vec>; +type ImageMiddleware = Vec>; + +fn middleware( + owner: Owner, + params: ProviderWrapParams, +) -> Result<(LanguageMiddleware, ImageMiddleware), OpsError> { + let mut language = Vec::with_capacity(params.language_model_middleware.len()); + for handle in params.language_model_middleware { + match get(owner, handle, HandleKind::Middleware)? { + Entry::Middleware(middleware) => match middleware.as_ref() { + Middleware::Language(middleware) => language.push(middleware.clone()), + Middleware::Image(_) => { + return Err(unsupported("languageModelMiddleware capability")); + } + }, + entry => return Err(invalid_handle(&[HandleKind::Middleware], entry.kind())), + } + } + let mut image = Vec::with_capacity(params.image_model_middleware.len()); + for handle in params.image_model_middleware { + match get(owner, handle, HandleKind::Middleware)? { + Entry::Middleware(middleware) => match middleware.as_ref() { + Middleware::Image(middleware) => image.push(middleware.clone()), + Middleware::Language(_) => { + return Err(unsupported("imageModelMiddleware capability")); + } + }, + entry => return Err(invalid_handle(&[HandleKind::Middleware], entry.kind())), + } + } + Ok((language, image)) +} + +fn wrap(ctx: Ctx, entry: Target, params: Value) -> CallFuture { + Box::pin(async move { + let params: ProviderWrapParams = decode(params)?; + let provider = provider(entry)?; + let method = default_method(&provider)?; + let (language, image) = middleware(ctx.owner, params)?; + insert_provider( + ctx.owner, + wrap_provider(provider, &language, &image), + method, + ) + }) +} + +fn custom(ctx: Ctx, _entry: Target, params: Value) -> CallFuture { + Box::pin(async move { + let params: ProviderCustomParams = decode(params)?; + let mut options = CustomProviderOptions::default(); + macro_rules! models { + ($field:ident, $kind:ident) => { + for (id, handle) in params.$field { + match get(ctx.owner, handle, HandleKind::$kind)? { + Entry::$kind(model) => { + options.$field.insert(id, model); + } + entry => return Err(invalid_handle(&[HandleKind::$kind], entry.kind())), + } + } + }; + } + models!(language_models, Language); + models!(embedding_models, Embedding); + models!(image_models, Image); + models!(speech_models, Speech); + models!(transcription_models, Transcription); + models!(reranking_models, Reranking); + models!(video_models, Video); + models!(search_models, Search); + models!(decision_models, Decision); + if let Some(handle) = params.files { + options.files = match get(ctx.owner, handle, HandleKind::Files)? { + Entry::Files(files) => Some(files), + entry => return Err(invalid_handle(&[HandleKind::Files], entry.kind())), + }; + } + if let Some(handle) = params.fallback_provider { + options.fallback_provider = Some(provider(Target::Handle(get( + ctx.owner, + handle, + HandleKind::Provider, + )?))?); + } + let method = match params.default_method { + Some(method) => Some(ModelMethod::parse(&method)?), + None => match &options.fallback_provider { + Some(provider) => default_method(provider)?, + None => None, + }, + }; + let (language, image) = middleware( + ctx.owner, + ProviderWrapParams { + language_model_middleware: params.language_model_middleware, + image_model_middleware: params.image_model_middleware, + }, + )?; + let provider: Arc = Arc::new(custom_provider(options)); + let provider = if language.is_empty() && image.is_empty() { + provider + } else { + wrap_provider(provider, &language, &image) + }; + insert_provider(ctx.owner, provider, method) + }) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, SystemTime}; + + use futures::future::BoxFuture; + + use super::*; + use aimux_core::embedding_model::EmbeddingModel; + use aimux_core::files_model::Files; + use aimux_core::image_model::ImageModel; + use aimux_core::language_model::LanguageModel; + use aimux_core::model_catalogue::RuntimeModel; + use aimux_core::provider::ProviderDiscovery; + use aimux_provider_utils::fetch::{Fetch, FetchError, FetchRequest, FetchResponse}; + use serde_json::json; + + fn owner() -> Owner { + Owner::new_node().unwrap() + } + + fn ctx(owner: Owner) -> Ctx { + Ctx { + owner, + abort: None, + now: SystemTime::now, + } + } + + async fn call( + owner: Owner, + handle: HandleId, + op: &str, + params: Value, + ) -> Result { + crate::dispatch::call(ctx(owner), handle, op, params) + .await + .map_err(|error| match error { + OpsError::Projected(error) => error.source, + error => error, + }) + } + + fn id(value: &Value) -> HandleId { + HandleId(value["handle"].as_str().unwrap().parse().unwrap()) + } + + fn handle(owner: Owner, entry: Entry) -> HandleId { + HandleRegistry::global().insert(owner, entry).unwrap() + } + + fn entry(owner: Owner, result: &Value, kind: HandleKind) -> Entry { + HandleRegistry::global() + .get(owner, id(result), &[kind]) + .unwrap() + } + + fn package(name: &str) -> Arc { + aimux_providers::create_provider(name, PresetSettings::default()).unwrap() + } + + #[derive(Clone, Copy)] + enum Discovery { + Success, + Error, + Pending, + } + + struct MockProvider { + base: Arc, + discovery: Discovery, + calls: Arc, + } + + impl Provider for MockProvider { + fn language_model(&self, id: &str) -> Result, AiMuxError> { + self.base.language_model(id) + } + fn embedding_model(&self, id: &str) -> Result, AiMuxError> { + self.base.embedding_model(id) + } + fn image_model(&self, id: &str) -> Result, AiMuxError> { + self.base.image_model(id) + } + fn files(&self) -> Option> { + self.base.files() + } + fn discovery(&self) -> Option<&dyn ProviderDiscovery> { + Some(self) + } + } + + impl ProviderDiscovery for MockProvider { + fn list_models(&self) -> BoxFuture<'_, Result, AiMuxError>> { + Box::pin(async move { + self.calls.fetch_add(1, Ordering::Relaxed); + match self.discovery { + Discovery::Success => Ok(vec![RuntimeModel { + id: "mock-model".into(), + owned_by: None, + created: None, + }]), + Discovery::Error => Err(AiMuxError::InvalidArgument("discovery failed".into())), + Discovery::Pending => futures::future::pending().await, + } + }) + } + } + + fn mock(owner: Owner, discovery: Discovery) -> (HandleId, Arc) { + let calls = Arc::new(AtomicUsize::new(0)); + let provider = MockProvider { + base: package("openai"), + discovery, + calls: calls.clone(), + }; + (handle(owner, Entry::Provider(Arc::new(provider))), calls) + } + + struct LanguageOverride; + impl LanguageModelMiddleware for LanguageOverride { + fn override_provider(&self, _: &dyn LanguageModel) -> Option { + Some("wrapped.language".into()) + } + } + + struct ImageOverride; + impl ImageModelMiddleware for ImageOverride { + fn override_provider(&self, _: &dyn ImageModel) -> Option { + Some("wrapped.image".into()) + } + } + + struct MockFetch(AtomicUsize); + impl Fetch for MockFetch { + fn fetch<'a, 'f>( + &'a self, + _: FetchRequest, + ) -> BoxFuture<'f, Result> + where + 'a: 'f, + Self: 'f, + { + Box::pin(async move { + self.0.fetch_add(1, Ordering::Relaxed); + Err(FetchError::Other("mock transport".into())) + }) + } + } + + #[tokio::test] + async fn create_uses_the_existing_package_factory() { + let owner = owner(); + let result = call(owner, HandleId(0), "provider.create", json!({ + "package": "openai", "settings": {"apiKey": "test-key", "baseUrl": "http://localhost:1/v1", "headers": {"x-test": "value"}} + })).await.unwrap(); + assert_eq!(result["kind"], "Provider"); + let Entry::Provider(provider) = entry(owner, &result, HandleKind::Provider) else { + panic!() + }; + assert_eq!(provider.specification_version(), "v4"); + assert_eq!( + provider.language_model("model").unwrap().model_id(), + "model" + ); + assert_eq!(result.as_object().unwrap().len(), 2); + } + + #[tokio::test] + async fn create_rejects_unknown_packages_and_unknown_control_fields() { + let owner = owner(); + assert!(matches!( + call( + owner, + HandleId(0), + "provider.create", + json!({"package":"missing-package"}) + ) + .await, + Err(OpsError::AiMux(AiMuxError::NoSuchProvider { .. })) + )); + for params in [ + json!({"package":"openai", "callback":{}}), + json!({"package":"openai", "settings":{"fetch":{"callback":"host"}}}), + json!({"package":"openai", "settings":{"unknown":1}}), + json!({"package":"openai", "settings":null}), + ] { + assert!(matches!( + call(owner, HandleId(0), "provider.create", params).await, + Err(OpsError::Protocol(ProtocolError::InvalidParams(_))) + )); + } + } + + #[tokio::test] + async fn default_uses_preset_descriptor_and_requires_global_scope() { + let owner = owner(); + let result = call( + owner, + HandleId(0), + "provider.default", + json!({"package":"ollama"}), + ) + .await + .unwrap(); + let model = call( + owner, + id(&result), + "provider.model", + json!({"modelId":"local"}), + ) + .await + .unwrap(); + assert_eq!(model["kind"], "Language"); + assert!(matches!( + call( + owner, + id(&result), + "provider.default", + json!({"package":"openai"}) + ) + .await, + Err(OpsError::Binding(BindingError::InvalidHandle { .. })) + )); + assert!(matches!( + call( + owner, + HandleId(0), + "provider.default", + json!({"package":"openai", "settings":{}}) + ) + .await, + Err(OpsError::Protocol(ProtocolError::InvalidParams(_))) + )); + } + + #[tokio::test] + async fn model_returns_all_eight_exact_handle_kinds() { + let owner = owner(); + for (package, method, kind) in [ + ("openai", "languageModel", HandleKind::Language), + ("openai", "embeddingModel", HandleKind::Embedding), + ("openai", "imageModel", HandleKind::Image), + ("openai", "speechModel", HandleKind::Speech), + ("openai", "transcriptionModel", HandleKind::Transcription), + ("cohere", "rerankingModel", HandleKind::Reranking), + ("google", "videoModel", HandleKind::Video), + ("tavily", "searchModel", HandleKind::Search), + ] { + let fallback = handle(owner, Entry::Provider(self::package(package))); + let provider = call( + owner, + HandleId(0), + "provider.custom", + json!({"fallbackProvider":fallback.0.to_string()}), + ) + .await + .unwrap(); + let result = call( + owner, + id(&provider), + "provider.model", + json!({"method":method, "modelId":"test-model"}), + ) + .await + .unwrap(); + assert_eq!(entry(owner, &result, kind).kind(), kind); + assert!( + HandleRegistry::global() + .remove(owner, id(&result)) + .is_some() + ); + assert!( + HandleRegistry::global() + .remove(owner, id(&provider)) + .is_some() + ); + assert!(HandleRegistry::global().remove(owner, fallback).is_some()); + } + } + + #[tokio::test] + async fn model_rejects_unavailable_methods_and_missing_default_metadata() { + let owner = owner(); + let (provider, _) = mock(owner, Discovery::Success); + for params in [ + json!({"modelId":"x"}), + json!({"method":"speechModel", "modelId":"x"}), + json!({"method":"skills", "modelId":"x"}), + json!({"method":"LanguageModel", "modelId":"x"}), + ] { + assert!(matches!( + call(owner, provider, "provider.model", params).await, + Err(OpsError::AiMux(AiMuxError::UnsupportedFunctionality(_))) + )); + } + assert!(matches!( + call( + owner, + provider, + "provider.model", + json!({"method":"languageModel"}) + ) + .await, + Err(OpsError::Protocol(ProtocolError::InvalidParams(_))) + )); + } + + #[tokio::test] + async fn invoke_returns_only_runtime_discovery_and_forbids_arbitrary_rpc() { + let owner = owner(); + let (provider, calls) = mock(owner, Discovery::Success); + let result = call( + owner, + provider, + "provider.invoke", + json!({"method":"listModels"}), + ) + .await + .unwrap(); + assert_eq!(result, json!([{"id":"mock-model"}])); + assert_eq!(calls.load(Ordering::Relaxed), 1); + assert!(matches!( + call( + owner, + provider, + "provider.invoke", + json!({"method":"deleteEverything"}) + ) + .await, + Err(OpsError::AiMux(AiMuxError::UnsupportedFunctionality(_))) + )); + assert!(matches!( + call( + owner, + provider, + "provider.invoke", + json!({"method":"listModels", "args":{}}) + ) + .await, + Err(OpsError::Protocol(ProtocolError::InvalidParams(_))) + )); + assert_eq!(calls.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn invoke_preserves_discovery_errors_and_reports_unsupported_discovery() { + let owner = owner(); + let (provider, _) = mock(owner, Discovery::Error); + assert!( + matches!(call(owner, provider, "provider.invoke", json!({"method":"listModels"})).await, + Err(OpsError::AiMux(AiMuxError::InvalidArgument(message))) if message == "discovery failed") + ); + let provider = handle( + owner, + Entry::Provider(Arc::new(custom_provider(CustomProviderOptions::default()))), + ); + assert!(matches!( + call( + owner, + provider, + "provider.invoke", + json!({"method":"listModels"}) + ) + .await, + Err(OpsError::AiMux(AiMuxError::UnsupportedFunctionality(_))) + )); + } + + #[tokio::test] + async fn invoke_cancels_pending_discovery() { + let owner = owner(); + let (provider, _) = mock(owner, Discovery::Pending); + let abort = aimux_core::AbortSignal::new(); + let mut context = ctx(owner); + context.abort = Some(abort.clone()); + let future = crate::dispatch::call( + context, + provider, + "provider.invoke", + json!({"method":"listModels"}), + ); + let cancel = async move { + tokio::task::yield_now().await; + abort.abort(); + }; + let result = tokio::time::timeout(Duration::from_secs(1), async { + tokio::join!(future, cancel).0 + }) + .await + .unwrap(); + assert!(matches!( + result.as_ref().map_err(OpsError::unprojected), + Err(OpsError::AiMux(AiMuxError::Aborted(_))) + )); + } + + #[tokio::test] + async fn wrap_applies_each_middleware_and_keeps_files_and_discovery() { + let owner = owner(); + let (provider, _) = mock(owner, Discovery::Success); + let language = handle( + owner, + Entry::Middleware(Arc::new(Middleware::Language(Arc::new(LanguageOverride)))), + ); + let image = handle( + owner, + Entry::Middleware(Arc::new(Middleware::Image(Arc::new(ImageOverride)))), + ); + let result = call(owner, provider, "provider.wrap", json!({"languageModelMiddleware":[language.0.to_string()], "imageModelMiddleware":[image.0.to_string()]})).await.unwrap(); + assert!(HandleRegistry::global().remove(owner, provider).is_some()); + assert!(HandleRegistry::global().remove(owner, language).is_some()); + assert!(HandleRegistry::global().remove(owner, image).is_some()); + let Entry::Provider(wrapped) = entry(owner, &result, HandleKind::Provider) else { + panic!() + }; + assert_eq!( + wrapped.language_model("x").unwrap().provider(), + "wrapped.language" + ); + assert_eq!( + wrapped.image_model("x").unwrap().provider(), + "wrapped.image" + ); + assert!(wrapped.files().is_some()); + assert_eq!( + wrapped.discovery().unwrap().list_models().await.unwrap()[0].id, + "mock-model" + ); + assert!(wrapped.embedding_model("x").is_ok()); + } + + #[tokio::test] + async fn wrap_checks_each_middleware_capability_kind_and_owner() { + let owner = owner(); + let (provider, _) = mock(owner, Discovery::Success); + let image = handle( + owner, + Entry::Middleware(Arc::new(Middleware::Image(Arc::new(ImageOverride)))), + ); + assert!(matches!( + call( + owner, + provider, + "provider.wrap", + json!({"languageModelMiddleware":[image.0.to_string()]}) + ) + .await, + Err(OpsError::AiMux(AiMuxError::UnsupportedFunctionality(_))) + )); + assert!(matches!( + call( + owner, + provider, + "provider.wrap", + json!({"imageModelMiddleware":[provider.0.to_string()]}) + ) + .await, + Err(OpsError::Binding(BindingError::InvalidHandle { + actual: Some(HandleKind::Provider), + .. + })) + )); + let foreign = handle( + self::owner(), + Entry::Middleware(Arc::new(Middleware::Image(Arc::new(ImageOverride)))), + ); + assert!(matches!( + call( + owner, + provider, + "provider.wrap", + json!({"imageModelMiddleware":[foreign.0.to_string()]}) + ) + .await, + Err(OpsError::Binding(BindingError::InvalidHandle { + actual: None, + .. + })) + )); + } + + #[tokio::test] + async fn custom_keeps_aliases_default_method_and_strong_references() { + let owner = owner(); + let language = package("openai").language_model("original").unwrap(); + let child = handle(owner, Entry::Language(language.clone())); + let result = call(owner, HandleId(0), "provider.custom", json!({"languageModels":{"alias":child.0.to_string()}, "defaultMethod":"languageModel"})).await.unwrap(); + assert!(HandleRegistry::global().remove(owner, child).is_some()); + let resolved = call( + owner, + id(&result), + "provider.model", + json!({"modelId":"alias"}), + ) + .await + .unwrap(); + let Entry::Language(model) = entry(owner, &resolved, HandleKind::Language) else { + panic!() + }; + assert!(Arc::ptr_eq(&language, &model)); + assert!(matches!( + call( + owner, + id(&result), + "provider.model", + json!({"modelId":"missing"}) + ) + .await, + Err(OpsError::AiMux(AiMuxError::NoSuchModel { .. })) + )); + let wrapped = call(owner, id(&result), "provider.wrap", json!({})) + .await + .unwrap(); + assert!( + call( + owner, + id(&wrapped), + "provider.model", + json!({"modelId":"alias"}) + ) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn custom_resolves_all_eight_model_maps() { + let owner = owner(); + let openai = package("openai"); + let mappings = [ + ( + "languageModels", + "languageModel", + Entry::Language(openai.language_model("x").unwrap()), + ), + ( + "embeddingModels", + "embeddingModel", + Entry::Embedding(openai.embedding_model("x").unwrap()), + ), + ( + "imageModels", + "imageModel", + Entry::Image(openai.image_model("x").unwrap()), + ), + ( + "speechModels", + "speechModel", + Entry::Speech(openai.speech_model("x").unwrap().unwrap()), + ), + ( + "transcriptionModels", + "transcriptionModel", + Entry::Transcription(openai.transcription_model("x").unwrap().unwrap()), + ), + ( + "rerankingModels", + "rerankingModel", + Entry::Reranking(package("cohere").reranking_model("x").unwrap().unwrap()), + ), + ( + "videoModels", + "videoModel", + Entry::Video(package("google").video_model("x").unwrap().unwrap()), + ), + ( + "searchModels", + "searchModel", + Entry::Search(package("tavily").search_model("x").unwrap().unwrap()), + ), + ]; + for (field, method, model) in mappings { + let kind = model.kind(); + let model = handle(owner, model); + let provider = call( + owner, + HandleId(0), + "provider.custom", + json!({(field):{"alias":model.0.to_string()}}), + ) + .await + .unwrap(); + let resolved = call( + owner, + id(&provider), + "provider.model", + json!({"method":method,"modelId":"alias"}), + ) + .await + .unwrap(); + assert_eq!(entry(owner, &resolved, kind).kind(), kind); + } + } + + #[tokio::test] + async fn custom_rejects_wrong_kind_owner_and_host_function_fields() { + let owner = owner(); + let (provider, _) = mock(owner, Discovery::Success); + assert!(matches!( + call( + owner, + HandleId(0), + "provider.custom", + json!({"languageModels":{"alias":provider.0.to_string()}}) + ) + .await, + Err(OpsError::Binding(BindingError::InvalidHandle { + actual: Some(HandleKind::Provider), + .. + })) + )); + let foreign = handle( + self::owner(), + Entry::Language(package("openai").language_model("x").unwrap()), + ); + assert!(matches!( + call( + owner, + HandleId(0), + "provider.custom", + json!({"languageModels":{"alias":foreign.0.to_string()}}) + ) + .await, + Err(OpsError::Binding(BindingError::InvalidHandle { + actual: None, + .. + })) + )); + for params in [ + json!({"languageModels":{"x":1}}), + json!({"languageModels":{"x":"-1"}}), + json!({"languageModels":{"x":"18446744073709551616"}}), + json!({"callback":{}}), + json!({"skills":{}}), + ] { + assert!(matches!( + call(owner, HandleId(0), "provider.custom", params).await, + Err(OpsError::Protocol(ProtocolError::InvalidParams(_))) + )); + } + } + + #[tokio::test] + async fn custom_preserves_fallback_extensions_and_preset_default() { + let owner = owner(); + let (fallback, _) = mock(owner, Discovery::Success); + let result = call( + owner, + HandleId(0), + "provider.custom", + json!({"fallbackProvider":fallback.0.to_string()}), + ) + .await + .unwrap(); + assert!(HandleRegistry::global().remove(owner, fallback).is_some()); + let Entry::Provider(custom) = entry(owner, &result, HandleKind::Provider) else { + panic!() + }; + assert!(custom.files().is_some()); + assert_eq!( + call( + owner, + id(&result), + "provider.invoke", + json!({"method":"listModels"}) + ) + .await + .unwrap(), + json!([{"id":"mock-model"}]) + ); + let fallback = call( + owner, + HandleId(0), + "provider.default", + json!({"package":"ollama"}), + ) + .await + .unwrap(); + let result = call( + owner, + HandleId(0), + "provider.custom", + json!({"fallbackProvider":fallback["handle"]}), + ) + .await + .unwrap(); + assert!( + call(owner, id(&result), "provider.model", json!({"modelId":"x"})) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn create_validates_runtime_slot_kind_and_owner() { + let owner = owner(); + let (provider, _) = mock(owner, Discovery::Success); + for field in ["fetch", "webSocket", "credentialResolver"] { + assert!(matches!( + call( + owner, + HandleId(0), + "provider.create", + json!({"package":"openai", "settings":{(field):provider.0.to_string()}}) + ) + .await, + Err(OpsError::Binding(BindingError::InvalidHandle { + actual: Some(HandleKind::Provider), + .. + })) + )); + } + let foreign = handle( + self::owner(), + Entry::Fetch(Arc::new(MockFetch(AtomicUsize::new(0)))), + ); + assert!(matches!( + call( + owner, + HandleId(0), + "provider.create", + json!({"package":"openai", "settings":{"fetch":foreign.0.to_string()}}) + ) + .await, + Err(OpsError::Binding(BindingError::InvalidHandle { + actual: None, + .. + })) + )); + } + + #[tokio::test] + async fn create_injects_fetch_and_models_retain_it_after_drop() { + let owner = owner(); + let fetch = Arc::new(MockFetch(AtomicUsize::new(0))); + let transport = handle(owner, Entry::Fetch(fetch.clone())); + let provider = call( + owner, + HandleId(0), + "provider.create", + json!({"package":"ollama", "settings":{"fetch":transport.0.to_string()}}), + ) + .await + .unwrap(); + let model = call( + owner, + id(&provider), + "provider.model", + json!({"modelId":"local"}), + ) + .await + .unwrap(); + assert!(HandleRegistry::global().remove(owner, transport).is_some()); + assert!( + HandleRegistry::global() + .remove(owner, id(&provider)) + .is_some() + ); + let Entry::Language(model) = entry(owner, &model, HandleKind::Language) else { + panic!() + }; + let result = model + .do_generate(&aimux_core::options::CallOptions::new(vec![])) + .await; + assert!(result.is_err()); + assert_eq!(fetch.0.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn every_provider_scoped_op_rejects_foreign_owner_and_wrong_kind() { + let owner = owner(); + let (provider, _) = mock(owner, Discovery::Success); + let language = handle( + owner, + Entry::Language(package("openai").language_model("x").unwrap()), + ); + for (op, params) in [ + ( + "provider.model", + json!({"method":"languageModel", "modelId":"x"}), + ), + ("provider.invoke", json!({"method":"listModels"})), + ("provider.wrap", json!({})), + ] { + assert!(matches!( + call(self::owner(), provider, op, params.clone()).await, + Err(OpsError::Binding(BindingError::InvalidHandle { + actual: None, + .. + })) + )); + assert!(matches!( + call(owner, language, op, params).await, + Err(OpsError::Binding(BindingError::InvalidHandle { + actual: Some(HandleKind::Language), + .. + })) + )); + } + } + + #[test] + fn descriptors_register_only_the_six_owned_contract_ops() { + let mut handlers = Vec::new(); + register(&mut handlers); + assert_eq!(handlers.len(), 6); + assert_eq!( + handlers.iter().map(|(d, _)| d.name).collect::>(), + vec![ + "provider.create", + "provider.default", + "provider.model", + "provider.invoke", + "provider.wrap", + "provider.custom" + ] + ); + for (descriptor, _) in handlers { + assert!(matches!(descriptor.mode, OpMode::Call)); + assert!(descriptor.event_type.is_none()); + assert_eq!(descriptor.since_ops_version, "1.0"); + } + } +} diff --git a/aimux-ops/src/ops/recording.rs b/aimux-ops/src/ops/recording.rs new file mode 100644 index 000000000..4265383db --- /dev/null +++ b/aimux-ops/src/ops/recording.rs @@ -0,0 +1,709 @@ +//! Recorder operations. The registry keeps the core `Arc` payload. +//! Recorder/session capture remains process-wide (registered gap G-a). +//! +//! Creation requires `{"backend":{"type":"ring","capacity":2048}, +//! "capturePolicy":"full"}` or a `jsonl` backend with `directory`. +//! The full policy uses core recording redaction. There is no implicit policy. +//! All other operations accept `{}`; export returns `{"jsonl":"..."}`. + +use std::path::PathBuf; +use std::sync::Arc; + +use aimux_core::decision_model::DecideOptions; +use aimux_core::options::CallOptions; +use aimux_core::recording::{ + self, HttpExchange, JsonlRecorder, OutcomeRecord, ProviderRecord, Recorder, RecordingError, + ResponseRecord, RingRecorder, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::dispatch::{CallFuture, Ctx, Handler, Target, decode as params}; +use crate::envelope::{BindingError, OpsError, ProtocolError}; +use crate::handle::{Entry, HandleId, HandleKind, HandleRegistry}; +use crate::op::{Cancellation, OpDescriptor, OpMode, RequestBytePolicy, Scope}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +enum CapturePolicy { + Full, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)] +enum BackendConfig { + Ring { capacity: Option }, + Jsonl { directory: PathBuf }, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CreateRecorderParams { + backend: BackendConfig, + capture_policy: CapturePolicy, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct EmptyParams {} + +enum Backend { + Ring(RingRecorder), + Jsonl(JsonlRecorder), +} + +/// Keeps backend diagnostics. Full capture uses core redaction of sensitive fields. +/// Creating a handle does not attach it; stopping does not flush it. +struct RecorderHandle { + backend: Backend, +} + +impl RecorderHandle { + fn new(params: CreateRecorderParams) -> Result { + let CreateRecorderParams { + backend, + capture_policy: CapturePolicy::Full, + } = params; + let backend = match backend { + BackendConfig::Ring { capacity } => { + let capacity = capacity.unwrap_or(recording::DEFAULT_RING_CAPACITY); + if capacity == 0 + || capacity > isize::MAX as usize / std::mem::size_of::() + { + return Err(invalid("ring capacity must be positive and fit in memory")); + } + Backend::Ring(RingRecorder::with_capacity(capacity)) + } + BackendConfig::Jsonl { directory } => { + Backend::Jsonl(JsonlRecorder::try_new(directory).map_err(OpsError::Recording)?) + } + }; + Ok(Self { backend }) + } + + fn recorder(&self) -> &dyn Recorder { + match &self.backend { + Backend::Ring(recorder) => recorder, + Backend::Jsonl(recorder) => recorder, + } + } + + fn stats(&self) -> RecorderStats { + match &self.backend { + Backend::Ring(recorder) => RecorderStats::Ring { + completed: recorder.len(), + pending: recorder.pending_count(), + dropped: recorder.dropped_count(), + pending_evicted: recorder.pending_evicted_count(), + inconsistent_call_ids: recorder.inconsistent_call_ids(), + }, + Backend::Jsonl(recorder) => RecorderStats::Jsonl { + path: recorder.path(), + dropped: recorder.dropped_count(), + inconsistent_call_ids: recorder.inconsistent_call_ids(), + last_write_error: recorder.last_write_error(), + }, + } + } + + fn export(&self) -> Result { + match &self.backend { + Backend::Ring(recorder) => { + let mut bytes = Vec::new(); + recorder + .export_jsonl(&mut bytes) + .map_err(|e| OpsError::Recording(RecordingError::Write(e.to_string())))?; + String::from_utf8(bytes).map_err(|e| { + OpsError::Binding(BindingError::ResultSerialization(e.to_string())) + }) + } + Backend::Jsonl(recorder) => { + recorder.try_flush().map_err(OpsError::Recording)?; + std::fs::read_to_string(recorder.path()) + .map_err(|e| OpsError::Recording(RecordingError::Write(e.to_string()))) + } + } + } +} + +impl Recorder for RecorderHandle { + fn as_any(&self) -> Option<&dyn std::any::Any> { + Some(self) + } + + fn record_input(&self, id: &str, options: &CallOptions, provider: &str, model: &str) { + self.recorder().record_input(id, options, provider, model); + } + + fn record_decision_input( + &self, + id: &str, + options: &DecideOptions, + provider: &str, + model: &str, + ) { + self.recorder() + .record_decision_input(id, options, provider, model); + } + + fn record_session(&self, id: &str, session: &str, step: u32) { + self.recorder().record_session(id, session, step); + } + + fn record_provider(&self, id: &str, provider: &ProviderRecord) { + self.recorder().record_provider(id, provider); + } + + fn record_exchange(&self, id: &str, exchange: &HttpExchange) { + self.recorder().record_exchange(id, exchange); + } + + fn record_exchange_update( + &self, + id: &str, + attempt: u32, + index: u32, + response: &ResponseRecord, + error: Option, + ) { + self.recorder() + .record_exchange_update(id, attempt, index, response, error); + } + + fn record_outcome(&self, id: &str, outcome: &OutcomeRecord) { + self.recorder().record_outcome(id, outcome); + } + + fn record_transport_closed(&self, id: &str) { + self.recorder().record_transport_closed(id); + } + + fn flush(&self) { + self.recorder().flush(); + } + + fn try_flush(&self) -> Result<(), RecordingError> { + self.recorder().try_flush() + } +} + +fn invalid(message: impl Into) -> OpsError { + OpsError::Protocol(ProtocolError::InvalidParams(message.into())) +} + +#[derive(Serialize)] +struct RecorderHandleResult { + handle: HandleId, +} + +#[derive(Serialize)] +struct RecorderExport { + jsonl: String, +} + +#[derive(Serialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +enum RecorderStats { + Ring { + completed: usize, + pending: usize, + dropped: u64, + pending_evicted: u64, + inconsistent_call_ids: Vec, + }, + Jsonl { + path: PathBuf, + dropped: u64, + inconsistent_call_ids: Vec, + last_write_error: Option, + }, +} + +fn result(value: impl Serialize) -> Result { + serde_json::to_value(value) + .map_err(|error| BindingError::ResultSerialization(error.to_string()).into()) +} + +fn recorder_entry(target: Target) -> Result, OpsError> { + match target { + Target::Handle(Entry::Recorder(recorder)) => Ok(recorder), + target => Err(BindingError::InvalidHandle { + expected: vec![HandleKind::Recorder], + actual: match target { + Target::Global => None, + Target::Handle(entry) => Some(entry.kind()), + }, + } + .into()), + } +} + +fn recorder_handle(recorder: &dyn Recorder) -> Result<&RecorderHandle, OpsError> { + recorder + .as_any() + .and_then(|value| value.downcast_ref()) + .ok_or_else(|| { + aimux_core::AiMuxError::UnsupportedFunctionality( + "recorder backend does not expose diagnostics/export".into(), + ) + .into() + }) +} + +async fn blocking( + work: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tokio::task::spawn_blocking(work).await.map_err(|error| { + OpsError::Protocol(ProtocolError::InvalidState(format!( + "recorder worker failed: {error}" + ))) + })? +} + +fn create(ctx: Ctx, _target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let params = params(value)?; + blocking(move || { + let recorder = Arc::new(RecorderHandle::new(params)?); + let handle = HandleRegistry::global().insert(ctx.owner, Entry::Recorder(recorder))?; + result(RecorderHandleResult { handle }) + }) + .await + }) +} + +fn attach(_ctx: Ctx, target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let _: EmptyParams = params(value)?; + let recorder = recorder_entry(target)?; + blocking(move || { + recording::init_recording(Some(recorder)); + Ok(Value::Null) + }) + .await + }) +} + +fn stop(_ctx: Ctx, target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let _: EmptyParams = params(value)?; + let recorder = recorder_entry(target)?; + blocking(move || { + drop(recording::detach_recording_if(&recorder)); + Ok(Value::Null) + }) + .await + }) +} + +fn flush(_ctx: Ctx, target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let _: EmptyParams = params(value)?; + let recorder = recorder_entry(target)?; + blocking(move || { + recorder.try_flush().map_err(OpsError::Recording)?; + Ok(Value::Null) + }) + .await + }) +} + +fn stats(_ctx: Ctx, target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let _: EmptyParams = params(value)?; + let recorder = recorder_entry(target)?; + blocking(move || result(recorder_handle(recorder.as_ref())?.stats())).await + }) +} + +fn export(_ctx: Ctx, target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let _: EmptyParams = params(value)?; + let recorder = recorder_entry(target)?; + blocking(move || { + result(RecorderExport { + jsonl: recorder_handle(recorder.as_ref())?.export()?, + }) + }) + .await + }) +} + +const fn descriptor( + name: &'static str, + global: bool, + request_type: &'static str, + result_type: &'static str, + side_effects: bool, + source_symbols: &'static [&'static str], +) -> OpDescriptor { + OpDescriptor { + name, + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: if global { &[] } else { &[HandleKind::Recorder] }, + request_type, + result_type, + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + side_effects, + retry_owner: "none", + source_symbols, + } +} + +pub(crate) static OPS: &[OpDescriptor] = &[ + descriptor( + "recorder.create", + true, + "aimux_ops::ops::recording::CreateRecorderParams", + "aimux_ops::ops::recording::RecorderHandleResult", + true, + &[ + "aimux_init_recording", + "aimux_init_recording_ring", + "aimux_init_recording_ring_default", + ], + ), + descriptor( + "recorder.attach", + false, + "aimux_ops::ops::recording::EmptyParams", + "()", + true, + &["aimux_init_recording"], + ), + descriptor( + "recorder.stop", + false, + "aimux_ops::ops::recording::EmptyParams", + "()", + true, + &["aimux_recording_stop"], + ), + descriptor( + "recorder.flush", + false, + "aimux_ops::ops::recording::EmptyParams", + "()", + true, + &["aimux_recording_flush", "aimux_recording_try_flush"], + ), + descriptor( + "recorder.stats", + false, + "aimux_ops::ops::recording::EmptyParams", + "aimux_ops::ops::recording::RecorderStats", + false, + &[ + "RingRecorder::len", + "RingRecorder::dropped_count", + "JsonlRecorder::last_write_error", + ], + ), + descriptor( + "recorder.export", + false, + "aimux_ops::ops::recording::EmptyParams", + "aimux_ops::ops::recording::RecorderExport", + true, + &["RingRecorder::export_jsonl", "JsonlRecorder::path"], + ), +]; + +pub(crate) fn register(registry: &mut Vec<(&'static OpDescriptor, Handler)>) { + let handlers: [fn(Ctx, Target, Value) -> CallFuture; 6] = + [create, attach, stop, flush, stats, export]; + registry.extend( + OPS.iter() + .zip(handlers) + .map(|(descriptor, handler)| (descriptor, Handler::Call(handler))), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use aimux_core::recording::{OutcomeStatus, Recording}; + use serde_json::json; + use std::sync::atomic::{AtomicU64, Ordering}; + + fn ring() -> Arc { + Arc::new( + RecorderHandle::new( + params(json!({ + "backend": {"type": "ring", "capacity": 2}, "capturePolicy": "full" + })) + .unwrap(), + ) + .unwrap(), + ) + } + + fn record(recorder: &RecorderHandle, id: &str) { + let options: CallOptions = serde_json::from_value(json!({ + "prompt": [{"role": "user", "content": [{"type": "text", "text": "hello"}]}] + })) + .unwrap(); + recorder.record_input(id, &options, "test", "model"); + recorder.record_outcome( + id, + &OutcomeRecord { + status: OutcomeStatus::Success, + ..Default::default() + }, + ); + recorder.record_transport_closed(id); + } + + fn context() -> Ctx { + Ctx { + owner: crate::handle::Owner::CProcess, + abort: None, + now: std::time::SystemTime::now, + } + } + + #[test] + fn strict_controls_and_explicit_policy() { + for value in [ + json!({"backend": {"type": "ring"}}), + json!({"backend": {"type": "ring", "unknown": 1}, "capturePolicy": "full"}), + json!({"backend": {"type": "ring"}, "capturePolicy": "unknown"}), + json!({"backend": {"type": "ring"}, "capturePolicy": "full", "unknown": 1}), + ] { + assert!(params::(value).is_err()); + } + assert!(params::(json!({"unknown": 1})).is_err()); + for capacity in [0, usize::MAX] { + assert!( + RecorderHandle::new(CreateRecorderParams { + backend: BackendConfig::Ring { + capacity: Some(capacity) + }, + capture_policy: CapturePolicy::Full, + }) + .is_err() + ); + } + } + + #[test] + fn ring_exports_real_recordings_and_reports_eviction() { + let recorder = ring(); + assert!(recorder.export().unwrap().is_empty()); + for id in ["first", "second", "third"] { + record(&recorder, id); + } + recorder.try_flush().unwrap(); + let exported = recorder.export().unwrap(); + let records: Vec = exported + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(records.len(), 2); + assert_eq!(records[0].call_id, "second"); + assert!(records.iter().all(|r| r.complete)); + assert_eq!(result(recorder.stats()).unwrap()["dropped"], 1); + assert_eq!(result(recorder.stats()).unwrap()["pending"], 0); + } + + #[test] + fn forwards_session_provider_and_stream_exchange_completion() { + let recorder = ring(); + let exchange: HttpExchange = serde_json::from_value(json!({ + "attempt": 1, "exchangeIndex": 1, + "request": {"method": "POST", "url": "https://example.test", "headers": [], "body": "request"}, + "timing": {"latencyMs": 1}, "finalized": false + })).unwrap(); + recorder.record_session("stream", "session", 2); + recorder.record_provider( + "stream", + &ProviderRecord::new("provider-id", "test", "model"), + ); + recorder.record_exchange("stream", &exchange); + record(&recorder, "stream"); + assert_eq!(result(recorder.stats()).unwrap()["completed"], 0); + assert_eq!(result(recorder.stats()).unwrap()["pending"], 1); + let response: ResponseRecord = serde_json::from_value(json!({ + "status": 200, "headers": [], "body": "completed", "streamChunks": 2 + })) + .unwrap(); + recorder.record_exchange_update("stream", 1, 1, &response, None); + let saved: Recording = serde_json::from_str(recorder.export().unwrap().trim()).unwrap(); + assert!(saved.complete); + assert_eq!(saved.session_id.as_deref(), Some("session")); + assert_eq!(saved.step, Some(2)); + assert_eq!(saved.provider.provider_id, "provider-id"); + assert_eq!( + saved.exchanges[0] + .response + .as_ref() + .unwrap() + .body + .as_deref(), + Some("completed") + ); + } + + fn directory() -> PathBuf { + static SEQ: AtomicU64 = AtomicU64::new(0); + std::env::temp_dir().join(format!( + "aimux-ops-recorder-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )) + } + + #[test] + fn jsonl_flush_and_export_and_init_failures() { + let dir = directory(); + let recorder = RecorderHandle::new(CreateRecorderParams { + backend: BackendConfig::Jsonl { + directory: dir.clone(), + }, + capture_policy: CapturePolicy::Full, + }) + .unwrap(); + record(&recorder, "persisted"); + recorder.try_flush().unwrap(); + let exported = recorder.export().unwrap(); + let saved: Recording = serde_json::from_str(exported.trim()).unwrap(); + assert_eq!(saved.call_id, "persisted"); + assert!(saved.complete); + assert_eq!( + std::fs::read_to_string(dir.join("recordings.jsonl")).unwrap(), + exported + ); + // Export reports a real read failure rather than returning empty data. + std::fs::remove_file(dir.join("recordings.jsonl")).unwrap(); + assert!(matches!( + recorder.export(), + Err(OpsError::Recording(RecordingError::Write(_))) + )); + drop(recorder); + let blocked_file = dir.join("blocked-file"); + std::fs::create_dir_all(blocked_file.join("recordings.jsonl")).unwrap(); + assert!(matches!( + RecorderHandle::new(CreateRecorderParams { + backend: BackendConfig::Jsonl { + directory: blocked_file + }, + capture_policy: CapturePolicy::Full, + }), + Err(OpsError::Recording(RecordingError::OpenFile { .. })) + )); + let blocker = dir.join("blocker"); + std::fs::write(&blocker, "occupied").unwrap(); + assert!(matches!( + RecorderHandle::new(CreateRecorderParams { + backend: BackendConfig::Jsonl { + directory: blocker.join("nested") + }, + capture_policy: CapturePolicy::Full, + }), + Err(OpsError::Recording(RecordingError::Init { .. })) + )); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn flush_reports_actual_persistence_failure() { + use std::os::unix::fs::symlink; + let dir = directory(); + std::fs::create_dir_all(&dir).unwrap(); + symlink("/dev/full", dir.join("recordings.jsonl")).unwrap(); + let recorder = Arc::new( + RecorderHandle::new(CreateRecorderParams { + backend: BackendConfig::Jsonl { + directory: dir.clone(), + }, + capture_policy: CapturePolicy::Full, + }) + .unwrap(), + ); + record(&recorder, "cannot-sync"); + // The real writer receives ENOSPC when flushing its buffer. + assert!(matches!( + flush( + context(), + Target::Handle(Entry::Recorder(recorder.clone())), + json!({}) + ) + .await, + Err(OpsError::Recording(RecordingError::Write(_))) + )); + assert!(recorder.export().is_err()); + drop(recorder); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[tokio::test] + async fn attach_is_explicit_and_stop_only_detaches_its_own_recorder() { + let first = ring(); + let second = ring(); + { + attach( + context(), + Target::Handle(Entry::Recorder(first.clone())), + json!({}), + ) + .await + .unwrap(); + let snapshot = recording::recorder().unwrap(); + attach( + context(), + Target::Handle(Entry::Recorder(second.clone())), + json!({}), + ) + .await + .unwrap(); + stop(context(), Target::Handle(Entry::Recorder(first)), json!({})) + .await + .unwrap(); + let second_trait: Arc = second.clone(); + assert!(Arc::ptr_eq(&recording::recorder().unwrap(), &second_trait)); + stop( + context(), + Target::Handle(Entry::Recorder(second.clone())), + json!({}), + ) + .await + .unwrap(); + assert!(recording::recorder().is_none()); + assert!(!Arc::ptr_eq(&snapshot, &second_trait)); + record(&second, "after-stop"); + assert_eq!(result(second.stats()).unwrap()["completed"], 1); + assert_eq!( + stats( + context(), + Target::Handle(Entry::Recorder(second.clone())), + json!({}) + ) + .await + .unwrap()["completed"], + 1 + ); + assert!( + export( + context(), + Target::Handle(Entry::Recorder(second)), + json!({"extra": true}) + ) + .await + .is_err() + ); + } + } +} diff --git a/aimux-ops/src/ops/registry.rs b/aimux-ops/src/ops/registry.rs new file mode 100644 index 000000000..d91dfea22 --- /dev/null +++ b/aimux-ops/src/ops/registry.rs @@ -0,0 +1,896 @@ +//! Explicit provider registries and model references. Resolution never uses a gateway. + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex, OnceLock, Weak}; + +use aimux_core::error::AiMuxError; +use aimux_core::provider_registry::{ + ProviderRegistry, ProviderRegistryOptions, create_provider_registry, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::dispatch::{CallFuture, Ctx, Handler, Target, decode, resolve_secondary}; +use crate::envelope::{BindingError, OpsError, ProtocolError}; +use crate::handle::{Entry, HandleId, HandleKind, HandleRegistry, Middleware, Owner}; +use crate::op::{Cancellation, OpDescriptor, OpMode, RequestBytePolicy, Scope}; + +const MODELS: &[HandleKind] = &[ + HandleKind::Language, + HandleKind::Embedding, + HandleKind::Image, + HandleKind::Speech, + HandleKind::Transcription, + HandleKind::Reranking, + HandleKind::Video, + HandleKind::Search, + HandleKind::Decision, +]; + +const fn descriptor( + name: &'static str, + kinds: &'static [HandleKind], + request_type: &'static str, + result_type: &'static str, + symbols: &'static [&'static str], +) -> OpDescriptor { + OpDescriptor { + name, + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: kinds, + request_type, + result_type, + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: match name.as_bytes() { + b"registry.create" => &[("entries.*.provider", &[HandleKind::Provider])], + b"model.wrap" => &[("middleware.*", &[HandleKind::Middleware])], + _ => &[], + }, + required_capabilities: &[], + cancellation: Cancellation::None, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + side_effects: matches!(name.as_bytes(), b"registry.create"), + retry_owner: "none", + source_symbols: symbols, + } +} + +pub(crate) static OPS: &[OpDescriptor] = &[ + descriptor( + "registry.create", + &[], + "aimux_ops::ops::registry::RegistryCreateParams", + "aimux_ops::ops::registry::HandleResult", + &["aimux_core::provider_registry::create_provider_registry"], + ), + descriptor( + "registry.provider", + &[HandleKind::Registry], + "aimux_ops::ops::registry::RegistryProviderParams", + "aimux_ops::ops::registry::HandleResult", + &["aimux_core::provider_registry::ProviderRegistry::provider"], + ), + descriptor( + "model.resolve", + &[HandleKind::Registry], + "aimux_ops::ops::registry::ModelResolveParams", + "aimux_ops::ops::registry::HandleResult", + &["aimux_core::provider_registry::ProviderRegistry"], + ), + descriptor( + "model.info", + MODELS, + "aimux_ops::ops::registry::ModelInfoParams", + "aimux_ops::ops::registry::ModelInfo", + &["aimux_core::language_model::LanguageModel::provider"], + ), + descriptor( + "model.wrap", + MODELS, + "aimux_ops::ops::registry::ModelWrapParams", + "aimux_ops::ops::registry::HandleResult", + &[ + "aimux_core::language_model_middleware::wrap_language_model", + "aimux_core::image_model_middleware::wrap_image_model", + ], + ), +]; + +pub(crate) fn register(r: &mut Vec<(&'static OpDescriptor, Handler)>) { + for (op, handler) in OPS.iter().zip([ + create as fn(Ctx, Target, Value) -> CallFuture, + provider, + resolve, + info, + wrap, + ]) { + r.push((op, Handler::Call(handler))); + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RegistryCreateParams { + #[serde(default)] + namespace: String, + entries: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RegistryEntry { + key: String, + provider: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RegistryProviderParams { + key: String, +} + +#[derive(Clone, Deserialize, Serialize, PartialEq, Debug)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ModelRef { + namespace: String, + key: String, + method: ModelMethod, + model_id: String, +} + +#[derive(Clone, Copy, Deserialize, Serialize, PartialEq, Debug)] +#[serde(rename_all = "camelCase")] +enum ModelMethod { + LanguageModel, + EmbeddingModel, + ImageModel, + SpeechModel, + TranscriptionModel, + RerankingModel, + VideoModel, + SearchModel, + #[serde(rename = "decision")] + Decision, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ModelResolveParams { + model_ref: ModelRef, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ModelInfoParams {} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ModelWrapParams { + middleware: Vec, +} + +fn invalid(message: impl Into) -> OpsError { + OpsError::Protocol(ProtocolError::InvalidParams(message.into())) +} + +fn handle(value: &str) -> Result { + value.parse().map_err(invalid) +} + +fn wrong(entry: &Entry, expected: &[HandleKind]) -> OpsError { + OpsError::Binding(BindingError::InvalidHandle { + expected: expected.to_vec(), + actual: Some(entry.kind()), + }) +} + +#[derive(Serialize)] +struct HandleResult { + handle: HandleId, + kind: HandleKind, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ModelInfo<'a> { + provider: &'a str, + model_id: &'a str, + modality: &'static str, + capabilities: Value, + source: Value, +} + +fn target_entry(target: Target, expected: &[HandleKind]) -> Result { + match target { + Target::Handle(entry) if expected.contains(&entry.kind()) => Ok(entry), + Target::Handle(entry) => Err(wrong(&entry, expected)), + Target::Global => Err(BindingError::InvalidHandle { + expected: expected.to_vec(), + actual: None, + } + .into()), + } +} + +fn insert(owner: Owner, entry: Entry) -> Result { + let kind = entry.kind(); + let id = HandleRegistry::global().insert(owner, entry)?; + Ok(json!(HandleResult { handle: id, kind })) +} + +// Core registries and model traits have no source fields. These weak sidecars +// contain only reference metadata, never settings, credentials, or providers. +// Dead objects are pruned on access, including when an allocator reuses an address. +macro_rules! weak_entries { + ($($variant:ident($ty:ty)),+ $(,)?) => { + enum WeakEntry { $($variant(Weak<$ty>)),+ } + impl WeakEntry { + fn new(entry: &Entry) -> Option { + match entry { + $(Entry::$variant(value) => Some(Self::$variant(Arc::downgrade(value)))),+, + _ => None, + } + } + fn matches(&self, entry: &Entry) -> bool { + match (self, entry) { + $((Self::$variant(weak), Entry::$variant(value)) => weak.upgrade() + .is_some_and(|live| Arc::ptr_eq(&live, value))),+, + _ => false, + } + } + fn alive(&self) -> bool { + match self { $(Self::$variant(weak) => weak.strong_count() > 0),+ } + } + } + }; +} + +weak_entries! { + Registry(ProviderRegistry), + Language(dyn aimux_core::language_model::LanguageModel), + Embedding(dyn aimux_core::embedding_model::EmbeddingModel), + Image(dyn aimux_core::image_model::ImageModel), + Speech(dyn aimux_core::speech_model::SpeechModel), + Transcription(dyn aimux_core::transcription_model::TranscriptionModel), + Reranking(dyn aimux_core::reranking_model::RerankingModel), + Video(dyn aimux_core::video_model::VideoModel), + Search(dyn aimux_core::search_model::SearchModel), + Decision(dyn aimux_core::decision_model::DecisionModel), +} + +#[derive(Clone)] +enum Metadata { + Namespace(String), + Sources(Vec), +} + +struct Sidecar { + owner: Owner, + entry: WeakEntry, + metadata: Metadata, +} + +fn sidecars() -> &'static Mutex> { + static SIDECARS: OnceLock>> = OnceLock::new(); + SIDECARS.get_or_init(|| Mutex::new(Vec::new())) +} + +fn metadata(owner: Owner, entry: &Entry, update: Option) -> Option { + let mut sidecars = sidecars() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + sidecars.retain(|sidecar| sidecar.entry.alive()); + if let Some(sidecar) = sidecars + .iter_mut() + .find(|sidecar| sidecar.owner == owner && sidecar.entry.matches(entry)) + { + if let Some(update) = update { + match (&mut sidecar.metadata, update) { + (Metadata::Sources(sources), Metadata::Sources(additions)) => { + for source in additions { + if !sources.contains(&source) { + sources.push(source); + } + } + } + (current, update) => *current = update, + } + } + return Some(sidecar.metadata.clone()); + } + if let (Some(weak), Some(update)) = (WeakEntry::new(entry), update) { + sidecars.push(Sidecar { + owner, + entry: weak, + metadata: update.clone(), + }); + return Some(update); + } + None +} + +fn create(ctx: Ctx, target: Target, params: Value) -> CallFuture { + Box::pin(async move { + if let Target::Handle(entry) = target { + return Err(wrong(&entry, &[])); + } + let params: RegistryCreateParams = decode(params)?; + let mut providers = BTreeMap::new(); + for registration in params.entries { + if registration.key.is_empty() { + return Err(invalid("registry entry key must not be empty")); + } + if providers.contains_key(®istration.key) { + return Err(invalid(format!( + "duplicate registry key: {}", + registration.key + ))); + } + let provider = resolve_secondary( + HandleRegistry::global(), + &ctx, + &json!(handle(®istration.provider)?), + &[HandleKind::Provider], + )?; + let Entry::Provider(provider) = provider else { + return Err(wrong(&provider, &[HandleKind::Provider])); + }; + providers.insert(registration.key, provider); + } + let entry = Entry::Registry(Arc::new(create_provider_registry( + providers, + ProviderRegistryOptions::default(), + ))); + metadata( + ctx.owner, + &entry, + Some(Metadata::Namespace(params.namespace)), + ); + insert(ctx.owner, entry) + }) +} + +fn provider(ctx: Ctx, target: Target, params: Value) -> CallFuture { + Box::pin(async move { + let entry = target_entry(target, &[HandleKind::Registry])?; + let params: RegistryProviderParams = decode(params)?; + let Entry::Registry(registry) = &entry else { + return Err(wrong(&entry, &[HandleKind::Registry])); + }; + let provider = registry.provider(¶ms.key).map_err(OpsError::AiMux)?; + insert(ctx.owner, Entry::Provider(Arc::clone(provider))) + }) +} + +fn resolve(ctx: Ctx, target: Target, params: Value) -> CallFuture { + Box::pin(async move { + let entry = target_entry(target, &[HandleKind::Registry])?; + let params: ModelResolveParams = decode(params)?; + let Entry::Registry(registry) = &entry else { + return Err(wrong(&entry, &[HandleKind::Registry])); + }; + let namespace = match metadata(ctx.owner, &entry, None) { + Some(Metadata::Namespace(namespace)) => namespace, + _ => String::new(), + }; + if params.model_ref.namespace != namespace { + return Err(invalid("modelRef namespace does not match this registry")); + } + let model = resolve_model(registry, ¶ms.model_ref)?; + metadata( + ctx.owner, + &model, + Some(Metadata::Sources(vec![params.model_ref])), + ); + insert(ctx.owner, model) + }) +} + +fn resolve_model(registry: &ProviderRegistry, reference: &ModelRef) -> Result { + // Look up the exact key instead of constructing a combined id: both keys + // and model ids may themselves contain the registry separator. + let provider = registry.provider(&reference.key).map_err(OpsError::AiMux)?; + let id = reference.model_id.as_str(); + let result = match reference.method { + ModelMethod::LanguageModel => provider.language_model(id).map(Entry::Language), + ModelMethod::EmbeddingModel => provider.embedding_model(id).map(Entry::Embedding), + ModelMethod::ImageModel => provider.image_model(id).map(Entry::Image), + ModelMethod::SpeechModel => provider + .speech_model(id) + .unwrap_or_else(|| Err(AiMuxError::no_such_model(id, "speechModel"))) + .map(Entry::Speech), + ModelMethod::TranscriptionModel => provider + .transcription_model(id) + .unwrap_or_else(|| Err(AiMuxError::no_such_model(id, "transcriptionModel"))) + .map(Entry::Transcription), + ModelMethod::RerankingModel => provider + .reranking_model(id) + .unwrap_or_else(|| Err(AiMuxError::no_such_model(id, "rerankingModel"))) + .map(Entry::Reranking), + ModelMethod::VideoModel => provider + .video_model(id) + .unwrap_or_else(|| Err(AiMuxError::no_such_model(id, "videoModel"))) + .map(Entry::Video), + ModelMethod::SearchModel => provider + .search_model(id) + .unwrap_or_else(|| Err(AiMuxError::no_such_model(id, "searchModel"))) + .map(Entry::Search), + ModelMethod::Decision => provider + .decision_model(id) + .unwrap_or_else(|| Err(AiMuxError::no_such_model(id, "decisionModel"))) + .map(Entry::Decision), + }; + result.map_err(OpsError::AiMux) +} + +fn info(ctx: Ctx, target: Target, params: Value) -> CallFuture { + Box::pin(async move { + let entry = target_entry(target, MODELS)?; + let _: ModelInfoParams = decode(params)?; + model_info(ctx.owner, &entry) + }) +} + +fn model_info(owner: Owner, entry: &Entry) -> Result { + let source = match metadata(owner, entry, None) { + Some(Metadata::Sources(references)) => json!({"references": references}), + _ => Value::Null, + }; + let (provider, model_id, modality, capabilities) = match entry { + Entry::Language(m) => ( + m.provider(), + m.model_id(), + "language", + json!({"generate": true, "stream": true}), + ), + Entry::Embedding(m) => ( + m.provider(), + m.model_id(), + "embedding", + json!({"embed": true, "maxEmbeddingsPerCall": m.max_embeddings_per_call(), "supportsParallelCalls": m.supports_parallel_calls()}), + ), + Entry::Image(m) => ( + m.provider(), + m.model_id(), + "image", + json!({"generate": true, "maxImagesPerCall": m.max_images_per_call(), "supportsFileInputs": m.supports_file_inputs(), "supportsMaskInputs": m.supports_mask_inputs()}), + ), + Entry::Speech(m) => ( + m.provider(), + m.model_id(), + "speech", + json!({"generate": true}), + ), + Entry::Transcription(m) => ( + m.provider(), + m.model_id(), + "transcription", + json!({"generate": true}), + ), + Entry::Reranking(m) => ( + m.provider(), + m.model_id(), + "reranking", + json!({"rerank": true}), + ), + Entry::Video(m) => ( + m.provider(), + m.model_id(), + "video", + json!({"start": true, "status": true, "maxVideosPerCall": m.max_videos_per_call()}), + ), + Entry::Search(m) => ( + m.provider(), + m.model_id(), + "search", + json!({"search": true}), + ), + Entry::Decision(m) => ( + m.provider(), + m.model_id(), + "decision", + json!({"decide": true, "supportedQuestionTypes": m.supported_question_types()}), + ), + _ => return Err(wrong(entry, MODELS)), + }; + Ok(json!(ModelInfo { + provider, + model_id, + modality, + capabilities, + source + })) +} + +fn wrap(ctx: Ctx, target: Target, params: Value) -> CallFuture { + Box::pin(async move { + let entry = target_entry(target, MODELS)?; + let params: ModelWrapParams = decode(params)?; + // Resolve the complete chain before any middleware hook can run. + let mut middleware = Vec::with_capacity(params.middleware.len()); + for id in ¶ms.middleware { + let resolved = resolve_secondary( + HandleRegistry::global(), + &ctx, + &json!(handle(id)?), + &[HandleKind::Middleware], + )?; + let Entry::Middleware(value) = resolved else { + return Err(wrong(&resolved, &[HandleKind::Middleware])); + }; + middleware.push(value); + } + let sources = metadata(ctx.owner, &entry, None); + let entry = if middleware.is_empty() { + entry + } else { + match entry { + Entry::Language(model) => { + let chain = middleware + .iter() + .map(|value| match value.as_ref() { + Middleware::Language(value) => Ok(Arc::clone(value)), + Middleware::Image(_) => { + Err(invalid("image middleware cannot wrap a language model")) + } + }) + .collect::, _>>()?; + Entry::Language(aimux_core::language_model_middleware::wrap_language_model( + model, &chain, None, None, + )) + } + Entry::Image(model) => { + let chain = middleware + .iter() + .map(|value| match value.as_ref() { + Middleware::Image(value) => Ok(Arc::clone(value)), + Middleware::Language(_) => { + Err(invalid("language middleware cannot wrap an image model")) + } + }) + .collect::, _>>()?; + Entry::Image(aimux_core::image_model_middleware::wrap_image_model( + model, &chain, None, None, + )) + } + _ => { + return Err(AiMuxError::UnsupportedFunctionality( + "middleware is only available for language and image models".into(), + ) + .into()); + } + } + }; + if let Some(sources) = sources { + metadata(ctx.owner, &entry, Some(sources)); + } + insert(ctx.owner, entry) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use aimux_core::custom_provider::{CustomProviderOptions, custom_provider}; + use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel, EmbeddingResult}; + + struct Embedding; + + // Spell out the async-trait ABI so this module needs no extra dependency. + impl EmbeddingModel for Embedding { + fn provider(&self) -> &str { + "test" + } + fn model_id(&self) -> &str { + "actual:model:id" + } + fn max_embeddings_per_call(&self) -> Option { + Some(8) + } + fn supports_parallel_calls(&self) -> bool { + true + } + fn do_embed<'a, 'b, 'f>( + &'a self, + _: &'b EmbeddingCallOptions, + ) -> futures::future::BoxFuture<'f, Result> + where + 'a: 'f, + 'b: 'f, + Self: 'f, + { + Box::pin(async { Err(AiMuxError::UnsupportedFunctionality("unused".into())) }) + } + } + + fn owner() -> Owner { + Owner::new_node().unwrap() + } + + fn ctx(owner: Owner) -> Ctx { + Ctx { + owner, + abort: None, + now: std::time::SystemTime::now, + } + } + + fn fixture(owner: Owner) -> (HandleId, Entry) { + let options = CustomProviderOptions { + embedding_models: std::collections::HashMap::from([( + "alias:with:colons".into(), + Arc::new(Embedding) as Arc, + )]), + ..Default::default() + }; + let provider = Entry::Provider(Arc::new(custom_provider(options))); + let id = HandleRegistry::global() + .insert(owner, provider.clone()) + .unwrap(); + (id, provider) + } + + fn result_entry(owner: Owner, result: &Value, kind: HandleKind) -> Entry { + HandleRegistry::global() + .get( + owner, + handle(result["handle"].as_str().unwrap()).unwrap(), + &[kind], + ) + .unwrap() + } + + async fn registry(owner: Owner, provider: &HandleId) -> Entry { + let result = create( + ctx(owner), + Target::Global, + json!({"namespace": "tenant", "entries": [ + {"key": "key:with:colon", "provider": provider.0.to_string()} + ]}), + ) + .await + .unwrap(); + result_entry(owner, &result, HandleKind::Registry) + } + + fn reference(namespace: &str, key: &str, method: &str, model_id: &str) -> Value { + json!({"modelRef": {"namespace": namespace, "key": key, "method": method, "modelId": model_id}}) + } + + #[test] + fn control_objects_reject_unknown_fields_and_numeric_handles() { + assert!(decode::(json!({"entries": [], "settings": {}})).is_err()); + assert!( + decode::(json!({"key": "x", "provider": "1", "secret": "x"})).is_err() + ); + assert!(decode::(json!({"key": "x", "extra": true})).is_err()); + assert!(decode::(json!({"settings": true})).is_err()); + assert!(decode::(json!({"middleware": [], "extra": 1})).is_err()); + assert!(decode::(json!({"key": "x", "provider": 1})).is_err()); + assert!(decode::(json!({"modelRef": {"namespace": "x", "key": "x", "method": "embeddingModel", "modelId": "m", "settings": {}}})).is_err()); + } + + #[test] + fn handles_follow_the_handle_id_rule() { + for bad in ["", " 1", "+1", "-1", "1.0", "18446744073709551616"] { + assert!(handle(bad).is_err(), "{bad}"); + } + assert_eq!(handle("01").unwrap().0, 1); + assert_eq!(handle("18446744073709551615").unwrap().0, u64::MAX); + } + + #[tokio::test] + async fn duplicate_keys_are_rejected() { + let owner = owner(); + let (id, _entry) = fixture(owner); + let result = create( + ctx(owner), + Target::Global, + json!({"entries": [ + {"key": "same", "provider": id.0.to_string()}, + {"key": "same", "provider": id.0.to_string()} + ]}), + ) + .await; + assert!(matches!( + result, + Err(OpsError::Protocol(ProtocolError::InvalidParams(_))) + )); + drop(HandleRegistry::global().remove(owner, id)); + } + + #[tokio::test] + async fn provider_reference_is_owner_checked() { + let owner = owner(); + let (id, _entry) = fixture(owner); + let result = create( + ctx(Owner::Python(999)), + Target::Global, + json!({"entries": [ + {"key": "x", "provider": id.0.to_string()} + ]}), + ) + .await; + assert!(matches!( + result, + Err(OpsError::Binding(BindingError::InvalidHandle { .. })) + )); + drop(HandleRegistry::global().remove(owner, id)); + } + + #[tokio::test] + async fn registry_retains_exact_provider_after_original_handle_drop() { + let owner = owner(); + let (id, original) = fixture(owner); + let registry = registry(owner, &id).await; + drop(HandleRegistry::global().remove(owner, id)); + let result = provider( + ctx(owner), + Target::Handle(registry), + json!({"key": "key:with:colon"}), + ) + .await + .unwrap(); + let returned = result_entry(owner, &result, HandleKind::Provider); + let (Entry::Provider(a), Entry::Provider(b)) = (original, returned) else { + panic!() + }; + assert!(Arc::ptr_eq(&a, &b)); + } + + #[tokio::test] + async fn resolution_preserves_exact_ids_and_reports_safe_source() { + let owner = owner(); + let (id, _input) = fixture(owner); + let registry = registry(owner, &id).await; + let params = reference( + "tenant", + "key:with:colon", + "embeddingModel", + "alias:with:colons", + ); + let result = resolve(ctx(owner), Target::Handle(registry), params.clone()) + .await + .unwrap(); + let model = result_entry(owner, &result, HandleKind::Embedding); + let info = info(ctx(owner), Target::Handle(model), json!({})) + .await + .unwrap(); + assert_eq!(info["modelId"], "actual:model:id"); + assert_eq!(info["provider"], "test"); + assert_eq!(info["modality"], "embedding"); + assert_eq!(info["capabilities"]["maxEmbeddingsPerCall"], 8); + assert_eq!(info["source"]["references"], json!([params["modelRef"]])); + assert_eq!(info.as_object().unwrap().len(), 5); + } + + #[tokio::test] + async fn missing_namespace_key_model_and_modality_do_not_fall_back() { + let owner = owner(); + let (id, _input) = fixture(owner); + let registry = registry(owner, &id).await; + assert!( + resolve( + ctx(owner), + Target::Handle(registry.clone()), + reference( + "other", + "key:with:colon", + "embeddingModel", + "alias:with:colons" + ) + ) + .await + .is_err() + ); + for (key, method, model) in [ + ("missing", "embeddingModel", "alias:with:colons"), + ("key:with:colon", "embeddingModel", "missing"), + ("key:with:colon", "speechModel", "alias:with:colons"), + ("key:with:colon", "languageModel", "alias:with:colons"), + ("key:with:colon", "imageModel", "alias:with:colons"), + ("key:with:colon", "transcriptionModel", "alias:with:colons"), + ("key:with:colon", "rerankingModel", "alias:with:colons"), + ("key:with:colon", "videoModel", "alias:with:colons"), + ("key:with:colon", "searchModel", "alias:with:colons"), + ("key:with:colon", "decision", "alias:with:colons"), + ] { + assert!(matches!( + resolve( + ctx(owner), + Target::Handle(registry.clone()), + reference("tenant", key, method, model) + ) + .await, + Err(OpsError::AiMux(_)) + )); + } + } + + #[tokio::test] + async fn direct_models_have_no_invented_source_and_empty_wrap_retains_modality() { + let owner = owner(); + let model = Entry::Embedding(Arc::new(Embedding)); + assert_eq!(model_info(owner, &model).unwrap()["source"], Value::Null); + let result = wrap(ctx(owner), Target::Handle(model), json!({"middleware": []})) + .await + .unwrap(); + assert_eq!( + result_entry(owner, &result, HandleKind::Embedding).kind(), + HandleKind::Embedding + ); + } + + #[tokio::test] + async fn shared_model_sources_are_accumulated_and_wrap_preserves_them() { + let owner = owner(); + let (id, _input) = fixture(owner); + let first = registry(owner, &id).await; + let second = create( + ctx(owner), + Target::Global, + json!({"namespace": "other", "entries": [ + {"key": "second", "provider": id.0.to_string()} + ]}), + ) + .await + .unwrap(); + let second = result_entry(owner, &second, HandleKind::Registry); + let a = reference( + "tenant", + "key:with:colon", + "embeddingModel", + "alias:with:colons", + ); + let b = reference("other", "second", "embeddingModel", "alias:with:colons"); + let result = resolve(ctx(owner), Target::Handle(first.clone()), a.clone()) + .await + .unwrap(); + let model = result_entry(owner, &result, HandleKind::Embedding); + resolve(ctx(owner), Target::Handle(first), a.clone()) + .await + .unwrap(); + resolve(ctx(owner), Target::Handle(second), b.clone()) + .await + .unwrap(); + let wrapped = wrap(ctx(owner), Target::Handle(model), json!({"middleware": []})) + .await + .unwrap(); + let wrapped = result_entry(owner, &wrapped, HandleKind::Embedding); + assert_eq!( + model_info(owner, &wrapped).unwrap()["source"]["references"], + json!([a["modelRef"], b["modelRef"]]) + ); + assert_eq!( + model_info(Owner::Python(999), &wrapped).unwrap()["source"], + Value::Null + ); + } + + #[tokio::test] + async fn wrap_rejects_invalid_middleware_and_wrong_primary_kind() { + let owner = owner(); + let (id, input) = fixture(owner); + assert!(matches!( + wrap( + ctx(owner), + Target::Handle(Entry::Embedding(Arc::new(Embedding))), + json!({"middleware": [id.0.to_string()]}) + ) + .await, + Err(OpsError::Binding(BindingError::InvalidHandle { .. })) + )); + assert!(matches!( + info(ctx(owner), Target::Handle(input.clone()), json!({})).await, + Err(OpsError::Binding(_)) + )); + assert!(matches!( + wrap(ctx(owner), Target::Handle(input), json!({"middleware": []})).await, + Err(OpsError::Binding(_)) + )); + } +} diff --git a/aimux-ops/src/ops/replay.rs b/aimux-ops/src/ops/replay.rs new file mode 100644 index 000000000..57218528b --- /dev/null +++ b/aimux-ops/src/ops/replay.rs @@ -0,0 +1,356 @@ +//! Model-level replay using existing core recordings and caller-owned live targets. +//! Transport replay is Future (G-c); recording schema migration remains G-b. + +use std::sync::Arc; + +use aimux_core::recording::{Recording, RecordingOperation}; +use aimux_core::replay::{MockReplayModel, replay_decision_with_model, replay_with_model}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::dispatch::{CallFuture, Ctx, Handler, Target, decode, resolve_secondary}; +use crate::envelope::{BindingError, OpsError, ProtocolError}; +use crate::handle::{Entry, HandleId, HandleKind, HandleRegistry}; +use crate::op::{Cancellation, OpDescriptor, OpMode, RequestBytePolicy, Scope}; +use crate::ops::operation::EmptyParams; + +pub(crate) static OPS: &[OpDescriptor] = &[ + OpDescriptor { + name: "replay.create", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[], + request_type: "aimux_ops::ops::replay::ReplayCreateParams", + result_type: "aimux_ops::ops::replay::ReplaySessionHandle", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + side_effects: true, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + retry_owner: "none", + source_symbols: &["aimux_mock_replay_new"], + }, + OpDescriptor { + name: "replay.capabilities", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[HandleKind::ReplaySession], + request_type: "EmptyParams", + result_type: "()", + event_type: None, + scope: Scope::Future, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + side_effects: false, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + retry_owner: "none", + source_symbols: &["aimux_provider_utils::fetch::Fetch"], + }, + OpDescriptor { + name: "replay.inspect", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[HandleKind::ReplaySession], + request_type: "EmptyParams", + result_type: "aimux_ops::ops::replay::ReplayInspection", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + side_effects: false, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + retry_owner: "none", + source_symbols: &["aimux_core::replay::MockReplayModel::recordings"], + }, + OpDescriptor { + name: "replay.dryRun", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[HandleKind::ReplaySession], + request_type: "EmptyParams", + result_type: "aimux_ops::ops::replay::ReplayPlan", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + side_effects: false, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + retry_owner: "none", + source_symbols: &["aimux_core::recording::Recording"], + }, + OpDescriptor { + name: "replay.live", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[HandleKind::ReplaySession], + request_type: "aimux_ops::ops::replay::ReplayLiveParams", + result_type: "aimux_ops::ops::replay::ReplayLiveResult", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[ + ( + "target.handle", + &[ + HandleKind::Language, + HandleKind::Decision, + HandleKind::Registry, + ], + ), + ("abort", &[HandleKind::Abort]), + ], + required_capabilities: &[], + cancellation: Cancellation::Abort, + side_effects: true, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + retry_owner: "core", + source_symbols: &["aimux_core::replay::replay_with_model"], + }, +]; +pub(crate) fn register(r: &mut Vec<(&'static OpDescriptor, Handler)>) { + for (op, handler) in OPS + .iter() + .zip([create, capabilities, inspect, dry_run, live] + as [fn(Ctx, Target, Value) -> CallFuture; 5]) + { + r.push((op, Handler::Call(handler))); + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReplayCreateParams { + recordings_jsonl: String, +} + +#[derive(Serialize)] +pub struct ReplaySessionHandle { + handle: HandleId, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReplayLiveParams { + call_id: String, + target: LiveTarget, + // Dispatch resolves this into Ctx before invoking the handler. + #[serde(rename = "abort")] + _abort: Option, +} + +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +enum LiveTarget { + Model { handle: HandleId }, + Registry { handle: HandleId }, +} + +#[derive(Serialize)] +pub struct ReplayInspection { + recordings: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ReplayRecordingInfo { + call_id: String, + provider: aimux_core::recording::ProviderRecord, + operation: RecordingOperation, + complete: bool, + transport_closed: bool, + exchange_count: usize, +} + +#[derive(Serialize)] +pub struct ReplayPlan { + calls: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ReplayPlannedCall { + call_id: String, + provider: aimux_core::recording::ProviderRecord, + operation: RecordingOperation, + live_supported: bool, + requires_target: bool, +} + +#[derive(Serialize)] +#[serde(untagged)] +pub enum ReplayLiveResult { + Language(aimux_core::generate::GenerateTextResult), + Decision(aimux_core::decision_model::DecideResult), +} + +fn invalid(message: impl Into) -> OpsError { + ProtocolError::InvalidParams(message.into()).into() +} + +fn serialize(value: impl Serialize) -> Result { + serde_json::to_value(value) + .map_err(|error| BindingError::ResultSerialization(error.to_string()).into()) +} + +fn session(target: Target) -> Result, OpsError> { + match target { + Target::Handle(Entry::ReplaySession(session)) => Ok(session), + _ => Err(ProtocolError::InvalidState("expected replay session target".into()).into()), + } +} + +fn from_jsonl(jsonl: &str) -> Result { + let mut recordings: Vec = Vec::new(); + for (index, line) in jsonl.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + // Use the same canonical reader as aimux_mock_replay_new. G-b leaves + // the existing schema/alias handling in core until RFC-0041. + let recording: Recording = serde_json::from_str(line) + .map_err(|error| invalid(format!("recordingsJsonl line {}: {error}", index + 1)))?; + if recording.call_id.is_empty() || recordings.iter().any(|r| r.call_id == recording.call_id) + { + return Err(invalid("recording callId must be nonempty and unique")); + } + recordings.push(recording); + } + let first = recordings + .first() + .ok_or_else(|| invalid("recordingsJsonl: no recordings"))?; + Ok(MockReplayModel::new( + first.provider.provider.clone(), + first.provider.model_id.clone(), + recordings, + )) +} + +fn create(ctx: Ctx, _target: Target, params: Value) -> CallFuture { + Box::pin(async move { + let params: ReplayCreateParams = decode(params)?; + let session = from_jsonl(¶ms.recordings_jsonl)?; + let handle = + HandleRegistry::global().insert(ctx.owner, Entry::ReplaySession(Arc::new(session)))?; + serialize(ReplaySessionHandle { handle }) + }) +} + +fn capabilities(_ctx: Ctx, target: Target, params: Value) -> CallFuture { + Box::pin(async move { + let _: EmptyParams = decode(params)?; + let _session = session(target)?; + Err(aimux_core::AiMuxError::UnsupportedFunctionality( + "replay transport capabilities (Fetch/WebSocket/Download) are Future (G-c)".into(), + ) + .into()) + }) +} + +fn inspect(_ctx: Ctx, target: Target, params: Value) -> CallFuture { + Box::pin(async move { + let _: EmptyParams = decode(params)?; + let session = session(target)?; + serialize(ReplayInspection { + recordings: session + .recordings() + .iter() + .map(|r| ReplayRecordingInfo { + call_id: r.call_id.clone(), + provider: r.provider.clone(), + operation: r.input.operation, + complete: r.complete, + transport_closed: r.transport_closed, + exchange_count: r.exchanges.len(), + }) + .collect(), + }) + }) +} + +fn dry_run(_ctx: Ctx, target: Target, params: Value) -> CallFuture { + Box::pin(async move { + let _: EmptyParams = decode(params)?; + let session = session(target)?; + serialize(ReplayPlan { + calls: session + .recordings() + .iter() + .map(|r| ReplayPlannedCall { + call_id: r.call_id.clone(), + provider: r.provider.clone(), + operation: r.input.operation, + live_supported: true, + requires_target: true, + }) + .collect(), + }) + }) +} + +fn live(ctx: Ctx, target: Target, params: Value) -> CallFuture { + Box::pin(async move { + let params: ReplayLiveParams = decode(params)?; + let session = session(target)?; + let recording = session + .recordings() + .iter() + .find(|r| r.call_id == params.call_id) + .ok_or_else(|| invalid("callId is not in this replay session"))?; + let model_kind = match recording.input.operation { + RecordingOperation::LanguageModel => HandleKind::Language, + RecordingOperation::Decision => HandleKind::Decision, + }; + let (handle, kind) = match params.target { + LiveTarget::Model { handle } => (handle, model_kind), + LiveTarget::Registry { handle } => (handle, HandleKind::Registry), + }; + let entry = + resolve_secondary(HandleRegistry::global(), &ctx, &serialize(handle)?, &[kind])?; + let result = match entry { + Entry::Language(model) => ReplayLiveResult::Language( + replay_with_model(recording, model.as_ref(), None).await?, + ), + Entry::Decision(model) => ReplayLiveResult::Decision( + replay_decision_with_model(recording, model.as_ref()).await?, + ), + Entry::Registry(registry) => match recording.input.operation { + RecordingOperation::LanguageModel => { + let model = + aimux_providers::replay::rebuild_provider(&recording.provider, ®istry)?; + ReplayLiveResult::Language( + replay_with_model(recording, model.as_ref(), None).await?, + ) + } + RecordingOperation::Decision => { + let model = aimux_providers::replay::rebuild_decision_provider( + &recording.provider, + ®istry, + )?; + ReplayLiveResult::Decision( + replay_decision_with_model(recording, model.as_ref()).await?, + ) + } + }, + _ => { + return Err( + ProtocolError::InvalidState("unexpected replay target kind".into()).into(), + ); + } + }; + serialize(result) + }) +} diff --git a/aimux-ops/src/ops/runtime.rs b/aimux-ops/src/ops/runtime.rs new file mode 100644 index 000000000..f0257a35c --- /dev/null +++ b/aimux-ops/src/ops/runtime.rs @@ -0,0 +1,261 @@ +//! Process runtime discovery and startup configuration. + +use crate::envelope::ProtocolError; +use aimux_provider_utils::{ProxyConfig, fetch, logging}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::{Value, json}; + +use crate::dispatch::{CallFuture, Ctx, Handler, Target, decode as parse}; +use crate::op::{ + ABI_VERSION, Cancellation, OPS_VERSION, OpDescriptor, OpMode, RequestBytePolicy, Scope, +}; + +pub(crate) static OPS: &[OpDescriptor] = &[ + OpDescriptor { + name: "runtime.describe", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[], + request_type: "aimux_ops::ops::runtime::RuntimeDescribeParams", + result_type: "serde_json::Value", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + side_effects: false, + retry_owner: "none", + source_symbols: &[ + "aimux_ops::manifest::manifest", + "aimux_ops::manifest::manifest_digest", + ], + }, + OpDescriptor { + name: "runtime.configure", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[], + request_type: "aimux_ops::ops::runtime::RuntimeConfigureParams", + result_type: "serde_json::Value", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + side_effects: true, + retry_owner: "none", + source_symbols: &[ + "aimux_provider_utils::logging::configure_logging", + "aimux_provider_utils::fetch::configure_startup_proxy", + ], + }, +]; + +pub(crate) fn register(r: &mut Vec<(&'static OpDescriptor, Handler)>) { + r.push((&OPS[0], Handler::Call(describe))); + r.push((&OPS[1], Handler::Call(configure))); +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RuntimeDescribeParams {} + +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RuntimeConfigureParams { + logging: Option, + #[serde(default, deserialize_with = "deserialize_proxy")] + proxy: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LoggingConfig { + level: LogLevel, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +enum LogLevel { + Off, + Error, + Warn, + Info, + Debug, + Trace, +} + +impl LogLevel { + fn as_str(self) -> &'static str { + match self { + Self::Off => "off", + Self::Error => "error", + Self::Warn => "warn", + Self::Info => "info", + Self::Debug => "debug", + Self::Trace => "trace", + } + } +} + +// Reuse the transport's wire type, adding strict control-key validation only +// at this boundary. ProxyConfig remains open for its existing Rust consumers. +fn deserialize_proxy<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(None); + }; + let fields = value + .as_object() + .ok_or_else(|| serde::de::Error::custom("proxy must be an object"))?; + for key in fields.keys() { + if !matches!( + key.as_str(), + "http_url" | "https_url" | "all_url" | "no_proxy" + ) { + return Err(serde::de::Error::custom("unknown proxy configuration key")); + } + } + serde_json::from_value(value) + .map(Some) + .map_err(serde::de::Error::custom) +} + +fn describe(_ctx: Ctx, _target: Target, params: Value) -> CallFuture { + Box::pin(async move { + let _: RuntimeDescribeParams = parse(params)?; + let manifest = crate::manifest::manifest(); + Ok(description(&manifest, crate::manifest::manifest_digest())) + }) +} + +fn description(manifest: &Value, digest: String) -> Value { + let operations: Vec<&str> = manifest["ops"] + .as_array() + .into_iter() + .flatten() + .filter_map(|op| op["name"].as_str()) + .collect(); + json!({ + "version": env!("CARGO_PKG_VERSION"), + "abiVersion": ABI_VERSION, + "opsVersion": OPS_VERSION, + "manifestRevision": manifest["manifestRevision"], + "manifestDigest": digest, + "capabilities": { + "operations": operations, + "handleKinds": manifest["handleKinds"], + "packages": manifest["packages"], + }, + }) +} + +fn configure(_ctx: Ctx, _target: Target, params: Value) -> CallFuture { + Box::pin(async move { + let params: RuntimeConfigureParams = parse(params)?; + if let Some(config) = params.logging { + logging::configure_logging(config.level.as_str()) + .map_err(|_| ProtocolError::InvalidState("logging is frozen".into()))?; + } + if let Some(proxy) = params.proxy { + fetch::configure_startup_proxy(proxy) + .map_err(|_| ProtocolError::InvalidState("startup proxy is frozen".into()))?; + } + let (configured, frozen) = fetch::startup_proxy_state(); + Ok(json!({ + "logging": {"requestedLevel": logging::requested_logging_level(), "frozen": logging::logging_initialized()}, + "proxy": {"configured": configured, "frozen": frozen}, + })) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::envelope::{OpsError, ProtocolError}; + + #[test] + fn controls_reject_unknown_keys_and_wrong_types() { + for params in [ + json!({"timeoutMs": 10}), + json!({"logging": {"level": "warn", "body": true}}), + json!({"logging": {"level": "verbose"}}), + json!({"proxy": {"http_url": "http://localhost", "unknown": true}}), + json!({"proxy": {"http_url": 42}}), + json!({"proxy": []}), + json!([]), + Value::Null, + ] { + assert!(matches!( + parse::(params), + Err(OpsError::Protocol(ProtocolError::InvalidParams(_))) + )); + } + assert!(parse::(json!({"credentials": true})).is_err()); + assert!(parse::(json!({})).is_ok()); + } + + #[test] + fn proxy_reuses_existing_wire_type() { + let params: RuntimeConfigureParams = parse(json!({"proxy": { + "http_url": "http://user:secret@localhost:8080", + "https_url": null, + "no_proxy": "localhost", + }})) + .unwrap(); + let proxy = params.proxy.unwrap(); + assert_eq!( + proxy.http_url.as_deref(), + Some("http://user:secret@localhost:8080") + ); + assert_eq!(proxy.no_proxy.as_deref(), Some("localhost")); + } + + #[test] + fn description_uses_manifest_capabilities_and_digest() { + let result = description( + &json!({ + "manifestRevision": "revision", + "ops": [{"name": "runtime.describe"}, {"name": "text.generate"}], + "handleKinds": ["Language"], + "packages": ["jev", "openai"], + }), + "digest".into(), + ); + assert_eq!(result["abiVersion"], ABI_VERSION); + assert_eq!(result["opsVersion"], OPS_VERSION); + assert_eq!(result["manifestDigest"], "digest"); + assert_eq!( + result["capabilities"]["operations"], + json!(["runtime.describe", "text.generate"]) + ); + assert_eq!(result["capabilities"]["handleKinds"], json!(["Language"])); + assert_eq!(result["capabilities"]["packages"], json!(["jev", "openai"])); + } + + #[test] + fn descriptors_are_global_calls_with_accurate_side_effects() { + assert_eq!(OPS.len(), 2); + for op in OPS { + assert!(op.allowed_handle_kinds.is_empty()); + assert!(matches!(op.mode, OpMode::Call)); + assert_eq!(op.cancellation, Cancellation::None); + assert_eq!(op.scope, Scope::Current); + assert!(op.runtime_handle_fields.is_empty()); + } + assert!(!OPS[0].side_effects); + assert!(OPS[1].side_effects); + let mut handlers = Vec::new(); + register(&mut handlers); + assert_eq!(handlers.len(), 2); + } +} diff --git a/aimux-ops/src/ops/session.rs b/aimux-ops/src/ops/session.rs new file mode 100644 index 000000000..8a3a46a1b --- /dev/null +++ b/aimux-ops/src/ops/session.rs @@ -0,0 +1,174 @@ +//! Session grouping and queries backed by the core session index. + +use std::sync::Arc; + +use aimux_core::session::{SessionStore, init_session_infer, init_session_store}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::dispatch::{CallFuture, Ctx, Handler, Target, decode}; +use crate::envelope::{BindingError, OpsError}; +use crate::handle::{Entry, HandleId, HandleKind, HandleRegistry}; +use crate::op::{Cancellation, OpDescriptor, OpMode, RequestBytePolicy, Scope}; +use crate::ops::operation::EmptyParams; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SessionStoreCreateParams { + infer: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SessionStoreCallsParams { + session_id: String, +} + +#[derive(Serialize)] +struct SessionStoreHandle { + handle: HandleId, +} + +type SessionViews = Vec; +type SessionCalls = Vec; + +pub(crate) static OPS: &[OpDescriptor] = &[ + OpDescriptor { + name: "sessionStore.create", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[], + request_type: "aimux_ops::ops::session::SessionStoreCreateParams", + result_type: "aimux_ops::ops::session::SessionStoreHandle", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + side_effects: true, + retry_owner: "none", + source_symbols: &["aimux_session_store_init", "aimux_session_infer_init"], + }, + OpDescriptor { + name: "sessionStore.attach", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[HandleKind::SessionStore], + request_type: "EmptyParams", + result_type: "()", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + side_effects: true, + retry_owner: "none", + source_symbols: &["aimux_core::session::init_session_store"], + }, + OpDescriptor { + name: "sessionStore.list", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[HandleKind::SessionStore], + request_type: "EmptyParams", + result_type: "aimux_ops::ops::session::SessionViews", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + side_effects: false, + retry_owner: "none", + source_symbols: &["aimux_list_sessions"], + }, + OpDescriptor { + name: "sessionStore.calls", + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds: &[HandleKind::SessionStore], + request_type: "aimux_ops::ops::session::SessionStoreCallsParams", + result_type: "aimux_ops::ops::session::SessionCalls", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + side_effects: false, + retry_owner: "none", + source_symbols: &["aimux_session_calls"], + }, +]; + +pub(crate) fn register(r: &mut Vec<(&'static OpDescriptor, Handler)>) { + r.push((&OPS[0], Handler::Call(create))); + r.push((&OPS[1], Handler::Call(attach))); + r.push((&OPS[2], Handler::Call(list))); + r.push((&OPS[3], Handler::Call(calls))); +} + +fn json(value: impl Serialize) -> Result { + serde_json::to_value(value) + .map_err(|error| OpsError::Binding(BindingError::ResultSerialization(error.to_string()))) +} + +fn store(target: Target) -> Result, OpsError> { + match target { + Target::Handle(Entry::SessionStore(store)) => Ok(store), + other => Err(OpsError::Binding(BindingError::InvalidHandle { + expected: vec![HandleKind::SessionStore], + actual: match other { + Target::Global => None, + Target::Handle(entry) => Some(entry.kind()), + }, + })), + } +} + +fn create(ctx: Ctx, _target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let request: SessionStoreCreateParams = decode(value)?; + let handle = HandleRegistry::global().insert( + ctx.owner, + Entry::SessionStore(Arc::new(SessionStore::new())), + )?; + // Core inference is process-wide, as in aimux_session_infer_init. + init_session_infer(request.infer); + json(SessionStoreHandle { handle }) + }) +} + +fn attach(_ctx: Ctx, target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let _: EmptyParams = decode(value)?; + init_session_store(store(target)?); + Ok(Value::Null) + }) +} + +fn list(_ctx: Ctx, target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let _: EmptyParams = decode(value)?; + let sessions: SessionViews = store(target)?.list_sessions(); + json(sessions) + }) +} + +fn calls(_ctx: Ctx, target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let request: SessionStoreCallsParams = decode(value)?; + let calls: SessionCalls = store(target)?.session_calls(&request.session_id); + json(calls) + }) +} diff --git a/aimux-ops/src/ops/text.rs b/aimux-ops/src/ops/text.rs new file mode 100644 index 000000000..fa2f79beb --- /dev/null +++ b/aimux-ops/src/ops/text.rs @@ -0,0 +1,1133 @@ +//! Text operations backed by Core's generation and OpenAI output paths. + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use aimux_core::generate::{ + GenerateTextOptions, generate_text, generate_text_as_openai, stream_text, +}; +use aimux_core::message::ModelPrompt; +use aimux_core::openai_output::{ + ChatCompletionChunk, OpenAiStreamOptions, to_chat_completion_stream, +}; +use aimux_core::stream_part::TextStreamPart; +use aimux_core::{AbortSignal, AiMuxError, LanguageModel}; +use futures::{Stream, StreamExt}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::sync::oneshot; + +use crate::dispatch::{CallFuture, Ctx, EventStream, Handler, Target, decode as params}; +use crate::envelope::{BindingError, OpsError}; +use crate::handle::{Entry, HandleId, HandleKind}; +use crate::op::{Cancellation, OpDescriptor, OpMode, RequestBytePolicy, Scope}; + +const LANGUAGE: &[HandleKind] = &[HandleKind::Language]; + +const fn descriptor( + name: &'static str, + mode: OpMode, + request_type: &'static str, + result_type: &'static str, + event_type: Option<&'static str>, + source_symbols: &'static [&'static str], +) -> OpDescriptor { + OpDescriptor { + name, + since_ops_version: "1.0", + mode, + allowed_handle_kinds: LANGUAGE, + request_type, + result_type, + event_type, + scope: Scope::Current, + terminal_type: if matches!(mode, OpMode::Stream) { + Some("StreamFrame") + } else { + None + }, + runtime_handle_fields: &[("abort", &[HandleKind::Abort])], + required_capabilities: &[], + cancellation: Cancellation::Abort, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + side_effects: true, + retry_owner: "core", + source_symbols, + } +} + +pub(crate) static OPS: &[OpDescriptor] = &[ + descriptor( + "text.generate", + OpMode::Call, + "aimux_ops::ops::text::TextRequest", + "aimux_core::generate::GenerateTextResult", + None, + &["aimux_generate_text"], + ), + descriptor( + "text.generateObject", + OpMode::Call, + "aimux_ops::ops::text::TextRequest", + "aimux_core::generate::GenerateObjectResult", + None, + &["aimux_generate_object"], + ), + descriptor( + "text.consume", + OpMode::Call, + "aimux_ops::ops::text::TextRequest", + "aimux_core::generate::GenerateTextResult", + None, + &["aimux_consume_stream_text"], + ), + descriptor( + "text.generateOpenAI", + OpMode::Call, + "aimux_ops::ops::text::TextRequest", + "aimux_core::openai_output::ChatCompletion", + None, + &["aimux_generate_text_as_openai"], + ), + descriptor( + "text.stream", + OpMode::Stream, + "aimux_ops::ops::text::TextRequest", + "StreamFrame", + Some("aimux_core::stream_part::TextStreamPart"), + &["aimux_stream_text", "aimux_stream_text_with_abort"], + ), + descriptor( + "text.streamOpenAI", + OpMode::Stream, + "aimux_ops::ops::text::OpenAiTextRequest", + "StreamFrame", + Some("aimux_core::openai_output::ChatCompletionChunk"), + &[ + "aimux_stream_text_as_openai", + "aimux_stream_text_as_openai_with_abort", + ], + ), +]; + +pub(crate) fn register(r: &mut Vec<(&'static OpDescriptor, Handler)>) { + r.extend([ + (&OPS[0], Handler::Call(generate)), + (&OPS[1], Handler::Call(generate_object)), + (&OPS[2], Handler::Call(consume)), + (&OPS[3], Handler::Call(generate_openai)), + (&OPS[4], Handler::Stream(stream)), + (&OPS[5], Handler::Stream(stream_openai)), + ]); +} + +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct Options { + #[serde(flatten)] + core: GenerateTextOptions, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TextRequest { + prompt: ModelPrompt, + #[serde(default)] + options: Options, + #[serde(default, rename = "abort")] + _abort: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct OpenAiTextRequest { + prompt: ModelPrompt, + #[serde(default)] + options: Options, + #[serde(default, rename = "abort")] + _abort: Option, + #[serde(default)] + stream_options: StreamOptions, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields, default)] +struct StreamOptions { + include_usage: bool, + include_reasoning: bool, +} + +impl Default for StreamOptions { + fn default() -> Self { + Self { + include_usage: true, + include_reasoning: true, + } + } +} + +fn json(value: T) -> Result { + serde_json::to_value(value) + .map_err(|e| OpsError::Binding(BindingError::ResultSerialization(e.to_string()))) +} + +fn language(target: Target) -> Result, OpsError> { + match target { + Target::Handle(Entry::Language(model)) => Ok(model), + other => Err(OpsError::Binding(BindingError::InvalidHandle { + expected: LANGUAGE.to_vec(), + actual: match other { + Target::Global => None, + Target::Handle(entry) => Some(entry.kind()), + }, + })), + } +} + +impl TextRequest { + fn lift(mut self, ctx: &Ctx) -> Self { + // Dispatch has already resolved the optional abort handle with its registry + // and owner policy. Retain that signal instead of looking up the ID again. + self.options.core.abort_signal = ctx.abort.clone(); + self + } +} + +async fn cancellable( + signal: Option, + future: impl Future>, +) -> Result { + match signal { + Some(signal) => tokio::select! { + biased; + _ = signal.cancelled() => Err(OpsError::AiMux(AiMuxError::from_abort_signal(&signal))), + result = future => result.map_err(OpsError::AiMux), + }, + None => future.await.map_err(OpsError::AiMux), + } +} + +fn generate(ctx: Ctx, target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let model = language(target)?; + let request = params::(value)?.lift(&ctx); + json( + cancellable( + request.options.core.abort_signal.clone(), + generate_text(model.as_ref(), request.prompt, request.options.core), + ) + .await?, + ) + }) +} + +#[allow(deprecated)] +fn generate_object(ctx: Ctx, target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let model = language(target)?; + let request = params::(value)?.lift(&ctx); + json( + cancellable( + request.options.core.abort_signal.clone(), + aimux_core::generate::generate_object( + model.as_ref(), + request.prompt, + request.options.core, + ), + ) + .await?, + ) + }) +} + +fn consume(ctx: Ctx, target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let model = language(target)?; + let request = params::(value)?.lift(&ctx); + json( + cancellable(request.options.core.abort_signal.clone(), async move { + stream_text(model.as_ref(), request.prompt, request.options.core) + .await? + .consume() + .await + }) + .await?, + ) + }) +} + +fn generate_openai(ctx: Ctx, target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let model = language(target)?; + let request = params::(value)?.lift(&ctx); + json( + cancellable( + request.options.core.abort_signal.clone(), + generate_text_as_openai(model.as_ref(), request.prompt, request.options.core), + ) + .await?, + ) + }) +} + +fn stream(ctx: Ctx, target: Target, value: Value) -> Result { + let model = language(target)?; + let request = params::(value)?.lift(&ctx); + Ok(start_stream(model, request, None)) +} + +fn stream_openai(ctx: Ctx, target: Target, value: Value) -> Result { + let model = language(target)?; + let request: OpenAiTextRequest = params(value)?; + let options = OpenAiStreamOptions { + include_usage: request.stream_options.include_usage, + include_reasoning: request.stream_options.include_reasoning, + }; + let request = TextRequest { + prompt: request.prompt, + options: request.options, + _abort: request._abort, + } + .lift(&ctx); + Ok(start_stream(model, request, Some(options))) +} + +fn start_stream( + model: Arc, + request: TextRequest, + openai: Option, +) -> EventStream { + let signal = request.options.core.abort_signal.clone(); + let setup = async move { + let result = cancellable( + signal.clone(), + stream_text(model.as_ref(), request.prompt, request.options.core), + ) + .await?; + let native = NativeEvents::new(result.stream, signal); + let events: EventStream = match openai { + Some(options) => { + let (sender, terminal) = oneshot::channel(); + let input = TerminalGuard { + native, + sender: Some(sender), + }; + let converted = + to_chat_completion_stream(Box::pin(input), model.model_id(), options); + Box::pin(OpenAiEvents { + chunks: converted.stream, + terminal, + ended: false, + }) + } + None => Box::pin(native.map(json_part)), + }; + Ok(events) + }; + Box::pin( + futures::stream::once(setup) + .map(|result| match result { + Ok(events) => events, + Err(error) => { + Box::pin(futures::stream::once(std::future::ready(Err(error)))) as EventStream + } + }) + .flatten(), + ) +} + +fn json_part(part: Result) -> Result { + json(part.map_err(OpsError::AiMux)?) +} + +type NativeStream = Pin> + Send>>; + +struct NativeEvents { + inner: NativeStream, + cancelled: Option + Send>>>, + signal: Option, + ended: bool, +} + +impl NativeEvents { + fn new(inner: NativeStream, signal: Option) -> Self { + let cancelled = signal + .as_ref() + .map(|signal| Box::pin(signal.cancelled()) as Pin + Send>>); + Self { + inner, + cancelled, + signal, + ended: false, + } + } +} + +impl Stream for NativeEvents { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + if this.ended { + return Poll::Ready(None); + } + if let Some(cancelled) = &mut this.cancelled + && cancelled.as_mut().poll(cx).is_ready() + { + this.ended = true; + return Poll::Ready(Some(Err(AiMuxError::Aborted("request aborted".into())))); + } + match this.inner.as_mut().poll_next(cx) { + Poll::Ready(Some(Err(error))) if error.is_recoverable_stream_error() => { + Poll::Ready(Some(Ok(TextStreamPart::Error { error }))) + } + Poll::Ready(Some(Ok(TextStreamPart::Abort { .. }))) => { + this.ended = true; + Poll::Ready(Some(Err(this.signal.as_ref().map_or_else( + || AiMuxError::Aborted("request aborted".into()), + AiMuxError::from_abort_signal, + )))) + } + Poll::Ready(Some(Err(error))) => { + this.ended = true; + Poll::Ready(Some(Err(error))) + } + Poll::Ready(None) => { + this.ended = true; + Poll::Ready(None) + } + other => other, + } + } +} + +// Preserve runtime failures before Core's OpenAI converter turns them into chunks. +struct TerminalGuard { + native: NativeEvents, + sender: Option>, +} + +impl Stream for TerminalGuard { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + match Pin::new(&mut this.native).poll_next(cx) { + Poll::Ready(Some(Err(error))) => { + if let Some(sender) = this.sender.take() { + let _ = sender.send(error); + } + Poll::Ready(None) + } + other => other, + } + } +} + +struct OpenAiEvents { + chunks: Pin> + Send>>, + terminal: oneshot::Receiver, + ended: bool, +} + +impl Stream for OpenAiEvents { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + if this.ended { + return Poll::Ready(None); + } + let next = this.chunks.as_mut().poll_next(cx); + if let Ok(error) = this.terminal.try_recv() { + this.ended = true; + return Poll::Ready(Some(Err(OpsError::AiMux(error)))); + } + match next { + Poll::Ready(Some(Ok(chunk))) => Poll::Ready(Some(json(chunk))), + Poll::Ready(Some(Err(error))) => { + this.ended = true; + Poll::Ready(Some(Err(OpsError::AiMux(error)))) + } + Poll::Ready(None) => { + this.ended = true; + Poll::Ready(None) + } + Poll::Pending => Poll::Pending, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::handle::{HandleRegistry, Owner}; + use aimux_core::options::CallOptions; + use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; + use aimux_core::stream_part::StreamPart; + use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; + use serde_json::json; + use std::sync::Mutex; + use std::time::SystemTime; + + #[derive(Clone)] + struct MockModel { + text: String, + parts: Vec>, + pending: bool, + setup_error: bool, + seen: Arc>>, + } + + fn finish() -> FinishReason { + FinishReason { + unified: FinishReasonUnified::Stop, + raw: Some("stop".into()), + } + } + + impl MockModel { + fn new(text: &str) -> Self { + Self { + text: text.into(), + parts: vec![ + Ok(StreamPart::StreamStart { warnings: vec![] }), + Ok(StreamPart::TextStart { + id: "a".into(), + provider_metadata: None, + }), + Ok(StreamPart::TextDelta { + id: "a".into(), + delta: text.into(), + provider_metadata: None, + }), + Ok(StreamPart::TextEnd { + id: "a".into(), + provider_metadata: None, + }), + Ok(StreamPart::Finish { + finish_reason: finish(), + usage: Usage::default(), + provider_metadata: None, + }), + ], + pending: false, + setup_error: false, + seen: Arc::new(Mutex::new(None)), + } + } + fn entry(&self) -> Target { + Target::Handle(Entry::Language(Arc::new(self.clone()))) + } + } + + // Spell out async_trait's signatures so this module needs no extra test dependency. + impl LanguageModel for MockModel { + fn provider(&self) -> &str { + "mock.chat" + } + fn model_id(&self) -> &str { + "mock-model" + } + fn do_generate<'life0, 'life1, 'async_trait>( + &'life0 self, + options: &'life1 CallOptions, + ) -> Pin> + Send + 'async_trait>> + where + 'life0: 'async_trait, + 'life1: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + *self.seen.lock().unwrap() = Some(options.clone()); + if self.pending { + return futures::future::pending().await; + } + Ok(GenerateResult { + content: vec![GenerateContent::Text { + text: self.text.clone(), + provider_metadata: None, + }], + finish_reason: finish(), + usage: Usage::default(), + warnings: vec![], + provider_metadata: None, + response: None, + request: None, + }) + }) + } + fn do_stream<'life0, 'life1, 'async_trait>( + &'life0 self, + options: &'life1 CallOptions, + ) -> Pin> + Send + 'async_trait>> + where + 'life0: 'async_trait, + 'life1: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + *self.seen.lock().unwrap() = Some(options.clone()); + if self.text == "setup-pending" { + return futures::future::pending().await; + } + if self.setup_error { + return Err(AiMuxError::Other("setup failed".into())); + } + let stream: Pin> + Send>> = + if self.pending { + Box::pin(futures::stream::pending()) + } else { + Box::pin(futures::stream::iter(self.parts.clone())) + }; + Ok(StreamResult { + stream, + request: None, + response: None, + }) + }) + } + } + + fn ctx() -> Ctx { + Ctx { + owner: Owner::CProcess, + abort: None, + now: SystemTime::now, + } + } + fn request() -> Value { + json!({"prompt":"hello", "options":{"maxRetries":0}}) + } + + #[tokio::test] + async fn calls_reuse_core_results_and_options() { + let model = MockModel::new("你好 🌏"); + let result = generate( + ctx(), + model.entry(), + json!({ + "prompt":[{"role":"user","content":"hi"}], + "options":{"maxRetries":0,"maxOutputTokens":7,"temperature":0.2, + "providerOptions":{"custom":{"anything":{"x":1}}}} + }), + ) + .await + .unwrap(); + assert_eq!(result["text"], "你好 🌏"); + assert_eq!(result["steps"].as_array().unwrap().len(), 1); + let seen = model.seen.lock().unwrap().clone().unwrap(); + assert_eq!(seen.max_output_tokens, Some(7)); + assert_eq!(seen.temperature, Some(0.2)); + assert_eq!(seen.provider_options.unwrap()["custom"]["anything"]["x"], 1); + let result = consume(ctx(), model.entry(), request()).await.unwrap(); + assert_eq!(result["text"], "你好 🌏"); + let result = generate_openai(ctx(), model.entry(), request()) + .await + .unwrap(); + assert_eq!(result["object"], "chat.completion"); + assert_eq!(result["choices"][0]["message"]["content"], "你好 🌏"); + } + + #[tokio::test] + async fn object_generation_uses_core_validation() { + let result = generate_object(ctx(), MockModel::new(r#"{"answer":42}"#).entry(), request()) + .await + .unwrap(); + assert_eq!(result["object"], json!({"answer":42})); + assert!(matches!( + generate_object(ctx(), MockModel::new("invalid JSON").entry(), request()).await, + Err(OpsError::AiMux(_)) + )); + } + + #[test] + fn controls_are_strict_but_provider_options_stay_open() { + for value in [ + json!({"prompt":"x","typo":1}), + json!({"prompt":"x","options":{"typo":1}}), + json!({"prompt":"x","options":{"abortSignal":{}}}), + json!({"prompt":"x","abort":7}), + ] { + assert!(stream(ctx(), MockModel::new("x").entry(), value).is_err()); + } + assert!( + stream_openai( + ctx(), + MockModel::new("x").entry(), + json!({ + "prompt":"x","streamOptions":{"typo":true} + }) + ) + .is_err() + ); + assert!( + stream( + ctx(), + MockModel::new("x").entry(), + json!({ + "prompt":"x", "options":{"providerOptions":{"custom":{"unknown":true}}} + }) + ) + .is_ok() + ); + } + + #[tokio::test] + async fn native_errors_are_data_and_later_text_survives() { + let mut model = MockModel::new("after"); + model.parts.insert( + 1, + Ok(StreamPart::Error { + error: AiMuxError::Other("provider warning".into()), + }), + ); + model + .parts + .insert(2, Err(AiMuxError::JsonParse("malformed frame".into()))); + let events = stream(ctx(), model.entry(), request()) + .unwrap() + .collect::>() + .await; + assert!(events.iter().all(Result::is_ok)); + let events = events.into_iter().map(Result::unwrap).collect::>(); + assert_eq!(events.iter().filter(|v| v["type"] == "error").count(), 2); + assert!( + events + .iter() + .any(|v| v["type"] == "text-delta" && v["text"] == "after") + ); + assert_eq!(events.last().unwrap()["type"], "finish"); + } + + #[tokio::test] + async fn both_streams_preserve_runtime_failure() { + for openai in [false, true] { + let mut model = MockModel::new("before"); + model + .parts + .insert(3, Err(AiMuxError::Other("connection lost".into()))); + let mut events = if openai { + stream_openai(ctx(), model.entry(), request()) + } else { + stream(ctx(), model.entry(), request()) + } + .unwrap(); + let mut errors = 0; + while let Some(event) = events.next().await { + if let Err(OpsError::AiMux(AiMuxError::Other(message))) = event { + assert_eq!(message, "connection lost"); + errors += 1; + assert!(events.next().await.is_none()); + break; + } + } + assert_eq!(errors, 1, "openai={openai}"); + } + } + + #[tokio::test] + async fn setup_failure_is_one_stream_error() { + for openai in [false, true] { + let mut model = MockModel::new("x"); + model.setup_error = true; + let events = if openai { + stream_openai(ctx(), model.entry(), request()) + } else { + stream(ctx(), model.entry(), request()) + } + .unwrap() + .collect::>() + .await; + assert_eq!(events.len(), 1); + assert!( + matches!(&events[0], Err(OpsError::AiMux(AiMuxError::Other(message))) if message == "setup failed") + ); + } + } + + #[tokio::test] + async fn openai_stream_errors_remain_data_and_options_apply() { + let mut model = MockModel::new("after"); + model + .parts + .insert(1, Err(AiMuxError::JsonParse("malformed frame".into()))); + let events = stream_openai( + ctx(), + model.entry(), + json!({ + "prompt":"x", "streamOptions":{"includeUsage":false,"includeReasoning":false} + }), + ) + .unwrap() + .collect::>() + .await; + assert!(events.iter().all(Result::is_ok)); + let events = events.into_iter().map(Result::unwrap).collect::>(); + assert!( + events + .iter() + .all(|v| v["object"] == "chat.completion.chunk") + ); + assert!( + events + .iter() + .any(|v| v["choices"][0]["delta"]["content"] == "after") + ); + assert!(events.iter().all(|v| v.get("usage").is_none())); + } + + #[tokio::test] + async fn cancellation_interrupts_setup_call_and_idle_stream() { + let mut model = MockModel::new("x"); + model.pending = true; + let signal = AbortSignal::new(); + let mut context = ctx(); + context.abort = Some(signal.clone()); + let pending = generate(context, model.entry(), request()); + signal.abort(); + assert!(matches!( + pending.await, + Err(OpsError::AiMux(AiMuxError::Aborted(_))) + )); + for openai in [false, true] { + let signal = AbortSignal::new(); + let mut context = ctx(); + context.abort = Some(signal.clone()); + let mut events = if openai { + stream_openai(context, model.entry(), request()) + } else { + stream(context, model.entry(), request()) + } + .unwrap(); + let cancel = async { + tokio::task::yield_now().await; + signal.abort(); + }; + let read = async { + while let Some(event) = events.next().await { + if matches!(event, Err(OpsError::AiMux(AiMuxError::Aborted(_)))) { + assert!(events.next().await.is_none()); + return; + } + } + panic!("missing cancellation"); + }; + tokio::time::timeout(std::time::Duration::from_secs(2), async { + tokio::join!(cancel, read); + }) + .await + .unwrap(); + } + } + + #[test] + fn invalid_model_and_abort_handles_are_rejected_before_acceptance() { + assert!(matches!( + stream( + ctx(), + Target::Handle(Entry::Abort(Arc::new(AbortSignal::new()))), + request() + ), + Err(OpsError::Binding(BindingError::InvalidHandle { .. })) + )); + for abort in ["not-a-handle", "18446744073709551616"] { + assert!( + stream( + ctx(), + MockModel::new("x").entry(), + json!({"prompt":"x","abort":abort}) + ) + .is_err() + ); + } + } + + #[tokio::test] + async fn explicit_abort_handle_is_lifted_and_cloned() { + let signal = AbortSignal::new(); + let handle = HandleRegistry::global() + .insert(Owner::CProcess, Entry::Abort(Arc::new(signal.clone()))) + .unwrap(); + let registry = HandleRegistry::global(); + let model = registry + .insert( + Owner::CProcess, + Entry::Language(Arc::new(MockModel::new("x"))), + ) + .unwrap(); + let events = crate::dispatch::stream( + ctx(), + model, + "text.stream", + json!({"prompt":"x", "abort":handle}), + ) + .unwrap(); + drop(registry.remove(Owner::CProcess, handle)); + drop(registry.remove(Owner::CProcess, model)); + signal.abort(); + let frames = events.collect::>().await; + assert_eq!(frames.len(), 1); + assert_eq!( + serde_json::to_value(&frames[0]).unwrap()["outcome"], + "cancelled" + ); + } + + #[tokio::test] + async fn cancellation_interrupts_pending_provider_setup() { + let model = MockModel::new("setup-pending"); + let signal = AbortSignal::new(); + let mut context = ctx(); + context.abort = Some(signal.clone()); + let mut events = stream_openai(context, model.entry(), request()).unwrap(); + let cancel = async { + tokio::task::yield_now().await; + signal.abort(); + }; + let read = async { + assert!(matches!( + events.next().await, + Some(Err(OpsError::AiMux(AiMuxError::Aborted(_)))) + )); + assert!(events.next().await.is_none()); + }; + tokio::time::timeout(std::time::Duration::from_secs(2), async { + tokio::join!(cancel, read); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn real_dispatch_runs_all_six_text_operations() { + let registry = HandleRegistry::default(); + let mut registrations = vec![]; + register(&mut registrations); + let dispatcher = crate::dispatch::Dispatcher::new(®istry, ®istrations); + let model = MockModel::new(r#"{"answer":42}"#); + let id = registry + .insert(ctx().owner, Entry::Language(Arc::new(model.clone()))) + .unwrap(); + for name in [ + "text.generate", + "text.generateObject", + "text.consume", + "text.generateOpenAI", + ] { + let result = dispatcher.call(ctx(), id, name, request()).await.unwrap(); + match name { + "text.generateObject" => assert_eq!(result["object"], json!({"answer":42})), + "text.generateOpenAI" => { + assert_eq!(result["object"], "chat.completion"); + assert_eq!(result["choices"][0]["message"]["content"], model.text); + } + _ => assert_eq!(result["text"], model.text), + } + } + for name in ["text.stream", "text.streamOpenAI"] { + let frames = dispatcher + .stream(ctx(), id, name, request()) + .unwrap() + .map(|frame| serde_json::to_value(frame).unwrap()) + .collect::>() + .await; + assert!(frames.len() > 1); + for (seq, frame) in frames.iter().enumerate() { + assert_eq!(frame["seq"], seq.to_string()); + } + assert_eq!( + frames.iter().filter(|frame| frame["type"] == "end").count(), + 1 + ); + assert_eq!(frames.last().unwrap()["outcome"], "success"); + } + } + + #[tokio::test] + async fn every_text_op_rejects_foreign_unknown_and_wrong_kind_abort_before_provider() { + let registry = HandleRegistry::default(); + let mut registrations = vec![]; + register(&mut registrations); + let dispatcher = crate::dispatch::Dispatcher::new(®istry, ®istrations); + let owner = ctx().owner; + let foreign = Owner::new_node().unwrap(); + let model = MockModel::new("unused"); + let id = registry + .insert(owner, Entry::Language(Arc::new(model.clone()))) + .unwrap(); + let abort = registry + .insert(foreign, Entry::Abort(Arc::new(AbortSignal::new()))) + .unwrap(); + for descriptor in OPS { + let mut hidden = vec![]; + for secondary in [abort, HandleId(u64::MAX), id, HandleId(0)] { + let value = json!({"prompt":"x", "abort":secondary}); + let error = if descriptor.mode == OpMode::Stream { + dispatcher + .stream(ctx(), id, descriptor.name, value) + .err() + .unwrap() + } else { + dispatcher + .call(ctx(), id, descriptor.name, value) + .await + .unwrap_err() + }; + assert!(matches!(error, OpsError::Projected(_))); + assert_eq!(error.code(), 203); + if secondary == abort || secondary == HandleId(u64::MAX) { + assert!(matches!( + error.unprojected(), + OpsError::Binding(BindingError::InvalidHandle { actual: None, .. }) + )); + hidden.push(error.envelope(SystemTime::UNIX_EPOCH)); + } + } + assert_eq!(hidden[0], hidden[1]); + } + assert!(model.seen.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn isolated_dispatch_resolves_and_retains_abort_without_global_lookup() { + let registry = HandleRegistry::default(); + let mut registrations = vec![]; + register(&mut registrations); + let dispatcher = crate::dispatch::Dispatcher::new(®istry, ®istrations); + let model = MockModel::new("hello"); + let signal = AbortSignal::new(); + let id = registry + .insert(ctx().owner, Entry::Language(Arc::new(model.clone()))) + .unwrap(); + let abort = registry + .insert(ctx().owner, Entry::Abort(Arc::new(signal.clone()))) + .unwrap(); + let value = json!({"prompt":"x", "options":{"maxRetries":0}, "abort":abort}); + dispatcher + .call(ctx(), id, "text.generate", value.clone()) + .await + .unwrap(); + let received = model + .seen + .lock() + .unwrap() + .as_ref() + .unwrap() + .abort_signal + .clone() + .unwrap(); + let stream = dispatcher.stream(ctx(), id, "text.stream", value).unwrap(); + drop(registry.remove(ctx().owner, abort)); + signal.abort(); + assert!(received.is_aborted()); + let frames = stream + .map(|frame| serde_json::to_value(frame).unwrap()) + .collect::>() + .await; + assert_eq!(frames.len(), 1); + assert_eq!(frames[0]["outcome"], "cancelled"); + assert_eq!(frames[0]["error"]["code"], 13); + } + + #[tokio::test] + async fn real_text_streams_frame_recoverable_setup_and_runtime_errors() { + let registry = HandleRegistry::default(); + let mut registrations = vec![]; + register(&mut registrations); + let dispatcher = crate::dispatch::Dispatcher::new(®istry, ®istrations); + for name in ["text.stream", "text.streamOpenAI"] { + for case in ["recoverable", "setup", "runtime", "large"] { + let mut model = MockModel::new("after"); + match case { + "recoverable" => model + .parts + .insert(1, Err(AiMuxError::JsonParse("malformed frame".into()))), + "setup" => model.setup_error = true, + "runtime" => model + .parts + .insert(3, Err(AiMuxError::Other("connection lost".into()))), + "large" => model.parts.insert( + 3, + Ok(StreamPart::TextDelta { + id: "a".into(), + delta: "x".repeat(crate::limits::ITEM_BYTES), + provider_metadata: None, + }), + ), + _ => unreachable!(), + } + let id = registry + .insert(ctx().owner, Entry::Language(Arc::new(model))) + .unwrap(); + let frames = dispatcher + .stream(ctx(), id, name, request()) + .unwrap() + .map(|frame| serde_json::to_value(frame).unwrap()) + .collect::>() + .await; + drop(registry.remove(ctx().owner, id)); + assert_eq!( + frames.iter().filter(|frame| frame["type"] == "end").count(), + 1 + ); + let end = frames.last().unwrap(); + if case == "recoverable" { + assert_eq!(end["outcome"], "success"); + assert!(frames.iter().any(|frame| frame["event"]["text"] == "after" + || frame["event"]["choices"][0]["delta"]["content"] == "after")); + } else { + assert_eq!(end["outcome"], "error", "{name}: {case}"); + assert_eq!(end["error"]["code"], if case == "large" { 305 } else { 1 }); + assert_eq!( + end["error"]["kind"], + if case == "large" { "protocol" } else { "aimux" } + ); + } + } + } + } + + fn fixed_now() -> SystemTime { + SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000) + } + + #[tokio::test] + async fn text_stream_terminal_caches_retry_after_at_dispatch_boundary() { + let registry = HandleRegistry::default(); + let mut registrations = vec![]; + register(&mut registrations); + let dispatcher = crate::dispatch::Dispatcher::new(®istry, ®istrations); + for name in ["text.stream", "text.streamOpenAI"] { + let mut api = aimux_core::error::ApiCallError::new( + "retry later", + "https://mock.invalid", + json!({}), + ); + api.response_headers = Some(std::collections::HashMap::from([( + "Retry-After".into(), + "Tue, 14 Nov 2023 22:13:22 GMT".into(), + )])); + let mut model = MockModel::new("before"); + model + .parts + .insert(3, Err(AiMuxError::ApiCall(Box::new(api)))); + let id = registry + .insert(ctx().owner, Entry::Language(Arc::new(model))) + .unwrap(); + let mut context = ctx(); + context.now = fixed_now; + let frames = dispatcher + .stream(context, id, name, request()) + .unwrap() + .map(|frame| serde_json::to_value(frame).unwrap()) + .collect::>() + .await; + assert_eq!(frames.last().unwrap()["error"]["code"], 11); + assert_eq!(frames.last().unwrap()["error"]["retryAfterMs"], 2000); + } + } +} diff --git a/aimux-ops/src/ops/trace.rs b/aimux-ops/src/ops/trace.rs new file mode 100644 index 000000000..b6bdddf1b --- /dev/null +++ b/aimux-ops/src/ops/trace.rs @@ -0,0 +1,279 @@ +//! Trace storage and queries. Session identifiers and filters are ordinary data. + +use std::sync::Arc; + +use aimux_core::error::AiMuxError; +use aimux_core::trace::store::{DEFAULT_RING_CAPACITY, DEFAULT_SCOPE_CAP, TraceAuditMode}; +use aimux_core::trace::{RingTraceStore, TraceFilter}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::dispatch::{CallFuture, Ctx, Handler, Target, decode}; +use crate::envelope::{BindingError, OpsError, ProtocolError}; +use crate::handle::{Entry, HandleKind, HandleRegistry}; +use crate::op::{Cancellation, OpDescriptor, OpMode, RequestBytePolicy, Scope}; +use crate::ops::operation::EmptyParams; + +pub type TraceAggregateResult = Vec; +pub type TraceTrajectoryResult = Vec; + +#[derive(Clone, Copy, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AuditMode { + #[default] + Off, + Shared, + Strict, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TraceCreateParams { + #[serde(default = "default_capacity")] + capacity: usize, + #[serde(default = "default_scope_capacity")] + per_scope_capacity: usize, + #[serde(default)] + audit_mode: AuditMode, +} + +fn default_capacity() -> usize { + DEFAULT_RING_CAPACITY +} + +fn default_scope_capacity() -> usize { + DEFAULT_SCOPE_CAP +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TraceAggregateParams { + #[serde(default, deserialize_with = "deserialize_filter")] + filter: TraceFilter, +} + +// TraceFilter is the canonical core DTO, whose open deserializer is also used +// elsewhere. Validate its control keys here without duplicating its fields. +fn deserialize_filter<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let value = Value::deserialize(d)?; + let object = value + .as_object() + .ok_or_else(|| serde::de::Error::custom("trace filter must be an object"))?; + for key in object.keys() { + if !matches!( + key.as_str(), + "provider" | "model" | "sessionId" | "sinceUnixMs" + ) { + return Err(serde::de::Error::custom(format!( + "unknown filter field `{key}`" + ))); + } + } + serde_json::from_value(value).map_err(serde::de::Error::custom) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TraceSessionParams { + session_id: String, +} + +fn result(value: impl Serialize) -> Result { + serde_json::to_value(value) + .map_err(|error| OpsError::Binding(BindingError::ResultSerialization(error.to_string()))) +} + +fn store(target: Target) -> Result, OpsError> { + match target { + Target::Handle(Entry::TraceStore(store)) => Ok(store), + other => Err(OpsError::Binding(BindingError::InvalidHandle { + expected: vec![HandleKind::TraceStore], + actual: match other { + Target::Global => None, + Target::Handle(entry) => Some(entry.kind()), + }, + })), + } +} + +fn create(ctx: Ctx, _target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let p: TraceCreateParams = decode(value)?; + if p.capacity == 0 || p.per_scope_capacity == 0 { + return Err(OpsError::Protocol(ProtocolError::InvalidParams( + "trace capacities must be greater than zero".into(), + ))); + } + let mode = match p.audit_mode { + AuditMode::Off => TraceAuditMode::Off, + AuditMode::Shared => TraceAuditMode::Shared, + AuditMode::Strict => TraceAuditMode::Strict, + }; + let trace = Arc::new( + RingTraceStore::with_capacity(p.capacity, p.per_scope_capacity).with_audit_mode(mode), + ); + let id = HandleRegistry::global().insert(ctx.owner, Entry::TraceStore(trace))?; + result(id) + }) +} + +fn aggregate(_ctx: Ctx, target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let p: TraceAggregateParams = decode(value)?; + let stats: TraceAggregateResult = store(target)?.aggregate(&p.filter); + result(stats) + }) +} + +fn session_chain(_ctx: Ctx, target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let p: TraceSessionParams = decode(value)?; + let view = store(target)?.session_chain(&p.session_id).ok_or_else(|| { + OpsError::AiMux(AiMuxError::InvalidArgument("unknown session".into())) + })?; + result(view) + }) +} + +fn session_trajectory(_ctx: Ctx, target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let p: TraceSessionParams = decode(value)?; + let steps: TraceTrajectoryResult = store(target)?.session_cache_trajectory(&p.session_id); + result(steps) + }) +} + +fn export(_ctx: Ctx, target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let _: EmptyParams = decode(value)?; + let mut bytes = Vec::new(); + store(target)?.export_jsonl(&mut bytes).map_err(|error| { + OpsError::Binding(BindingError::ResultSerialization(format!( + "export: {error}" + ))) + })?; + let jsonl = String::from_utf8(bytes).map_err(|error| { + OpsError::Binding(BindingError::ResultSerialization(format!("utf8: {error}"))) + })?; + Ok(Value::String(jsonl)) + }) +} + +fn clear(_ctx: Ctx, target: Target, value: Value) -> CallFuture { + Box::pin(async move { + let _: EmptyParams = decode(value)?; + store(target)?.clear(); + Ok(Value::Null) + }) +} + +const fn descriptor( + name: &'static str, + allowed_handle_kinds: &'static [HandleKind], + request_type: &'static str, + result_type: &'static str, + side_effects: bool, + source_symbols: &'static [&'static str], +) -> OpDescriptor { + OpDescriptor { + name, + since_ops_version: "1.0", + mode: OpMode::Call, + allowed_handle_kinds, + request_type, + result_type, + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::None, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + side_effects, + retry_owner: "none", + source_symbols, + } +} + +pub(crate) static OPS: &[OpDescriptor] = &[ + descriptor( + "trace.create", + &[], + "aimux_ops::ops::trace::TraceCreateParams", + "HandleId", + true, + &["aimux_core::trace::RingTraceStore::with_capacity"], + ), + descriptor( + "trace.aggregate", + &[HandleKind::TraceStore], + "aimux_ops::ops::trace::TraceAggregateParams", + "aimux_ops::ops::trace::TraceAggregateResult", + false, + &[ + "aimux_trace_aggregate", + "aimux_core::trace::RingTraceStore::aggregate", + ], + ), + descriptor( + "trace.sessionChain", + &[HandleKind::TraceStore], + "aimux_ops::ops::trace::TraceSessionParams", + "aimux_core::trace::SessionChainView", + false, + &[ + "aimux_trace_session_chain", + "aimux_core::trace::RingTraceStore::session_chain", + ], + ), + descriptor( + "trace.sessionTrajectory", + &[HandleKind::TraceStore], + "aimux_ops::ops::trace::TraceSessionParams", + "aimux_ops::ops::trace::TraceTrajectoryResult", + false, + &[ + "aimux_trace_session_trajectory", + "aimux_core::trace::RingTraceStore::session_cache_trajectory", + ], + ), + descriptor( + "trace.export", + &[HandleKind::TraceStore], + "EmptyParams", + "String", + false, + &[ + "aimux_trace_export_jsonl", + "aimux_core::trace::RingTraceStore::export_jsonl", + ], + ), + descriptor( + "trace.clear", + &[HandleKind::TraceStore], + "EmptyParams", + "()", + true, + &[ + "aimux_trace_clear", + "aimux_core::trace::RingTraceStore::clear", + ], + ), +]; + +pub(crate) fn register(r: &mut Vec<(&'static OpDescriptor, Handler)>) { + let handlers = [ + create, + aggregate, + session_chain, + session_trajectory, + export, + clear, + ]; + r.extend( + OPS.iter() + .zip(handlers) + .map(|(op, handler)| (op, Handler::Call(handler))), + ); +} diff --git a/aimux-ops/src/retry_hint.rs b/aimux-ops/src/retry_hint.rs new file mode 100644 index 000000000..3c25a6e0c --- /dev/null +++ b/aimux-ops/src/retry_hint.rs @@ -0,0 +1,2 @@ +//! The canonical retry hint parser lives in aimux-core. +pub use aimux_core::error::retry_after_ms_from_headers as retry_after_ms; diff --git a/aimux-ops/tests/composite.rs b/aimux-ops/tests/composite.rs new file mode 100644 index 000000000..657062f6f --- /dev/null +++ b/aimux-ops/tests/composite.rs @@ -0,0 +1,477 @@ +use aimux_core::LanguageModel; +use aimux_core::error::AiMuxError; +use aimux_core::operation::OperationSettings; +use aimux_core::options::CallOptions; +use aimux_core::result::{GenerateResult, StreamResult}; +use aimux_ops::dispatch::{self, Ctx}; +use aimux_ops::envelope::OpsError; +use aimux_ops::handle::{Entry, HandleId, HandleKind, HandleRegistry, Owner}; +use serde_json::Value; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +struct FailingChild(Arc, bool); + +// Spell out async-trait's return type to avoid an ops-only macro dependency. +impl LanguageModel for FailingChild { + fn provider(&self) -> &str { + "mock" + } + fn model_id(&self) -> &str { + "mock" + } + fn do_generate<'a, 'b, 'f>( + &'a self, + _: &'b CallOptions, + ) -> Pin> + Send + 'f>> + where + 'a: 'f, + 'b: 'f, + Self: 'f, + { + Box::pin(async move { + self.0.fetch_add(1, Ordering::SeqCst); + if self.1 { + return Ok(GenerateResult { + content: vec![], + finish_reason: aimux_core::types::FinishReason { + unified: aimux_core::types::FinishReasonUnified::Stop, + raw: None, + }, + usage: Default::default(), + warnings: vec![], + provider_metadata: None, + response: None, + request: None, + }); + } + Err(AiMuxError::ApiCall(Box::new(aimux_core::ApiCallError { + is_retryable: true, + response_headers: Some(std::collections::HashMap::from([( + "retry-after-ms".into(), + "0".into(), + )])), + ..aimux_core::ApiCallError::new("mock", "https://example.test", Value::Null) + }))) + }) + } + fn do_stream<'a, 'b, 'f>( + &'a self, + _: &'b CallOptions, + ) -> Pin> + Send + 'f>> + where + 'a: 'f, + 'b: 'f, + Self: 'f, + { + Box::pin(async { Err(AiMuxError::Other("mock stream".into())) }) + } +} + +fn child(owner: Owner) -> (String, Arc) { + let calls = Arc::new(AtomicUsize::new(0)); + let id = HandleRegistry::global() + .insert( + owner, + Entry::Language(Arc::new(FailingChild(calls.clone(), false))), + ) + .unwrap(); + (id.0.to_string(), calls) +} + +fn ctx(owner: Owner) -> Ctx { + Ctx { + owner, + abort: None, + now: std::time::SystemTime::now, + } +} + +async fn construct(owner: Owner, name: &str, params: Value) -> Result { + dispatch::call(ctx(owner), HandleId(0), name, params).await +} + +fn language(owner: Owner, id: &str) -> Result, OpsError> { + match dispatch::resolve_secondary( + HandleRegistry::global(), + &ctx(owner), + &Value::String(id.to_owned()), + &[HandleKind::Language], + )? { + Entry::Language(model) => Ok(model), + _ => panic!("typed resolver returned a non-language entry"), + } +} + +fn remove(owner: Owner, id: &str) { + drop(HandleRegistry::global().remove(owner, HandleId(id.parse().unwrap()))); +} + +#[tokio::test] +async fn rejects_bad_children_and_control_fields() { + let owner = Owner::new_node().unwrap(); + let (valid, _) = child(owner); + for params in [ + serde_json::json!({"children": [], "strategy": {"type": "rule"}}), + serde_json::json!({"children": [valid]}), + serde_json::json!({"children": [valid], "strategy": {"type": "unknown"}}), + serde_json::json!({"children": [valid], "strategy": {"type": "rule", "extra": true}}), + serde_json::json!({"children": [valid], "strategy": {"type": "rule"}, "extra": true}), + serde_json::json!({"children": [valid, "0"], "strategy": {"type": "rule"}}), + serde_json::json!({"children": [1], "strategy": {"type": "rule"}}), + ] { + assert!(construct(owner, "composite.router", params).await.is_err()); + } + assert!( + construct( + Owner::new_python().unwrap(), + "composite.router", + serde_json::json!({ + "children": [valid], "strategy": {"type": "rule"} + }) + ) + .await + .is_err() + ); + for params in [ + serde_json::json!({"references": [valid]}), + serde_json::json!({"references": [valid], "aggregator": "0"}), + serde_json::json!({"references": ["0"], "aggregator": valid}), + serde_json::json!({"references": [], "aggregator": valid, "extra": true}), + ] { + assert!(construct(owner, "composite.moa", params).await.is_err()); + } + remove(owner, &valid); + assert!( + construct( + owner, + "composite.router", + serde_json::json!({ + "children": [valid], "strategy": {"type": "rule"} + }) + ) + .await + .is_err() + ); +} + +#[tokio::test] +async fn dropped_router_children_keep_working_with_independent_retry_budgets() { + let owner = Owner::new_node().unwrap(); + let (first, first_calls) = child(owner); + let (second, second_calls) = child(owner); + let id = construct( + owner, + "composite.router", + serde_json::json!({ + "children": [first, second], "strategy": {"type": "rule"} + }), + ) + .await + .unwrap(); + remove(owner, &first); + remove(owner, &second); + let model = language(owner, id.as_str().unwrap()).unwrap(); + let options = CallOptions::default(); + let result = OperationSettings { + max_retries: Some(1), + ..Default::default() + } + .scope(model.do_generate(&options)) + .await; + assert!(result.is_err()); + assert_eq!(first_calls.load(Ordering::SeqCst), 2); + assert_eq!(second_calls.load(Ordering::SeqCst), 2); + remove(owner, id.as_str().unwrap()); +} + +#[tokio::test] +async fn weighted_strategy_and_no_fallback_are_explicit() { + let owner = Owner::new_node().unwrap(); + let (first, first_calls) = child(owner); + let (second, second_calls) = child(owner); + let id = construct( + owner, + "composite.router", + serde_json::json!({ + "children": [first, second], "strategy": {"type": "weighted", "weights": [0, 1]}, + "fallback": "none" + }), + ) + .await + .unwrap(); + let model = language(owner, id.as_str().unwrap()).unwrap(); + let options = CallOptions::default(); + assert!( + OperationSettings { + max_retries: Some(0), + ..Default::default() + } + .scope(model.do_generate(&options)) + .await + .is_err() + ); + assert_eq!(first_calls.load(Ordering::SeqCst), 0); + assert_eq!(second_calls.load(Ordering::SeqCst), 1); + for handle in [&first, &second, id.as_str().unwrap()] { + remove(owner, handle); + } +} + +#[tokio::test] +async fn moa_retains_aggregator_after_its_handle_is_dropped() { + let owner = Owner::new_node().unwrap(); + let (aggregator, calls) = child(owner); + let id = construct( + owner, + "composite.moa", + serde_json::json!({ + "references": [], "aggregator": aggregator + }), + ) + .await + .unwrap(); + remove(owner, &aggregator); + let model = language(owner, id.as_str().unwrap()).unwrap(); + let options = CallOptions::default(); + assert!( + OperationSettings { + max_retries: Some(0), + ..Default::default() + } + .scope(model.do_generate(&options)) + .await + .is_err() + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + remove(owner, id.as_str().unwrap()); +} +#[tokio::test] +async fn moa_retains_references_and_aggregator_and_completes() { + let owner = Owner::new_node().unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let registry = HandleRegistry::global(); + let reference = registry + .insert( + owner, + Entry::Language(Arc::new(FailingChild(calls.clone(), true))), + ) + .unwrap(); + let aggregator = registry + .insert( + owner, + Entry::Language(Arc::new(FailingChild(calls.clone(), true))), + ) + .unwrap(); + let id = construct( + owner, + "composite.moa", + serde_json::json!({ + "references": [reference.0.to_string()], "aggregator": aggregator.0.to_string(), + "failMode": "fail-fast", "stripReferenceTools": false + }), + ) + .await + .unwrap(); + drop(registry.remove(owner, reference)); + drop(registry.remove(owner, aggregator)); + let model = language(owner, id.as_str().unwrap()).unwrap(); + assert!(model.do_generate(&CallOptions::default()).await.is_ok()); + assert_eq!(calls.load(Ordering::SeqCst), 2); + remove(owner, id.as_str().unwrap()); +} +#[tokio::test] +async fn router_completes_after_child_handle_is_dropped() { + let owner = Owner::new_node().unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let child = HandleRegistry::global() + .insert( + owner, + Entry::Language(Arc::new(FailingChild(calls.clone(), true))), + ) + .unwrap(); + let id = construct( + owner, + "composite.router", + serde_json::json!({ + "children": [child.0.to_string()], "strategy": {"type": "rule"} + }), + ) + .await + .unwrap(); + drop(HandleRegistry::global().remove(owner, child)); + let model = language(owner, id.as_str().unwrap()).unwrap(); + assert!(model.do_generate(&CallOptions::default()).await.is_ok()); + assert_eq!(calls.load(Ordering::SeqCst), 1); + remove(owner, id.as_str().unwrap()); +} + +#[tokio::test] +async fn every_secondary_slot_hides_foreign_handles_and_rejects_wrong_kinds() { + let registry = HandleRegistry::global(); + let owner = Owner::new_node().unwrap(); + let foreign = Owner::new_python().unwrap(); + let (valid, calls) = child(owner); + let (foreign_id, foreign_calls) = child(foreign); + let wrong = registry + .insert( + owner, + Entry::Abort(Arc::new(aimux_core::AbortSignal::new())), + ) + .unwrap(); + for slot in 0..3 { + let params = |id: Value| match slot { + 0 => serde_json::json!({"children": [valid, id], "strategy": {"type":"rule"}}), + 1 => serde_json::json!({"references": [valid, id], "aggregator": valid}), + _ => serde_json::json!({"references": [valid], "aggregator": id}), + }; + let name = if slot == 0 { + "composite.router" + } else { + "composite.moa" + }; + let foreign_error = construct(owner, name, params(Value::String(foreign_id.clone()))) + .await + .unwrap_err(); + let unknown_error = construct(owner, name, params(Value::String(u64::MAX.to_string()))) + .await + .unwrap_err(); + assert!(matches!(foreign_error, OpsError::Projected(_))); + let envelope = foreign_error.envelope(std::time::UNIX_EPOCH); + assert_eq!(envelope.code, 203); + assert_eq!(envelope.error["actual"], Value::Null); + assert_eq!(envelope, unknown_error.envelope(std::time::UNIX_EPOCH)); + assert_eq!( + envelope, + foreign_error.envelope(std::time::SystemTime::now()) + ); + let error = construct(owner, name, params(Value::String(wrong.0.to_string()))) + .await + .unwrap_err(); + assert_eq!(error.code(), 203); + assert_eq!( + error.envelope(std::time::UNIX_EPOCH).error["actual"], + "Abort" + ); + for malformed in [ + serde_json::json!(1), + Value::Null, + serde_json::json!(""), + serde_json::json!("-1"), + serde_json::json!("+1"), + serde_json::json!("1e3"), + serde_json::json!("18446744073709551616"), + ] { + let error = construct(owner, name, params(malformed)).await.unwrap_err(); + assert_eq!(error.code(), 301, "{error:?}"); + assert!(matches!(error, OpsError::Projected(_))); + } + } + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(foreign_calls.load(Ordering::SeqCst), 0); + // Failed construction never inserts a new handle or removes dependencies. + assert_eq!(registry.release_owner(owner), 2); + assert_eq!(registry.release_owner(foreign), 1); +} + +#[tokio::test] +async fn control_configs_are_strict_and_preserve_model_identity() { + let owner = Owner::new_node().unwrap(); + let (valid, _) = child(owner); + for (name, params) in [ + ( + "composite.router", + serde_json::json!({"children":[valid],"strategy":{"type":"weighted"}}), + ), + ( + "composite.router", + serde_json::json!({"children":[valid],"strategy":{"type":"weighted","weights":[],"extra":1}}), + ), + ( + "composite.router", + serde_json::json!({"children":[valid],"strategy":{"type":"rule"},"fallback":"unexpected"}), + ), + ( + "composite.router", + serde_json::json!({"children":[valid],"strategy":{"type":"rule"},"provider_name":"alias"}), + ), + ( + "composite.moa", + serde_json::json!({"references":[],"aggregator":valid,"provider_name":"alias"}), + ), + ( + "composite.moa", + serde_json::json!({"references":[],"aggregator":valid,"failMode":"unknown"}), + ), + ( + "composite.moa", + serde_json::json!({"references":[],"aggregator":valid,"stripReferenceTools":1}), + ), + ] { + assert_eq!( + construct(owner, name, params).await.unwrap_err().code(), + 301 + ); + } + for (name, params) in [ + ( + "composite.router", + serde_json::json!({"children":[valid],"strategy":{"type":"rule"},"fallback":"on-error","providerName":"custom","modelId":"id"}), + ), + ( + "composite.moa", + serde_json::json!({"references":[],"aggregator":valid,"providerName":"custom","modelId":"id","aggregatorInstructions":"combine","stripReferenceTools":false,"failMode":"best-effort"}), + ), + ] { + let id = construct(owner, name, params).await.unwrap(); + let model = language(owner, id.as_str().unwrap()).unwrap(); + assert_eq!(model.provider(), "custom"); + assert_eq!(model.model_id(), "id"); + remove(owner, id.as_str().unwrap()); + } + remove(owner, &valid); +} + +#[test] +fn descriptors_publish_concrete_types_and_all_secondary_slots() { + use aimux_ops::op::{Cancellation, OpMode, RequestBytePolicy, Scope}; + for (name, request, slots) in [ + ("composite.router", "RouterParams", vec!["children.*"]), + ( + "composite.moa", + "MoaParams", + vec!["references.*", "aggregator"], + ), + ] { + let op = dispatch::descriptor(name).unwrap(); + assert_eq!(op.mode, OpMode::Call); + assert_eq!(op.scope, Scope::Current); + assert!(op.allowed_handle_kinds.is_empty()); + assert_eq!( + op.request_type, + format!("aimux_ops::ops::composite::{request}") + ); + assert_eq!(op.result_type, "HandleId"); + assert_eq!( + op.runtime_handle_fields + .iter() + .map(|(path, _)| *path) + .collect::>(), + slots + ); + assert!( + op.runtime_handle_fields + .iter() + .all(|(_, kinds)| *kinds == [HandleKind::Language]) + ); + assert_eq!(op.cancellation, Cancellation::None); + assert_eq!(op.request_byte_policy, RequestBytePolicy::Json); + assert_eq!(op.result_ownership, "owned"); + assert!(op.event_type.is_none() && op.terminal_type.is_none()); + assert!(op.required_capabilities.is_empty()); + assert!(op.side_effects); + assert_eq!(op.retry_owner, "operation"); + } +} diff --git a/aimux-ops/tests/decision.rs b/aimux-ops/tests/decision.rs new file mode 100644 index 000000000..d6c3e9fbb --- /dev/null +++ b/aimux-ops/tests/decision.rs @@ -0,0 +1,543 @@ +//! Exercise real decision dispatch without network or a second decision engine. +use std::{ + future::Future, + pin::Pin, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + time::{Duration, SystemTime}, +}; + +use aimux_core::{ + AbortSignal, AiMuxError, Provider, + decision_model::{ + DecideOptions, DecisionCallOptions, DecisionModel, DecisionQuestionType, DecisionResult, + decide, decision_model_properties, + }, + error::ApiCallError, +}; +use aimux_ops::{ + Ctx, Entry, HandleId, HandleKind, HandleRegistry, OpsError, Owner, dispatch, manifest::manifest, +}; +use serde_json::{Value, json}; + +#[derive(Clone, Copy)] +enum Reply { + Valid, + Invalid, + Error, + Pending, + Retry, +} + +struct Model { + reply: Reply, + calls: AtomicUsize, + seen: Mutex>, + entered: tokio::sync::Notify, + supported: &'static [DecisionQuestionType], +} +impl Model { + fn new(reply: Reply) -> Arc { + Arc::new(Self { + reply, + calls: AtomicUsize::new(0), + seen: Mutex::new(Vec::new()), + entered: tokio::sync::Notify::new(), + supported: DecisionQuestionType::ALL, + }) + } +} + +impl DecisionModel for Model { + fn provider(&self) -> &str { + "test-decision" + } + fn model_id(&self) -> &str { + "test-model" + } + fn supported_question_types(&self) -> &[DecisionQuestionType] { + self.supported + } + + // Match DecisionModel's async-trait ABI without adding a dependency to the + // production crate solely for this test double. + fn do_decide<'a, 'b, 'f>( + &'a self, + options: &'b DecisionCallOptions, + ) -> Pin> + Send + 'f>> + where + 'a: 'f, + 'b: 'f, + Self: 'f, + { + Box::pin(async move { + let call = self.calls.fetch_add(1, Ordering::SeqCst); + self.seen.lock().unwrap().push(options.clone()); + self.entered.notify_one(); + match self.reply { + Reply::Pending => std::future::pending::<()>().await, + Reply::Error | Reply::Retry if call == 0 || matches!(self.reply, Reply::Error) => { + let mut error = + ApiCallError::new("busy", "https://example.test/decide", json!({})); + error.status_code = Some(503); + error.is_retryable = true; + error.response_headers = Some(match self.reply { + Reply::Error => { + [("retry-after".into(), "Thu, 01 Jan 1970 00:16:45 GMT".into())].into() + } + _ => [("retry-after-ms".into(), "0".into())].into(), + }); + return Err(AiMuxError::ApiCall(Box::new(error))); + } + _ => {} + } + let answers = if matches!(self.reply, Reply::Invalid) { + json!({}) + } else { + json!({ + "choice":{"type":"choice","choice":"yes","probabilities":{"yes":0.75,"no":0.25}}, + "score":{"type":"score","score":0.5}, + "boolean":{"type":"boolean","probability":0.25} + }) + }; + Ok(serde_json::from_value(json!({ + "answers":answers, + "usage":{"inputTokens":3,"outputTokens":2}, + "warnings":[], + "providerMetadata":{"test-decision":{"probabilitySource":"native"}}, + "response":{"id":"response-1","timestamp":"2026-10-15T00:00:00.000Z","modelId":"test-model"} + })).unwrap()) + }) + } +} + +struct Fixture { + owner: Owner, + id: HandleId, + model: Arc, +} +impl Fixture { + fn new(reply: Reply) -> Self { + let model = Model::new(reply); + Self::with_model(model) + } + fn with_model(model: Arc) -> Self { + let owner = Owner::new_node().unwrap(); + let id = HandleRegistry::global() + .insert(owner, Entry::Decision(model.clone())) + .unwrap(); + Self { owner, id, model } + } + fn ctx(&self) -> Ctx { + ctx(self.owner) + } + async fn call(&self, op: &str, params: Value) -> Result { + dispatch::call(self.ctx(), self.id, op, params).await + } + fn abort(&self) -> (HandleId, AbortSignal) { + let signal = AbortSignal::new(); + let id = HandleRegistry::global() + .insert(self.owner, Entry::Abort(Arc::new(signal.clone()))) + .unwrap(); + (id, signal) + } +} +impl Drop for Fixture { + fn drop(&mut self) { + HandleRegistry::global().release_owner(self.owner); + } +} +fn now() -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(1_000) +} +fn ctx(owner: Owner) -> Ctx { + Ctx { + owner, + abort: None, + now, + } +} +fn options() -> Value { + json!({"state":{"文本":"原始状态 🦀"},"questions":{ + "choice":{"type":"choice","instructions":"选一个","criteria":{"yes":null,"no":"不"}}, + "score":{"type":"score","instructions":"评分","criteria":[null,"高"]}, + "boolean":{"type":"boolean","instructions":"是否?","criteria":{"true":null}} + },"maxRetries":0,"headers":{"X-Request":"value"}, + "providerOptions":{"test-decision":{"arbitrary":{"nested":[1,null,true]}}}}) +} +fn envelope(error: &OpsError) -> Value { + serde_json::to_value(error.envelope(now())).unwrap() +} + +#[tokio::test] +async fn decide_matches_core_for_all_state_shapes_and_question_types() { + for state in [ + json!("文本 🦀"), + json!({"key":[1,null,true]}), + json!(["state",{"n":2}]), + ] { + let fixture = Fixture::new(Reply::Valid); + let mut options = options(); + options["state"] = state; + let expected = decide( + fixture.model.as_ref(), + serde_json::from_value(options.clone()).unwrap(), + ) + .await + .unwrap(); + let actual = fixture + .call("decision.decide", json!({"options":options})) + .await + .unwrap(); + assert_eq!(actual, serde_json::to_value(expected).unwrap()); + assert_eq!(actual["usage"]["totalTokens"], 5); + let seen = fixture.model.seen.lock().unwrap(); + assert_eq!(seen.len(), 2); + assert_eq!( + serde_json::to_value(&seen[0]).unwrap(), + serde_json::to_value(&seen[1]).unwrap() + ); + } +} + +#[tokio::test] +async fn capabilities_match_existing_providers_and_children_survive_parent_drop() { + for source in ["native", "logit_scoring", "model_estimate"] { + let mut config = aimux_providers::JevConfig::new("secret") + .with_endpoint("http://127.0.0.1:1/never-called"); + config.probability_source = source.parse().unwrap(); + let owner = Owner::new_python().unwrap(); + let provider: Arc = Arc::new(aimux_providers::JevProvider::new(config)); + let model = provider.decision_model("jev-latest").unwrap().unwrap(); + let expected = decision_model_properties(model.as_ref()); + let registry = HandleRegistry::global(); + let parent = registry.insert(owner, Entry::Provider(provider)).unwrap(); + let child = registry.insert(owner, Entry::Decision(model)).unwrap(); + drop(registry.remove(owner, parent)); + let mut context = ctx(owner); + let aborted = AbortSignal::new(); + aborted.abort(); + context.abort = Some(aborted); + let actual = dispatch::call(context, child, "decision.capabilities", json!({})) + .await + .unwrap(); + assert_eq!(actual, expected); + assert_eq!( + actual["supportedQuestionTypes"], + json!(["choice", "score", "boolean"]) + ); + assert!(!actual.to_string().contains("secret")); + assert_eq!(registry.release_owner(owner), 1); + } + let provider = aimux_providers::create_openai(Default::default()).unwrap(); + let model = provider.decision_model("gpt-4o").unwrap().unwrap(); + let owner = Owner::new_node().unwrap(); + let id = HandleRegistry::global() + .insert(owner, Entry::Decision(model.clone())) + .unwrap(); + assert_eq!( + dispatch::call(ctx(owner), id, "decision.capabilities", json!({})) + .await + .unwrap(), + decision_model_properties(model.as_ref()) + ); + HandleRegistry::global().release_owner(owner); +} + +#[tokio::test] +async fn strict_controls_reject_unknown_fields_aliases_and_invalid_shapes_before_model() { + let fixture = Fixture::new(Reply::Valid); + for request in [ + json!({}), + json!({"options":null}), + json!({"options":{},"abort":null}), + json!({"options":options(),"modelId":"old-provider-shape"}), + json!({"options":options(),"abort_signal":"1"}), + json!({"options":options(),"unknown":true}), + ] { + assert_eq!( + fixture + .call("decision.decide", request) + .await + .unwrap_err() + .code(), + 301 + ); + } + for request in [json!({"modelId":"old"}), json!({"unknown":true})] { + assert_eq!( + fixture + .call("decision.capabilities", request) + .await + .unwrap_err() + .code(), + 301 + ); + } + assert_eq!(fixture.model.calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn business_validation_and_unsupported_types_match_core_before_model() { + let fixture = Fixture::new(Reply::Valid); + let mut request = options(); + request["questions"] = json!({}); + let expected = decide( + fixture.model.as_ref(), + serde_json::from_value(request.clone()).unwrap(), + ) + .await + .unwrap_err(); + let error = fixture + .call("decision.decide", json!({"options":request})) + .await + .unwrap_err(); + assert_eq!(envelope(&error), envelope(&OpsError::from(expected))); + assert_eq!(fixture.model.calls.load(Ordering::SeqCst), 0); + + let mut model = Model::new(Reply::Valid); + Arc::get_mut(&mut model).unwrap().supported = &[DecisionQuestionType::Boolean]; + let fixture = Fixture::with_model(model); + let expected = decide( + fixture.model.as_ref(), + serde_json::from_value(options()).unwrap(), + ) + .await + .unwrap_err(); + let error = fixture + .call("decision.decide", json!({"options":options()})) + .await + .unwrap_err(); + assert_eq!(envelope(&error), envelope(&OpsError::from(expected))); + assert_eq!(error.code(), 8); + assert_eq!(fixture.model.calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn invalid_model_answers_keep_core_error_and_cached_projection() { + let fixture = Fixture::new(Reply::Invalid); + let expected = decide( + fixture.model.as_ref(), + serde_json::from_value(options()).unwrap(), + ) + .await + .unwrap_err(); + let error = fixture + .call("decision.decide", json!({"options":options()})) + .await + .unwrap_err(); + assert!(matches!(error, OpsError::Projected(_))); + assert_eq!(envelope(&error), envelope(&OpsError::from(expected))); + assert_eq!(error.code(), 3); + assert_eq!( + serde_json::to_value(error.envelope(SystemTime::UNIX_EPOCH)).unwrap(), + envelope(&error) + ); +} + +#[tokio::test] +async fn retry_and_provider_errors_remain_owned_by_core() { + let fixture = Fixture::new(Reply::Error); + let error = fixture + .call("decision.decide", json!({"options":options()})) + .await + .unwrap_err(); + assert_eq!(error.code(), 11); + assert_eq!(envelope(&error)["retryAfterMs"], 5_000); + assert_eq!( + serde_json::to_value(error.envelope(SystemTime::UNIX_EPOCH)).unwrap(), + envelope(&error) + ); + assert_eq!(fixture.model.calls.load(Ordering::SeqCst), 1); + let fixture = Fixture::new(Reply::Retry); + let mut request = options(); + request["maxRetries"] = json!(1); + fixture + .call("decision.decide", json!({"options":request})) + .await + .unwrap(); + assert_eq!(fixture.model.calls.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn secondary_handles_are_typed_owner_checked_and_nondisclosing() { + let fixture = Fixture::new(Reply::Valid); + let foreign = Fixture::new(Reply::Valid); + let (abort, _) = foreign.abort(); + let unknown = fixture + .call( + "decision.decide", + json!({"options":options(),"abort":u64::MAX.to_string()}), + ) + .await + .unwrap_err(); + let foreign_error = fixture + .call( + "decision.decide", + json!({"options":options(),"abort":abort}), + ) + .await + .unwrap_err(); + assert_eq!(envelope(&unknown), envelope(&foreign_error)); + assert_eq!(foreign_error.code(), 203); + let wrong = fixture + .call( + "decision.decide", + json!({"options":options(),"abort":fixture.id}), + ) + .await + .unwrap_err(); + assert_eq!(wrong.code(), 203); + assert_eq!(envelope(&wrong)["error"]["actual"], "Decision"); + for abort in [ + json!(0), + json!("0"), + json!("18446744073709551616"), + json!("-1"), + ] { + let error = fixture + .call( + "decision.decide", + json!({"options":options(),"abort":abort}), + ) + .await + .unwrap_err(); + assert!(matches!(error.code(), 203 | 301)); + } + assert_eq!(fixture.model.calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn primary_handles_reject_global_wrong_kind_foreign_and_dropped() { + let fixture = Fixture::new(Reply::Valid); + let foreign = Fixture::new(Reply::Valid); + let (wrong, _) = fixture.abort(); + for op in ["decision.capabilities", "decision.decide"] { + let params = if op == "decision.decide" { + json!({"options":options()}) + } else { + json!({}) + }; + let unknown = dispatch::call(fixture.ctx(), HandleId(u64::MAX), op, params.clone()) + .await + .unwrap_err(); + let foreign_error = dispatch::call(fixture.ctx(), foreign.id, op, params.clone()) + .await + .unwrap_err(); + assert_eq!(envelope(&unknown), envelope(&foreign_error)); + for id in [HandleId(0), wrong] { + assert_eq!( + dispatch::call(fixture.ctx(), id, op, params.clone()) + .await + .unwrap_err() + .code(), + 203 + ); + } + } + drop(HandleRegistry::global().remove(fixture.owner, fixture.id)); + assert_eq!( + fixture + .call("decision.capabilities", json!({})) + .await + .unwrap_err() + .code(), + 203 + ); + assert_eq!( + fixture + .call("decision.decide", json!({"options": options()})) + .await + .unwrap_err() + .code(), + 203 + ); + assert_eq!(fixture.model.calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn abort_from_context_or_secondary_prevents_model_execution() { + let fixture = Fixture::new(Reply::Valid); + let (id, signal) = fixture.abort(); + signal.abort(); + let error = fixture + .call("decision.decide", json!({"options":options(),"abort":id})) + .await + .unwrap_err(); + assert_eq!(error.code(), 13); + let mut context = fixture.ctx(); + context.abort = Some(signal); + let error = dispatch::call( + context, + fixture.id, + "decision.decide", + json!({"options":options()}), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), 13); + assert_eq!(fixture.model.calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn abort_reaches_running_model_and_timeout_uses_core() { + let fixture = Fixture::new(Reply::Pending); + let (id, signal) = fixture.abort(); + let request = fixture.call("decision.decide", json!({"options":options(),"abort":id})); + let cancel = async { + fixture.model.entered.notified().await; + let seen_signal = fixture.model.seen.lock().unwrap()[0] + .abort_signal + .clone() + .unwrap(); + signal.abort(); + assert!(seen_signal.is_aborted()); + }; + let (result, ()) = tokio::join!(request, cancel); + assert_eq!(result.unwrap_err().code(), 13); + let mut options = options(); + options["timeout"] = json!({"totalMs":10}); + assert_eq!( + fixture + .call("decision.decide", json!({"options":options})) + .await + .unwrap_err() + .code(), + 12 + ); +} + +#[test] +fn descriptors_publish_decision_controls_and_canonical_types() { + let descriptor = dispatch::descriptor("decision.decide").unwrap(); + assert_eq!(descriptor.allowed_handle_kinds, &[HandleKind::Decision]); + assert_eq!( + descriptor.runtime_handle_fields, + &[("abort", &[HandleKind::Abort][..])] + ); + assert_eq!(descriptor.retry_owner, "core"); + assert_eq!( + descriptor.result_type, + "aimux_core::decision_model::DecideResult" + ); + let manifest = manifest(); + let ty = manifest["types"] + .as_array() + .unwrap() + .iter() + .find(|ty| ty["id"] == descriptor.request_type) + .unwrap(); + assert_eq!( + ty["rustPath"], + "aimux_ops::ops::decision::DecisionDecideRequest" + ); + assert!( + serde_json::from_value::( + json!({"options": options(), "abort": null}) + ) + .is_ok() + ); + let _: DecideOptions = serde_json::from_value(options()).unwrap(); +} diff --git a/aimux-ops/tests/dispatch_contract.rs b/aimux-ops/tests/dispatch_contract.rs new file mode 100644 index 000000000..1b1d5e266 --- /dev/null +++ b/aimux-ops/tests/dispatch_contract.rs @@ -0,0 +1,331 @@ +//! Dispatch, ownership and error projection invariants, with no provider I/O. +use aimux_core::error::ApiCallError; +use aimux_core::{AbortSignal, AiMuxError}; +use aimux_ops::dispatch::{CallFuture, Ctx, Dispatcher, EventStream, Handler, Target}; +use aimux_ops::envelope::{BindingError, OpsError, ProtocolError}; +use aimux_ops::handle::{Entry, HandleId, HandleKind, HandleRegistry, Owner}; +use aimux_ops::op::{Cancellation, OpDescriptor, OpMode, RequestBytePolicy, Scope}; +use futures::StreamExt; +use serde_json::{Value, json}; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +use std::time::{Duration, SystemTime}; + +const fn descriptor( + name: &'static str, + mode: OpMode, + kinds: &'static [HandleKind], +) -> OpDescriptor { + OpDescriptor { + name, + since_ops_version: "1.0", + mode, + allowed_handle_kinds: kinds, + request_type: "Value", + result_type: "Value", + event_type: None, + scope: Scope::Current, + terminal_type: None, + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::Abort, + side_effects: false, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + retry_owner: "none", + source_symbols: &[], + } +} +fn now() -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000) +} +fn ctx(owner: Owner) -> Ctx { + Ctx { + owner, + abort: None, + now, + } +} +fn unknown(error: &OpsError) { + assert!(matches!( + error.unprojected(), + OpsError::Binding(BindingError::InvalidHandle { actual: None, .. }) + )); +} +fn success(_: Ctx, target: Target, _: Value) -> CallFuture { + Box::pin(async move { + assert!(matches!(target, Target::Global)); + Ok(Value::Null) + }) +} + +#[tokio::test] +async fn global_target_and_mode_preflight_are_explicit_and_projected() { + static GLOBAL: OpDescriptor = descriptor("test.global", OpMode::Call, &[]); + static BINARY: OpDescriptor = + descriptor("test.binary", OpMode::BinaryPush, &[HandleKind::Operation]); + let registry = HandleRegistry::default(); + let owner = Owner::new_node().unwrap(); + let id = registry + .insert(owner, Entry::Abort(Arc::new(AbortSignal::new()))) + .unwrap(); + let registrations = [ + (&GLOBAL, Handler::Call(success)), + (&BINARY, Handler::Call(success)), + ]; + let dispatcher = Dispatcher::new(®istry, ®istrations); + assert!( + dispatcher + .call(ctx(owner), HandleId(0), GLOBAL.name, json!({})) + .await + .is_ok() + ); + let error = dispatcher + .call(ctx(owner), id, GLOBAL.name, json!({})) + .await + .unwrap_err(); + assert!(matches!(&error, OpsError::Projected(_))); + assert!(matches!( + error.unprojected(), + OpsError::Binding(BindingError::InvalidHandle { + actual: Some(HandleKind::Abort), + .. + }) + )); + for id in [HandleId(0), id, HandleId(u64::MAX)] { + assert_eq!( + dispatcher + .call(ctx(owner), id, BINARY.name, json!({})) + .await + .unwrap_err() + .code(), + 300 + ); + } + assert!( + dispatcher + .stream(ctx(owner), HandleId(0), GLOBAL.name, json!({})) + .is_err() + ); +} + +static SECONDARY_CALLS: AtomicUsize = AtomicUsize::new(0); +fn with_abort(ctx: Ctx, _: Target, _: Value) -> CallFuture { + SECONDARY_CALLS.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { + assert!(ctx.abort.is_some()); + Ok(Value::Null) + }) +} +#[tokio::test] +async fn secondary_handles_use_the_same_owner_resolver_before_handler_entry() { + static SECONDARY: OpDescriptor = OpDescriptor { + runtime_handle_fields: &[("abort", &[HandleKind::Abort])], + ..descriptor("test.secondary", OpMode::Call, &[]) + }; + let registry = HandleRegistry::default(); + let owner = Owner::new_node().unwrap(); + let foreign = Owner::new_python().unwrap(); + let own = registry + .insert(owner, Entry::Abort(Arc::new(AbortSignal::new()))) + .unwrap(); + let other = registry + .insert(foreign, Entry::Abort(Arc::new(AbortSignal::new()))) + .unwrap(); + let registrations = [(&SECONDARY, Handler::Call(with_abort))]; + let dispatcher = Dispatcher::new(®istry, ®istrations); + let foreign_error = dispatcher + .call( + ctx(owner), + HandleId(0), + SECONDARY.name, + json!({"abort":other}), + ) + .await + .unwrap_err(); + let unknown_error = dispatcher + .call( + ctx(owner), + HandleId(0), + SECONDARY.name, + json!({"abort":"18446744073709551615"}), + ) + .await + .unwrap_err(); + unknown(&foreign_error); + assert_eq!(foreign_error.envelope(now()), unknown_error.envelope(now())); + assert_eq!(SECONDARY_CALLS.load(Ordering::SeqCst), 0); + assert!( + dispatcher + .call( + ctx(owner), + HandleId(0), + SECONDARY.name, + json!({"abort":own}) + ) + .await + .is_ok() + ); + assert_eq!(SECONDARY_CALLS.load(Ordering::SeqCst), 1); +} + +fn api_failure(_: Ctx, _: Target, _: Value) -> CallFuture { + Box::pin(async { + let mut api = ApiCallError::new("retry later", "https://mock.invalid", json!({})); + api.response_headers = Some(std::collections::HashMap::from([( + "Retry-After".into(), + "Tue, 14 Nov 2023 22:13:22 GMT".into(), + )])); + Err(AiMuxError::ApiCall(Box::new(api)).into()) + }) +} +#[tokio::test] +async fn the_dispatch_projection_keeps_the_original_clock_and_cause() { + static FAIL: OpDescriptor = descriptor("test.failure", OpMode::Call, &[]); + let registry = HandleRegistry::default(); + let registrations = [(&FAIL, Handler::Call(api_failure))]; + let dispatcher = Dispatcher::new(®istry, ®istrations); + let error = dispatcher + .call(ctx(Owner::CProcess), HandleId(0), FAIL.name, json!({})) + .await + .unwrap_err(); + assert!(matches!( + error.unprojected(), + OpsError::AiMux(AiMuxError::ApiCall(_)) + )); + assert_eq!(error.envelope(now()).retry_after_ms, Some(2000)); + assert_eq!( + error.envelope(now() + Duration::from_secs(100)), + error.envelope(now()) + ); + assert_eq!( + error + .project(now() + Duration::from_secs(100)) + .envelope(now()) + .retry_after_ms, + Some(2000) + ); +} + +fn events(_: Ctx, _: Target, params: Value) -> Result { + match params["case"].as_str() { + Some("reject") => { + Err(ProtocolError::InvalidParams("reject before acceptance".into()).into()) + } + Some("pending") => Ok(Box::pin(futures::stream::pending())), + Some("large") => Ok(Box::pin(futures::stream::iter([Ok(json!( + "x".repeat(aimux_ops::limits::ITEM_BYTES) + ))]))), + _ => Ok(Box::pin(futures::stream::iter([ + Ok(json!({"type":"error"})), + Ok(json!({"type":"finish"})), + Err(AiMuxError::Other("terminal".into()).into()), + Ok(Value::Null), + ]))), + } +} +#[tokio::test] +async fn streams_preserve_business_errors_and_commit_exactly_one_terminal() { + static STREAM: OpDescriptor = descriptor("test.stream", OpMode::Stream, &[]); + let registry = HandleRegistry::default(); + let table = [(&STREAM, Handler::Stream(events))]; + let dispatcher = Dispatcher::new(®istry, &table); + assert_eq!( + dispatcher + .stream( + ctx(Owner::CProcess), + HandleId(0), + STREAM.name, + json!({"case":"reject"}) + ) + .err() + .unwrap() + .code(), + 301 + ); + let frames = dispatcher + .stream(ctx(Owner::CProcess), HandleId(0), STREAM.name, json!({})) + .unwrap() + .collect::>() + .await; + assert_eq!(frames.len(), 3); + assert_eq!( + serde_json::to_value(&frames[0]).unwrap()["event"]["type"], + "error" + ); + assert_eq!( + serde_json::to_value(&frames[2]).unwrap()["error"]["code"], + 1 + ); + let frames = dispatcher + .stream( + ctx(Owner::CProcess), + HandleId(0), + STREAM.name, + json!({"case":"large"}), + ) + .unwrap() + .collect::>() + .await; + assert_eq!(frames.len(), 1); + assert_eq!( + serde_json::to_value(&frames[0]).unwrap()["error"]["code"], + 305 + ); + let signal = AbortSignal::new(); + let mut context = ctx(Owner::CProcess); + context.abort = Some(signal.clone()); + let stream = dispatcher + .stream(context, HandleId(0), STREAM.name, json!({"case":"pending"})) + .unwrap(); + drop(stream); + assert!(signal.is_aborted()); +} + +struct ReentrantDrop { + registry: &'static HandleRegistry, + owner: Owner, +} +// An Arc-backed built-in closure is only a canonical resource payload here, not +// an ops configuration callback; its captured guard probes registry-lock release. +impl Drop for ReentrantDrop { + fn drop(&mut self) { + let id = self + .registry + .insert(self.owner, Entry::Abort(Arc::new(AbortSignal::new()))) + .unwrap(); + drop(self.registry.remove(self.owner, id)); + } +} +#[test] +fn owner_allocations_are_unique_and_teardown_releases_locks_before_destruction() { + let mut owners = std::collections::HashSet::new(); + for _ in 0..100 { + assert!(owners.insert(Owner::new_node().unwrap())); + assert!(owners.insert(Owner::new_python().unwrap())); + } + let registry = HandleRegistry::global(); + let owner = Owner::new_node().unwrap(); + let other = Owner::new_python().unwrap(); + let guard = ReentrantDrop { registry, owner }; + let resolver = aimux_provider_utils::Resolvable::from_fn(move || { + let _ = &guard; + Ok("test".to_owned()) + }); + let id = registry + .insert(owner, Entry::CredentialResolver(Arc::new(resolver))) + .unwrap(); + let retained = registry + .insert(other, Entry::Abort(Arc::new(AbortSignal::new()))) + .unwrap(); + assert_eq!(registry.release_owner(owner), 1); + assert!( + registry + .get(owner, id, &[HandleKind::CredentialResolver]) + .is_err() + ); + assert!(registry.get(other, retained, &[HandleKind::Abort]).is_ok()); + registry.release_owner(other); +} diff --git a/aimux-ops/tests/error_envelope_golden.rs b/aimux-ops/tests/error_envelope_golden.rs new file mode 100644 index 000000000..0bc9397e7 --- /dev/null +++ b/aimux-ops/tests/error_envelope_golden.rs @@ -0,0 +1,429 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use aimux_core::error::{AiMuxError, ApiCallError, RetryError, RetryErrorReason}; +use aimux_core::recording::RecordingError; +use aimux_ops::envelope::{BindingError, ErrorDomain, ErrorEnvelope, OpsError, ProtocolError}; +use aimux_ops::handle::HandleKind; +use serde_json::{Value, json}; + +fn now() -> SystemTime { + UNIX_EPOCH + Duration::from_secs(1_700_000_000) +} + +fn fixture(name: &str) -> Value { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../contract-tests/ops/errors") + .join(format!("{name}.json")); + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() +} + +const AIMUX: &[&str] = &[ + "other", + "json-parse", + "invalid-response-data", + "tool-call-not-found-for-approval", + "invalid-argument", + "invalid-prompt", + "missing-tool-results", + "token-expired", + "unsupported-functionality", + "no-such-model", + "no-such-provider", + "api-call", + "timeout", + "aborted", + "retry", + "no-such-tool", + "invalid-tool-input", + "tool-call-repair", + "no-output-generated", + "load-api-key", + "load-setting", + "tool-choice-violation", +]; + +fn recording_errors() -> Vec<(&'static str, OpsError)> { + vec![ + ( + "recording-init", + RecordingError::Init { + path: "recordings/session.jsonl".into(), + source: std::io::Error::other("permission denied"), + } + .into(), + ), + ( + "recording-open-file", + RecordingError::OpenFile { + path: "recordings/session.jsonl".into(), + source: std::io::Error::other("permission denied"), + } + .into(), + ), + ( + "recording-spawn", + RecordingError::Spawn { + source: std::io::Error::other("thread limit"), + } + .into(), + ), + ("recording-writer-gone", RecordingError::WriterGone.into()), + ( + "recording-flush-timeout", + RecordingError::FlushTimeout.into(), + ), + ( + "recording-write", + RecordingError::Write("disk full".into()).into(), + ), + ] +} + +fn binding_errors() -> Vec<(&'static str, OpsError)> { + vec![ + ( + "binding-null-pointer", + BindingError::NullPointer { + argument: "params_json", + } + .into(), + ), + ( + "binding-invalid-utf8", + BindingError::InvalidUtf8 { argument: "op" }.into(), + ), + ( + "binding-invalid-wire-json", + BindingError::InvalidWireJson { + argument: "params_json".into(), + message: "expected object".into(), + } + .into(), + ), + ( + "binding-invalid-handle", + BindingError::InvalidHandle { + expected: vec![HandleKind::Language, HandleKind::Embedding], + actual: Some(HandleKind::Provider), + } + .into(), + ), + ("binding-reentrant-call", BindingError::ReentrantCall.into()), + ( + "binding-result-serialization", + BindingError::ResultSerialization("invalid result".into()).into(), + ), + ( + "binding-callback-failure", + BindingError::CallbackFailure("callback stopped".into()).into(), + ), + ] +} + +fn protocol_errors() -> Vec<(&'static str, OpsError)> { + vec![ + ( + "protocol-invalid-op", + ProtocolError::InvalidOp("missing.op".into()).into(), + ), + ( + "protocol-invalid-params", + ProtocolError::InvalidParams("expected object".into()).into(), + ), + ( + "protocol-unsupported-ops-version", + ProtocolError::UnsupportedOpsVersion("2.0".into()).into(), + ), + ( + "protocol-concurrent-consumer", + ProtocolError::ConcurrentConsumer.into(), + ), + ( + "protocol-invalid-state", + ProtocolError::InvalidState("already finished".into()).into(), + ), + ( + "protocol-resource-limit", + ProtocolError::ResourceLimit("input queue full".into()).into(), + ), + ( + "protocol-handle-exhausted", + ProtocolError::HandleExhausted.into(), + ), + ] +} + +fn assert_golden(name: &str, error: OpsError) { + let expected = fixture(name); + let envelope = error.envelope(now()); + assert_eq!(serde_json::to_value(&envelope).unwrap(), expected, "{name}"); + assert_eq!(error.code(), envelope.code, "{name}"); + let decoded: ErrorEnvelope = serde_json::from_value(expected.clone()).unwrap(); + assert_eq!(decoded, envelope, "{name}"); + assert_eq!(serde_json::to_value(decoded).unwrap(), expected, "{name}"); + assert!(expected.get("retryable").is_none(), "{name}"); + assert!(expected["error"].get("type").is_none(), "{name}"); + assert!(expected["error"]["name"].is_string(), "{name}"); +} + +#[test] +fn every_aimux_variant_matches_golden_and_round_trips() { + for slug in AIMUX { + let name = format!("aimux-{slug}"); + let error: AiMuxError = serde_json::from_value(fixture(&name)["error"].clone()).unwrap(); + assert_golden(&name, error.into()); + } +} + +#[test] +fn every_recording_variant_matches_golden_and_round_trips() { + for (name, error) in recording_errors() { + assert_golden(name, error); + } +} + +#[test] +fn every_binding_variant_matches_golden_and_round_trips() { + for (name, error) in binding_errors() { + assert_golden(name, error); + } +} + +#[test] +fn every_protocol_variant_matches_golden_and_round_trips() { + for (name, error) in protocol_errors() { + assert_golden(name, error); + } +} + +#[test] +fn codes_preserve_existing_groups_and_have_disjoint_ranges() { + let mut codes: BTreeMap> = BTreeMap::new(); + for slug in AIMUX { + let expected = fixture(&format!("aimux-{slug}")); + let error: AiMuxError = serde_json::from_value(expected["error"].clone()).unwrap(); + let error = OpsError::from(error); + assert!((1..=20).contains(&error.code())); + assert_ne!(error.code(), 4, "retired code must stay unused"); + codes + .entry(error.code()) + .or_default() + .insert((*slug).into()); + } + assert_eq!(codes.len(), 19); + let aliases: BTreeMap<_, _> = codes + .into_iter() + .filter(|(_, names)| names.len() > 1) + .collect(); + assert_eq!( + aliases, + BTreeMap::from([ + ( + 3, + BTreeSet::from([ + "invalid-response-data".into(), + "tool-call-not-found-for-approval".into(), + "tool-choice-violation".into(), + ]) + ), + ( + 6, + BTreeSet::from(["invalid-prompt".into(), "missing-tool-results".into()]) + ), + ]) + ); + for (errors, range) in [ + (recording_errors(), 100..=105), + (binding_errors(), 200..=206), + (protocol_errors(), 300..=306), + ] { + let mut seen = BTreeSet::new(); + for (name, error) in errors { + assert!(range.contains(&error.code()), "{name}"); + assert!(seen.insert(error.code()), "duplicate code for {name}"); + } + assert_eq!(seen, range.collect()); + } +} + +#[test] +fn unknown_variants_and_nested_nulls_survive_without_reclassification() { + for kind in ["aimux", "recording", "binding", "protocol"] { + let mut expected = fixture("unknown-aimux"); + expected["kind"] = json!(kind); + let envelope: ErrorEnvelope = serde_json::from_value(expected.clone()).unwrap(); + assert_eq!(envelope.code, 21); + assert_eq!(envelope.error["name"], "AI_FutureError"); + assert!(envelope.error["providerData"]["explicitNull"].is_null()); + assert_eq!(serde_json::to_value(envelope).unwrap(), expected); + } +} + +fn api(headers: &[(&str, &str)]) -> ApiCallError { + ApiCallError { + response_headers: Some( + headers + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect::>(), + ), + is_retryable: true, + ..ApiCallError::new("retry later", "https://example.test/v1", json!({})) + } +} + +#[test] +fn optional_payload_fields_distinguish_absent_and_explicit_null() { + let absent = OpsError::from(AiMuxError::ApiCall(Box::new(ApiCallError::new( + "reset", + "https://example.test/v1", + Value::Null, + )))) + .envelope(now()); + let mut api = ApiCallError::new("reset", "https://example.test/v1", Value::Null); + api.data = Some(Value::Null); + let explicit_null = OpsError::from(AiMuxError::ApiCall(Box::new(api))).envelope(now()); + assert!(absent.error.get("data").is_none()); + assert_eq!(explicit_null.error.get("data"), Some(&Value::Null)); + for envelope in [absent, explicit_null] { + for key in [ + "statusCode", + "providerCode", + "responseBody", + "responseHeaders", + ] { + assert!(envelope.error.get(key).is_none(), "{key}"); + } + let wire = serde_json::to_value(&envelope).unwrap(); + assert!(wire.get("retryAfterMs").is_none()); + let decoded: ErrorEnvelope = serde_json::from_value(wire).unwrap(); + assert_eq!(decoded, envelope); + } +} + +#[test] +fn invalid_handle_without_actual_kind_omits_the_field() { + let error = OpsError::from(BindingError::InvalidHandle { + expected: vec![HandleKind::Language], + actual: None, + }) + .envelope(now()); + assert_eq!(error.kind, ErrorDomain::Binding); + assert_eq!(error.code, 203); + assert_eq!( + error.error, + json!({"name":"InvalidHandle", "expected":["Language"]}) + ); +} + +#[test] +fn retry_hint_uses_the_captured_clock_and_distinguishes_zero() { + let error = OpsError::from(AiMuxError::ApiCall(Box::new(api(&[( + "Retry-After", + "Tue, 14 Nov 2023 22:13:22 GMT", + )])))); + let envelope = error.envelope(now()); + assert_eq!(envelope.retry_after_ms, Some(2000)); + assert_eq!( + error + .envelope(now() + Duration::from_secs(1)) + .retry_after_ms, + Some(1000) + ); + let encoded = serde_json::to_string(&envelope).unwrap(); + let decoded: ErrorEnvelope = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded.retry_after_ms, Some(2000)); + let zero = OpsError::from(AiMuxError::ApiCall(Box::new(api(&[( + "retry-after-ms", + "0", + )])))) + .envelope(now()); + assert_eq!(zero.retry_after_ms, Some(0)); + assert_eq!(serde_json::to_value(zero).unwrap()["retryAfterMs"], 0); +} + +#[test] +fn only_direct_api_calls_get_top_level_retry_hints() { + let attempt = AiMuxError::ApiCall(Box::new(api(&[("retry-after-ms", "1500")]))); + for error in [ + AiMuxError::Retry(RetryError { + reason: RetryErrorReason::MaxRetriesExceeded, + errors: vec![attempt.clone()], + }), + AiMuxError::ToolCallRepair { + original_error: Box::new(attempt), + cause: Box::new(AiMuxError::Other("repair failed".into())), + }, + AiMuxError::Timeout("timeout".into()), + AiMuxError::Aborted("aborted".into()), + ] { + let payload = serde_json::to_value(&error).unwrap(); + let envelope = OpsError::from(error).envelope(now()); + assert_eq!(envelope.retry_after_ms, None); + assert_eq!(envelope.error, payload); + assert!( + serde_json::to_value(envelope) + .unwrap() + .get("retryAfterMs") + .is_none() + ); + } +} + +#[test] +fn settings_errors_redact_secret_canary() { + const VARIABLE: &str = "AIMUX_OPS_REDACTION_CANARY"; + const SECRET: &str = "sk-redaction-canary-do-not-expose"; + const CHILD: &str = "AIMUX_OPS_REDACTION_CHILD"; + if std::env::var_os(CHILD).is_some() { + let key = aimux_provider_utils::api_key::load_api_key(None, VARIABLE, "test provider") + .unwrap_err(); + let setting = aimux_provider_utils::api_key::load_setting(None, VARIABLE, "test setting") + .unwrap_err(); + assert!(matches!(key, AiMuxError::LoadApiKey { .. })); + assert!(matches!(setting, AiMuxError::LoadSetting { .. })); + for error in [key, setting] { + let nested = AiMuxError::ToolCallRepair { + original_error: Box::new(error.clone()), + cause: Box::new(AiMuxError::Retry(RetryError { + reason: RetryErrorReason::MaxRetriesExceeded, + errors: vec![error.clone()], + })), + }; + for error in [error, nested] { + let wire = serde_json::to_string(&OpsError::from(error).envelope(now())).unwrap(); + assert!(!wire.contains(SECRET), "secret leaked in envelope: {wire}"); + } + } + return; + } + + // Separate processes avoid mutating the environment of parallel tests. + let run = |value: Option| { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", "settings_errors_redact_secret_canary"]) + .env(CHILD, "1") + .env_remove(VARIABLE); + if let Some(value) = value { + command.env(VARIABLE, value); + } + let output = command.output().unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + }; + run(None); + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + let mut invalid = SECRET.as_bytes().to_vec(); + invalid.push(0xff); + run(Some(std::ffi::OsString::from_vec(invalid))); + } +} diff --git a/aimux-ops/tests/files.rs b/aimux-ops/tests/files.rs new file mode 100644 index 000000000..0725bbd1a --- /dev/null +++ b/aimux-ops/tests/files.rs @@ -0,0 +1,476 @@ +//! Real-dispatch files-family coverage; no network or FFI runtime. +use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, +}; +use std::time::{Duration, SystemTime}; + +use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileResult}; +use aimux_core::provider::Provider; +use aimux_core::{AbortSignal, AiMuxError}; +use aimux_ops::dispatch::{self, Ctx}; +use aimux_ops::envelope::{BindingError, OpsError}; +use aimux_ops::handle::{Entry, HandleId, HandleKind, HandleRegistry, Owner}; +use aimux_ops::op::{Cancellation, RequestBytePolicy}; +use serde_json::{Value, json}; +use tokio::sync::Notify; + +#[derive(Default)] +enum Reply { + #[default] + Success, + RetryableError, + WaitForAbort, +} + +#[derive(Default)] +struct MockFiles { + calls: Mutex>, + reply: Reply, + started: Notify, +} + +impl Files for MockFiles { + fn provider(&self) -> &str { + "mock" + } + + fn upload_file<'a, 'b, 'f>( + &'a self, + options: &'b UploadFileCallOptions, + ) -> std::pin::Pin< + Box> + Send + 'f>, + > + where + 'a: 'f, + 'b: 'f, + Self: 'f, + { + Box::pin(async move { + self.calls.lock().unwrap().push(options.clone()); + self.started.notify_one(); + match self.reply { + Reply::Success => Ok(UploadFileResult { + provider_reference: [("mock".into(), "file-1".into())].into(), + media_type: Some(options.media_type.clone()), + filename: options.filename.clone(), + provider_metadata: Some( + serde_json::from_value(json!({"mock":{"detail":null}})).unwrap(), + ), + warnings: vec![], + }), + Reply::RetryableError => { + let mut error = aimux_core::error::ApiCallError::new( + "upload failed", + "https://example.invalid/upload", + json!({"data":"redacted"}), + ); + error.is_retryable = true; + error.status_code = Some(503); + error.response_headers = Some( + [("retry-after".into(), "Tue, 14 Nov 2023 22:13:30 GMT".into())].into(), + ); + Err(AiMuxError::ApiCall(Box::new(error))) + } + Reply::WaitForAbort => { + options.abort_signal.as_ref().unwrap().cancelled().await; + Err(AiMuxError::Aborted("provider cancelled".into())) + } + } + }) + } +} + +struct MockProvider { + files: Option>, + calls: AtomicUsize, +} +impl Provider for MockProvider { + fn language_model( + &self, + _: &str, + ) -> Result, AiMuxError> { + Err(AiMuxError::UnsupportedFunctionality("language".into())) + } + fn embedding_model( + &self, + _: &str, + ) -> Result, AiMuxError> { + Err(AiMuxError::UnsupportedFunctionality("embedding".into())) + } + fn image_model( + &self, + _: &str, + ) -> Result, AiMuxError> { + Err(AiMuxError::UnsupportedFunctionality("image".into())) + } + fn files(&self) -> Option> { + self.calls.fetch_add(1, Ordering::SeqCst); + self.files.clone() + } +} + +struct Resources(Owner); +impl Resources { + fn new() -> Self { + Self(Owner::new_node().unwrap()) + } + fn insert(&self, entry: Entry) -> HandleId { + HandleRegistry::global().insert(self.0, entry).unwrap() + } +} +impl Drop for Resources { + fn drop(&mut self) { + HandleRegistry::global().release_owner(self.0); + } +} + +fn now() -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000) +} +fn ctx(owner: Owner) -> Ctx { + Ctx { + owner, + abort: None, + now, + } +} +fn text_params() -> Value { + json!({"data":{"type":"text","text":"hello 中文😀"},"mediaType":"text/plain"}) +} +fn assert_invalid_handle(error: &OpsError, expected: HandleKind, actual: Option) { + assert!(matches!(error, OpsError::Projected(_))); + match error.unprojected() { + OpsError::Binding(BindingError::InvalidHandle { + expected: found, + actual: kind, + }) => { + assert_eq!(found, &[expected]); + assert_eq!(*kind, actual); + } + error => panic!("expected InvalidHandle, got {error}"), + } +} + +#[tokio::test] +async fn provider_files_returns_an_owned_handle_that_survives_parent_drop() { + let resources = Resources::new(); + let model = Arc::new(MockFiles::default()); + let provider = Arc::new(MockProvider { + files: Some(model.clone()), + calls: AtomicUsize::new(0), + }); + let parent = resources.insert(Entry::Provider(provider.clone())); + let result = dispatch::call(ctx(resources.0), parent, "provider.files", json!({})) + .await + .unwrap(); + assert!( + result + .as_str() + .is_some_and(|id| id.parse::().unwrap() > 0) + ); + let child: HandleId = serde_json::from_value(result).unwrap(); + assert!( + HandleRegistry::global() + .get(resources.0, child, &[HandleKind::Files]) + .is_ok() + ); + assert_eq!(provider.calls.load(Ordering::SeqCst), 1); + dispatch::call(ctx(resources.0), parent, "handle.drop", json!({})) + .await + .unwrap(); + drop(provider); + let result = dispatch::call(ctx(resources.0), child, "files.upload", text_params()) + .await + .unwrap(); + assert_eq!(result["providerReference"], json!({"mock":"file-1"})); + assert!(result.get("filename").is_none()); + assert_eq!(model.calls.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn provider_files_validates_controls_and_reports_missing_capability() { + let resources = Resources::new(); + let provider = Arc::new(MockProvider { + files: None, + calls: AtomicUsize::new(0), + }); + let id = resources.insert(Entry::Provider(provider.clone())); + for params in [json!({"modelId":"unused"}), Value::Null, json!([])] { + let error = dispatch::call(ctx(resources.0), id, "provider.files", params) + .await + .unwrap_err(); + assert_eq!(error.code(), 301); + } + assert_eq!(provider.calls.load(Ordering::SeqCst), 0); + let error = dispatch::call(ctx(resources.0), id, "provider.files", json!({})) + .await + .unwrap_err(); + assert_eq!(error.code(), 8); + assert!(matches!( + error.unprojected(), + OpsError::AiMux(AiMuxError::UnsupportedFunctionality(_)) + )); + assert_eq!(provider.calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn uploads_canonical_base64_and_preserves_options_and_context_abort() { + let resources = Resources::new(); + let model = Arc::new(MockFiles::default()); + let id = resources.insert(Entry::Files(model.clone())); + let params = json!({ + "data":{"type":"data","data":"AAEC/w=="}, + "mediaType":"application/octet-stream","filename":"测试😀.bin", + "providerOptions":{"mock":{"custom":true,"nested":{"anything":[null,1]}}} + }); + let signal = AbortSignal::new(); + let mut context = ctx(resources.0); + context.abort = Some(signal.clone()); + let result = dispatch::call(context, id, "files.upload", params.clone()) + .await + .unwrap(); + assert_eq!( + result, + json!({ + "providerReference":{"mock":"file-1"},"mediaType":"application/octet-stream", + "filename":"测试😀.bin","providerMetadata":{"mock":{"detail":null}},"warnings":[] + }) + ); + signal.abort(); + let calls = model.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert!(calls[0].abort_signal.as_ref().unwrap().is_aborted()); + assert_eq!(serde_json::to_value(&calls[0]).unwrap(), params); +} + +#[tokio::test] +async fn retryable_upload_error_is_projected_once_without_retry() { + let resources = Resources::new(); + let model = Arc::new(MockFiles { + reply: Reply::RetryableError, + ..Default::default() + }); + let id = resources.insert(Entry::Files(model.clone())); + let error = dispatch::call(ctx(resources.0), id, "files.upload", text_params()) + .await + .unwrap_err(); + assert!(matches!(error, OpsError::Projected(_))); + assert!( + matches!(error.unprojected(), OpsError::AiMux(AiMuxError::ApiCall(api)) + if api.is_retryable && api.status_code == Some(503)) + ); + let envelope = error.envelope(now()); + assert_eq!(envelope.code, 11); + assert_eq!(envelope.error["name"], "AI_APICallError"); + assert_eq!(envelope.error["isRetryable"], true); + assert_eq!(envelope.retry_after_ms, Some(10_000)); + assert_eq!(envelope, error.envelope(now() + Duration::from_secs(3600))); + assert_eq!(model.calls.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn rejects_invalid_controls_before_upload() { + let resources = Resources::new(); + let model = Arc::new(MockFiles::default()); + let id = resources.insert(Entry::Files(model.clone())); + for params in [ + json!({"data":{"type":"data","data":[1,2]},"mediaType":"text/plain"}), + json!({"data":{"type":"text","text":"ok","extra":1},"mediaType":"text/plain"}), + json!({"data":{"type":"text","text":"ok"},"mediaType":"text/plain","retry":3}), + json!({"data":{"type":"text","text":"ok"},"media_type":"text/plain"}), + json!({"data":{"type":"text","text":"ok"},"mediaType":"text/plain","abortSignal":null}), + json!({"data":{"type":"url","url":"https://example.invalid"},"mediaType":"text/plain"}), + json!({"data":{"type":"data","data":""},"mediaType":42}), + json!({"mediaType":"text/plain"}), + json!({}), + Value::Null, + json!([]), + ] { + let error = dispatch::call(ctx(resources.0), id, "files.upload", params.clone()) + .await + .unwrap_err(); + assert_eq!(error.code(), 301, "{params}: {error}"); + } + assert!(model.calls.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn secondary_abort_is_owner_checked_and_typed_before_upload() { + let resources = Resources::new(); + let foreign = Resources::new(); + let model = Arc::new(MockFiles::default()); + let id = resources.insert(Entry::Files(model.clone())); + let foreign_abort = foreign.insert(Entry::Abort(Arc::new(AbortSignal::new()))); + let mut envelopes = Vec::new(); + for (abort, actual) in [ + (foreign_abort, None), + (HandleId(u64::MAX), None), + (id, Some(HandleKind::Files)), + ] { + let mut params = text_params(); + params["abort"] = json!(abort); + let error = dispatch::call(ctx(resources.0), id, "files.upload", params) + .await + .unwrap_err(); + assert_invalid_handle(&error, HandleKind::Abort, actual); + envelopes.push(error.envelope(now())); + } + assert_eq!(envelopes[0], envelopes[1]); + for abort in [ + json!(1), + json!("-1"), + json!("18446744073709551616"), + json!("invalid"), + ] { + let mut params = text_params(); + params["abort"] = abort; + let error = dispatch::call(ctx(resources.0), id, "files.upload", params) + .await + .unwrap_err(); + assert_eq!(error.code(), 301); + } + assert!(model.calls.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn owned_abort_handle_cancels_an_inflight_upload() { + let resources = Resources::new(); + let model = Arc::new(MockFiles { + reply: Reply::WaitForAbort, + ..Default::default() + }); + let id = resources.insert(Entry::Files(model.clone())); + let signal = AbortSignal::new(); + let abort = resources.insert(Entry::Abort(Arc::new(signal.clone()))); + let mut params = text_params(); + params["abort"] = json!(abort); + let upload = tokio::spawn(dispatch::call(ctx(resources.0), id, "files.upload", params)); + tokio::time::timeout(Duration::from_secs(2), model.started.notified()) + .await + .unwrap(); + signal.abort(); + let error = tokio::time::timeout(Duration::from_secs(2), upload) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert_eq!(error.code(), 13); + let calls = model.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert!(calls[0].abort_signal.as_ref().unwrap().is_aborted()); +} + +#[tokio::test] +async fn already_aborted_request_never_calls_provider() { + let resources = Resources::new(); + let model = Arc::new(MockFiles::default()); + let id = resources.insert(Entry::Files(model.clone())); + let signal = AbortSignal::new(); + signal.abort(); + let abort = resources.insert(Entry::Abort(Arc::new(signal))); + let mut params = text_params(); + params["abort"] = json!(abort); + let error = dispatch::call(ctx(resources.0), id, "files.upload", params) + .await + .unwrap_err(); + assert_eq!(error.code(), 13); + assert!(model.calls.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn primary_handles_reject_global_unknown_dropped_foreign_and_wrong_kind() { + let resources = Resources::new(); + let foreign = Resources::new(); + let model = Arc::new(MockFiles::default()); + let provider = Arc::new(MockProvider { + files: Some(model.clone()), + calls: AtomicUsize::new(0), + }); + let wrong_kind = resources.insert(Entry::Abort(Arc::new(AbortSignal::new()))); + for (name, expected, entry, params) in [ + ( + "provider.files", + HandleKind::Provider, + Entry::Provider(provider.clone()), + json!({}), + ), + ( + "files.upload", + HandleKind::Files, + Entry::Files(model.clone()), + text_params(), + ), + ] { + let dropped = resources.insert(entry.clone()); + drop(HandleRegistry::global().remove(resources.0, dropped)); + let other = foreign.insert(entry); + let mut envelopes = Vec::new(); + for id in [HandleId(0), HandleId(u64::MAX), dropped, other] { + let error = dispatch::call(ctx(resources.0), id, name, params.clone()) + .await + .unwrap_err(); + assert_invalid_handle(&error, expected, None); + envelopes.push(error.envelope(now())); + } + assert!(envelopes.windows(2).all(|pair| pair[0] == pair[1])); + let error = dispatch::call(ctx(resources.0), wrong_kind, name, params) + .await + .unwrap_err(); + assert_invalid_handle(&error, expected, Some(HandleKind::Abort)); + } + assert_eq!(provider.calls.load(Ordering::SeqCst), 0); + assert!(model.calls.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn one_shot_text_upload_is_not_capped_by_stream_item_limit() { + let resources = Resources::new(); + let model = Arc::new(MockFiles::default()); + let id = resources.insert(Entry::Files(model.clone())); + let mut params = text_params(); + params["data"]["text"] = json!("x".repeat(1024 * 1024 + 1)); + params["abort"] = Value::Null; + dispatch::call(ctx(resources.0), id, "files.upload", params) + .await + .unwrap(); + assert_eq!(model.calls.lock().unwrap().len(), 1); +} + +#[test] +fn files_descriptors_use_concrete_types_and_abort_metadata() { + let provider = dispatch::descriptor("provider.files").unwrap(); + assert_eq!(provider.request_type, "EmptyParams"); + assert_eq!(provider.result_type, "HandleId"); + assert_eq!(provider.required_capabilities, &["files"]); + let upload = dispatch::descriptor("files.upload").unwrap(); + assert_eq!( + upload.request_type, + "aimux_ops::ops::files::UploadFileParams" + ); + assert_eq!( + upload.result_type, + "aimux_core::files_model::UploadFileResult" + ); + assert_eq!(upload.cancellation, Cancellation::Abort); + assert_eq!( + upload.runtime_handle_fields, + &[("abort", &[HandleKind::Abort][..])] + ); + assert_eq!( + upload.request_byte_policy, + RequestBytePolicy::JsonWithBase64 + ); + assert_eq!(upload.retry_owner, "none"); + assert!(upload.side_effects); + let manifest = aimux_ops::manifest::manifest(); + for id in [upload.request_type, upload.result_type] { + let ty = manifest["types"] + .as_array() + .unwrap() + .iter() + .find(|ty| ty["id"] == id) + .unwrap(); + assert_eq!(ty["rustPath"], id); + } +} diff --git a/aimux-ops/tests/handle_lifecycle.rs b/aimux-ops/tests/handle_lifecycle.rs new file mode 100644 index 000000000..60bc2d9b0 --- /dev/null +++ b/aimux-ops/tests/handle_lifecycle.rs @@ -0,0 +1,410 @@ +//! RFC-0039 §3.3 handle lifetime and owner-isolation gate. +//! +//! Exhaustion needs an isolated allocator seeded near u64::MAX; the frozen +//! registry API exposes only the process-global allocator. That boundary must +//! be covered by the handle implementation's unit tests, not by exhausting the +//! process-global allocator here. Realtime construction is tested in operation.rs. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Barrier}; +use std::thread; +use std::time::{Duration, Instant, SystemTime}; + +use aimux_core::custom_provider::{CustomProviderOptions, custom_provider}; +use aimux_core::error::AiMuxError; +use aimux_core::language_model::LanguageModel; +use aimux_core::options::CallOptions; +use aimux_core::provider::Provider; +use aimux_core::provider_registry::{ProviderRegistryOptions, create_provider_registry}; +use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; +use aimux_core::router::{FallbackPolicy, RouterConfig, RouterModel, RuleRouter}; +use aimux_ops::dispatch::{self, Ctx}; +use aimux_ops::envelope::{BindingError, OpsError}; +use aimux_ops::handle::{Entry, HandleId, HandleKind, HandleRegistry, Owner}; +use futures::future::BoxFuture; +use serde_json::json; + +const OWNER: Owner = Owner::Node(44); +const LANGUAGE: &[HandleKind] = &[HandleKind::Language]; +const WAIT: Duration = Duration::from_secs(5); + +static REENTRIES: AtomicUsize = AtomicUsize::new(0); + +struct Model { + reenter: bool, +} + +fn reenter_registry() { + REENTRIES.fetch_add(1, Ordering::SeqCst); + let registry = HandleRegistry::global(); + let id = registry.insert(OWNER, entry()).unwrap(); + assert!(registry.get(OWNER, id, LANGUAGE).is_ok()); + assert!(registry.remove(OWNER, id).is_some()); +} + +impl LanguageModel for Model { + fn provider(&self) -> &str { + if self.reenter { + reenter_registry(); + } + "lifecycle" + } + + fn model_id(&self) -> &str { + "model" + } + + // Match async_trait's expanded signature without adding a dev dependency + // to the crate manifest. + fn do_generate<'a, 'b, 'f>( + &'a self, + _options: &'b CallOptions, + ) -> BoxFuture<'f, Result> + where + 'a: 'f, + 'b: 'f, + Self: 'f, + { + Box::pin(async move { + tokio::task::yield_now().await; + if self.reenter { + reenter_registry(); + } + Ok(GenerateResult { + content: vec![GenerateContent::Text { + text: "still alive".into(), + provider_metadata: None, + }], + finish_reason: aimux_core::types::FinishReason { + unified: aimux_core::types::FinishReasonUnified::Stop, + raw: None, + }, + usage: Default::default(), + warnings: Vec::new(), + provider_metadata: None, + request: None, + response: None, + }) + }) + } + + fn do_stream<'a, 'b, 'f>( + &'a self, + _options: &'b CallOptions, + ) -> BoxFuture<'f, Result> + where + 'a: 'f, + 'b: 'f, + Self: 'f, + { + Box::pin(async { + Err(AiMuxError::UnsupportedFunctionality( + "unused in this gate".into(), + )) + }) + } +} + +fn entry() -> Entry { + Entry::Language(Arc::new(Model { reenter: false })) +} + +fn language(entry: Entry) -> Arc { + match entry { + Entry::Language(model) => model, + _ => panic!("expected a language model"), + } +} + +fn invalid_handle( + result: Result, + actual: Option, +) -> serde_json::Value { + let error = match result { + Err(error) => error, + Ok(_) => panic!("lookup unexpectedly succeeded"), + }; + match &error { + OpsError::Binding(BindingError::InvalidHandle { + expected, + actual: found, + }) => { + assert_eq!(expected, LANGUAGE); + assert_eq!(*found, actual); + } + _ => panic!("expected InvalidHandle"), + } + assert_eq!(error.code(), 203); + serde_json::to_value(error.envelope(SystemTime::UNIX_EPOCH)).unwrap() +} + +async fn assert_working(model: Arc) { + let result = model + .do_generate(&CallOptions::new(Vec::new())) + .await + .unwrap(); + assert!( + matches!(result.content.as_slice(), [GenerateContent::Text { text, .. }] if text == "still alive") + ); +} + +#[test] +fn ids_are_nonzero_monotonic_and_never_reused_after_drop() { + let registry = HandleRegistry::global(); + let mut previous = 0; + for _ in 0..1_024 { + let id = registry.insert(OWNER, entry()).unwrap(); + assert!(id.0 > previous); + previous = id.0; + assert!(registry.remove(OWNER, id).is_some()); + invalid_handle(registry.get(OWNER, id, LANGUAGE), None); + } +} + +#[test] +fn every_owner_pair_is_isolated_and_foreign_errors_reveal_nothing() { + let registry = HandleRegistry::global(); + let owners = [ + Owner::CProcess, + Owner::Node(44), + Owner::Node(45), + Owner::Python(44), + Owner::Python(45), + ]; + for owner in owners { + // A Provider is deliberately different from the requested Language kind: + // a foreign-owner error must hide even that type distinction. + let provider = Arc::new(custom_provider(CustomProviderOptions::default())); + let id = registry.insert(owner, Entry::Provider(provider)).unwrap(); + for caller in owners { + if caller == owner { + continue; + } + let foreign = invalid_handle(registry.get(caller, id, LANGUAGE), None); + let unknown = invalid_handle(registry.get(caller, HandleId(0), LANGUAGE), None); + assert_eq!(foreign, unknown, "foreign lookup leaked details"); + assert!(registry.remove(caller, id).is_none()); + assert!(registry.get(owner, id, &[HandleKind::Provider]).is_ok()); + } + invalid_handle( + registry.get(owner, id, LANGUAGE), + Some(HandleKind::Provider), + ); + assert!(registry.remove(owner, id).is_some()); + } +} + +#[test] +fn remove_is_idempotent_for_live_dropped_zero_and_unknown_handles() { + let registry = HandleRegistry::global(); + assert!(registry.remove(OWNER, HandleId(0)).is_none()); + assert!(registry.remove(OWNER, HandleId(u64::MAX)).is_none()); + assert!(registry.remove(OWNER, HandleId(u64::MAX)).is_none()); + let id = registry.insert(OWNER, entry()).unwrap(); + assert!(registry.remove(OWNER, id).is_some()); + assert!(registry.remove(OWNER, id).is_none()); + // An allocated-and-removed ID is guaranteed unknown even while other tests + // allocate from the same process-global registry. + invalid_handle(registry.get(OWNER, id, LANGUAGE), None); + invalid_handle(registry.get(OWNER, HandleId(0), LANGUAGE), None); +} + +#[tokio::test] +async fn an_admitted_call_survives_removal_and_can_reenter_after_await() { + let registry = HandleRegistry::global(); + let id = registry + .insert(OWNER, Entry::Language(Arc::new(Model { reenter: true }))) + .unwrap(); + let admitted = language(registry.get(OWNER, id, LANGUAGE).unwrap()); + let options = CallOptions::new(Vec::new()); + let call = admitted.do_generate(&options); + assert!(registry.remove(OWNER, id).is_some()); + invalid_handle(registry.get(OWNER, id, LANGUAGE), None); + let result = tokio::time::timeout(WAIT, call).await.unwrap().unwrap(); + assert!( + matches!(result.content.as_slice(), [GenerateContent::Text { text, .. }] if text == "still alive") + ); +} + +#[tokio::test] +async fn models_survive_provider_and_registry_removal() { + let handles = HandleRegistry::global(); + let provider: Arc = Arc::new(custom_provider(CustomProviderOptions { + language_models: HashMap::from([( + "model".into(), + Arc::new(Model { reenter: false }) as Arc, + )]), + ..Default::default() + })); + let provider_id = handles + .insert(OWNER, Entry::Provider(provider.clone())) + .unwrap(); + let registry = Arc::new(create_provider_registry( + BTreeMap::from([("local".into(), provider)]), + ProviderRegistryOptions::default(), + )); + let registry_id = handles.insert(OWNER, Entry::Registry(registry)).unwrap(); + let resolved = match handles + .get(OWNER, registry_id, &[HandleKind::Registry]) + .unwrap() + { + Entry::Registry(registry) => registry.language_model("local:model").unwrap(), + _ => panic!("expected registry"), + }; + let model_id = handles.insert(OWNER, Entry::Language(resolved)).unwrap(); + drop(handles.remove(OWNER, provider_id).unwrap()); + drop(handles.remove(OWNER, registry_id).unwrap()); + assert_working(language(handles.get(OWNER, model_id, LANGUAGE).unwrap())).await; + drop(handles.remove(OWNER, model_id).unwrap()); +} + +#[tokio::test] +async fn a_composite_survives_removal_of_its_child_handle() { + let registry = HandleRegistry::global(); + let child_id = registry.insert(OWNER, entry()).unwrap(); + let child = language(registry.get(OWNER, child_id, LANGUAGE).unwrap()); + let composite = RouterModel::new( + vec![child], + Box::new(RuleRouter), + FallbackPolicy::None, + RouterConfig::default(), + ); + let composite_id = registry + .insert(OWNER, Entry::Language(Arc::new(composite))) + .unwrap(); + drop(registry.remove(OWNER, child_id).unwrap()); + invalid_handle(registry.get(OWNER, child_id, LANGUAGE), None); + assert_working(language( + registry.get(OWNER, composite_id, LANGUAGE).unwrap(), + )) + .await; + drop(registry.remove(OWNER, composite_id).unwrap()); +} + +#[test] +fn concurrent_get_and_remove_preserve_admitted_references_and_unique_ids() { + let registry = HandleRegistry::global(); + let barrier = Arc::new(Barrier::new(8)); + let workers: Vec<_> = (0..8) + .map(|worker| { + let barrier = barrier.clone(); + thread::spawn(move || { + let owner = Owner::Node(4_400 + worker); + let mut ids = Vec::new(); + for _ in 0..128 { + let id = registry.insert(owner, entry()).unwrap(); + assert!(ids.last().is_none_or(|previous| *previous < id.0)); + ids.push(id.0); + let admitted = language(registry.get(owner, id, LANGUAGE).unwrap()); + barrier.wait(); + thread::scope(|scope| { + scope.spawn(|| { + drop(registry.remove(owner, id)); + }); + scope.spawn(|| match registry.get(owner, id, LANGUAGE) { + Ok(found) => assert_eq!(language(found).model_id(), "model"), + Err(error) => { + invalid_handle(Err(error), None); + } + }); + }); + assert_eq!(admitted.model_id(), "model"); + invalid_handle(registry.get(owner, id, LANGUAGE), None); + assert!(registry.remove(owner, id).is_none()); + } + ids + }) + }) + .collect(); + let ids: Vec<_> = workers + .into_iter() + .flat_map(|worker| worker.join().unwrap()) + .collect(); + assert_eq!(ids.len(), 1_024); + assert_eq!(ids.iter().copied().collect::>().len(), ids.len()); +} + +#[test] +fn dispatch_releases_registry_lock_before_calling_model_code() { + const CHILD: &str = "AIMUX_HANDLE_LIFECYCLE_REENTRY_CHILD"; + if std::env::var_os(CHILD).is_some() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let registry = HandleRegistry::global(); + let id = registry + .insert(OWNER, Entry::Language(Arc::new(Model { reenter: true }))) + .unwrap(); + let registrations = [( + dispatch::descriptor("model.info").unwrap(), + dispatch::Handler::Call(info_probe), + )]; + let dispatcher = dispatch::Dispatcher::new(registry, ®istrations); + let result = dispatcher + .call( + Ctx { + owner: OWNER, + abort: None, + now: SystemTime::now, + }, + id, + "model.info", + json!({}), + ) + .await; + drop(registry.remove(OWNER, id)); + assert!(result.is_ok(), "model.info failed"); + assert!( + REENTRIES.load(Ordering::SeqCst) > 0, + "model.info did not invoke the reentrant model" + ); + }); + return; + } + + // Isolate a potential synchronous mutex deadlock from the other tests. + // An async timeout cannot interrupt a blocked executor thread. + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "dispatch_releases_registry_lock_before_calling_model_code", + "--nocapture", + ]) + .env(CHILD, "1") + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + let deadline = Instant::now() + WAIT; + loop { + if let Some(status) = child.try_wait().unwrap() { + assert!( + status.success(), + "reentrant dispatch child failed: {status}" + ); + break; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("dispatch deadlocked while model.info re-entered the registry"); + } + thread::sleep(Duration::from_millis(10)); + } +} + +// Use the actual model.info descriptor with a reentrant probe to isolate +// registry lock behavior from model metadata. +fn info_probe(_: Ctx, target: dispatch::Target, _: serde_json::Value) -> dispatch::CallFuture { + Box::pin(async move { + if let dispatch::Target::Handle(Entry::Language(model)) = target { + Ok(json!({"provider": model.provider()})) + } else { + panic!("expected language target") + } + }) +} diff --git a/aimux-ops/tests/handles.rs b/aimux-ops/tests/handles.rs new file mode 100644 index 000000000..c9ed33af6 --- /dev/null +++ b/aimux-ops/tests/handles.rs @@ -0,0 +1,456 @@ +//! Built-in handle capabilities exercised through shared dispatch. +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use aimux_core::download::PlannedDownload; +use aimux_core::trace::RingTraceStore; +use aimux_core::{AbortSignal, AiMuxError}; +use aimux_ops::dispatch::{self, Ctx}; +use aimux_ops::envelope::{BindingError, OpsError, ProtocolError}; +use aimux_ops::handle::{Entry, HandleId, HandleKind, HandleRegistry, Owner}; +use aimux_ops::op::{Cancellation, OpMode, RequestBytePolicy, Scope}; +use serde_json::{Value, json}; + +struct TestOwner(Owner); +impl TestOwner { + fn new() -> Self { + Self(Owner::new_node().unwrap()) + } + + async fn call(&self, id: HandleId, op: &str, params: Value) -> Result { + dispatch::call(ctx(self.0), id, op, params).await + } + + async fn create(&self, op: &str, params: Value) -> Value { + self.call(HandleId(0), op, params).await.unwrap() + } + + fn get(&self, result: &Value, kind: HandleKind) -> Entry { + HandleRegistry::global() + .get(self.0, id(result), &[kind]) + .unwrap() + } +} +impl Drop for TestOwner { + fn drop(&mut self) { + HandleRegistry::global().release_owner(self.0); + } +} + +fn ctx(owner: Owner) -> Ctx { + Ctx { + owner, + abort: None, + now: SystemTime::now, + } +} +fn id(value: &Value) -> HandleId { + serde_json::from_value(value["handle"].clone()).unwrap() +} +fn invalid_params(result: Result) { + let error = result.unwrap_err(); + assert!(matches!( + error.unprojected(), + OpsError::Protocol(ProtocolError::InvalidParams(_)) + )); + assert!(matches!(error, OpsError::Projected(_))); +} +fn invalid_handle(result: Result, actual: Option) -> Value { + let error = result.unwrap_err(); + assert!( + matches!(error.unprojected(), OpsError::Binding(BindingError::InvalidHandle { actual: found, .. }) if *found == actual) + ); + serde_json::to_value(error.envelope(SystemTime::UNIX_EPOCH)).unwrap() +} + +#[tokio::test] +async fn abort_is_idempotent_and_signals_another_thread() { + let owner = TestOwner::new(); + let result = owner.create("abort.create", json!({})).await; + assert_eq!(result["kind"], "Abort"); + let Entry::Abort(signal) = owner.get(&result, HandleKind::Abort) else { + panic!("wrong kind") + }; + let waiter = tokio::spawn(signal.cancelled()); + let handle = id(&result); + let token = owner.0; + std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(dispatch::call( + ctx(token), + handle, + "abort.signal", + json!({}), + )) + .unwrap(); + }) + .join() + .unwrap(); + assert_eq!( + owner.call(handle, "abort.signal", json!({})).await.unwrap(), + Value::Null + ); + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .unwrap() + .unwrap(); + assert!(signal.is_aborted()); +} + +#[tokio::test] +async fn dropping_an_abort_preserves_cloned_capabilities_without_signalling() { + let owner = TestOwner::new(); + let result = owner.create("abort.create", json!({})).await; + let Entry::Abort(signal) = owner.get(&result, HandleKind::Abort) else { + panic!("wrong kind") + }; + invalid_params( + owner + .call(id(&result), "handle.drop", json!({"force":true})) + .await, + ); + assert_eq!( + owner.get(&result, HandleKind::Abort).kind(), + HandleKind::Abort + ); + for handle in [id(&result), id(&result), HandleId(0), HandleId(u64::MAX)] { + assert_eq!( + owner.call(handle, "handle.drop", json!({})).await.unwrap(), + Value::Null + ); + } + invalid_handle( + owner.call(id(&result), "abort.signal", json!({})).await, + None, + ); + assert!(!signal.is_aborted()); + signal.abort(); + assert!(signal.is_aborted()); +} + +#[tokio::test] +async fn foreign_owner_cannot_signal_or_drop_a_capability() { + let owner = TestOwner::new(); + let other = TestOwner::new(); + let result = owner.create("abort.create", json!({})).await; + let foreign = invalid_handle( + other.call(id(&result), "abort.signal", json!({})).await, + None, + ); + let unknown = invalid_handle( + other + .call(HandleId(u64::MAX), "abort.signal", json!({})) + .await, + None, + ); + assert_eq!(foreign, unknown); + assert_eq!( + other + .call(id(&result), "handle.drop", json!({})) + .await + .unwrap(), + Value::Null + ); + let Entry::Abort(signal) = owner.get(&result, HandleKind::Abort) else { + panic!("wrong kind") + }; + assert!(!signal.is_aborted()); +} + +#[tokio::test] +async fn transports_have_precise_kinds_and_decimal_ids() { + let owner = TestOwner::new(); + for (kind, expected) in [ + ("fetch", HandleKind::Fetch), + ("ws", HandleKind::WebSocket), + ("download", HandleKind::Download), + ] { + let result = owner.create("transport.create", json!({"kind":kind})).await; + let handle = result["handle"].as_str().unwrap(); + assert!(handle.bytes().all(|b| b.is_ascii_digit())); + assert_ne!(id(&result), HandleId(0)); + assert_eq!(result["kind"], serde_json::to_value(expected).unwrap()); + assert_eq!(owner.get(&result, expected).kind(), expected); + } +} + +#[tokio::test] +async fn proxy_settings_reuse_existing_transport_configuration() { + let owner = TestOwner::new(); + for kind in ["fetch", "ws"] { + let result = owner + .create( + "transport.create", + json!({"kind":kind,"proxy":{ + "http_url":"http://proxy.invalid:80", "https_url":"http://proxy.invalid:81", + "all_url":null, "no_proxy":"localhost" + }}), + ) + .await; + assert!(id(&result).0 > 0); + } +} + +#[tokio::test] +async fn builtin_download_keeps_the_guard_and_model_supported_url_policy() { + let owner = TestOwner::new(); + let result = owner + .create("transport.create", json!({"kind":"download","maxBytes":7})) + .await; + let Entry::Download(download) = owner.get(&result, HandleKind::Download) else { + panic!("wrong kind") + }; + let url = "http://127.0.0.1:9/private".to_owned(); + assert!(matches!( + download + .download(vec![PlannedDownload { + url: url.clone(), + is_url_supported_by_model: false + }]) + .await, + Err(AiMuxError::InvalidArgument(_)) + )); + assert_eq!( + download + .download(vec![PlannedDownload { + url, + is_url_supported_by_model: true + }]) + .await + .unwrap(), + [None] + ); + assert!(download.download(Vec::new()).await.unwrap().is_empty()); +} + +#[tokio::test] +async fn builtin_download_retains_the_resolved_abort_signal() { + let owner = TestOwner::new(); + let signal = AbortSignal::new(); + signal.abort(); + let mut context = ctx(owner.0); + context.abort = Some(signal); + let result = dispatch::call( + context, + HandleId(0), + "transport.create", + json!({"kind":"download"}), + ) + .await + .unwrap(); + let Entry::Download(download) = owner.get(&result, HandleKind::Download) else { + panic!("wrong kind") + }; + assert!(matches!( + download + .download(vec![PlannedDownload { + url: "https://example.invalid/file".into(), + is_url_supported_by_model: false + }]) + .await, + Err(AiMuxError::Aborted(_)) + )); +} + +#[tokio::test] +async fn control_objects_reject_unknown_fields_and_callbacks() { + let owner = TestOwner::new(); + invalid_params( + owner + .call(HandleId(0), "abort.create", json!({"callback":true})) + .await, + ); + let abort = owner.create("abort.create", json!({})).await; + invalid_params(owner.call(id(&abort), "abort.signal", Value::Null).await); + invalid_params( + owner + .call(id(&abort), "abort.signal", json!({"extra":true})) + .await, + ); + for value in [ + json!({"kind":"fetch", "callback":{}}), + json!({"kind":"fetch", "proxy":{"callback":true}}), + json!({"kind":"fetch", "proxy":null}), + json!({"kind":"fetch", "proxy":{"httpUrl":"http://proxy.invalid"}}), + json!({"kind":"ws", "maxBytes":5}), + json!({"kind":"download", "proxy":{}}), + json!({"kind":"download", "maxBytes":-1}), + json!({"kind":"download", "maxBytes":1.5}), + json!({"kind":"FETCH"}), + json!({}), + ] { + invalid_params(owner.call(HandleId(0), "transport.create", value).await); + } +} + +#[tokio::test] +async fn credential_creation_is_future_and_never_exports_supplied_material() { + let owner = TestOwner::new(); + let error = owner + .call( + HandleId(0), + "credential.create", + json!({"kind":"secret-sentinel"}), + ) + .await + .unwrap_err(); + assert!(matches!( + error.unprojected(), + OpsError::AiMux(AiMuxError::UnsupportedFunctionality(_)) + )); + assert_eq!(error.code(), 8); + assert!( + !serde_json::to_string(&error.envelope(SystemTime::UNIX_EPOCH)) + .unwrap() + .contains("secret-sentinel") + ); + let error = owner + .call( + HandleId(0), + "credential.create", + json!({"kind":"static","secret":"secret-sentinel"}), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), 301); + assert!( + !serde_json::to_string(&error.envelope(SystemTime::UNIX_EPOCH)) + .unwrap() + .contains("secret-sentinel") + ); + assert_eq!( + dispatch::descriptor("credential.create").unwrap().scope, + Scope::Future + ); +} + +#[tokio::test] +async fn trace_store_resolution_hides_foreign_kinds_and_rejects_wrong_kinds() { + let owner = TestOwner::new(); + let other = TestOwner::new(); + let store = HandleRegistry::global() + .insert(owner.0, Entry::TraceStore(Arc::new(RingTraceStore::new()))) + .unwrap(); + let request = json!({"kind":"trace","traceStore":store}); + let foreign = invalid_handle( + other.call(HandleId(0), "middleware.create", request).await, + None, + ); + let unknown = invalid_handle( + other + .call( + HandleId(0), + "middleware.create", + json!({"kind":"trace","traceStore":u64::MAX.to_string()}), + ) + .await, + None, + ); + assert_eq!(foreign, unknown); + let abort = owner.create("abort.create", json!({})).await; + invalid_handle( + owner + .call( + HandleId(0), + "middleware.create", + json!({"kind":"trace","traceStore":abort["handle"]}), + ) + .await, + Some(HandleKind::Abort), + ); +} + +#[tokio::test] +async fn middleware_validates_control_fields_and_handle_wire_format() { + let owner = TestOwner::new(); + for value in [ + json!({"kind":"trace"}), + json!({"kind":"trace","traceStore":1}), + json!({"kind":"trace","traceStore":"+1"}), + json!({"kind":"trace","traceStore":""}), + json!({"kind":"trace","traceStore":"18446744073709551616"}), + json!({"kind":"trace","callback":true}), + json!({"kind":"trace","auditMode":"invalid"}), + ] { + invalid_params(owner.call(HandleId(0), "middleware.create", value).await); + } + let error = owner + .call( + HandleId(0), + "middleware.create", + json!({"kind":"hostCallback"}), + ) + .await + .unwrap_err(); + assert!(matches!( + error.unprojected(), + OpsError::AiMux(AiMuxError::UnsupportedFunctionality(_)) + )); +} + +#[tokio::test] +async fn trace_middleware_creation_retains_store_and_audit_configuration() { + let owner = TestOwner::new(); + let store = Arc::new(RingTraceStore::new()); + let handle = HandleRegistry::global() + .insert(owner.0, Entry::TraceStore(store.clone())) + .unwrap(); + let references = Arc::strong_count(&store); + for audit_mode in ["disabled", "shared", "strict"] { + let result = owner.create("middleware.create", json!({"kind":"trace","traceStore":handle,"scopeName":"test","auditMode":audit_mode})).await; + assert_eq!(result["kind"], "Middleware"); + assert_eq!(Arc::strong_count(&store), references + 1); + assert!( + HandleRegistry::global() + .remove(owner.0, id(&result)) + .is_some() + ); + assert_eq!(Arc::strong_count(&store), references); + } + assert_eq!(store.len(), 0); +} + +#[test] +fn registrations_publish_complete_metadata() { + let names = [ + "abort.create", + "abort.signal", + "transport.create", + "credential.create", + "middleware.create", + "handle.drop", + ]; + for name in names { + let op = dispatch::descriptor(name).unwrap(); + assert_eq!(op.mode, OpMode::Call); + assert_eq!(op.cancellation, Cancellation::None); + assert_eq!(op.request_byte_policy, RequestBytePolicy::Json); + assert_eq!(op.terminal_type, None); + assert!(op.side_effects); + assert!(op.required_capabilities.is_empty()); + assert_ne!(op.request_type, "serde_json::Value"); + assert_ne!(op.result_type, "serde_json::Value"); + } + assert_eq!( + dispatch::descriptor("abort.signal") + .unwrap() + .allowed_handle_kinds, + [HandleKind::Abort] + ); + assert_eq!( + dispatch::descriptor("handle.drop") + .unwrap() + .allowed_handle_kinds + .len(), + HandleKind::ALL.len() + ); + assert_eq!( + dispatch::descriptor("middleware.create") + .unwrap() + .runtime_handle_fields, + [("traceStore", &[HandleKind::TraceStore][..])] + ); +} diff --git a/aimux-ops/tests/limits.rs b/aimux-ops/tests/limits.rs new file mode 100644 index 000000000..16b8984b8 --- /dev/null +++ b/aimux-ops/tests/limits.rs @@ -0,0 +1,390 @@ +use std::time::Duration; + +use aimux_core::AbortSignal; +use aimux_ops::envelope::{OpsError, ProtocolError}; +use aimux_ops::limits::{ + AUDIO_INPUT_ITEMS, ITEM_BYTES, OUTPUT_ITEMS, QUEUE_BYTES, QueueEvent, QueueLimits, QueueStatus, + bounded_queue, +}; +use futures::poll; +use tokio::time::timeout; + +fn small(items: usize, bytes: usize) -> QueueLimits { + QueueLimits::output().lowered(items, bytes, bytes).unwrap() +} + +fn assert_resource_limit(result: Result) { + assert!(matches!( + result, + Err(OpsError::Protocol(ProtocolError::ResourceLimit(_))) + )); +} + +fn assert_closed(result: Result) { + assert!(matches!( + result, + Err(OpsError::Protocol(ProtocolError::InvalidState(_))) + )); +} + +#[test] +fn defaults_match_contract_and_only_lowering_is_allowed() { + let input = QueueLimits::audio_input(); + let output = QueueLimits::output(); + assert_eq!(input.max_items(), AUDIO_INPUT_ITEMS); + assert_eq!(input.max_items(), 64); + assert_eq!(output.max_items(), OUTPUT_ITEMS); + assert_eq!(output.max_items(), 256); + assert_eq!(input.max_bytes(), QUEUE_BYTES); + assert_eq!(output.max_bytes(), 8 * 1024 * 1024); + assert_eq!(input.max_item_bytes(), ITEM_BYTES); + assert_eq!(output.max_item_bytes(), 1024 * 1024); + let lower = output.lowered(2, 8, 4).unwrap(); + assert_eq!(lower.max_items(), 2); + assert_eq!(lower.max_bytes(), 8); + assert_eq!(lower.max_item_bytes(), 4); + for (items, bytes, item) in [ + (0, 8, 4), + (2, 0, 4), + (2, 8, 0), + (3, 8, 4), + (2, 9, 4), + (2, 8, 5), + (usize::MAX, usize::MAX, usize::MAX), + ] { + assert_resource_limit(lower.lowered(items, bytes, item)); + } + assert_resource_limit(input.lowered(65, QUEUE_BYTES, ITEM_BYTES)); +} + +#[tokio::test] +async fn oversize_is_rejected_before_payload_allocation_even_when_full() { + let (tx, _rx) = bounded_queue::, ()>(small(1, 4)); + tx.send(vec![0; 4], None).await.unwrap(); + assert_resource_limit(tx.reserve(5, None).await); + assert_resource_limit(tx.reserve(usize::MAX, None).await); + assert_resource_limit(QueueLimits::output().check_item(ITEM_BYTES + 1)); + QueueLimits::output().check_item(ITEM_BYTES).unwrap(); + // A lowered total-byte budget can be stricter than the single-item budget. + let lower = QueueLimits::output().lowered(1, 2, 4).unwrap(); + assert_resource_limit(lower.check_item(3)); +} + +#[tokio::test] +async fn item_budget_backpressures_and_preserves_fifo() { + let (tx, mut rx) = bounded_queue::, ()>(small(2, 8)); + tx.send(vec![1], None).await.unwrap(); + tx.send(vec![2], None).await.unwrap(); + let waiting = tx.send(vec![3], None); + tokio::pin!(waiting); + assert!(poll!(waiting.as_mut()).is_pending()); + assert_eq!(rx.recv().await, Some(QueueEvent::Data(vec![1]))); + timeout(Duration::from_secs(1), waiting) + .await + .unwrap() + .unwrap(); + tx.finish(()); + assert_eq!(rx.recv().await, Some(QueueEvent::Data(vec![2]))); + assert_eq!(rx.recv().await, Some(QueueEvent::Data(vec![3]))); + assert_eq!(rx.recv().await, Some(QueueEvent::Terminal(()))); + assert_eq!(rx.recv().await, None); +} + +#[tokio::test] +async fn byte_budget_backpressures_before_item_budget() { + let (tx, mut rx) = bounded_queue::, ()>(small(4, 4)); + tx.send(vec![1; 3], None).await.unwrap(); + let waiting = tx.send(vec![2; 2], None); + tokio::pin!(waiting); + assert!(poll!(waiting.as_mut()).is_pending()); + assert_eq!(rx.recv().await, Some(QueueEvent::Data(vec![1; 3]))); + timeout(Duration::from_secs(1), waiting) + .await + .unwrap() + .unwrap(); + assert_eq!(rx.recv().await, Some(QueueEvent::Data(vec![2; 2]))); +} + +#[tokio::test] +async fn defaults_enforce_the_actual_item_counts() { + for limits in [QueueLimits::audio_input(), QueueLimits::output()] { + let (tx, mut rx) = bounded_queue::, ()>(limits); + for _ in 0..limits.max_items() { + tx.send(Vec::new(), None).await.unwrap(); + } + let waiting = tx.reserve(0, None); + tokio::pin!(waiting); + assert!(poll!(waiting.as_mut()).is_pending()); + assert_eq!(rx.recv().await, Some(QueueEvent::Data(Vec::new()))); + drop( + timeout(Duration::from_secs(1), waiting) + .await + .unwrap() + .unwrap(), + ); + } +} + +#[tokio::test] +async fn default_eight_mib_budget_and_terminal_survive_a_full_queue() { + let (tx, mut rx) = bounded_queue::, &str>(QueueLimits::output()); + for _ in 0..8 { + tx.send(vec![0; ITEM_BYTES], None).await.unwrap(); + } + let waiting = tx.reserve(1, None); + tokio::pin!(waiting); + assert!(poll!(waiting.as_mut()).is_pending()); + assert!(tx.finish("provider error")); + assert_closed(timeout(Duration::from_secs(1), waiting).await.unwrap()); + for _ in 0..8 { + assert!(matches!(rx.recv().await, Some(QueueEvent::Data(_)))); + } + assert_eq!( + rx.recv().await, + Some(QueueEvent::Terminal("provider error")) + ); + assert_eq!(rx.recv().await, None); +} + +#[tokio::test] +async fn abort_interrupts_both_kinds_of_capacity_wait() { + for (items, bytes) in [(1, 4), (4, 1)] { + let (tx, mut rx) = bounded_queue::, &str>(small(items, bytes)); + let abort = AbortSignal::new(); + tx.send(vec![1], None).await.unwrap(); + let waiting = tx.send(vec![2], Some(&abort)); + tokio::pin!(waiting); + assert!(poll!(waiting.as_mut()).is_pending()); + abort.abort(); + assert!(matches!( + timeout(Duration::from_secs(1), waiting).await.unwrap(), + Err(OpsError::AiMux(aimux_core::error::AiMuxError::Aborted(_))) + )); + assert!(tx.finish("cancelled")); + assert_eq!(rx.recv().await, Some(QueueEvent::Data(vec![1]))); + assert_eq!(rx.recv().await, Some(QueueEvent::Terminal("cancelled"))); + } +} + +#[tokio::test] +async fn already_aborted_send_does_not_take_free_capacity() { + let (tx, mut rx) = bounded_queue::, ()>(small(1, 1)); + let abort = AbortSignal::new(); + abort.abort(); + assert!(matches!( + tx.send(vec![1], Some(&abort)).await, + Err(OpsError::AiMux(aimux_core::error::AiMuxError::Aborted(_))) + )); + tx.send(vec![2], None).await.unwrap(); + assert_eq!(rx.recv().await, Some(QueueEvent::Data(vec![2]))); +} + +#[tokio::test] +async fn terminal_is_first_writer_wins_and_not_capacity_bound() { + let (tx, mut rx) = bounded_queue::, &str>(small(1, 1)); + tx.send(vec![1], None).await.unwrap(); + assert_eq!(tx.status(), QueueStatus::Open); + assert!(tx.finish("success")); + assert!(!tx.finish("cancelled")); + assert_eq!(tx.status(), QueueStatus::Finished); + assert_closed(tx.send(vec![2], None).await); + assert_eq!(rx.recv().await, Some(QueueEvent::Data(vec![1]))); + assert_eq!(rx.recv().await, Some(QueueEvent::Terminal("success"))); + assert_eq!(rx.recv().await, None); + assert!(!tx.finish("late error")); + drop(rx); + assert_eq!(tx.status(), QueueStatus::Finished); +} + +#[tokio::test] +async fn terminal_does_not_wait_for_outstanding_reservations() { + let (tx, mut rx) = bounded_queue::, &str>(small(1, 1)); + let permit = tx.reserve(1, None).await.unwrap(); + tx.finish("error"); + assert_eq!(rx.recv().await, Some(QueueEvent::Terminal("error"))); + assert_closed(permit.send(vec![1])); + assert_eq!(rx.recv().await, None); +} + +#[tokio::test] +async fn dropped_reservations_release_item_and_byte_capacity() { + let (tx, mut rx) = bounded_queue::, ()>(small(1, 4)); + let permit = tx.reserve(4, None).await.unwrap(); + let waiting = tx.send(vec![2; 4], None); + tokio::pin!(waiting); + assert!(poll!(waiting.as_mut()).is_pending()); + drop(permit); + timeout(Duration::from_secs(1), waiting) + .await + .unwrap() + .unwrap(); + assert_eq!(rx.recv().await, Some(QueueEvent::Data(vec![2; 4]))); +} + +#[tokio::test] +async fn smaller_payload_returns_unused_reserved_bytes() { + let (tx, mut rx) = bounded_queue::, ()>(small(2, 4)); + let permit = tx.reserve(4, None).await.unwrap(); + permit.send(vec![1]).unwrap(); + tx.send(vec![2; 3], None).await.unwrap(); + assert_eq!(rx.recv().await, Some(QueueEvent::Data(vec![1]))); + assert_eq!(rx.recv().await, Some(QueueEvent::Data(vec![2; 3]))); +} + +#[tokio::test] +async fn oversized_commit_releases_reservation_without_enqueuing() { + let (tx, mut rx) = bounded_queue::, ()>(small(1, 4)); + let permit = tx.reserve(1, None).await.unwrap(); + assert_resource_limit(permit.send(vec![0; 2])); + tx.send(vec![1; 4], None).await.unwrap(); + assert_eq!(rx.recv().await, Some(QueueEvent::Data(vec![1; 4]))); +} + +#[tokio::test] +async fn explicit_close_discards_data_and_wakes_all_waiters() { + let (tx, mut rx) = bounded_queue::, ()>(small(1, 1)); + tx.send(vec![1], None).await.unwrap(); + let waiting1 = tx.send(vec![2], None); + let waiting2 = tx.send(vec![3], None); + tokio::pin!(waiting1, waiting2); + assert!(poll!(waiting1.as_mut()).is_pending()); + assert!(poll!(waiting2.as_mut()).is_pending()); + rx.close(); + rx.close(); + assert_closed(timeout(Duration::from_secs(1), waiting1).await.unwrap()); + assert_closed(timeout(Duration::from_secs(1), waiting2).await.unwrap()); + assert_eq!(tx.status(), QueueStatus::ConsumerClosed); + assert_eq!(rx.recv().await, None); + assert!(!tx.finish(())); +} + +#[tokio::test] +async fn dropping_consumer_wakes_sender_and_invalidates_reservations() { + let (tx, rx) = bounded_queue::, ()>(small(1, 1)); + let permit = tx.reserve(1, None).await.unwrap(); + let waiting = tx.send(vec![2], None); + tokio::pin!(waiting); + assert!(poll!(waiting.as_mut()).is_pending()); + drop(rx); + assert_closed(timeout(Duration::from_secs(1), waiting).await.unwrap()); + assert_closed(permit.send(vec![1])); + assert_eq!(tx.status(), QueueStatus::ConsumerClosed); +} + +#[tokio::test] +async fn close_releases_only_queued_budget_not_outstanding_reservations() { + let (tx, mut rx) = bounded_queue::, ()>(small(3, 4)); + let permit = tx.reserve(2, None).await.unwrap(); + tx.send(vec![0; 2], None).await.unwrap(); + rx.close(); + drop(permit); // Must not underflow the budget after close. + assert_closed(tx.reserve(1, None).await); +} + +#[tokio::test] +async fn waiting_receive_wakes_for_data_and_terminal() { + let (tx, mut rx) = bounded_queue::(small(1, 4)); + { + let waiting = rx.recv(); + tokio::pin!(waiting); + assert!(poll!(waiting.as_mut()).is_pending()); + tx.send("hi".to_string(), None).await.unwrap(); + assert_eq!( + timeout(Duration::from_secs(1), waiting).await.unwrap(), + Some(QueueEvent::Data("hi".to_string())) + ); + } + let waiting = rx.recv(); + tokio::pin!(waiting); + assert!(poll!(waiting.as_mut()).is_pending()); + tx.finish("done"); + assert_eq!( + timeout(Duration::from_secs(1), waiting).await.unwrap(), + Some(QueueEvent::Terminal("done")) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn concurrent_senders_make_progress_without_loss() { + let (tx, mut rx) = bounded_queue::, ()>(small(3, 5)); + let mut tasks = Vec::new(); + for producer in 0..4u8 { + let tx = tx.clone(); + tasks.push(tokio::spawn(async move { + for sequence in 0..100u8 { + tx.reserve(2, None) + .await + .unwrap() + .send(vec![producer, sequence]) + .unwrap(); + } + })); + } + let terminal = tx.clone(); + let finish = tokio::spawn(async move { + for task in tasks { + task.await.unwrap(); + } + terminal.finish(()); + }); + timeout(Duration::from_secs(5), async { + let mut next = [0u8; 4]; + while let Some(event) = rx.recv().await { + match event { + QueueEvent::Data(data) => { + assert_eq!(data[1], next[data[0] as usize]); + next[data[0] as usize] += 1; + } + QueueEvent::Terminal(()) => assert_eq!(next, [100; 4]), + } + } + finish.await.unwrap(); + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn retained_allocation_capacity_and_utf8_bytes_are_budgeted() { + let (tx, mut rx) = bounded_queue::, ()>(small(2, 4)); + let mut spare = Vec::with_capacity(4); + spare.push(1); + tx.send(spare, None).await.unwrap(); + let waiting = tx.reserve(1, None); + tokio::pin!(waiting); + assert!(poll!(waiting.as_mut()).is_pending()); + assert!(matches!(rx.recv().await, Some(QueueEvent::Data(_)))); + drop( + timeout(Duration::from_secs(1), waiting) + .await + .unwrap() + .unwrap(), + ); + + let (tx, _rx) = bounded_queue::(small(2, 4)); + assert_resource_limit(tx.send("你好".into(), None).await); + tx.send("你".into(), None).await.unwrap(); +} + +#[tokio::test] +async fn cancelling_waiting_send_does_not_consume_future_capacity() { + let (tx, mut rx) = bounded_queue::, ()>(small(1, 1)); + tx.send(vec![1].into_boxed_slice(), None).await.unwrap(); + { + let waiting = tx.send(vec![2].into_boxed_slice(), None); + tokio::pin!(waiting); + assert!(poll!(waiting.as_mut()).is_pending()); + // Dropping this future must not consume capacity or enqueue the item. + } + assert_eq!( + rx.recv().await, + Some(QueueEvent::Data(vec![1].into_boxed_slice())) + ); + tx.send(vec![3].into_boxed_slice(), None).await.unwrap(); + tx.finish(()); + assert_eq!( + rx.recv().await, + Some(QueueEvent::Data(vec![3].into_boxed_slice())) + ); + assert_eq!(rx.recv().await, Some(QueueEvent::Terminal(()))); +} diff --git a/aimux-ops/tests/manifest_contract.rs b/aimux-ops/tests/manifest_contract.rs new file mode 100644 index 000000000..4b1626821 --- /dev/null +++ b/aimux-ops/tests/manifest_contract.rs @@ -0,0 +1,652 @@ +//! Frozen RFC-0039 §3.2 inventory, independent of the implementation table. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; + +use aimux_ops::dispatch::descriptor; +use aimux_ops::manifest::{manifest, manifest_digest}; +use aimux_ops::op::{ABI_VERSION, OPS_VERSION}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +const RFC_OPS: &[(&str, &str, &[&str])] = &[ + ("decision.decide", "call", &["Decision"]), + ("decision.capabilities", "call", &["Decision"]), + ("runtime.describe", "call", &[]), + ("runtime.configure", "call", &[]), + ("provider.create", "call", &[]), + ("provider.default", "call", &[]), + ("registry.create", "call", &[]), + ("registry.provider", "call", &["Registry"]), + ("provider.model", "call", &["Provider"]), + ("provider.invoke", "call", &["Provider"]), + ("provider.wrap", "call", &["Provider"]), + ("provider.custom", "call", &[]), + ("model.resolve", "call", &["Registry"]), + ( + "model.info", + "call", + &[ + "Language", + "Embedding", + "Image", + "Speech", + "Transcription", + "Reranking", + "Video", + "Search", + "Decision", + ], + ), + ( + "model.wrap", + "call", + &[ + "Language", + "Embedding", + "Image", + "Speech", + "Transcription", + "Reranking", + "Video", + "Search", + "Decision", + ], + ), + ("composite.router", "call", &[]), + ("composite.moa", "call", &[]), + ("text.generate", "call", &["Language"]), + ("text.generateObject", "call", &["Language"]), + ("text.consume", "call", &["Language"]), + ("text.generateOpenAI", "call", &["Language"]), + ("text.stream", "stream", &["Language"]), + ("text.streamOpenAI", "stream", &["Language"]), + ("embedding.embed", "call", &["Embedding"]), + ("embedding.embedMany", "call", &["Embedding"]), + ("image.generate", "call", &["Image"]), + ("speech.generate", "call", &["Speech"]), + ("transcription.generate", "call", &["Transcription"]), + ("reranking.rerank", "call", &["Reranking"]), + ("video.generate", "call", &["Video"]), + ("search.search", "call", &["Search"]), + ("provider.files", "call", &["Provider"]), + ("files.upload", "call", &["Files"]), + ("transcription.start", "call", &["Transcription"]), + ("operation.push", "binaryPush", &["Operation"]), + ("operation.inputDone", "call", &["Operation"]), + ("operation.next", "poll", &["Operation"]), + ("operation.status", "call", &["Operation"]), + ("operation.cancel", "call", &["Operation"]), + ("abort.create", "call", &[]), + ("abort.signal", "call", &["Abort"]), + ("transport.create", "call", &[]), + ("credential.create", "call", &[]), + ("middleware.create", "call", &[]), + ("trace.create", "call", &[]), + ("trace.aggregate", "call", &["TraceStore"]), + ("trace.sessionChain", "call", &["TraceStore"]), + ("trace.sessionTrajectory", "call", &["TraceStore"]), + ("trace.export", "call", &["TraceStore"]), + ("trace.clear", "call", &["TraceStore"]), + ("recorder.create", "call", &[]), + ("recorder.attach", "call", &["Recorder"]), + ("recorder.stop", "call", &["Recorder"]), + ("recorder.flush", "call", &["Recorder"]), + ("recorder.stats", "call", &["Recorder"]), + ("recorder.export", "call", &["Recorder"]), + ("sessionStore.create", "call", &[]), + ("sessionStore.attach", "call", &["SessionStore"]), + ("sessionStore.list", "call", &["SessionStore"]), + ("sessionStore.calls", "call", &["SessionStore"]), + ("replay.create", "call", &[]), + ("replay.capabilities", "call", &["ReplaySession"]), + ("replay.inspect", "call", &["ReplaySession"]), + ("replay.dryRun", "call", &["ReplaySession"]), + ("replay.live", "call", &["ReplaySession"]), + ("catalogue.fetch", "call", &[]), + ("toolRepair.context", "call", &[]), + ("toolRepair.apply", "call", &[]), + ("toolRepair.applyResult", "call", &[]), + ("output.toOpenAI", "call", &["Language"]), + ("auth.codexRefresh", "call", &[]), + ( + "handle.drop", + "call", + &[ + "Provider", + "Registry", + "Language", + "Embedding", + "Image", + "Speech", + "Transcription", + "Reranking", + "Video", + "Search", + "Decision", + "Files", + "Operation", + "Abort", + "Fetch", + "WebSocket", + "Download", + "CredentialResolver", + "Middleware", + "TraceStore", + "Recorder", + "SessionStore", + "ReplaySession", + ], + ), +]; + +fn strings(value: &Value) -> BTreeSet<&str> { + let values = value.as_array().expect("manifest string array"); + let result: BTreeSet<_> = values.iter().map(|v| v.as_str().expect("string")).collect(); + assert_eq!( + values.len(), + result.len(), + "duplicate array entries: {value}" + ); + result +} + +#[test] +fn rfc_inventory_has_exact_names_modes_and_handle_scopes() { + let manifest = manifest(); + assert_eq!(manifest["abiVersion"], ABI_VERSION); + assert_eq!(manifest["opsVersion"], OPS_VERSION); + assert!(!manifest["manifestRevision"].is_null()); + let ops = manifest["ops"].as_array().expect("manifest.ops"); + let mut actual = BTreeMap::new(); + for op in ops { + let name = op["name"].as_str().expect("op.name"); + assert!(actual.insert(name, op).is_none(), "duplicate op: {name}"); + } + let expected: BTreeSet<_> = RFC_OPS.iter().map(|(name, _, _)| *name).collect(); + assert_eq!(expected.len(), RFC_OPS.len(), "duplicate RFC test fixture"); + assert_eq!( + actual.keys().copied().collect::>(), + expected, + "missing RFC ops or extra aliases/capabilities" + ); + let expected_kinds: BTreeSet<_> = RFC_OPS + .iter() + .find(|(name, _, _)| *name == "handle.drop") + .unwrap() + .2 + .iter() + .copied() + .collect(); + assert_eq!( + strings(&manifest["handleKinds"]), + expected_kinds, + "HandleKind inventory" + ); + for &(name, mode, kinds) in RFC_OPS { + let op = actual[name]; + assert_eq!(op["mode"], mode, "{name} mode"); + assert_eq!( + strings(&op["allowedHandleKinds"]), + kinds.iter().copied().collect(), + "{name} scope" + ); + let registered = descriptor(name).unwrap_or_else(|| panic!("{name} has no dispatcher")); + assert_eq!(registered.name, name); + let registered_kinds = serde_json::to_value(registered.allowed_handle_kinds).unwrap(); + assert_eq!( + strings(®istered_kinds), + strings(&op["allowedHandleKinds"]), + "{name}: manifest and dispatcher disagree" + ); + assert_eq!(registered.request_type, op["requestType"].as_str().unwrap()); + assert_eq!(registered.result_type, op["resultType"].as_str().unwrap()); + assert_eq!( + registered.event_type, + op.get("eventType").and_then(Value::as_str) + ); + } +} + +#[test] +fn names_are_case_sensitive_and_have_no_implicit_aliases() { + for &(name, _, _) in RFC_OPS { + assert!(descriptor(name).is_some(), "missing {name}"); + let first_uppercase = format!("{}{}", name[..1].to_ascii_uppercase(), &name[1..]); + for wrong in [ + name.to_ascii_uppercase(), + first_uppercase, + format!(" {name}"), + format!("{name} "), + name.replace('.', "_"), + ] { + assert!(descriptor(&wrong).is_none(), "unexpected alias: {wrong}"); + } + } + for unknown in [ + "", + "text.Generate", + "text.streamopenai", + "Text.generate", + "not.an.op", + ] { + assert!(descriptor(unknown).is_none(), "unexpected op: {unknown}"); + } +} + +// Serialize recursively instead of relying on serde_json::Map ordering: the +// workspace enables preserve_order, which must not affect canonical hashing. +fn canonical(value: &Value, output: &mut String) { + match value { + Value::Object(object) => { + output.push('{'); + for (index, (key, value)) in object + .iter() + .collect::>() + .into_iter() + .enumerate() + { + if index != 0 { + output.push(','); + } + output.push_str(&serde_json::to_string(key).unwrap()); + output.push(':'); + canonical(value, output); + } + output.push('}'); + } + Value::Array(array) => { + output.push('['); + for (index, value) in array.iter().enumerate() { + if index != 0 { + output.push(','); + } + canonical(value, output); + } + output.push(']'); + } + _ => output.push_str(&serde_json::to_string(value).unwrap()), + } +} + +#[test] +fn digest_is_stable_and_hashes_the_complete_canonical_manifest() { + let first = manifest(); + let digest = manifest_digest(); + let mut bytes = String::new(); + assert_eq!(first["digest"], digest); + let mut unsigned = first.clone(); + unsigned.as_object_mut().unwrap().remove("digest"); + canonical(&unsigned, &mut bytes); + assert_eq!(digest, format!("{:x}", Sha256::digest(bytes.as_bytes()))); + for _ in 0..8 { + assert_eq!(manifest(), first); + assert_eq!(manifest_digest(), digest); + } +} + +fn collect_type_names(path: &Path, names: &mut BTreeSet) { + for entry in fs::read_dir(path).unwrap_or_else(|e| panic!("{}: {e}", path.display())) { + let path = entry.unwrap().path(); + if path.is_dir() { + collect_type_names(&path, names); + } else if path.extension().is_some_and(|extension| extension == "rs") { + let source = fs::read_to_string(&path).unwrap(); + // DTOs may be private to their ops family. Inspect definitions, not + // just public exports, without importing FFI into the canonical layer. + for line in source.lines() { + let line = line.trim_start(); + if line.starts_with("//") || line.starts_with('*') { + continue; + } + let tokens: Vec<_> = line + .split(|c: char| !c.is_ascii_alphanumeric() && c != '_') + .filter(|s| !s.is_empty()) + .collect(); + for pair in tokens.windows(2) { + if matches!(pair[0], "struct" | "enum" | "type" | "trait") { + names.insert(pair[1].to_owned()); + } + } + } + } + } +} + +#[test] +fn every_descriptor_type_reference_names_an_existing_type() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + let mut names = BTreeSet::from([ + "()".to_owned(), + "Value".to_owned(), + "String".to_owned(), + "bool".to_owned(), + "u64".to_owned(), + "u32".to_owned(), + "i64".to_owned(), + "i32".to_owned(), + ]); + for crate_name in [ + "aimux-ops", + "aimux-core", + "aimux-providers", + "aimux-provider-utils", + ] { + collect_type_names(&root.join(crate_name).join("src"), &mut names); + } + let manifest = manifest(); + for op in manifest["ops"].as_array().unwrap() { + for field in ["requestType", "resultType", "eventType"] { + let Some(reference) = op.get(field) else { + assert_eq!(field, "eventType", "{} missing {field}", op["name"]); + continue; + }; + let reference = reference.as_str().expect("type reference string"); + let leaf = reference.rsplit("::").next().unwrap(); + assert!( + names.contains(leaf), + "{} {field} references undefined type {reference}", + op["name"] + ); + } + } +} + +#[test] +fn metadata_and_owned_control_schemas_are_complete() { + let manifest = manifest(); + let operations = manifest["ops"].as_array().unwrap(); + assert!( + operations + .windows(2) + .all(|pair| pair[0]["name"].as_str() < pair[1]["name"].as_str()) + ); + let types: BTreeMap<_, _> = manifest["types"] + .as_array() + .unwrap() + .iter() + .map(|ty| (ty["id"].as_str().unwrap(), ty)) + .collect(); + assert_eq!(types.len(), manifest["types"].as_array().unwrap().len()); + for op in operations { + for field in [ + "scope", + "sourceSymbolInventory", + "terminalType", + "runtimeHandleFields", + "requiredCapabilities", + "cancellation", + "resultOwnership", + "requestBytePolicy", + ] { + assert!(op.get(field).is_some(), "{} missing {field}", op["name"]); + } + assert!(op.get("cancellable").is_none()); + assert!(op.get("sourceSymbols").is_none()); + for field in ["requestType", "resultType", "eventType", "terminalType"] { + if let Some(id) = op[field].as_str() { + assert!(types.contains_key(id), "{} missing type {id}", op["name"]); + } + } + } + for id in [ + "HandleId", + "StreamFrame", + "ErrorEnvelope", + "EmptyParams", + "OperationNextParams", + "TranscriptionStartParams", + "OperationStatus", + "OperationPollResult", + "CancelResult", + ] { + let ty = types[id]; + assert!(ty["rustPath"].as_str().unwrap().starts_with("aimux_ops::")); + } + for (id, valid, invalid) in [ + ( + "EmptyParams", + serde_json::json!({}), + serde_json::json!({"unknown":true}), + ), + ( + "OperationNextParams", + serde_json::json!({"timeoutMs":0}), + serde_json::json!({"timeoutMs":-1}), + ), + ( + "OperationNextParams", + serde_json::json!({}), + serde_json::json!({"unknown":true}), + ), + ( + "TranscriptionStartParams", + serde_json::json!({"providerOptions":{"vendor":{"open":true}}}), + serde_json::json!({"unknown":true}), + ), + ( + "TranscriptionStartParams", + serde_json::json!({"timeout":{"chunkMs":1}}), + serde_json::json!({"timeout":{"unknown":true}}), + ), + ( + "CancelResult", + serde_json::json!("alreadyTerminal"), + serde_json::json!("success"), + ), + ( + "OperationPollResult", + serde_json::json!({"type":"part","part":{}}), + serde_json::json!({"type":"part"}), + ), + ] { + let validator = jsonschema::validator_for(&types[id]["schema"]).unwrap(); + assert!(validator.is_valid(&valid), "{id} rejects {valid}"); + assert!(!validator.is_valid(&invalid), "{id} admits {invalid}"); + } + assert!( + types["serde_json::Value"].get("schema").is_none(), + "business schema generation is G-d" + ); + let codes = manifest["errorCodes"].as_array().unwrap(); + assert_eq!( + codes.iter().find(|code| code["code"] == 3).unwrap()["aliases"], + serde_json::json!([ + "InvalidResponseData", + "ToolCallNotFoundForApproval", + "ToolChoiceViolation" + ]) + ); + assert_eq!( + codes.iter().find(|code| code["code"] == 6).unwrap()["aliases"], + serde_json::json!(["InvalidPrompt", "MissingToolResults"]) + ); +} + +#[test] +fn all_owned_types_publish_schemas_and_family_controls_are_strict() { + let value = manifest(); + let types: BTreeMap<_, _> = value["types"] + .as_array() + .unwrap() + .iter() + .map(|ty| (ty["id"].as_str().unwrap(), ty)) + .collect(); + for ty in types.values() { + if ty["rustPath"].as_str().unwrap().starts_with("aimux_ops::") { + assert!(ty.get("schema").is_some(), "missing schema: {}", ty["id"]); + jsonschema::validator_for(&ty["schema"]).unwrap(); + } + } + for (id, valid) in [ + ( + "runtime::RuntimeConfigureParams", + serde_json::json!({"logging":{"level":"warn"},"proxy":{}}), + ), + ( + "provider::ProviderCreateParams", + serde_json::json!({"package":"openai","settings":{"headers":{"x-custom":null},"fetch":"1"}}), + ), + ( + "registry::RegistryCreateParams", + serde_json::json!({"entries":[{"key":"vendor","provider":"1"}]}), + ), + ( + "registry::ModelResolveParams", + serde_json::json!({"modelRef":{"namespace":"ns","key":"key","method":"decision","modelId":"m"}}), + ), + ( + "composite::RouterParams", + serde_json::json!({"children":["1"],"strategy":{"type":"rule"}}), + ), + ( + "composite::MoaParams", + serde_json::json!({"references":[],"aggregator":"1"}), + ), + ( + "text::TextRequest", + serde_json::json!({"prompt":"hello","options":{"providerOptions":{"vendor":{"open":true}}},"abort":null}), + ), + ( + "text::OpenAiTextRequest", + serde_json::json!({"prompt":"hello","streamOptions":{"includeUsage":true}}), + ), + ( + "decision::DecisionDecideRequest", + serde_json::json!({"options":{},"abort":"18446744073709551615"}), + ), + ( + "files::UploadFileParams", + serde_json::json!({"data":{"type":"text","text":"hello"},"mediaType":"text/plain","providerOptions":{"vendor":{"open":true}}}), + ), + ( + "handles::TransportParams", + serde_json::json!({"kind":"fetch","proxy":{"http_url":null}}), + ), + ( + "handles::CredentialParams", + serde_json::json!({"kind":"future"}), + ), + ( + "handles::MiddlewareParams", + serde_json::json!({"kind":"trace","traceStore":"1","auditMode":"strict"}), + ), + ( + "trace::TraceCreateParams", + serde_json::json!({"auditMode":"shared","capacity":1}), + ), + ( + "trace::TraceAggregateParams", + serde_json::json!({"filter":{"sessionId":"s","sinceUnixMs":-1}}), + ), + ( + "session::SessionStoreCreateParams", + serde_json::json!({"infer":true}), + ), + ( + "recording::CreateRecorderParams", + serde_json::json!({"backend":{"type":"ring","capacity":1},"capturePolicy":"full"}), + ), + ( + "replay::ReplayCreateParams", + serde_json::json!({"recordingsJsonl":""}), + ), + ( + "replay::ReplayLiveParams", + serde_json::json!({"callId":"id","target":{"kind":"model","handle":"1"},"abort":null}), + ), + ( + "misc::CatalogueFetchRequest", + serde_json::json!({"sourceUrl":null,"abort":null}), + ), + ( + "misc::CodexRefreshRequest", + serde_json::json!({"refreshToken":"token","clientId":"client"}), + ), + ( + "misc::OpenAiOutputRequest", + serde_json::json!({"result":{},"metadataNamespace":"openai"}), + ), + ] { + let full_id = format!("aimux_ops::ops::{id}"); + let validator = jsonschema::validator_for(&types[full_id.as_str()]["schema"]).unwrap(); + assert!(validator.is_valid(&valid), "{id} rejects {valid}"); + let mut unknown = valid.clone(); + unknown["unknown"] = serde_json::json!(true); + assert!( + !validator.is_valid(&unknown), + "{id} admits unknown controls" + ); + assert!( + !validator.is_valid(&serde_json::json!([])), + "{id} admits non-object" + ); + } + for (id, invalid) in [ + ( + "provider::ProviderCreateParams", + serde_json::json!({"package":"openai","settings":{"unknown":true}}), + ), + ( + "registry::RegistryCreateParams", + serde_json::json!({"entries":[{"key":"k","provider":"1","unknown":true}]}), + ), + ( + "composite::RouterParams", + serde_json::json!({"children":["1"],"strategy":{"type":"rule","weights":[]}}), + ), + ( + "handles::TransportParams", + serde_json::json!({"kind":"fetch","proxy":{"unknown":true}}), + ), + ( + "recording::CreateRecorderParams", + serde_json::json!({"backend":{"type":"ring","unknown":true},"capturePolicy":"full"}), + ), + ( + "replay::ReplayLiveParams", + serde_json::json!({"callId":"id","target":{"kind":"model","handle":"1","unknown":true}}), + ), + ( + "text::OpenAiTextRequest", + serde_json::json!({"prompt":"hi","streamOptions":{"unknown":true}}), + ), + ( + "files::UploadFileParams", + serde_json::json!({"data":{"type":"text","text":"hi","data":"aGk="},"mediaType":"text/plain"}), + ), + ( + "trace::TraceAggregateParams", + serde_json::json!({"filter":{"unknown":true}}), + ), + ] { + let full_id = format!("aimux_ops::ops::{id}"); + assert!( + !jsonschema::validator_for(&types[full_id.as_str()]["schema"]) + .unwrap() + .is_valid(&invalid), + "{id} admits {invalid}" + ); + } + let handles = jsonschema::validator_for(&types["HandleId"]["schema"]).unwrap(); + assert!(handles.is_valid(&serde_json::json!("001"))); + assert!(!handles.is_valid(&serde_json::json!("18446744073709551616"))); + assert!(!handles.is_valid(&serde_json::json!(1))); +} + +#[test] +fn packages_list_every_provider_create_package_once() { + let manifest = manifest(); + let packages: Vec<&str> = manifest["packages"] + .as_array() + .expect("manifest.packages") + .iter() + .map(|name| name.as_str().expect("package name")) + .collect(); + assert!(packages.windows(2).all(|pair| pair[0] < pair[1])); + assert!(packages.contains(&"jev")); + for name in aimux_providers::provider_names() { + assert!(packages.contains(&name), "{name}"); + } +} diff --git a/aimux-ops/tests/misc.rs b/aimux-ops/tests/misc.rs new file mode 100644 index 000000000..bbe2693c2 --- /dev/null +++ b/aimux-ops/tests/misc.rs @@ -0,0 +1,188 @@ +//! Exercise the real misc registrations, owner resolution and projected errors. +use std::sync::Arc; +use std::time::SystemTime; + +use aimux_core::provider::Provider; +use aimux_ops::dispatch::{self, Ctx}; +use aimux_ops::handle::{Entry, HandleId, HandleKind, HandleRegistry, Owner}; +use serde_json::{Value, json}; + +fn ctx(owner: Owner) -> Ctx { + Ctx { + owner, + abort: None, + now: SystemTime::now, + } +} + +fn result() -> Value { + json!({ + "steps": [{ + "callId":"call", "stepNumber":0, + "model":{"provider":"test", "modelId":"fallback"}, + "content":[{"type":"text", "text":"hello"}], + "finishReason":"stop", "usage":{}, "performance":aimux_core::step_result::StepResultPerformance::default(), "warnings":[], + "request":{}, + "response":{"id":"response", "timestamp":"2026-01-01T00:00:00.000Z", "modelId":"", "messages":[]}, + "providerMetadata":{ + "openai":{"logprobs":[{"token":"wrong", "logprob":-1}]}, + "chosen":{"logprobs":[{"token":"right", "logprob":-0.5}]} + } + }], "totalUsage":{} + }) +} + +#[tokio::test] +async fn output_uses_explicit_metadata_and_model_fallback_without_generation() { + let owner = Owner::new_node().unwrap(); + let provider = aimux_providers::create_openai(Default::default()).unwrap(); + let model = provider.language_model("fallback").unwrap(); + let registry = HandleRegistry::global(); + let handle = registry.insert(owner, Entry::Language(model)).unwrap(); + for (namespace, expected) in [("chosen", Some("right")), ("missing", None)] { + let actual = dispatch::call( + ctx(owner), + handle, + "output.toOpenAI", + json!({ + "result":result(), "metadataNamespace":namespace + }), + ) + .await + .unwrap(); + assert_eq!(actual["model"], "fallback"); + assert_eq!(actual["id"], "response"); + assert_eq!(actual["choices"][0]["message"]["content"], "hello"); + assert_eq!( + actual["choices"][0]["logprobs"]["content"][0]["token"].as_str(), + expected + ); + } + for request in [ + json!({"result":result()}), + json!({"result":result(),"metadataNamespace":""}), + json!({"result":result(),"metadataNamespace":"chosen","extra":true}), + ] { + let error = dispatch::call(ctx(owner), handle, "output.toOpenAI", request) + .await + .unwrap_err(); + assert_eq!(error.code(), 301); + } + assert!(registry.remove(owner, handle).is_some()); +} + +#[tokio::test] +async fn catalogue_secondary_abort_hides_foreign_owners_and_supports_cancellation() { + let owner = Owner::new_node().unwrap(); + let foreign = Owner::new_node().unwrap(); + let registry = HandleRegistry::global(); + let abort = aimux_core::AbortSignal::new(); + abort.abort(); + let handle = registry + .insert(owner, Entry::Abort(Arc::new(abort))) + .unwrap(); + let params = json!({"sourceUrl":"http://127.0.0.1/catalogue", "abort":handle}); + let foreign_error = + dispatch::call(ctx(foreign), HandleId(0), "catalogue.fetch", params.clone()) + .await + .unwrap_err(); + let unknown_error = dispatch::call( + ctx(foreign), + HandleId(0), + "catalogue.fetch", + json!({ + "sourceUrl":"http://127.0.0.1/catalogue", "abort":HandleId(u64::MAX) + }), + ) + .await + .unwrap_err(); + assert_eq!(foreign_error.code(), 203); + assert_eq!( + serde_json::to_value(foreign_error.envelope(SystemTime::UNIX_EPOCH)).unwrap(), + serde_json::to_value(unknown_error.envelope(SystemTime::UNIX_EPOCH)).unwrap() + ); + let cancelled = dispatch::call(ctx(owner), HandleId(0), "catalogue.fetch", params) + .await + .unwrap_err(); + assert_eq!(cancelled.code(), 13); + let wrong = registry + .insert( + owner, + Entry::Provider(Arc::new( + aimux_providers::create_openai(Default::default()).unwrap(), + )), + ) + .unwrap(); + let error = dispatch::call( + ctx(owner), + HandleId(0), + "catalogue.fetch", + json!({"abort":wrong}), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), 203); + assert!(registry.remove(owner, handle).is_some()); + assert!(registry.remove(owner, wrong).is_some()); +} + +#[tokio::test] +async fn invalid_auth_request_is_projected_once_without_token_values() { + let owner = Owner::new_node().unwrap(); + let error = dispatch::call( + ctx(owner), + HandleId(0), + "auth.codexRefresh", + json!({ + "refreshToken":"secret-token", "clientId":"client", "secret-field":true + }), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), 301); + let first = error.envelope(SystemTime::UNIX_EPOCH); + let second = error.envelope(SystemTime::now()); + assert_eq!( + serde_json::to_value(&first).unwrap(), + serde_json::to_value(second).unwrap() + ); + assert!(!serde_json::to_string(&first).unwrap().contains("secret")); +} + +#[test] +fn misc_metadata_preserves_the_existing_inventory() { + let names = [ + "catalogue.fetch", + "toolRepair.context", + "toolRepair.apply", + "toolRepair.applyResult", + "auth.codexRefresh", + "output.toOpenAI", + ]; + for name in names { + let descriptor = dispatch::descriptor(name).unwrap(); + assert_ne!(descriptor.request_type, "serde_json::Value"); + assert!(!descriptor.source_symbols.is_empty()); + assert_eq!( + descriptor.allowed_handle_kinds, + if name == "output.toOpenAI" { + &[HandleKind::Language][..] + } else { + &[] + } + ); + } + assert_eq!( + dispatch::descriptor("catalogue.fetch") + .unwrap() + .runtime_handle_fields, + &[("abort", &[HandleKind::Abort][..])] + ); + assert_eq!( + aimux_ops::manifest::manifest()["ops"] + .as_array() + .unwrap() + .len(), + 72 + ); +} diff --git a/aimux-ops/tests/modalities.rs b/aimux-ops/tests/modalities.rs new file mode 100644 index 000000000..151e508d5 --- /dev/null +++ b/aimux-ops/tests/modalities.rs @@ -0,0 +1,358 @@ +//! Real modality registration, owner-aware abort lifting and cached projection. +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime}; + +use aimux_core::error::ApiCallError; +use aimux_core::search_model::{SearchCallOptions, SearchModel, SearchResult}; +use aimux_core::{AbortSignal, AiMuxError}; +use aimux_ops::dispatch::{self, Ctx}; +use aimux_ops::envelope::{BindingError, OpsError}; +use aimux_ops::handle::{Entry, HandleId, HandleKind, HandleRegistry, Owner}; +use aimux_ops::op::{Cancellation, RequestBytePolicy, Scope}; +use serde_json::{Value, json}; +use tokio::sync::Notify; + +enum Behavior { + Ready, + ApiError, + Pending, +} + +struct Search { + behavior: Behavior, + calls: Mutex)>>, + started: Notify, +} + +impl SearchModel for Search { + fn provider(&self) -> &str { + "mock" + } + fn model_id(&self) -> &str { + "search" + } + fn do_search<'life0, 'life1, 'async_trait>( + &'life0 self, + options: &'life1 SearchCallOptions, + ) -> Pin> + Send + 'async_trait>> + where + 'life0: 'async_trait, + 'life1: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + self.calls.lock().unwrap().push(( + serde_json::to_value(options).unwrap(), + options.abort_signal.clone(), + )); + self.started.notify_one(); + match self.behavior { + Behavior::Ready => { + Ok(serde_json::from_value(json!({"results": [], "warnings": []})).unwrap()) + } + Behavior::Pending => std::future::pending().await, + Behavior::ApiError => { + let mut error = ApiCallError::new("limited", "https://mock.test", json!({})); + error.status_code = Some(429); + error.is_retryable = true; + error.response_headers = Some(std::collections::HashMap::from([( + "retry-after".into(), + "Tue, 14 Nov 2023 22:13:25 GMT".into(), + )])); + Err(AiMuxError::ApiCall(Box::new(error))) + } + } + }) + } +} + +fn now() -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000) +} + +struct Fixture { + owner: Owner, + foreign: Owner, + handle: HandleId, + model: Arc, +} + +impl Fixture { + fn new(behavior: Behavior) -> Self { + let owner = Owner::new_node().unwrap(); + let foreign = Owner::new_python().unwrap(); + let model = Arc::new(Search { + behavior, + calls: Mutex::new(vec![]), + started: Notify::new(), + }); + let handle = HandleRegistry::global() + .insert(owner, Entry::Search(model.clone())) + .unwrap(); + Self { + owner, + foreign, + handle, + model, + } + } + + fn ctx(&self) -> Ctx { + Ctx { + owner: self.owner, + abort: None, + now, + } + } + + fn abort(&self, owner: Owner, signal: &AbortSignal) -> HandleId { + HandleRegistry::global() + .insert(owner, Entry::Abort(Arc::new(signal.clone()))) + .unwrap() + } + + async fn call(&self, params: Value) -> Result { + dispatch::call(self.ctx(), self.handle, "search.search", params).await + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + HandleRegistry::global().release_owner(self.owner); + HandleRegistry::global().release_owner(self.foreign); + } +} + +#[test] +fn manifest_uses_canonical_paths_and_complete_modality_metadata() { + for (name, module, prefix, kind, bytes) in [ + ( + "embedding.embed", + "embedding", + "Embedding", + HandleKind::Embedding, + false, + ), + ( + "embedding.embedMany", + "embedding", + "Embedding", + HandleKind::Embedding, + false, + ), + ("image.generate", "image", "Image", HandleKind::Image, true), + ( + "speech.generate", + "speech", + "Speech", + HandleKind::Speech, + false, + ), + ( + "transcription.generate", + "transcription", + "Transcription", + HandleKind::Transcription, + true, + ), + ( + "reranking.rerank", + "reranking", + "Reranking", + HandleKind::Reranking, + false, + ), + ("video.generate", "video", "Video", HandleKind::Video, true), + ( + "search.search", + "search", + "Search", + HandleKind::Search, + false, + ), + ] { + let descriptor = dispatch::descriptor(name).unwrap(); + assert_eq!(descriptor.allowed_handle_kinds, &[kind]); + assert_eq!( + descriptor.request_type, + format!("aimux_core::{module}_model::{prefix}CallOptions") + ); + assert_eq!( + descriptor.result_type, + format!("aimux_core::{module}_model::{prefix}Result") + ); + assert_eq!( + descriptor.runtime_handle_fields, + &[("abort", &[HandleKind::Abort][..])] + ); + assert_eq!(descriptor.scope, Scope::Current); + assert_eq!(descriptor.cancellation, Cancellation::Abort); + assert_eq!( + descriptor.request_byte_policy, + if bytes { + RequestBytePolicy::JsonWithBase64 + } else { + RequestBytePolicy::Json + } + ); + assert!(descriptor.required_capabilities.is_empty()); + assert!(descriptor.event_type.is_none()); + assert!(descriptor.terminal_type.is_none()); + assert_eq!(descriptor.result_ownership, "owned"); + assert_eq!(descriptor.retry_owner, "core"); + let manifest = aimux_ops::manifest::manifest(); + for ty in [descriptor.request_type, descriptor.result_type] { + let found = manifest["types"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["id"] == ty) + .unwrap(); + assert_eq!(found["rustPath"], ty); + } + } +} + +#[tokio::test] +async fn abort_is_optional_and_owned_signal_is_lifted_without_entering_core_json() { + let f = Fixture::new(Behavior::Ready); + for params in [json!({"query":"q"}), json!({"query":"q", "abort":null})] { + f.call(params).await.unwrap(); + } + let signal = AbortSignal::new(); + let abort = f.abort(f.owner, &signal); + let mut params = json!({"query":"q", "abort":abort, "maxRetries":0, + "headers":{"x-test":"value"}, "providerOptions":{"mock":{"open":{"abort":"data", "nested":[null,"值","🚀"]}}}}); + f.call(params.clone()).await.unwrap(); + params.as_object_mut().unwrap().remove("abort"); + signal.abort(); + let calls = f.model.calls.lock().unwrap(); + assert_eq!(calls.len(), 3); + assert!(calls[0].1.is_none()); + assert!(calls[1].1.is_none()); + assert_eq!(calls[2].0, params); + assert!(calls[2].1.as_ref().unwrap().is_aborted()); +} + +#[tokio::test] +async fn primary_and_secondary_foreign_handles_match_unknown_and_never_call_provider() { + let f = Fixture::new(Behavior::Ready); + let signal = AbortSignal::new(); + let foreign_abort = f.abort(f.foreign, &signal); + let mut errors = vec![]; + for abort in [foreign_abort, HandleId(u64::MAX)] { + let error = f + .call(json!({"query":"q", "abort":abort})) + .await + .unwrap_err(); + assert!(matches!( + error.unprojected(), + OpsError::Binding(BindingError::InvalidHandle { actual: None, .. }) + )); + errors.push(error.envelope(now())); + } + assert_eq!(errors[0], errors[1]); + let wrong_kind = f + .call(json!({"query":"q", "abort":f.handle})) + .await + .unwrap_err(); + assert!(matches!( + wrong_kind.unprojected(), + OpsError::Binding(BindingError::InvalidHandle { + actual: Some(HandleKind::Search), + .. + }) + )); + for invalid in [ + json!(7), + json!("+7"), + json!("18446744073709551616"), + json!({}), + ] { + assert_eq!( + f.call(json!({"query":"q", "abort":invalid})) + .await + .unwrap_err() + .code(), + 301 + ); + } + let mut foreign_ctx = f.ctx(); + foreign_ctx.owner = f.foreign; + let foreign = dispatch::call( + foreign_ctx.clone(), + f.handle, + "search.search", + json!({"query":"q"}), + ) + .await + .unwrap_err(); + let unknown = dispatch::call( + foreign_ctx, + HandleId(u64::MAX), + "search.search", + json!({"query":"q"}), + ) + .await + .unwrap_err(); + assert_eq!(foreign.envelope(now()), unknown.envelope(now())); + assert!(f.model.calls.lock().unwrap().is_empty()); + assert!(!signal.is_aborted()); +} + +#[tokio::test] +async fn owned_abort_cancels_before_and_during_provider_call() { + let f = Fixture::new(Behavior::Pending); + let signal = AbortSignal::new(); + let abort = f.abort(f.owner, &signal); + signal.abort(); + assert_eq!( + f.call(json!({"query":"q", "abort":abort})) + .await + .unwrap_err() + .code(), + 13 + ); + assert!(f.model.calls.lock().unwrap().is_empty()); + + let signal = AbortSignal::new(); + let abort = f.abort(f.owner, &signal); + let future = f.call(json!({"query":"q", "abort":abort})); + let cancel = async { + f.model.started.notified().await; + signal.abort(); + }; + let (result, ()) = tokio::time::timeout(Duration::from_secs(2), async { + tokio::join!(future, cancel) + }) + .await + .unwrap(); + assert_eq!(result.unwrap_err().code(), 13); + let calls = f.model.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert!(calls[0].1.as_ref().unwrap().is_aborted()); +} + +#[tokio::test] +async fn provider_failure_keeps_payload_and_one_retry_hint_projection() { + let f = Fixture::new(Behavior::ApiError); + let error = f + .call(json!({"query":"q", "maxRetries":0})) + .await + .unwrap_err(); + assert!(matches!(error, OpsError::Projected(_))); + assert!(matches!( + error.unprojected(), + OpsError::AiMux(AiMuxError::ApiCall(_)) + )); + let envelope = error.envelope(now()); + assert_eq!(envelope.code, 11); + assert_eq!(envelope.error["name"], "AI_APICallError"); + assert_eq!(envelope.error["statusCode"], 429); + assert_eq!(envelope.error["isRetryable"], true); + assert_eq!(envelope.retry_after_ms, Some(5000)); + assert_eq!(error.envelope(now() + Duration::from_secs(60)), envelope); + assert_eq!(f.model.calls.lock().unwrap().len(), 1); +} diff --git a/aimux-ops/tests/operation.rs b/aimux-ops/tests/operation.rs new file mode 100644 index 000000000..a73c6f9f5 --- /dev/null +++ b/aimux-ops/tests/operation.rs @@ -0,0 +1,1045 @@ +use std::pin::Pin; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::task::{Context, Poll}; +use std::time::Duration; + +use aimux_core::AbortSignal; +use aimux_core::error::AiMuxError; +use aimux_core::transcription_model::{ + AudioChunk, TranscriptionCallOptions, TranscriptionModel, TranscriptionResult, + TranscriptionStreamOptions, TranscriptionStreamPart, TranscriptionStreamResult, +}; +use aimux_ops::envelope::{BindingError, OpsError, ProtocolError}; +use aimux_ops::operation::{ + CancelResult, Operation, OperationLimits, OperationOptions, OperationOutcome, OperationState, + PollResult, +}; +use futures::future::BoxFuture; +use futures::{Stream, StreamExt, poll}; + +const WAIT: Duration = Duration::from_secs(2); + +enum Mode { + Unsupported, + ConnectFailure, + Pending, + Echo(AbortSignal), + Events(Mutex>>), + BlockingDrop(Arc), +} + +struct MockModel { + mode: Mode, + received: Arc>>>, + pulled: Arc, + provider_abort: Arc>>, +} + +impl MockModel { + fn new(mode: Mode) -> Arc { + Arc::new(Self { + mode, + received: Arc::default(), + pulled: Arc::default(), + provider_abort: Arc::default(), + }) + } +} + +impl TranscriptionModel for MockModel { + fn provider(&self) -> &str { + "mock" + } + fn model_id(&self) -> &str { + "realtime" + } + + fn do_generate<'a, 'b, 'c>( + &'a self, + _: &'b TranscriptionCallOptions, + ) -> BoxFuture<'c, Result> + where + 'a: 'c, + 'b: 'c, + Self: 'c, + { + Box::pin(async { Err(AiMuxError::Other("batch generation is unused".into())) }) + } + + fn do_stream( + &self, + options: TranscriptionStreamOptions, + ) -> Option>> { + *self.provider_abort.lock().unwrap() = options.abort_signal; + if matches!(self.mode, Mode::Unsupported) { + return None; + } + Some(Box::pin(async move { + let stream: Pin< + Box> + Send>, + > = match &self.mode { + Mode::Unsupported => return Err(AiMuxError::Other("unused".into())), + Mode::ConnectFailure => return Err(AiMuxError::Other("connect failed".into())), + Mode::Pending => return std::future::pending().await, + Mode::Echo(gate) => { + gate.cancelled().await; + Box::pin(EchoStream { + audio: options.audio, + received: self.received.clone(), + }) + } + Mode::Events(events) => { + let events = std::mem::take(&mut *events.lock().unwrap()); + let pulled = self.pulled.clone(); + Box::pin(futures::stream::iter(events).inspect(move |_| { + pulled.fetch_add(1, Ordering::SeqCst); + })) + } + Mode::BlockingDrop(gate) => Box::pin(BlockingDropStream(gate.clone())), + }; + Ok(TranscriptionStreamResult { + stream, + request: None, + response: None, + }) + })) + } +} + +struct EchoStream { + audio: Pin + Send>>, + received: Arc>>>, +} + +impl Stream for EchoStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.audio.as_mut().poll_next(cx) { + Poll::Ready(Some(AudioChunk::Binary(bytes))) => { + let text = String::from_utf8_lossy(&bytes).into_owned(); + self.received.lock().unwrap().push(bytes); + Poll::Ready(Some(Ok(delta(&text)))) + } + Poll::Ready(Some(AudioChunk::Base64(_))) => panic!("operation must pass binary audio"), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } +} + +#[derive(Default)] +struct DropGate { + polled: AbortSignal, + entered: AbortSignal, + released: Mutex, + changed: Condvar, +} + +struct BlockingDropStream(Arc); + +impl Stream for BlockingDropStream { + type Item = Result; + fn poll_next(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + self.0.polled.abort(); + Poll::Pending + } +} + +impl Drop for BlockingDropStream { + fn drop(&mut self) { + self.0.entered.abort(); + let mut released = self.0.released.lock().unwrap(); + while !*released { + released = self.0.changed.wait(released).unwrap(); + } + } +} + +struct ReleaseDropGate(Arc); + +impl Drop for ReleaseDropGate { + fn drop(&mut self) { + *self.0.released.lock().unwrap() = true; + self.0.changed.notify_all(); + } +} + +fn delta(text: &str) -> TranscriptionStreamPart { + TranscriptionStreamPart::TranscriptDelta { + id: None, + delta: text.into(), + provider_metadata: None, + } +} + +fn events(parts: Vec>) -> Arc { + MockModel::new(Mode::Events(Mutex::new(parts))) +} + +fn open_gate() -> AbortSignal { + let gate = AbortSignal::new(); + gate.abort(); + gate +} + +async fn start(model: Arc, limits: OperationLimits) -> Arc { + Operation::start( + model, + OperationOptions { + limits, + ..Default::default() + }, + None, + ) + .await + .unwrap_or_else(|_| panic!("mock operation failed admission")) +} + +#[test] +fn drop_after_runtime_shutdown_is_safe() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let model = MockModel::new(Mode::Pending); + let operation = runtime.block_on(start(model.clone(), OperationLimits::default())); + drop(runtime); + drop(operation); + assert!( + model + .provider_abort + .lock() + .unwrap() + .as_ref() + .unwrap() + .is_aborted() + ); +} + +#[test] +fn terminate_after_runtime_shutdown_is_safe() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let operation = runtime.block_on(start( + MockModel::new(Mode::Pending), + OperationLimits::default(), + )); + drop(runtime); + futures::executor::block_on(operation.terminate()); + assert!(operation.status().cleanup_failure.is_none()); + assert!(matches!(operation.status().state, OperationState::Terminal)); +} + +async fn until(mut predicate: impl FnMut() -> bool) { + tokio::time::timeout(WAIT, async { + while !predicate() { + tokio::task::yield_now().await; + } + }) + .await + .expect("state did not progress"); +} + +async fn next(operation: &Operation) -> Result { + tokio::time::timeout(WAIT, operation.next(None)) + .await + .expect("next hung") + .map_err(source_error) +} + +fn assert_part(result: Result, text: &str) { + match result { + Ok(PollResult::Part(TranscriptionStreamPart::TranscriptDelta { delta, .. })) => { + assert_eq!(delta, text); + } + _ => panic!("expected transcript delta"), + } +} + +#[tokio::test] +async fn capability_failure_precedes_acceptance() { + let result = Operation::start( + MockModel::new(Mode::Unsupported), + OperationOptions::default(), + None, + ) + .await; + assert!(matches!( + result, + Err(OpsError::AiMux(AiMuxError::UnsupportedFunctionality(_))) + )); +} + +#[tokio::test] +async fn connection_failure_is_saved_and_delivered_once() { + let operation = start( + MockModel::new(Mode::ConnectFailure), + OperationLimits::default(), + ) + .await; + assert!( + matches!(next(&operation).await, Err(OpsError::AiMux(AiMuxError::Other(message))) if message == "connect failed") + ); + assert!(matches!(next(&operation).await, Ok(PollResult::Ended))); + assert!(matches!(next(&operation).await, Ok(PollResult::Ended))); + let terminal = operation.status().terminal.unwrap(); + assert_eq!(terminal.outcome, OperationOutcome::Error); + assert!(terminal.error.is_some()); +} + +#[tokio::test] +async fn pushes_copy_data_and_input_done_is_idempotent() { + let model = MockModel::new(Mode::Echo(AbortSignal::new())); + let operation = start(model.clone(), OperationLimits::default()).await; + let mut buffer = b"original".to_vec(); + // SAFETY: the buffer remains readable and unchanged until push completes. + unsafe { operation.push_raw(buffer.as_ptr(), buffer.len()) } + .unwrap_or_else(|_| panic!("raw input rejected")) + .await + .unwrap_or_else(|_| panic!("push failed")); + buffer.fill(b'x'); + operation.input_done(); + let sequence = operation.status().input_done_sequence; + operation.input_done(); + assert_eq!(operation.status().input_done_sequence, sequence); + assert_eq!(operation.status().state, OperationState::InputClosed); + assert!(matches!( + operation.push(b"late").await, + Err(OpsError::Protocol(ProtocolError::InvalidState(_))) + )); + if let Mode::Echo(gate) = &model.mode { + gate.abort(); + } + assert_part(next(&operation).await, "original"); + assert!(matches!(next(&operation).await, Ok(PollResult::Ended))); + assert_eq!(*model.received.lock().unwrap(), vec![b"original".to_vec()]); + assert_eq!(operation.cancel(), CancelResult::AlreadyTerminal); + assert_eq!( + operation.status().terminal.unwrap().outcome, + OperationOutcome::Success + ); +} + +#[tokio::test] +async fn close_preserves_a_push_admitted_while_full() { + let gate = AbortSignal::new(); + let model = MockModel::new(Mode::Echo(gate.clone())); + let operation = start( + model.clone(), + OperationLimits { + input_items: 1, + ..Default::default() + }, + ) + .await; + operation + .push(b"first") + .await + .unwrap_or_else(|_| panic!("push failed")); + let mut second = Box::pin(operation.push(b"second")); + assert!(poll!(second.as_mut()).is_pending()); + operation.input_done(); + assert_eq!(operation.status().input_done_sequence.as_deref(), Some("2")); + assert!(operation.push(b"late").await.is_err()); + gate.abort(); + tokio::time::timeout(WAIT, second) + .await + .unwrap() + .unwrap_or_else(|_| panic!("admitted push rejected")); + assert_part(next(&operation).await, "first"); + assert_part(next(&operation).await, "second"); + assert!(matches!(next(&operation).await, Ok(PollResult::Ended))); + assert_eq!( + *model.received.lock().unwrap(), + vec![b"first".to_vec(), b"second".to_vec()] + ); +} + +#[tokio::test] +async fn dropped_waiting_push_does_not_leave_a_sequence_gap() { + let gate = AbortSignal::new(); + let operation = start( + MockModel::new(Mode::Echo(gate.clone())), + OperationLimits { + input_items: 1, + ..Default::default() + }, + ) + .await; + operation + .push(b"first") + .await + .unwrap_or_else(|_| panic!("push failed")); + let mut discarded = Box::pin(operation.push(b"discarded")); + assert!(poll!(discarded.as_mut()).is_pending()); + let mut third = Box::pin(operation.push(b"third")); + assert!(poll!(third.as_mut()).is_pending()); + drop(discarded); + operation.input_done(); + gate.abort(); + third + .await + .unwrap_or_else(|_| panic!("sequence gap stalled push")); + assert_part(next(&operation).await, "first"); + assert_part(next(&operation).await, "third"); + assert!(matches!(next(&operation).await, Ok(PollResult::Ended))); +} + +#[tokio::test] +async fn input_byte_budget_backpressures_before_item_limit() { + let gate = AbortSignal::new(); + let operation = start( + MockModel::new(Mode::Echo(gate.clone())), + OperationLimits { + input_bytes: 8, + ..Default::default() + }, + ) + .await; + operation + .push(b"12345678") + .await + .unwrap_or_else(|_| panic!("push failed")); + let mut waiting = Box::pin(operation.push(b"x")); + assert!(poll!(waiting.as_mut()).is_pending()); + assert_eq!(operation.status().queued_input_items, 1); + assert_eq!(operation.status().queued_input_bytes, 8); + gate.abort(); + waiting + .await + .unwrap_or_else(|_| panic!("capacity not released")); + operation.input_done(); + assert_part(next(&operation).await, "12345678"); + assert_part(next(&operation).await, "x"); + assert!(matches!(next(&operation).await, Ok(PollResult::Ended))); +} + +#[tokio::test] +async fn input_checks_raw_pointer_and_lengths_before_reading() { + let operation = start( + MockModel::new(Mode::Echo(open_gate())), + OperationLimits::default(), + ) + .await; + // SAFETY: zero-length input needs no readable pointer. + unsafe { operation.push_raw(std::ptr::null(), 0) } + .unwrap_or_else(|_| panic!("NULL+0 rejected")) + .await + .unwrap_or_else(|_| panic!("empty push failed")); + // SAFETY: invalid arguments must be rejected before any read. + let null = unsafe { operation.push_raw(std::ptr::null(), 1) }; + assert!(matches!( + null, + Err(OpsError::Binding(BindingError::NullPointer { .. })) + )); + // SAFETY: an impossible length is rejected without reading this pointer. + let oversized = + unsafe { operation.push_raw(std::ptr::NonNull::::dangling().as_ptr(), usize::MAX) }; + assert!(matches!( + oversized, + Err(OpsError::Protocol(ProtocolError::ResourceLimit(_))) + )); + // SAFETY: the overflowing address range must be rejected without reading it. + let wrapped = unsafe { operation.push_raw(usize::MAX as *const u8, 1) }; + assert!(matches!( + wrapped, + Err(OpsError::Protocol(ProtocolError::ResourceLimit(_))) + )); + let big = vec![0; 1024 * 1024 + 1]; + assert!(matches!( + operation.push(&big).await, + Err(OpsError::Protocol(ProtocolError::ResourceLimit(_))) + )); + operation.input_done(); + assert_part(next(&operation).await, ""); + assert!(matches!(next(&operation).await, Ok(PollResult::Ended))); +} + +#[tokio::test] +async fn default_input_item_and_byte_limits_are_both_enforced() { + let operation = start(MockModel::new(Mode::Pending), OperationLimits::default()).await; + for _ in 0..64 { + operation + .push(&[]) + .await + .unwrap_or_else(|_| panic!("push failed")); + } + let mut extra = Box::pin(operation.push(&[])); + assert!(poll!(extra.as_mut()).is_pending()); + assert_eq!(operation.status().queued_input_items, 64); + operation.cancel(); + assert!(extra.await.is_err()); + let operation = start(MockModel::new(Mode::Pending), OperationLimits::default()).await; + let chunk = vec![0; 1024 * 1024]; + for _ in 0..8 { + operation + .push(&chunk) + .await + .unwrap_or_else(|_| panic!("push failed")); + } + let mut extra = Box::pin(operation.push(b"x")); + assert!(poll!(extra.as_mut()).is_pending()); + assert_eq!(operation.status().queued_input_bytes, 8 * 1024 * 1024); + operation.cancel(); + assert!(extra.await.is_err()); +} + +#[tokio::test] +async fn a_concurrent_reader_fails_and_timeout_keeps_operation_running() { + let operation = start(MockModel::new(Mode::Pending), OperationLimits::default()).await; + let mut first = Box::pin(operation.next(None)); + assert!(poll!(first.as_mut()).is_pending()); + assert!(matches!( + operation.next(Some(Duration::ZERO)).await, + Err(OpsError::Protocol(ProtocolError::ConcurrentConsumer)) + )); + drop(first); + assert!(matches!( + operation.next(Some(Duration::ZERO)).await, + Ok(PollResult::Timeout) + )); + assert_eq!(operation.status().state, OperationState::Running); + assert_eq!(operation.cancel(), CancelResult::First); + assert_eq!(operation.cancel(), CancelResult::AlreadyRequested); + assert!(matches!( + next(&operation).await, + Err(OpsError::AiMux(AiMuxError::Aborted(_))) + )); + assert!(matches!(next(&operation).await, Ok(PollResult::Ended))); +} + +#[tokio::test] +async fn cancelling_a_pending_next_releases_the_reader() { + let operation = start(MockModel::new(Mode::Pending), OperationLimits::default()).await; + let mut reader = Box::pin(operation.next(None)); + assert!(poll!(reader.as_mut()).is_pending()); + operation.cancel(); + assert!(matches!( + reader.await.map_err(source_error), + Err(OpsError::AiMux(AiMuxError::Aborted(_))) + )); + assert!(matches!(next(&operation).await, Ok(PollResult::Ended))); +} + +#[tokio::test] +async fn full_output_queue_never_loses_cancellation_terminal() { + let model = events((0..300).map(|_| Ok(delta("data"))).collect()); + let operation = start(model.clone(), OperationLimits::default()).await; + until(|| operation.status().queued_output_items == 256).await; + assert_eq!(model.pulled.load(Ordering::SeqCst), 256); + assert_eq!(operation.cancel(), CancelResult::First); + operation.terminate().await; + for _ in 0..256 { + assert_part(next(&operation).await, "data"); + } + assert!(matches!( + next(&operation).await, + Err(OpsError::AiMux(AiMuxError::Aborted(_))) + )); + assert!(matches!(next(&operation).await, Ok(PollResult::Ended))); + let status = operation.status(); + assert_eq!( + status.terminal.unwrap().outcome, + OperationOutcome::Cancelled + ); + assert!(status.cleanup_failure.is_none()); +} + +#[tokio::test] +async fn output_bytes_are_bounded_and_events_remain_lossless() { + let text = "a".repeat(80); + let size = serde_json::to_vec(&delta(&text)).unwrap().len(); + let model = events((0..4).map(|_| Ok(delta(&text))).collect()); + let operation = start( + model, + OperationLimits { + output_bytes: size * 2, + ..Default::default() + }, + ) + .await; + until(|| operation.status().queued_output_items == 2).await; + assert_eq!(operation.status().queued_output_bytes, size * 2); + for _ in 0..4 { + assert_part(next(&operation).await, &text); + } + assert!(matches!(next(&operation).await, Ok(PollResult::Ended))); + assert_eq!(operation.status().queued_output_bytes, 0); +} + +#[tokio::test] +async fn provider_failure_after_a_full_queue_is_not_a_successful_eof() { + let mut parts: Vec<_> = (0..256).map(|_| Ok(delta("data"))).collect(); + parts.push(Err(AiMuxError::Timeout("provider timeout".into()))); + let operation = start(events(parts), OperationLimits::default()).await; + until(|| operation.status().queued_output_items == 256).await; + for _ in 0..256 { + assert_part(next(&operation).await, "data"); + } + assert!(matches!( + next(&operation).await, + Err(OpsError::AiMux(AiMuxError::Timeout(_))) + )); + assert!(matches!(next(&operation).await, Ok(PollResult::Ended))); + assert_eq!( + operation.status().terminal.unwrap().outcome, + OperationOutcome::Error + ); +} + +#[tokio::test] +async fn v4_error_and_finish_are_data_parts() { + let error = TranscriptionStreamPart::Error { + error: serde_json::json!({"recoverable": true}), + }; + let finish = TranscriptionStreamPart::Finish { + text: "done".into(), + segments: vec![], + language: None, + duration_in_seconds: None, + provider_metadata: None, + }; + let operation = start( + events(vec![Ok(error), Ok(delta("continued")), Ok(finish)]), + OperationLimits::default(), + ) + .await; + assert!(matches!( + next(&operation).await, + Ok(PollResult::Part(TranscriptionStreamPart::Error { .. })) + )); + assert_part(next(&operation).await, "continued"); + assert!(matches!( + next(&operation).await, + Ok(PollResult::Part(TranscriptionStreamPart::Finish { .. })) + )); + assert!(matches!(next(&operation).await, Ok(PollResult::Ended))); + assert_eq!( + operation.status().terminal.unwrap().outcome, + OperationOutcome::Success + ); +} + +#[tokio::test] +async fn oversized_serialized_event_is_a_terminal_resource_limit() { + let operation = start( + events(vec![Ok(delta(&"\n".repeat(600_000)))]), + OperationLimits::default(), + ) + .await; + assert!(matches!( + next(&operation).await, + Err(OpsError::Protocol(ProtocolError::ResourceLimit(_))) + )); + assert_eq!(operation.status().queued_output_items, 0); + assert!(matches!(next(&operation).await, Ok(PollResult::Ended))); +} + +#[tokio::test] +async fn invalid_or_unlimited_budgets_are_rejected_before_start() { + for limits in [ + OperationLimits { + input_items: 0, + ..Default::default() + }, + OperationLimits { + output_bytes: usize::MAX, + ..Default::default() + }, + OperationLimits { + item_bytes: 1024 * 1024 + 1, + ..Default::default() + }, + ] { + let result = Operation::start( + MockModel::new(Mode::Pending), + OperationOptions { + limits, + ..Default::default() + }, + None, + ) + .await; + assert!(matches!( + result, + Err(OpsError::Protocol(ProtocolError::ResourceLimit(_))) + )); + } +} + +#[tokio::test] +async fn shared_abort_cancels_connection_and_unblocks_a_push() { + let abort = AbortSignal::new(); + let model = MockModel::new(Mode::Pending); + let operation = Operation::start( + model.clone(), + OperationOptions { + limits: OperationLimits { + input_items: 1, + ..Default::default() + }, + ..Default::default() + }, + Some(abort.clone()), + ) + .await + .unwrap_or_else(|_| panic!("start failed")); + operation + .push(b"full") + .await + .unwrap_or_else(|_| panic!("push failed")); + let mut waiting = Box::pin(operation.push(b"waiting")); + assert!(poll!(waiting.as_mut()).is_pending()); + abort.abort(); + tokio::time::timeout(WAIT, waiting) + .await + .unwrap() + .expect_err("push must fail"); + assert!( + model + .provider_abort + .lock() + .unwrap() + .as_ref() + .unwrap() + .is_aborted() + ); + assert!(matches!( + next(&operation).await, + Err(OpsError::AiMux(AiMuxError::Aborted(_))) + )); +} + +#[tokio::test] +async fn consumer_close_abandons_queued_parts_and_marks_cancellation() { + let operation = start( + events((0..300).map(|_| Ok(delta("data"))).collect()), + OperationLimits::default(), + ) + .await; + until(|| operation.status().queued_output_items == 256).await; + operation.consumer_close(); + assert!(operation.status().consumer_closed); + assert_eq!(operation.status().queued_output_items, 0); + assert!(matches!( + next(&operation).await, + Err(OpsError::AiMux(AiMuxError::Aborted(_))) + )); + assert!(matches!(next(&operation).await, Ok(PollResult::Ended))); +} + +#[tokio::test] +async fn last_owner_drop_aborts_the_provider_without_a_reference_cycle() { + let model = MockModel::new(Mode::Pending); + let operation = start(model.clone(), OperationLimits::default()).await; + let abort = model.provider_abort.lock().unwrap().clone().unwrap(); + let second_owner = operation.clone(); + drop(operation); + assert!(!abort.is_aborted()); + std::thread::spawn(move || drop(second_owner)) + .join() + .unwrap(); + assert!(abort.is_aborted()); + until(|| Arc::strong_count(&model) == 1).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cleanup_is_capped_at_five_seconds_and_failure_is_recorded() { + let gate = Arc::new(DropGate::default()); + let release = ReleaseDropGate(gate.clone()); + let operation = start( + MockModel::new(Mode::BlockingDrop(gate.clone())), + OperationLimits::default(), + ) + .await; + tokio::time::timeout(WAIT, gate.polled.cancelled()) + .await + .expect("stream was not polled"); + operation.cancel(); + tokio::time::timeout(WAIT, gate.entered.cancelled()) + .await + .expect("stream was not dropped"); + let started = std::time::Instant::now(); + let mut cleanup = Box::pin(operation.terminate()); + assert!(poll!(cleanup.as_mut()).is_pending()); + drop(cleanup); + let joined = tokio::time::timeout(Duration::from_secs(7), async { + while operation.status().cleanup_failure.is_none() { + tokio::task::yield_now().await; + } + }) + .await; + let elapsed = started.elapsed(); + let failure = operation.status().cleanup_failure; + // Always release the deliberately blocked destructor before assertions. + drop(release); + assert!(joined.is_ok()); + assert!(elapsed >= Duration::from_secs(5)); + assert!(elapsed < Duration::from_secs(7)); + assert!( + failure + .as_deref() + .is_some_and(|message| message.contains("exceeded 5 seconds")) + ); + assert_eq!( + operation.status().terminal.unwrap().outcome, + OperationOutcome::Cancelled + ); +} + +// Terminal envelopes are cached; these tests inspect the original cause. +fn source_error(error: OpsError) -> OpsError { + match error { + OpsError::Projected(error) => source_error(error.source), + error => error, + } +} + +fn dispatch_context(owner: aimux_ops::Owner) -> aimux_ops::Ctx { + aimux_ops::Ctx { + owner, + abort: None, + now: fixed_clock, + } +} +fn fixed_clock() -> std::time::SystemTime { + std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000) +} + +#[tokio::test] +async fn native_binary_and_json_poll_share_the_registered_operation_service() { + use aimux_ops::dispatch; + use aimux_ops::{Entry, HandleId, HandleRegistry, Owner}; + let owner = Owner::new_node().unwrap(); + let registry = HandleRegistry::global(); + let gate = AbortSignal::new(); + let model = MockModel::new(Mode::Echo(gate.clone())); + let handle = registry + .insert(owner, Entry::Transcription(model.clone())) + .unwrap(); + let value = dispatch::call( + dispatch_context(owner), + handle, + "transcription.start", + serde_json::json!({}), + ) + .await + .unwrap(); + let op: HandleId = serde_json::from_value(value).unwrap(); + assert_eq!( + dispatch::call( + dispatch_context(owner), + op, + "operation.push", + serde_json::json!({}) + ) + .await + .unwrap_err() + .code(), + 300 + ); + dispatch::operation_push(&dispatch_context(owner), op, &[0, 128, 255]).unwrap(); + for _ in 0..2 { + dispatch::call( + dispatch_context(owner), + op, + "operation.inputDone", + serde_json::json!({}), + ) + .await + .unwrap(); + } + gate.abort(); + assert!(matches!( + dispatch::operation_next(&dispatch_context(owner), op, WAIT) + .await + .unwrap(), + dispatch::PollResult::Part(_) + )); + assert_eq!( + dispatch::call( + dispatch_context(owner), + op, + "operation.next", + serde_json::json!({"timeoutMs":2_000}) + ) + .await + .unwrap(), + serde_json::json!({"type":"ended"}) + ); + assert_eq!(*model.received.lock().unwrap(), vec![vec![0, 128, 255]]); + let status = dispatch::call( + dispatch_context(owner), + op, + "operation.status", + serde_json::json!({}), + ) + .await + .unwrap(); + assert_eq!(status["terminal"]["outcome"], "success"); + assert_eq!( + dispatch::call( + dispatch_context(owner), + op, + "operation.cancel", + serde_json::json!({}) + ) + .await + .unwrap(), + "alreadyTerminal" + ); + registry.release_owner(owner); +} + +#[tokio::test] +async fn binary_admission_checks_limits_and_owner_teardown_signals_retained_operations() { + use aimux_ops::dispatch; + use aimux_ops::{Entry, HandleKind, HandleRegistry, Owner}; + let owner = Owner::new_python().unwrap(); + let registry = HandleRegistry::global(); + let handle = registry + .insert(owner, Entry::Transcription(MockModel::new(Mode::Pending))) + .unwrap(); + let op = serde_json::from_value( + dispatch::call( + dispatch_context(owner), + handle, + "transcription.start", + serde_json::json!({}), + ) + .await + .unwrap(), + ) + .unwrap(); + let Entry::Operation(operation) = registry.get(owner, op, &[HandleKind::Operation]).unwrap() + else { + panic!("operation"); + }; + let foreign = Owner::new_node().unwrap(); + assert_eq!( + dispatch::operation_push(&dispatch_context(foreign), op, &[]) + .unwrap_err() + .code(), + 203 + ); + let too_large = vec![0; aimux_ops::limits::ITEM_BYTES + 1]; + assert_eq!( + dispatch::operation_push(&dispatch_context(owner), op, &too_large) + .unwrap_err() + .code(), + 305 + ); + for _ in 0..aimux_ops::limits::AUDIO_INPUT_ITEMS { + dispatch::operation_push(&dispatch_context(owner), op, &[]).unwrap(); + } + assert_eq!( + dispatch::operation_push(&dispatch_context(owner), op, &[]) + .unwrap_err() + .code(), + 305 + ); + assert_eq!( + operation.status().queued_input_items, + aimux_ops::limits::AUDIO_INPUT_ITEMS + ); + let context = dispatch_context(owner); + let mut reader = Box::pin(dispatch::operation_next(&context, op, WAIT)); + assert!(poll!(reader.as_mut()).is_pending()); + assert_eq!( + dispatch::call( + dispatch_context(owner), + op, + "operation.next", + serde_json::json!({}) + ) + .await + .unwrap_err() + .code(), + 303 + ); + assert_eq!(registry.release_owner(owner), 2); + assert_eq!( + operation.status().terminal.unwrap().outcome, + OperationOutcome::Cancelled + ); + assert_eq!(reader.await.unwrap_err().code(), 13); + operation.terminate().await; +} + +#[tokio::test] +async fn terminal_status_and_native_error_reuse_one_date_projection() { + use aimux_ops::dispatch; + use aimux_ops::{Entry, HandleRegistry, Owner}; + let mut api = aimux_core::error::ApiCallError::new( + "terminal", + "https://mock.invalid", + serde_json::json!({}), + ); + api.response_headers = Some(std::collections::HashMap::from([( + "Retry-After".into(), + "Tue, 14 Nov 2023 22:13:22 GMT".into(), + )])); + let model = MockModel::new(Mode::Events(Mutex::new(vec![Err(AiMuxError::ApiCall( + Box::new(api), + ))]))); + let operation = + Operation::start_with_clock(model, OperationOptions::default(), None, fixed_clock) + .await + .unwrap(); + let owner = Owner::new_node().unwrap(); + let op = HandleRegistry::global() + .insert(owner, Entry::Operation(operation.clone())) + .unwrap(); + let error = dispatch::operation_next(&dispatch_context(owner), op, WAIT) + .await + .unwrap_err(); + let saved = operation.status().terminal.unwrap().error.unwrap(); + assert_eq!(saved.retry_after_ms, Some(2_000)); + assert_eq!( + error.envelope(fixed_clock() + Duration::from_secs(60)), + saved + ); + assert!(matches!( + dispatch::operation_next(&dispatch_context(owner), op, WAIT) + .await + .unwrap(), + dispatch::PollResult::Ended + )); + assert_eq!(operation.status().terminal.unwrap().error.unwrap(), saved); + HandleRegistry::global().release_owner(owner); +} + +#[tokio::test] +async fn registered_controls_reject_unknown_fields_before_starting_provider_work() { + use aimux_ops::dispatch; + use aimux_ops::{Entry, HandleRegistry, Owner}; + let owner = Owner::new_node().unwrap(); + let model = MockModel::new(Mode::Pending); + let handle = HandleRegistry::global() + .insert(owner, Entry::Transcription(model.clone())) + .unwrap(); + for params in [ + serde_json::json!({"unknown":true}), + serde_json::json!({"inputAudioFormat":{"formatType":"audio/pcm", "unknown":true}}), + serde_json::json!({"timeout":{"unknown":1}}), + ] { + assert_eq!( + dispatch::call( + dispatch_context(owner), + handle, + "transcription.start", + params + ) + .await + .unwrap_err() + .code(), + 301 + ); + } + assert!(model.provider_abort.lock().unwrap().is_none()); + HandleRegistry::global().release_owner(owner); +} diff --git a/aimux-ops/tests/ops_matrix.rs b/aimux-ops/tests/ops_matrix.rs new file mode 100644 index 000000000..fd97fd0ff --- /dev/null +++ b/aimux-ops/tests/ops_matrix.rs @@ -0,0 +1,315 @@ +//! Table-driven gates over the real manifest and real dispatcher. +//! An isolated registry provides typed mock resources; counting handlers replace +//! business I/O while retaining every production descriptor and validation step. +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +use aimux_ops::dispatch::{ + self, CallFuture, Ctx, Dispatcher, EventStream, Handler, ResourceRegistry, Target, +}; +use aimux_ops::envelope::{BindingError, OpsError, ProtocolError}; +use aimux_ops::handle::{Entry, HandleId, HandleKind, HandleRegistry, Owner}; +use aimux_ops::op::OpMode; +use futures::StreamExt; +use serde_json::{Value, json}; + +#[derive(Default)] +struct Registry { + entries: Mutex>, + next: AtomicUsize, +} +impl Registry { + fn insert(&self, owner: Owner, kind: HandleKind) -> HandleId { + let id = self.next.fetch_add(1, Ordering::Relaxed) as u64 + 1; + self.entries.lock().unwrap().insert(id, (owner, kind)); + HandleId(id) + } +} +impl ResourceRegistry for Registry { + fn get(&self, owner: Owner, id: HandleId, allowed: &[HandleKind]) -> Result { + let entries = self.entries.lock().unwrap(); + let actual = entries + .get(&id.0) + .filter(|(found, _)| *found == owner) + .map(|(_, kind)| *kind); + if actual.is_some_and(|kind| allowed.contains(&kind)) { + // Handlers only count admissions. No fake model/provider behaviour. + Ok(Entry::Abort(Arc::new(aimux_core::AbortSignal::new()))) + } else { + Err(BindingError::InvalidHandle { + expected: allowed.to_vec(), + actual, + } + .into()) + } + } + fn remove(&self, owner: Owner, id: HandleId) -> Option { + let mut entries = self.entries.lock().unwrap(); + if entries.get(&id.0).is_some_and(|(found, _)| *found == owner) { + entries.remove(&id.0); + Some(Entry::Abort(Arc::new(aimux_core::AbortSignal::new()))) + } else { + None + } + } +} +static CALLS: AtomicUsize = AtomicUsize::new(0); +fn probe(_: Ctx, _: Target, _: Value) -> CallFuture { + CALLS.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(Value::Null) }) +} +fn stream_probe(_: Ctx, _: Target, _: Value) -> Result { + CALLS.fetch_add(1, Ordering::SeqCst); + Ok(Box::pin(futures::stream::iter([Ok( + json!({"probe": true}), + )]))) +} +fn ctx(owner: Owner) -> Ctx { + Ctx { + owner, + abort: None, + now: SystemTime::now, + } +} +fn invalid( + result: Result<(), OpsError>, + expected: &[HandleKind], + actual: Option, + label: &str, +) { + let error = result.expect_err(label); + match error.unprojected() { + OpsError::Binding(BindingError::InvalidHandle { + expected: found, + actual: kind, + }) => { + assert_eq!(found, expected, "{label}"); + assert_eq!(*kind, actual, "{label}"); + } + error => panic!("{label}: expected InvalidHandle, got {error}"), + } +} +async fn invoke( + dispatcher: &Dispatcher<'_>, + registry: &Registry, + owner: Owner, + id: HandleId, + name: &str, + mode: OpMode, +) -> Result<(), OpsError> { + match mode { + OpMode::Stream => { + let frames = dispatcher + .stream(ctx(owner), id, name, json!({}))? + .collect::>() + .await; + assert_eq!(frames.len(), 2); + Ok(()) + } + OpMode::BinaryPush => { + // Generic JSON dispatch rejects BinaryPush for every target. + let error = dispatcher + .call(ctx(owner), id, name, json!({})) + .await + .unwrap_err(); + assert!(matches!( + error.unprojected(), + OpsError::Protocol(ProtocolError::InvalidOp(_)) + )); + // Native binary admission uses this identical owner-aware typed resolver. + registry + .get(owner, id, &[HandleKind::Operation]) + .map(|_| ()) + } + _ => dispatcher + .call(ctx(owner), id, name, json!({})) + .await + .map(|_| ()), + } +} + +#[tokio::test] +async fn every_manifest_op_crosses_every_kind_and_invalid_handle_state() { + let manifest = aimux_ops::manifest::manifest(); + let operations = manifest["ops"].as_array().unwrap(); + assert_eq!( + operations.len(), + 72, + "RFC inventory plus Decision operations" + ); + let table: Vec<_> = operations + .iter() + .map(|op| { + let descriptor = dispatch::descriptor(op["name"].as_str().unwrap()).unwrap(); + let handler = if descriptor.mode == OpMode::Stream { + Handler::Stream(stream_probe) + } else { + Handler::Call(probe) + }; + (descriptor, handler) + }) + .collect(); + let registry = Registry::default(); + let dispatcher = Dispatcher::new(®istry, &table); + let owner = Owner::new_node().unwrap(); + let other = Owner::new_python().unwrap(); + let kinds = HandleKind::ALL; + assert_eq!( + manifest["handleKinds"], + serde_json::to_value(kinds).unwrap() + ); + let mut checked = 0; + for (descriptor, _) in &table { + let name = descriptor.name; + let expected = descriptor.allowed_handle_kinds; + for &kind in kinds { + let id = registry.insert(owner, kind); + let before = CALLS.load(Ordering::SeqCst); + let result = invoke(&dispatcher, ®istry, owner, id, name, descriptor.mode).await; + let label = format!("{name} x {kind:?}"); + if name == "handle.drop" || expected.contains(&kind) { + assert!(result.is_ok(), "{label}"); + assert_eq!( + CALLS.load(Ordering::SeqCst) - before, + usize::from(name != "handle.drop" && descriptor.mode != OpMode::BinaryPush), + "{label}" + ); + } else { + invalid(result, expected, Some(kind), &label); + assert_eq!(CALLS.load(Ordering::SeqCst), before, "{label}"); + } + if name == "handle.drop" { + assert!(registry.get(owner, id, &[kind]).is_err()); + } + drop(registry.remove(owner, id)); + checked += 1; + } + let dropped = registry.insert(owner, kinds[0]); + drop(registry.remove(owner, dropped)); + let foreign = registry.insert(other, kinds[0]); + for (label, id) in [ + ("global", HandleId(0)), + ("unknown", HandleId(u64::MAX)), + ("dropped", dropped), + ("foreign", foreign), + ] { + let before = CALLS.load(Ordering::SeqCst); + let result = invoke(&dispatcher, ®istry, owner, id, name, descriptor.mode).await; + if name == "handle.drop" || (id.0 == 0 && expected.is_empty()) { + assert!(result.is_ok(), "{name} x {label}"); + assert_eq!( + CALLS.load(Ordering::SeqCst) - before, + usize::from(name != "handle.drop") + ); + } else { + invalid(result, expected, None, &format!("{name} x {label}")); + assert_eq!(CALLS.load(Ordering::SeqCst), before); + } + checked += 1; + } + assert!(registry.get(other, foreign, &[kinds[0]]).is_ok()); + drop(registry.remove(other, foreign)); + } + assert_eq!(checked, operations.len() * (kinds.len() + 4)); + eprintln!("gate matrix: {checked} op/handle combinations"); +} + +#[test] +fn production_registry_uses_descriptor_allowlists_and_hides_foreign_handles() { + let registry = HandleRegistry::default(); + let owner = Owner::new_node().unwrap(); + let other = Owner::new_python().unwrap(); + let id = registry + .insert( + owner, + Entry::Abort(Arc::new(aimux_core::AbortSignal::new())), + ) + .unwrap(); + let dropped = registry + .insert( + owner, + Entry::Abort(Arc::new(aimux_core::AbortSignal::new())), + ) + .unwrap(); + drop(registry.remove(owner, dropped)); + for op in aimux_ops::manifest::manifest()["ops"].as_array().unwrap() { + let name = op["name"].as_str().unwrap(); + let expected = dispatch::descriptor(name).unwrap().allowed_handle_kinds; + let result = registry.get(owner, id, expected).map(|_| ()); + if expected.contains(&HandleKind::Abort) { + assert!(result.is_ok()); + } else { + invalid(result, expected, Some(HandleKind::Abort), name); + } + for (caller, id) in [ + (owner, HandleId(0)), + (owner, HandleId(u64::MAX)), + (owner, dropped), + (other, id), + ] { + invalid( + registry.get(caller, id, expected).map(|_| ()), + expected, + None, + name, + ); + } + } +} + +#[tokio::test] +async fn production_dispatch_rejects_invalid_handles_before_acceptance() { + let registry = HandleRegistry::global(); + let owner = Owner::new_node().unwrap(); + let other = Owner::new_node().unwrap(); + let id = registry + .insert( + owner, + Entry::Abort(Arc::new(aimux_core::AbortSignal::new())), + ) + .unwrap(); + let dropped = registry + .insert( + owner, + Entry::Abort(Arc::new(aimux_core::AbortSignal::new())), + ) + .unwrap(); + drop(registry.remove(owner, dropped)); + let foreign = registry + .insert( + other, + Entry::Abort(Arc::new(aimux_core::AbortSignal::new())), + ) + .unwrap(); + for op in aimux_ops::manifest::manifest()["ops"].as_array().unwrap() { + let name = op["name"].as_str().unwrap(); + if name == "handle.drop" { + continue; + } + let descriptor = dispatch::descriptor(name).unwrap(); + let expected = descriptor.allowed_handle_kinds; + let mut cases = vec![(HandleId(u64::MAX), None), (dropped, None), (foreign, None)]; + if !expected.is_empty() { + cases.push((HandleId(0), None)); + } + if !expected.contains(&HandleKind::Abort) { + cases.push((id, Some(HandleKind::Abort))); + } + for (handle, actual) in cases { + let result = if descriptor.mode == OpMode::Stream { + dispatch::stream(ctx(owner), handle, name, json!({})).map(|_| ()) + } else if descriptor.mode == OpMode::BinaryPush { + dispatch::operation_push(&ctx(owner), handle, &[]) + } else { + dispatch::call(ctx(owner), handle, name, json!({})) + .await + .map(|_| ()) + }; + invalid(result, expected, actual, name); + } + } + registry.release_owner(owner); + registry.release_owner(other); +} diff --git a/aimux-ops/tests/provider.rs b/aimux-ops/tests/provider.rs new file mode 100644 index 000000000..486a8536a --- /dev/null +++ b/aimux-ops/tests/provider.rs @@ -0,0 +1,402 @@ +//! Provider operations through shared dispatch and cached error envelopes. +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +use std::time::{Duration, SystemTime}; + +use aimux_core::custom_provider::{CustomProviderOptions, custom_provider}; +use aimux_core::model_catalogue::RuntimeModel; +use aimux_core::provider::ProviderDiscovery; +use aimux_core::{AiMuxError, Provider}; +use aimux_ops::dispatch::{Ctx, call, descriptor}; +use aimux_ops::envelope::OpsError; +use aimux_ops::handle::{Entry, HandleId, HandleKind, HandleRegistry, Owner}; +use futures::future::BoxFuture; +use serde_json::{Value, json}; + +fn ctx(owner: Owner) -> Ctx { + Ctx { + owner, + abort: None, + now: SystemTime::now, + } +} +fn id(value: &Value) -> HandleId { + serde_json::from_value(value["handle"].clone()).unwrap() +} +fn get(owner: Owner, value: &Value, kind: HandleKind) -> Entry { + HandleRegistry::global() + .get(owner, id(value), &[kind]) + .unwrap() +} +fn insert(owner: Owner, entry: Entry) -> HandleId { + HandleRegistry::global().insert(owner, entry).unwrap() +} +async fn jev(owner: Owner, source: &str) -> Value { + call(ctx(owner), HandleId(0), "provider.create", json!({ + "package":"jev", "settings":{"apiKey":"test-key", "endpoint":"http://localhost:1/decision", "probabilitySource":source, "headers":{"x-test":"v"}} + })).await.unwrap() +} + +#[tokio::test] +async fn jev_decision_construction_custom_aliases_and_wrap_keep_children_alive() { + for source in ["native", "logit_scoring", "model_estimate"] { + let owner = Owner::new_node().unwrap(); + let provider = jev(owner, source).await; + let decision = call( + ctx(owner), + id(&provider), + "provider.model", + json!({"method":"decision", "modelId":"original"}), + ) + .await + .unwrap(); + let Entry::Decision(original) = get(owner, &decision, HandleKind::Decision) else { + panic!() + }; + let custom = call(ctx(owner), HandleId(0), "provider.custom", json!({ + "decisionModels":{"alias":decision["handle"]}, "defaultMethod":"decision", "fallbackProvider":provider["handle"] + })).await.unwrap(); + let wrapped = call(ctx(owner), id(&custom), "provider.wrap", json!({})) + .await + .unwrap(); + for value in [&decision, &provider, &custom] { + call(ctx(owner), id(value), "handle.drop", json!({})) + .await + .unwrap(); + } + let resolved = call( + ctx(owner), + id(&wrapped), + "provider.model", + json!({"modelId":"alias"}), + ) + .await + .unwrap(); + let Entry::Decision(model) = get(owner, &resolved, HandleKind::Decision) else { + panic!() + }; + assert!(Arc::ptr_eq(&original, &model)); + assert_eq!(model.model_id(), "original"); + let fallback = call( + ctx(owner), + id(&wrapped), + "provider.model", + json!({"modelId":"fallback"}), + ) + .await + .unwrap(); + let Entry::Decision(model) = get(owner, &fallback, HandleKind::Decision) else { + panic!() + }; + assert_eq!(model.model_id(), "fallback"); + HandleRegistry::global().release_owner(owner); + } +} + +#[tokio::test] +async fn jev_preserves_constructor_errors_and_rejects_unavailable_settings() { + let owner = Owner::new_node().unwrap(); + for settings in [ + json!({"apiKey":"x", "probabilitySource":"unknown"}), + json!({"apiKey":"x", "baseUrl":"http://localhost:1"}), + ] { + let error = call( + ctx(owner), + HandleId(0), + "provider.create", + json!({"package":"jev","settings":settings}), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), 5); + assert!(matches!( + error.unprojected(), + OpsError::AiMux(AiMuxError::InvalidArgument(_)) + )); + } + let provider = jev(owner, "native").await; + let error = call( + ctx(owner), + id(&provider), + "provider.model", + json!({"method":"decision", "modelId":" "}), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), 5); + for method in ["decisionModel", "Decision", "skills"] { + assert_eq!( + call( + ctx(owner), + id(&provider), + "provider.model", + json!({"method":method,"modelId":"x"}) + ) + .await + .unwrap_err() + .code(), + 8 + ); + } + assert_eq!( + call( + ctx(owner), + HandleId(0), + "provider.create", + json!({"package":"openai","settings":{"endpoint":"x"}}) + ) + .await + .unwrap_err() + .code(), + 5 + ); + HandleRegistry::global().release_owner(owner); +} + +#[tokio::test] +async fn every_secondary_slot_hides_foreign_owner_and_matches_unknown() { + let owner = Owner::new_node().unwrap(); + let foreign_owner = Owner::new_python().unwrap(); + let foreign = insert( + foreign_owner, + Entry::Provider(Arc::new(custom_provider(CustomProviderOptions::default()))), + ); + let primary = insert( + owner, + Entry::Provider(Arc::new(custom_provider(CustomProviderOptions::default()))), + ); + let operations: &[(&str, &[&str])] = &[ + ( + "provider.create", + &[ + "settings.fetch", + "settings.webSocket", + "settings.credentialResolver", + ], + ), + ( + "provider.wrap", + &["languageModelMiddleware.*", "imageModelMiddleware.*"], + ), + ( + "provider.custom", + &[ + "languageModels.*", + "embeddingModels.*", + "imageModels.*", + "speechModels.*", + "transcriptionModels.*", + "rerankingModels.*", + "videoModels.*", + "searchModels.*", + "decisionModels.*", + "files", + "fallbackProvider", + "languageModelMiddleware.*", + "imageModelMiddleware.*", + ], + ), + ("provider.invoke", &["abort"]), + ]; + for &(name, paths) in operations { + let descriptor = descriptor(name).unwrap(); + assert_eq!( + descriptor + .runtime_handle_fields + .iter() + .map(|(path, _)| *path) + .collect::>(), + paths + ); + for &path in paths { + let make = |handle: String| { + let mut value = if name == "provider.create" { + json!({"package":"openai"}) + } else if name == "provider.invoke" { + json!({"method":"listModels"}) + } else { + json!({}) + }; + let parts: Vec<_> = path.split('.').collect(); + if parts.len() == 1 { + value[parts[0]] = json!(handle); + } else if parts[1] == "*" { + value[parts[0]] = if parts[0].ends_with("Middleware") { + json!([handle]) + } else { + json!({"alias":handle}) + }; + } else { + value[parts[0]] = json!({(parts[1]):handle}); + } + value + }; + let handle = if descriptor.allowed_handle_kinds.is_empty() { + HandleId(0) + } else { + primary + }; + let foreign_error = call(ctx(owner), handle, name, make(foreign.0.to_string())) + .await + .unwrap_err() + .envelope(SystemTime::UNIX_EPOCH); + let unknown_error = call(ctx(owner), handle, name, make(u64::MAX.to_string())) + .await + .unwrap_err() + .envelope(SystemTime::UNIX_EPOCH); + assert_eq!(foreign_error.code, 203, "{name}:{path}"); + assert_eq!( + serde_json::to_value(foreign_error).unwrap(), + serde_json::to_value(unknown_error).unwrap(), + "{name}:{path}" + ); + } + } + HandleRegistry::global().release_owner(owner); + HandleRegistry::global().release_owner(foreign_owner); +} + +struct Discovery(Arc); +impl Provider for Discovery { + fn language_model(&self, id: &str) -> Result, AiMuxError> { + Err(AiMuxError::no_such_model(id, "languageModel")) + } + fn embedding_model( + &self, + id: &str, + ) -> Result, AiMuxError> { + Err(AiMuxError::no_such_model(id, "embeddingModel")) + } + fn image_model( + &self, + id: &str, + ) -> Result, AiMuxError> { + Err(AiMuxError::no_such_model(id, "imageModel")) + } + fn discovery(&self) -> Option<&dyn ProviderDiscovery> { + Some(self) + } +} +impl ProviderDiscovery for Discovery { + fn list_models(&self) -> BoxFuture<'_, Result, AiMuxError>> { + Box::pin(async move { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(vec![]) + }) + } +} + +#[tokio::test] +async fn invoke_resolves_abort_before_any_discovery_call() { + let owner = Owner::new_node().unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let provider = insert(owner, Entry::Provider(Arc::new(Discovery(calls.clone())))); + let signal = aimux_core::AbortSignal::new(); + signal.abort(); + let abort = insert(owner, Entry::Abort(Arc::new(signal))); + let error = call( + ctx(owner), + provider, + "provider.invoke", + json!({"method":"listModels", "abort":abort}), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), 13); + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!( + call( + ctx(owner), + provider, + "provider.invoke", + json!({"method":"listModels"}) + ) + .await + .unwrap(), + json!([]) + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + HandleRegistry::global().release_owner(owner); +} + +#[tokio::test] +async fn handler_errors_are_projected_once_at_dispatch() { + static CLOCK: AtomicUsize = AtomicUsize::new(0); + fn now() -> SystemTime { + CLOCK.fetch_add(1, Ordering::SeqCst); + SystemTime::UNIX_EPOCH + } + let owner = Owner::new_node().unwrap(); + let provider = insert( + owner, + Entry::Provider(Arc::new(custom_provider(CustomProviderOptions::default()))), + ); + let mut context = ctx(owner); + context.now = now; + let error = call( + context, + provider, + "provider.model", + json!({"method":"unknown","modelId":"x"}), + ) + .await + .unwrap_err(); + assert!(matches!(error, OpsError::Projected(_))); + assert_eq!(CLOCK.load(Ordering::SeqCst), 1); + let envelope = error.envelope(SystemTime::UNIX_EPOCH + Duration::from_secs(999)); + assert_eq!(envelope.kind, aimux_ops::envelope::ErrorDomain::AiMux); + assert_eq!(envelope.code, 8); + assert_eq!(envelope.error["name"], "AI_UnsupportedFunctionalityError"); + assert_eq!(CLOCK.load(Ordering::SeqCst), 1); + HandleRegistry::global().release_owner(owner); +} + +#[tokio::test] +async fn strict_controls_reject_legacy_aliases_and_host_objects() { + let owner = Owner::new_node().unwrap(); + let provider = insert( + owner, + Entry::Provider(Arc::new(custom_provider(CustomProviderOptions::default()))), + ); + for (name, params) in [ + ( + "provider.create", + json!({"package":"openai","settings":{"base_url":"x"}}), + ), + ("provider.default", json!({"package":"openai","unknown":1})), + ( + "provider.model", + json!({"method":"languageModel","modelId":"x","unknown":1}), + ), + ( + "provider.invoke", + json!({"method":"listModels","callback":{}}), + ), + ( + "provider.wrap", + json!({"languageModelMiddleware":[{"callback":{}}]}), + ), + ( + "provider.custom", + json!({"decisionModels":{"x":"18446744073709551616"}}), + ), + ] { + let descriptor = descriptor(name).unwrap(); + let target = if descriptor.allowed_handle_kinds.is_empty() { + HandleId(0) + } else { + provider + }; + assert_eq!( + call(ctx(owner), target, name, params) + .await + .unwrap_err() + .code(), + 301, + "{name}" + ); + } + HandleRegistry::global().release_owner(owner); +} diff --git a/aimux-ops/tests/recording.rs b/aimux-ops/tests/recording.rs new file mode 100644 index 000000000..19bfea377 --- /dev/null +++ b/aimux-ops/tests/recording.rs @@ -0,0 +1,449 @@ +//! Recording family tests through production dispatch and owner resolution. +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime}; + +use aimux_core::recording::{self, Recorder, Recording, RecordingError, RingRecorder}; +use aimux_ops::dispatch::{self, Ctx}; +use aimux_ops::envelope::OpsError; +use aimux_ops::handle::{Entry, HandleId, HandleKind, HandleRegistry, Owner}; +use serde_json::{Value, json}; + +fn ctx(owner: Owner) -> Ctx { + Ctx { + owner, + abort: None, + now: SystemTime::now, + } +} + +async fn call( + owner: Owner, + handle: HandleId, + name: &str, + params: Value, +) -> Result { + dispatch::call(ctx(owner), handle, name, params).await +} + +async fn ring(owner: Owner) -> HandleId { + let value = call( + owner, + HandleId(0), + "recorder.create", + json!({ + "backend":{"type":"ring","capacity":2}, "capturePolicy":"full" + }), + ) + .await + .unwrap(); + serde_json::from_value(value["handle"].clone()).unwrap() +} + +fn record(recorder: &dyn Recorder, id: &str) { + let options = serde_json::from_value(json!({ + "prompt":[{"role":"user","content":[{"type":"text","text":"hello"}]}] + })) + .unwrap(); + recorder.record_input(id, &options, "test", "model"); + recorder.record_outcome( + id, + &recording::OutcomeRecord { + status: recording::OutcomeStatus::Success, + ..Default::default() + }, + ); + recorder.record_transport_closed(id); +} + +#[tokio::test] +async fn ring_lifecycle_keeps_snapshots_and_replacement_and_dropped_handles() { + let owner = Owner::new_node().unwrap(); + let previous = recording::recorder(); + let first = ring(owner).await; + assert!( + match (recording::recorder(), &previous) { + (Some(current), Some(previous)) => Arc::ptr_eq(¤t, previous), + (None, None) => true, + _ => false, + }, + "create must not attach" + ); + call(owner, first, "recorder.attach", json!({})) + .await + .unwrap(); + let snapshot = recording::recorder().unwrap(); + for id in ["first", "second", "third"] { + record(snapshot.as_ref(), id); + } + let stats = call(owner, first, "recorder.stats", json!({})) + .await + .unwrap(); + assert_eq!(stats["completed"], 2); + assert_eq!(stats["dropped"], 1); + let exported = call(owner, first, "recorder.export", json!({})) + .await + .unwrap(); + let records: Vec = exported["jsonl"] + .as_str() + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!( + records + .iter() + .map(|r| r.call_id.as_str()) + .collect::>(), + ["second", "third"] + ); + assert!(records.iter().all(|r| r.complete)); + let second = ring(owner).await; + call(owner, second, "recorder.attach", json!({})) + .await + .unwrap(); + let active = recording::recorder().unwrap(); + call(owner, first, "recorder.stop", json!({})) + .await + .unwrap(); + assert!(Arc::ptr_eq(&active, &recording::recorder().unwrap())); + call(owner, second, "recorder.stop", json!({})) + .await + .unwrap(); + call(owner, second, "recorder.stop", json!({})) + .await + .unwrap(); + assert!(recording::recorder().is_none()); + record(snapshot.as_ref(), "snapshot-after-stop"); + assert_eq!( + call(owner, first, "recorder.stats", json!({})) + .await + .unwrap()["completed"], + 2 + ); + call(owner, first, "recorder.flush", json!({})) + .await + .unwrap(); + call(owner, first, "handle.drop", json!({})).await.unwrap(); + assert_eq!( + call(owner, first, "recorder.stats", json!({})) + .await + .unwrap_err() + .code(), + 203 + ); + record(snapshot.as_ref(), "snapshot-after-drop"); + stalled_replacement(owner, second).await; + HandleRegistry::global().release_owner(owner); + recording::init_recording(previous); +} + +#[tokio::test] +async fn owner_resolution_and_strict_controls_precede_side_effects() { + let owner = Owner::new_node().unwrap(); + let foreign = Owner::new_python().unwrap(); + let handle = ring(owner).await; + for name in [ + "recorder.attach", + "recorder.stop", + "recorder.flush", + "recorder.stats", + "recorder.export", + ] { + let foreign_error = call(foreign, handle, name, json!({})).await.unwrap_err(); + let unknown_error = call(foreign, HandleId(u64::MAX), name, json!({})) + .await + .unwrap_err(); + assert_eq!( + serde_json::to_value(foreign_error.envelope(SystemTime::now())).unwrap(), + serde_json::to_value(unknown_error.envelope(SystemTime::now())).unwrap() + ); + assert_eq!(foreign_error.code(), 203); + assert_eq!( + call(owner, handle, name, json!({"unknown":true})) + .await + .unwrap_err() + .code(), + 301 + ); + } + for params in [ + json!({"backend":{"type":"ring"}}), + json!({"backend":{"type":"ring","unknown":true},"capturePolicy":"full"}), + json!({"backend":{"type":"ring"},"capturePolicy":"unknown"}), + json!({"backend":{"type":"ring"},"capturePolicy":"full","unknown":true}), + json!({"backend":{"type":"ring","capacity":0},"capturePolicy":"full"}), + json!({"backend":{"type":"ring","capacity":u64::MAX},"capturePolicy":"full"}), + ] { + assert_eq!( + call(owner, HandleId(0), "recorder.create", params) + .await + .unwrap_err() + .code(), + 301 + ); + } + call(foreign, handle, "handle.drop", json!({})) + .await + .unwrap(); + assert_eq!( + call(owner, handle, "recorder.stats", json!({})) + .await + .unwrap()["completed"], + 0 + ); + let wrong = HandleRegistry::global() + .insert( + owner, + Entry::Abort(Arc::new(aimux_core::AbortSignal::new())), + ) + .unwrap(); + assert_eq!( + call(owner, wrong, "recorder.flush", json!({})) + .await + .unwrap_err() + .code(), + 203 + ); + // Existing native recorder payloads still attach/flush; absent backend diagnostics are explicit. + let native = HandleRegistry::global() + .insert(owner, Entry::Recorder(Arc::new(RingRecorder::new()))) + .unwrap(); + call(owner, native, "recorder.flush", json!({})) + .await + .unwrap(); + assert_eq!( + call(owner, native, "recorder.stats", json!({})) + .await + .unwrap_err() + .code(), + 8 + ); + HandleRegistry::global().release_owner(owner); +} + +fn directory() -> PathBuf { + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + std::env::temp_dir().join(format!( + "aimux-recording-dispatch-{}-{}", + std::process::id(), + SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )) +} + +#[tokio::test] +async fn jsonl_dispatch_exports_real_data_and_preserves_recording_errors() { + let owner = Owner::new_node().unwrap(); + let dir = directory(); + let value = call( + owner, + HandleId(0), + "recorder.create", + json!({ + "backend":{"type":"jsonl","directory":dir}, "capturePolicy":"full" + }), + ) + .await + .unwrap(); + let handle: HandleId = serde_json::from_value(value["handle"].clone()).unwrap(); + let Entry::Recorder(recorder) = HandleRegistry::global() + .get(owner, handle, &[HandleKind::Recorder]) + .unwrap() + else { + unreachable!() + }; + record(recorder.as_ref(), "persisted"); + call(owner, handle, "recorder.flush", json!({})) + .await + .unwrap(); + let exported = call(owner, handle, "recorder.export", json!({})) + .await + .unwrap(); + let saved: Recording = + serde_json::from_str(exported["jsonl"].as_str().unwrap().trim()).unwrap(); + assert_eq!(saved.call_id, "persisted"); + assert!(saved.complete); + assert_eq!( + std::fs::read_to_string(dir.join("recordings.jsonl")).unwrap(), + exported["jsonl"] + ); + std::fs::remove_file(dir.join("recordings.jsonl")).unwrap(); + let error = call(owner, handle, "recorder.export", json!({})) + .await + .unwrap_err(); + let envelope = error.envelope(SystemTime::now()); + assert_eq!(envelope.code, 105); + assert_eq!(envelope.error["name"], "Write"); + drop(recorder); + HandleRegistry::global().release_owner(owner); + let blocker = dir.join("blocker"); + std::fs::write(&blocker, "occupied").unwrap(); + let error = call( + owner, + HandleId(0), + "recorder.create", + json!({ + "backend":{"type":"jsonl","directory":blocker.join("nested")}, "capturePolicy":"full" + }), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), 100); + let open = dir.join("open"); + std::fs::create_dir_all(open.join("recordings.jsonl")).unwrap(); + let error = call( + owner, + HandleId(0), + "recorder.create", + json!({ + "backend":{"type":"jsonl","directory":open}, "capturePolicy":"full" + }), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), 101); + std::fs::remove_dir_all(dir).unwrap(); +} + +struct StalledFlush { + started: Mutex>>, + release: Mutex>, + completed: std::sync::atomic::AtomicBool, +} +impl Recorder for StalledFlush { + fn record_input(&self, _: &str, _: &aimux_core::options::CallOptions, _: &str, _: &str) {} + fn record_provider(&self, _: &str, _: &recording::ProviderRecord) {} + fn record_exchange(&self, _: &str, _: &recording::HttpExchange) {} + fn record_exchange_update( + &self, + _: &str, + _: u32, + _: u32, + _: &recording::ResponseRecord, + _: Option, + ) { + } + fn record_outcome(&self, _: &str, _: &recording::OutcomeRecord) {} + fn flush(&self) {} + fn try_flush(&self) -> Result<(), RecordingError> { + self.started + .lock() + .unwrap() + .take() + .unwrap() + .send(()) + .unwrap(); + let _ = self + .release + .lock() + .unwrap() + .recv_timeout(Duration::from_secs(10)); + self.completed + .store(true, std::sync::atomic::Ordering::SeqCst); + Err(RecordingError::FlushTimeout) + } +} + +#[tokio::test(flavor = "current_thread")] +async fn stalled_flush_leaves_single_thread_runtime_and_registry_responsive() { + let owner = Owner::new_node().unwrap(); + let (started, ready) = tokio::sync::oneshot::channel(); + let (release, wait) = std::sync::mpsc::channel(); + let recorder = Arc::new(StalledFlush { + started: Mutex::new(Some(started)), + release: Mutex::new(wait), + completed: std::sync::atomic::AtomicBool::new(false), + }); + let handle = HandleRegistry::global() + .insert(owner, Entry::Recorder(recorder.clone())) + .unwrap(); + let task = tokio::spawn(call(owner, handle, "recorder.flush", json!({}))); + ready.await.unwrap(); + assert!( + !recorder.completed.load(std::sync::atomic::Ordering::SeqCst), + "flush blocked the runtime" + ); + assert!( + HandleRegistry::global() + .get(owner, handle, &[HandleKind::Recorder]) + .is_ok() + ); + let unrelated = ring(owner).await; + assert!(unrelated.0 > handle.0); + release.send(()).unwrap(); + let error = task.await.unwrap().unwrap_err(); + assert_eq!(error.code(), 104); + assert_eq!( + error.envelope(SystemTime::now()).error["name"], + "FlushTimeout" + ); + HandleRegistry::global().release_owner(owner); +} + +struct StalledDrop { + started: Option>, + release: std::sync::mpsc::Receiver<()>, + completed: Arc, +} +// The receiver is guarded for Sync without holding it during recorder lookup. +struct DropRecorder { + _cleanup: Mutex, +} +impl Recorder for DropRecorder { + fn record_input(&self, _: &str, _: &aimux_core::options::CallOptions, _: &str, _: &str) {} + fn record_provider(&self, _: &str, _: &recording::ProviderRecord) {} + fn record_exchange(&self, _: &str, _: &recording::HttpExchange) {} + fn record_exchange_update( + &self, + _: &str, + _: u32, + _: u32, + _: &recording::ResponseRecord, + _: Option, + ) { + } + fn record_outcome(&self, _: &str, _: &recording::OutcomeRecord) {} + fn flush(&self) {} +} +impl Drop for StalledDrop { + fn drop(&mut self) { + self.started.take().unwrap().send(()).unwrap(); + let _ = self.release.recv_timeout(Duration::from_secs(10)); + self.completed + .store(true, std::sync::atomic::Ordering::SeqCst); + } +} +async fn stalled_replacement(owner: Owner, replacement: HandleId) { + let (started, ready) = tokio::sync::oneshot::channel(); + let (release, wait) = std::sync::mpsc::channel(); + let completed = Arc::new(std::sync::atomic::AtomicBool::new(false)); + recording::init_recording(Some(Arc::new(DropRecorder { + _cleanup: Mutex::new(StalledDrop { + started: Some(started), + release: wait, + completed: completed.clone(), + }), + }))); + let task = tokio::spawn(call(owner, replacement, "recorder.attach", json!({}))); + ready.await.unwrap(); + assert!( + !completed.load(std::sync::atomic::Ordering::SeqCst), + "replacement blocked the runtime" + ); + let Entry::Recorder(expected) = HandleRegistry::global() + .get(owner, replacement, &[HandleKind::Recorder]) + .unwrap() + else { + unreachable!() + }; + assert!(Arc::ptr_eq(&recording::recorder().unwrap(), &expected)); + assert!( + !completed.load(std::sync::atomic::Ordering::SeqCst), + "replacement kept the state lock during cleanup" + ); + release.send(()).unwrap(); + task.await.unwrap().unwrap(); + call(owner, replacement, "recorder.stop", json!({})) + .await + .unwrap(); +} diff --git a/aimux-ops/tests/registry.rs b/aimux-ops/tests/registry.rs new file mode 100644 index 000000000..1ccf5beb0 --- /dev/null +++ b/aimux-ops/tests/registry.rs @@ -0,0 +1,506 @@ +//! Registry-family integration through the actual dispatcher and owner registry. +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use aimux_core::AiMuxError; +use aimux_core::custom_provider::{CustomProviderOptions, custom_provider}; +use aimux_core::decision_model::{ + DecisionCallOptions, DecisionModel, DecisionQuestionType, DecisionResult, +}; +use aimux_core::image_model::{ImageCallOptions, ImageModel, ImageResult}; +use aimux_core::image_model_middleware::ImageModelMiddleware; +use aimux_core::language_model::LanguageModel; +use aimux_core::language_model_middleware::LanguageModelMiddleware; +use aimux_core::options::CallOptions; +use aimux_core::result::{GenerateResult, StreamResult}; +use aimux_ops::dispatch::{Ctx, call}; +use aimux_ops::envelope::OpsError; +use aimux_ops::handle::{Entry, HandleId, HandleKind, HandleRegistry, Middleware, Owner}; +use futures::future::BoxFuture; +use serde_json::{Value, json}; + +struct Model; +impl LanguageModel for Model { + fn provider(&self) -> &str { + "fixture" + } + fn model_id(&self) -> &str { + "actual:model:id" + } + fn do_generate<'a, 'b, 'f>( + &'a self, + _: &'b CallOptions, + ) -> BoxFuture<'f, Result> + where + 'a: 'f, + 'b: 'f, + Self: 'f, + { + Box::pin(async { Err(AiMuxError::Other("generation reached".into())) }) + } + fn do_stream<'a, 'b, 'f>( + &'a self, + _: &'b CallOptions, + ) -> BoxFuture<'f, Result> + where + 'a: 'f, + 'b: 'f, + Self: 'f, + { + Box::pin(async { Err(AiMuxError::Other("stream reached".into())) }) + } +} +impl ImageModel for Model { + fn provider(&self) -> &str { + "fixture" + } + fn model_id(&self) -> &str { + "actual:model:id" + } + fn max_images_per_call(&self) -> Option { + Some(1) + } + fn do_generate<'a, 'b, 'f>( + &'a self, + _: &'b ImageCallOptions, + ) -> BoxFuture<'f, Result> + where + 'a: 'f, + 'b: 'f, + Self: 'f, + { + Box::pin(async { Err(AiMuxError::Other("image reached".into())) }) + } +} +impl DecisionModel for Model { + fn provider(&self) -> &str { + "fixture" + } + fn model_id(&self) -> &str { + "actual:model:id" + } + fn supported_question_types(&self) -> &[DecisionQuestionType] { + &[DecisionQuestionType::Boolean] + } + fn do_decide<'a, 'b, 'f>( + &'a self, + _: &'b DecisionCallOptions, + ) -> BoxFuture<'f, Result> + where + 'a: 'f, + 'b: 'f, + Self: 'f, + { + Box::pin(async { Err(AiMuxError::Other("decision reached".into())) }) + } +} + +struct Rename(&'static str, Arc); +impl LanguageModelMiddleware for Rename { + fn override_model_id(&self, _: &dyn LanguageModel) -> Option { + self.1.fetch_add(1, Ordering::SeqCst); + Some(self.0.into()) + } +} +impl ImageModelMiddleware for Rename { + fn override_model_id(&self, _: &dyn ImageModel) -> Option { + self.1.fetch_add(1, Ordering::SeqCst); + Some(self.0.into()) + } + fn override_max_images_per_call(&self, _: &dyn ImageModel) -> Option { + Some(3) + } + fn override_supports_file_inputs(&self, _: &dyn ImageModel) -> Option> { + Some(Some(true)) + } +} + +struct Env(Owner); +impl Env { + fn new() -> Self { + Self(Owner::new_node().unwrap()) + } + fn insert(&self, entry: Entry) -> HandleId { + HandleRegistry::global().insert(self.0, entry).unwrap() + } + async fn call(&self, id: HandleId, name: &str, params: Value) -> Result { + call( + Ctx { + owner: self.0, + abort: None, + now: epoch_clock, + }, + id, + name, + params, + ) + .await + } + fn provider(&self) -> HandleId { + self.insert(Entry::Provider(Arc::new(custom_provider( + CustomProviderOptions { + language_models: HashMap::from([( + "alias:with:colon".into(), + Arc::new(Model) as Arc, + )]), + image_models: HashMap::from([( + "alias:with:colon".into(), + Arc::new(Model) as Arc, + )]), + decision_models: HashMap::from([( + "alias:with:colon".into(), + Arc::new(Model) as Arc, + )]), + ..Default::default() + }, + )))) + } + async fn registry(&self, provider: HandleId) -> HandleId { + id(&self.call(HandleId(0), "registry.create", json!({"namespace":"tenant", "entries":[{"key":"key:with:colon", "provider":provider}]})).await.unwrap()) + } + async fn resolve(&self, registry: HandleId, method: &str) -> HandleId { + id(&self + .call(registry, "model.resolve", reference(method)) + .await + .unwrap()) + } + async fn drop_handle(&self, handle: HandleId) { + self.call(handle, "handle.drop", json!({})).await.unwrap(); + } +} +impl Drop for Env { + fn drop(&mut self) { + HandleRegistry::global().release_owner(self.0); + } +} +fn epoch_clock() -> SystemTime { + UNIX_EPOCH +} +fn id(result: &Value) -> HandleId { + serde_json::from_value(result["handle"].clone()).unwrap() +} +fn reference(method: &str) -> Value { + json!({"modelRef":{"namespace":"tenant", "key":"key:with:colon", "method":method, "modelId":"alias:with:colon"}}) +} + +#[tokio::test] +async fn decision_resolves_wraps_and_survives_parent_drop() { + let env = Env::new(); + let provider = env.provider(); + let registry = env.registry(provider).await; + let returned = env + .call( + registry, + "registry.provider", + json!({"key":"key:with:colon"}), + ) + .await + .unwrap(); + let original = HandleRegistry::global() + .get(env.0, provider, &[HandleKind::Provider]) + .unwrap(); + let copy = HandleRegistry::global() + .get(env.0, id(&returned), &[HandleKind::Provider]) + .unwrap(); + let (Entry::Provider(a), Entry::Provider(b)) = (original, copy) else { + panic!() + }; + assert!(Arc::ptr_eq(&a, &b)); + let model = env.resolve(registry, "decision").await; + let wrapped = env + .call(model, "model.wrap", json!({"middleware":[]})) + .await + .unwrap(); + assert_eq!(wrapped["kind"], "Decision"); + assert_ne!(id(&wrapped), model); + env.drop_handle(provider).await; + env.drop_handle(id(&returned)).await; + env.drop_handle(registry).await; + env.drop_handle(model).await; + let info = env + .call(id(&wrapped), "model.info", json!({})) + .await + .unwrap(); + assert_eq!(info["provider"], "fixture"); + assert_eq!(info["modelId"], "actual:model:id"); + assert_eq!(info["modality"], "decision"); + assert_eq!( + info["capabilities"], + json!({"decide":true,"supportedQuestionTypes":["boolean"]}) + ); + assert_eq!( + info["source"], + json!({"references":[reference("decision")["modelRef"]]}) + ); + assert_eq!(info.as_object().unwrap().len(), 5); +} + +#[tokio::test] +async fn language_and_image_middleware_use_core_order_and_keep_source() { + for (method, kind) in [ + ("languageModel", HandleKind::Language), + ("imageModel", HandleKind::Image), + ] { + let env = Env::new(); + let provider = env.provider(); + let registry = env.registry(provider).await; + let model = env.resolve(registry, method).await; + let calls = Arc::new(AtomicUsize::new(0)); + let middleware: Vec<_> = ["outer", "inner"] + .into_iter() + .map(|name| { + let value = Arc::new(Rename(name, calls.clone())); + env.insert(Entry::Middleware(Arc::new( + if kind == HandleKind::Language { + Middleware::Language(value) + } else { + Middleware::Image(value) + }, + ))) + }) + .collect(); + let wrapped = env + .call(model, "model.wrap", json!({"middleware":middleware})) + .await + .unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 2); + env.drop_handle(model).await; + env.drop_handle(registry).await; + env.drop_handle(provider).await; + for middleware in middleware { + env.drop_handle(middleware).await; + } + let info = env + .call(id(&wrapped), "model.info", json!({})) + .await + .unwrap(); + assert_eq!(info["modelId"], "outer"); + assert_eq!( + info["source"], + json!({"references":[reference(method)["modelRef"]]}) + ); + let entry = HandleRegistry::global() + .get(env.0, id(&wrapped), &[kind]) + .unwrap(); + match entry { + Entry::Language(model) => { + assert_eq!( + model + .do_generate(&CallOptions::default()) + .await + .unwrap_err() + .to_string(), + "generation reached" + ); + } + Entry::Image(model) => { + assert_eq!(info["capabilities"]["maxImagesPerCall"], 3); + assert_eq!(info["capabilities"]["supportsFileInputs"], true); + assert_eq!( + model + .do_generate(&ImageCallOptions::new("x")) + .await + .unwrap_err() + .to_string(), + "image reached" + ); + } + _ => panic!(), + } + } +} + +#[tokio::test] +async fn secondary_handles_are_owner_checked_without_disclosure() { + let env = Env::new(); + let other = Env::new(); + let foreign = other.provider(); + let unknown = HandleId(u64::MAX); + let primary = env.insert(Entry::Language(Arc::new(Model))); + for name in ["registry.create", "model.wrap"] { + let params = |handle| { + if name == "registry.create" { + json!({"entries":[{"key":"p","provider":handle}]}) + } else { + json!({"middleware":[handle]}) + } + }; + let primary = if name == "registry.create" { + HandleId(0) + } else { + primary + }; + let foreign = env.call(primary, name, params(foreign)).await.unwrap_err(); + let missing = env.call(primary, name, params(unknown)).await.unwrap_err(); + assert_eq!(foreign.code(), 203); + assert_eq!(foreign.envelope(UNIX_EPOCH), missing.envelope(UNIX_EPOCH)); + assert!(foreign.envelope(UNIX_EPOCH).error.get("actual").is_none()); + } +} + +#[tokio::test] +async fn entire_middleware_chain_is_checked_before_any_hook() { + let env = Env::new(); + let other = Env::new(); + let primary = env.insert(Entry::Language(Arc::new(Model))); + let calls = Arc::new(AtomicUsize::new(0)); + let good = env.insert(Entry::Middleware(Arc::new(Middleware::Language(Arc::new( + Rename("good", calls.clone()), + ))))); + let wrong = env.insert(Entry::Middleware(Arc::new(Middleware::Image(Arc::new( + Rename("wrong", calls.clone()), + ))))); + let foreign = other.insert(Entry::Middleware(Arc::new(Middleware::Language(Arc::new( + Rename("foreign", calls.clone()), + ))))); + for (bad, code) in [(wrong, 301), (foreign, 203), (HandleId(u64::MAX), 203)] { + let error = env + .call(primary, "model.wrap", json!({"middleware":[good,bad]})) + .await + .unwrap_err(); + assert_eq!(error.code(), code); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } + let primary = env.insert(Entry::Decision(Arc::new(Model))); + assert_eq!( + env.call(primary, "model.wrap", json!({"middleware":[good]})) + .await + .unwrap_err() + .code(), + 8 + ); + assert_eq!(calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn strict_controls_and_reference_errors_cross_projected_boundary() { + let env = Env::new(); + let provider = env.provider(); + let registry = env.registry(provider).await; + let model = env.resolve(registry, "languageModel").await; + let duplicate = + json!({"entries":[{"key":"p","provider":provider},{"key":"p","provider":provider}]}); + let mut cases = vec![ + (HandleId(0), "registry.create", duplicate, 301), + ( + HandleId(0), + "registry.create", + json!({"entries":[],"unknown":true}), + 301, + ), + ( + HandleId(0), + "registry.create", + json!({"entries":[{"key":"p","provider":provider,"settings":{}}]}), + 301, + ), + ( + HandleId(0), + "registry.create", + json!({"entries":[{"key":"","provider":provider}]}), + 301, + ), + (registry, "registry.provider", json!({"key":"missing"}), 10), + ( + registry, + "registry.provider", + json!({"key":"key:with:colon","unknown":true}), + 301, + ), + (model, "model.info", json!({"settings":true}), 301), + ( + model, + "model.wrap", + json!({"middleware":[],"unknown":true}), + 301, + ), + ]; + for method in ["decisionModel", "unknown", "LanguageModel"] { + cases.push((registry, "model.resolve", reference(method), 301)); + } + for (field, value, code) in [ + ("namespace", "other", 301), + ("key", "missing", 10), + ("modelId", "missing", 9), + ] { + let mut params = reference("languageModel"); + params["modelRef"][field] = json!(value); + cases.push((registry, "model.resolve", params, code)); + } + let mut extra = reference("languageModel"); + extra["modelRef"]["settings"] = json!({}); + cases.push((registry, "model.resolve", extra, 301)); + for value in [ + json!(provider.0), + json!("+1"), + json!("18446744073709551616"), + ] { + cases.push(( + HandleId(0), + "registry.create", + json!({"entries":[{"key":"p","provider":value}]}), + 301, + )); + } + let middleware = env.insert(Entry::Middleware(Arc::new(Middleware::Language(Arc::new( + Rename("unused", Arc::new(AtomicUsize::new(0))), + ))))); + for value in [ + json!(middleware.0), + json!("+1"), + json!("18446744073709551616"), + ] { + cases.push((model, "model.wrap", json!({"middleware":[value]}), 301)); + } + for (primary, name, params, code) in cases { + let error = env.call(primary, name, params.clone()).await.unwrap_err(); + assert_eq!(error.code(), code, "{name}: {params}"); + assert!(matches!(error, OpsError::Projected(_)), "{name}: {params}"); + assert_eq!( + error.envelope(UNIX_EPOCH), + error.envelope(SystemTime::now()) + ); + } +} + +#[test] +fn descriptors_publish_real_types_and_secondary_fields() { + for (name, field) in [ + ("registry.create", "entries.*.provider"), + ("model.wrap", "middleware.*"), + ] { + let descriptor = aimux_ops::dispatch::descriptor(name).unwrap(); + assert_eq!(descriptor.runtime_handle_fields[0].0, field); + assert!( + descriptor + .request_type + .starts_with("aimux_ops::ops::registry::") + ); + assert!( + descriptor + .result_type + .starts_with("aimux_ops::ops::registry::") + ); + } + let manifest = aimux_ops::manifest::manifest(); + for name in [ + "registry.create", + "registry.provider", + "model.resolve", + "model.info", + "model.wrap", + ] { + let descriptor = aimux_ops::dispatch::descriptor(name).unwrap(); + for id in [descriptor.request_type, descriptor.result_type] { + assert!( + manifest["types"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["id"] == id && entry["rustPath"] == id) + ); + } + } +} diff --git a/aimux-ops/tests/replay.rs b/aimux-ops/tests/replay.rs new file mode 100644 index 000000000..1e098ab2c --- /dev/null +++ b/aimux-ops/tests/replay.rs @@ -0,0 +1,533 @@ +//! Replay through the real dispatcher, registry, manifest and core replay paths. +use std::sync::Arc; +use std::time::SystemTime; + +use aimux_core::custom_provider::{CustomProviderOptions, custom_provider}; +use aimux_core::language_model::LanguageModel; +use aimux_core::options::CallOptions; +use aimux_core::recording::{ + HttpExchange, HttpRecord, InputRecord, OutcomeStatus, ProviderRecord, Recording, + RecordingOperation, ResponseRecord, TimingRecord, +}; +use aimux_core::replay::{MockDecisionReplayModel, MockReplayModel}; +use aimux_ops::dispatch::{self, Ctx}; +use aimux_ops::envelope::OpsError; +use aimux_ops::handle::{Entry, HandleId, HandleRegistry, Owner}; +use aimux_ops::op::{Cancellation, Scope}; +use serde_json::{Value, json}; + +struct Harness(Owner); +impl Harness { + fn new() -> Self { + Self(Owner::new_node().unwrap()) + } + fn ctx(&self) -> Ctx { + Ctx { + owner: self.0, + abort: None, + now: SystemTime::now, + } + } + fn insert(&self, entry: Entry) -> HandleId { + HandleRegistry::global().insert(self.0, entry).unwrap() + } + async fn call(&self, id: HandleId, op: &str, params: Value) -> Result { + dispatch::call(self.ctx(), id, op, params).await + } + async fn create(&self, recordings: &[Recording]) -> HandleId { + let jsonl = recordings + .iter() + .map(|r| serde_json::to_string(r).unwrap()) + .collect::>() + .join("\n"); + let result = self + .call( + HandleId(0), + "replay.create", + json!({"recordingsJsonl":jsonl}), + ) + .await + .unwrap(); + serde_json::from_value(result["handle"].clone()).unwrap() + } +} +impl Drop for Harness { + fn drop(&mut self) { + HandleRegistry::global().release_owner(self.0); + } +} + +fn fixture() -> Recording { + let cassette: Value = serde_json::from_str(include_str!( + "../../aimux-providers/tests/cassettes/openai/completions_api_agent_prompt.json" + )) + .unwrap(); + let options: CallOptions = serde_json::from_value(json!({ + "prompt": [{"role":"user","content":[{"type":"text","text":"Hi"}]}] + })) + .unwrap(); + let mut rec = Recording::new( + "replay-test", + InputRecord::from_call_options(&options), + ProviderRecord::new("openai", "openai.chat", "gpt-4o"), + ); + rec.complete = true; + rec.transport_closed = true; + rec.exchanges.push(HttpExchange { + step: None, + attempt: 1, + exchange_index: 1, + request: HttpRecord { + method: "POST".into(), + url: "https://api.openai.com/v1/chat/completions".into(), + headers: vec![("authorization".into(), "[REDACTED]".into())], + body: Some(serde_json::to_string(&cassette["request"]["body"]).unwrap()), + }, + response: Some(ResponseRecord { + status: 200, + headers: vec![("content-type".into(), "application/json".into())], + body: Some(cassette["response"]["body"].as_str().unwrap().into()), + stream_chunks: None, + ttfb_ms: None, + }), + timing: TimingRecord { + latency_ms: 0, + ttfb_ms: None, + }, + error: None, + finalized: true, + }); + rec +} + +fn decision_fixture() -> Recording { + let mut rec = fixture(); + rec.call_id = "decision-test".into(); + rec.provider = ProviderRecord::new("jev", "jev", "jev-model"); + rec.input.operation = RecordingOperation::Decision; + rec.input.prompt.clear(); + rec.input.options = json!({"state":"test", "questions":{ + "approve":{"type":"boolean", "instructions":"approve?"} + }, "maxRetries":0}); + rec.exchanges.clear(); + rec.outcome.status = OutcomeStatus::Success; + rec.outcome.decision_result = Some(json!({ + "answers":{"approve":{"type":"boolean", "probability":0.75}}, + "usage":{"inputTokens":1,"outputTokens":2,"totalTokens":3}, "warnings":[], + "response":{"id":"recorded-decision","timestamp":"2026-10-15T00:00:00Z","modelId":"jev-model"} + })); + rec +} + +fn target(kind: &str, handle: HandleId, call_id: &str) -> Value { + json!({"callId":call_id,"target":{"kind":kind,"handle":handle}}) +} + +// Response IDs/timestamps and per-step call IDs/timings vary between calls. +fn stable(mut value: Value) -> Value { + for step in value["steps"].as_array_mut().into_iter().flatten() { + let step = step.as_object_mut().unwrap(); + step.remove("callId"); + step.remove("performance"); + } + fn normalize(value: &mut Value) { + match value { + Value::Object(map) => { + if let Some(response) = map.get_mut("response").and_then(Value::as_object_mut) { + response.remove("id"); + response.remove("timestamp"); + } + for value in map.values_mut() { + normalize(value); + } + } + Value::Array(values) => { + for value in values { + normalize(value); + } + } + _ => {} + } + } + normalize(&mut value); + value +} + +#[test] +fn metadata_keeps_transport_future_and_declares_nested_handles_and_cancellation() { + let capabilities = dispatch::descriptor("replay.capabilities").unwrap(); + assert_eq!(capabilities.scope, Scope::Future); + let live = dispatch::descriptor("replay.live").unwrap(); + assert_eq!(live.cancellation, Cancellation::Abort); + assert!( + live.runtime_handle_fields + .iter() + .any(|(path, _)| *path == "target.handle") + ); + assert!( + live.runtime_handle_fields + .iter() + .any(|(path, _)| *path == "abort") + ); + assert!(live.side_effects); + for name in [ + "replay.create", + "replay.inspect", + "replay.dryRun", + "replay.live", + ] { + assert_eq!(dispatch::descriptor(name).unwrap().scope, Scope::Current); + } +} + +#[tokio::test] +async fn creation_inspection_and_planning_use_real_session_payloads() { + let h = Harness::new(); + let id = h.create(&[fixture(), decision_fixture()]).await; + let Entry::ReplaySession(model) = HandleRegistry::global() + .get(h.0, id, &[aimux_ops::HandleKind::ReplaySession]) + .unwrap() + else { + panic!() + }; + assert_eq!(model.recordings().len(), 2); + let inspect = h.call(id, "replay.inspect", json!({})).await.unwrap(); + assert_eq!(inspect["recordings"][0]["callId"], "replay-test"); + assert_eq!(inspect["recordings"][0]["exchangeCount"], 1); + assert_eq!(inspect["recordings"][1]["operation"], "decision"); + let plan = h.call(id, "replay.dryRun", json!({})).await.unwrap(); + assert_eq!(plan["calls"].as_array().unwrap().len(), 2); + for call in plan["calls"].as_array().unwrap() { + assert_eq!(call["liveSupported"], true); + assert_eq!(call["requiresTarget"], true); + } +} + +#[tokio::test] +async fn future_capabilities_return_projected_unsupported_even_with_complete_http_data() { + let h = Harness::new(); + for complete in [true, false] { + let mut rec = fixture(); + rec.complete = complete; + let id = h.create(&[rec]).await; + let error = h + .call(id, "replay.capabilities", json!({})) + .await + .unwrap_err(); + assert_eq!(error.code(), 8); + assert!(matches!(error, OpsError::Projected(_))); + assert_eq!( + error.envelope(SystemTime::UNIX_EPOCH).error["name"], + "AI_UnsupportedFunctionalityError" + ); + } +} + +#[tokio::test] +async fn invalid_jsonl_empty_duplicate_and_unknown_schemas_are_rejected() { + let h = Harness::new(); + let raw = serde_json::to_value(fixture()).unwrap(); + let mut future = raw.clone(); + future["schema"] = json!(3); + for jsonl in [ + String::new(), + "\n \n".into(), + "{broken".into(), + format!("{raw}\n{raw}"), + future.to_string(), + ] { + let error = h + .call( + HandleId(0), + "replay.create", + json!({"recordingsJsonl":jsonl}), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), 301); + } +} + +#[tokio::test] +async fn canonical_recording_reader_retains_registered_g_b_schema_support() { + let h = Harness::new(); + let mut raw = serde_json::to_value(fixture()).unwrap(); + raw["schema"] = json!(1); + let id = h + .call( + HandleId(0), + "replay.create", + json!({"recordingsJsonl":format!("\n{raw}\n\n")}), + ) + .await + .unwrap(); + let id = serde_json::from_value(id["handle"].clone()).unwrap(); + let info = h.call(id, "replay.inspect", json!({})).await.unwrap(); + assert_eq!(info["recordings"][0]["callId"], "replay-test"); +} + +#[tokio::test] +async fn controls_are_strict_including_nested_target_fields_and_wire_ids() { + let h = Harness::new(); + let id = h.create(&[fixture()]).await; + for op in ["replay.inspect", "replay.dryRun", "replay.capabilities"] { + assert_eq!( + h.call(id, op, json!({"unknown":true})) + .await + .unwrap_err() + .code(), + 301 + ); + } + assert_eq!( + h.call( + HandleId(0), + "replay.create", + json!({"recordingsJsonl":"", "settings":{}}) + ) + .await + .unwrap_err() + .code(), + 301 + ); + let model = h.insert(Entry::Language(Arc::new(MockReplayModel::new( + "openai.chat", + "gpt-4o", + vec![fixture()], + )))); + for bad in [ + json!({"callId":"replay-test"}), + json!({"call_id":"replay-test","target":{"kind":"model","handle":model}}), + json!({"callId":"replay-test","target":{"kind":"model","handle":model,"apiKey":"legacy"}}), + json!({"callId":"replay-test","target":{"kind":"unknown","handle":model}}), + json!({"callId":"replay-test","target":{"kind":"model","handle":model.0}}), + json!({"callId":"replay-test","target":{"kind":"model","handle":"+1"}}), + json!({"callId":"replay-test","target":{"kind":"model","handle":"18446744073709551616"}}), + json!({"callId":"replay-test","target":{"kind":"model","handle":model},"unknown":true}), + ] { + assert_eq!( + h.call(id, "replay.live", bad).await.unwrap_err().code(), + 301 + ); + } +} + +#[tokio::test] +async fn language_live_matches_the_existing_core_result_twice() { + let h = Harness::new(); + let rec = fixture(); + let id = h.create(std::slice::from_ref(&rec)).await; + let model = Arc::new(MockReplayModel::new( + "openai.chat", + "gpt-4o", + vec![rec.clone()], + )); + let model_id = h.insert(Entry::Language(model.clone())); + let expected = stable( + serde_json::to_value( + aimux_core::replay::replay_with_model(&rec, model.as_ref(), None) + .await + .unwrap(), + ) + .unwrap(), + ); + for _ in 0..2 { + let actual = h + .call(id, "replay.live", target("model", model_id, "replay-test")) + .await + .unwrap(); + assert_eq!(stable(actual.clone()), expected); + let decoded: aimux_core::generate::GenerateTextResult = + serde_json::from_value(actual).unwrap(); + assert_eq!(decoded.text(), "Hello! How can I assist you today?"); + } +} + +#[tokio::test] +async fn language_registry_live_reuses_the_explicit_registered_provider() { + let h = Harness::new(); + let id = h.create(&[fixture()]).await; + let model: Arc = Arc::new(MockReplayModel::new( + "openai.chat", + "gpt-4o", + vec![fixture()], + )); + let provider = custom_provider(CustomProviderOptions { + language_models: [("gpt-4o".into(), model)].into(), + ..Default::default() + }); + let providers: std::collections::BTreeMap> = + [( + "openai".into(), + Arc::new(provider) as Arc, + )] + .into(); + let registry = h.insert(Entry::Registry(Arc::new( + aimux_core::create_provider_registry(providers, Default::default()), + ))); + let actual = h + .call( + id, + "replay.live", + target("registry", registry, "replay-test"), + ) + .await + .unwrap(); + let decoded: aimux_core::generate::GenerateTextResult = serde_json::from_value(actual).unwrap(); + assert_eq!(decoded.text(), "Hello! How can I assist you today?"); +} + +#[tokio::test] +async fn decision_live_matches_existing_core_for_model_and_registry_targets() { + let h = Harness::new(); + let rec = decision_fixture(); + let id = h.create(std::slice::from_ref(&rec)).await; + let model = + Arc::new(MockDecisionReplayModel::new("jev", "jev-model", vec![rec.clone()]).unwrap()); + let expected = serde_json::to_value( + aimux_core::replay::replay_decision_with_model(&rec, model.as_ref()) + .await + .unwrap(), + ) + .unwrap(); + let model_id = h.insert(Entry::Decision(model.clone())); + let provider = custom_provider(CustomProviderOptions { + decision_models: [( + "jev-model".into(), + model as Arc, + )] + .into(), + ..Default::default() + }); + let providers: std::collections::BTreeMap> = + [( + "jev".into(), + Arc::new(provider) as Arc, + )] + .into(); + let registry = h.insert(Entry::Registry(Arc::new( + aimux_core::create_provider_registry(providers, Default::default()), + ))); + for (kind, handle) in [("model", model_id), ("registry", registry)] { + assert_eq!( + h.call(id, "replay.live", target(kind, handle, "decision-test")) + .await + .unwrap(), + expected + ); + } +} + +#[tokio::test] +async fn foreign_primary_and_secondary_handles_match_unknown_without_disclosure() { + let h = Harness::new(); + let foreign = Harness::new(); + let id = h.create(&[fixture()]).await; + let foreign_id = foreign.create(&[fixture()]).await; + let unknown = HandleId(u64::MAX); + for op in ["replay.inspect", "replay.capabilities", "replay.dryRun"] { + let a = h.call(foreign_id, op, json!({})).await.unwrap_err(); + let b = h.call(unknown, op, json!({})).await.unwrap_err(); + assert_eq!( + serde_json::to_value(a.envelope(SystemTime::UNIX_EPOCH)).unwrap(), + serde_json::to_value(b.envelope(SystemTime::UNIX_EPOCH)).unwrap() + ); + } + let model = foreign.insert(Entry::Language(Arc::new(MockReplayModel::new( + "openai.chat", + "gpt-4o", + vec![fixture()], + )))); + let a = h + .call(id, "replay.live", target("model", model, "replay-test")) + .await + .unwrap_err(); + let b = h + .call(id, "replay.live", target("model", unknown, "replay-test")) + .await + .unwrap_err(); + assert_eq!(a.code(), 203); + assert_eq!( + serde_json::to_value(a.envelope(SystemTime::UNIX_EPOCH)).unwrap(), + serde_json::to_value(b.envelope(SystemTime::UNIX_EPOCH)).unwrap() + ); +} + +#[tokio::test] +async fn unknown_call_and_target_kind_mismatches_do_not_replay() { + let h = Harness::new(); + let id = h.create(&[fixture()]).await; + let model = h.insert(Entry::Language(Arc::new(MockReplayModel::new( + "openai.chat", + "gpt-4o", + vec![fixture()], + )))); + assert_eq!( + h.call(id, "replay.live", target("model", model, "missing")) + .await + .unwrap_err() + .code(), + 301 + ); + assert_eq!( + h.call(id, "replay.live", target("registry", model, "replay-test")) + .await + .unwrap_err() + .code(), + 203 + ); + assert_eq!( + h.call(id, "replay.live", target("model", id, "replay-test")) + .await + .unwrap_err() + .code(), + 203 + ); +} + +#[tokio::test] +async fn live_accepts_context_and_owner_checked_wire_abort_signals() { + let h = Harness::new(); + let foreign = Harness::new(); + let id = h.create(&[fixture()]).await; + let model = h.insert(Entry::Language(Arc::new(MockReplayModel::new( + "openai.chat", + "gpt-4o", + vec![fixture()], + )))); + let signal = aimux_core::AbortSignal::new(); + signal.abort(); + let mut ctx = h.ctx(); + ctx.abort = Some(signal.clone()); + assert_eq!( + dispatch::call( + ctx, + id, + "replay.live", + target("model", model, "replay-test") + ) + .await + .unwrap_err() + .code(), + 13 + ); + let abort = h.insert(Entry::Abort(Arc::new(signal.clone()))); + let mut params = target("model", model, "replay-test"); + params["abort"] = json!(abort); + assert_eq!( + h.call(id, "replay.live", params.clone()) + .await + .unwrap_err() + .code(), + 13 + ); + params["abort"] = json!(foreign.insert(Entry::Abort(Arc::new(signal)))); + let foreign_error = h.call(id, "replay.live", params.clone()).await.unwrap_err(); + params["abort"] = json!(HandleId(u64::MAX)); + let unknown_error = h.call(id, "replay.live", params).await.unwrap_err(); + assert_eq!(foreign_error.code(), 203); + assert_eq!( + serde_json::to_value(foreign_error.envelope(SystemTime::UNIX_EPOCH)).unwrap(), + serde_json::to_value(unknown_error.envelope(SystemTime::UNIX_EPOCH)).unwrap() + ); +} diff --git a/aimux-ops/tests/retry_hint.rs b/aimux-ops/tests/retry_hint.rs new file mode 100644 index 000000000..cecccda44 --- /dev/null +++ b/aimux-ops/tests/retry_hint.rs @@ -0,0 +1,197 @@ +use std::collections::HashMap; +use std::time::{Duration, SystemTime}; + +use aimux_core::error::{AiMuxError, ApiCallError, RetryError, RetryErrorReason}; +use aimux_ops::retry_hint::retry_after_ms; + +fn now() -> SystemTime { + // Wed, 21 Oct 2015 07:28:00 GMT + SystemTime::UNIX_EPOCH + Duration::from_secs(1_445_412_480) +} + +fn headers(values: &[(&str, &str)]) -> HashMap { + values + .iter() + .map(|(name, value)| ((*name).into(), (*value).into())) + .collect() +} + +#[test] +fn numeric_golden_vectors() { + let cases = [ + ("250", Some(250)), + ("250.999", Some(250)), + ("0.999", Some(0)), + ("0", Some(0)), + ("-0", Some(0)), + (" 250.5 ", Some(250)), + ("1e3", Some(1_000)), + ("-1", None), + ("-0.1", None), + ("NaN", None), + ("Infinity", None), + ("-Infinity", None), + ("inf", None), + ("1e308", None), + ("18446744073709551616", None), + ("18446744073709551615", None), + // The largest f64 below 2^64 is valid; the endpoint itself is not. + ("18446744073709549568", Some(18_446_744_073_709_549_568)), + ("", None), + ("garbage", None), + ("12ms", None), + ]; + for (value, expected) in cases { + assert_eq!( + retry_after_ms(&headers(&[("retry-after-ms", value)]), now()), + expected, + "retry-after-ms: {value:?}" + ); + } +} + +#[test] +fn seconds_golden_vectors() { + for (value, expected) in [ + ("1.5", Some(1_500)), + ("1.2349", Some(1_234)), + ("0.0009", Some(0)), + ("0", Some(0)), + ("-0.0001", None), + ("-1", None), + ("NaN", None), + ("Infinity", None), + ("-Infinity", None), + ("1e308", None), + ("18446744073709552", None), + ("1.5 seconds", None), + ("invalid", None), + ] { + assert_eq!( + retry_after_ms(&headers(&[("retry-after", value)]), now()), + expected, + "retry-after: {value:?}" + ); + } +} + +#[test] +fn header_precedence_golden_vectors() { + for (ms, seconds, expected) in [ + ("250", "10", Some(250)), + ("0", "10", Some(0)), + ("-1", "10", None), + ("-0.1", "10", None), + ("Infinity", "10", None), + ("1e308", "10", None), + ("invalid", "1.5", Some(1_500)), + ("NaN", "1.5", None), + ("", "1.5", Some(1_500)), + ("invalid", "Wed, 21 Oct 2015 07:28:02 GMT", Some(2_000)), + ] { + assert_eq!( + retry_after_ms( + &headers(&[("retry-after-ms", ms), ("retry-after", seconds)]), + now(), + ), + expected, + "retry-after-ms: {ms:?}, retry-after: {seconds:?}" + ); + } +} + +#[test] +fn fixed_clock_http_date_golden_vectors() { + for (date, clock, expected) in [ + ("Wed, 21 Oct 2015 07:28:02 GMT", now(), Some(2_000)), + ( + "Wed, 21 Oct 2015 07:28:02 GMT", + now() + Duration::from_micros(1_234_567), + Some(765), + ), + ("Wed, 21 Oct 2015 07:28:00 GMT", now(), Some(0)), + ("Wed, 21 Oct 2015 07:27:59 GMT", now(), None), + ( + "Wed, 21 Oct 2015 07:28:00 GMT", + now() + Duration::from_nanos(1), + None, + ), + ("not a date", now(), None), + ] { + assert_eq!( + retry_after_ms(&headers(&[("retry-after", date)]), clock), + expected, + "retry-after: {date:?}, clock: {clock:?}" + ); + } +} + +#[test] +fn header_names_are_case_insensitive_and_missing_headers_have_no_hint() { + assert_eq!(retry_after_ms(&HashMap::new(), now()), None); + assert_eq!(retry_after_ms(&headers(&[("other", "5")]), now()), None); + assert_eq!( + retry_after_ms(&headers(&[("ReTrY-AfTeR-Ms", "250")]), now()), + Some(250) + ); + assert_eq!( + retry_after_ms(&headers(&[("Retry-After", "1.5")]), now()), + Some(1_500) + ); + assert_eq!( + retry_after_ms( + &headers(&[("RETRY-AFTER-MS", "250"), ("Retry-After", "10")]), + now(), + ), + Some(250) + ); +} + +fn api_error(values: &[(&str, &str)]) -> AiMuxError { + let mut api = ApiCallError::new("rate limited", "https://example.com", Default::default()); + api.response_headers = Some(headers(values)); + AiMuxError::ApiCall(Box::new(api)) +} + +#[test] +fn core_error_uses_shared_parser_with_checked_legacy_return_type() { + assert_eq!( + api_error(&[("Retry-After", "1.5")]).retry_after_hint(), + Some(1_500) + ); + assert_eq!( + api_error(&[("retry-after-ms", "-0.1")]).retry_after_hint(), + None + ); + assert_eq!( + api_error(&[("retry-after-ms", "9223372036854775808")]).retry_after_hint(), + None + ); + assert_eq!( + AiMuxError::Timeout("budget spent".into()).retry_after_hint(), + None + ); +} + +#[test] +fn retry_nesting_has_no_top_level_hint_and_retains_attempt_hints() { + let attempt = api_error(&[("retry-after-ms", "250")]); + let nested = AiMuxError::Retry(RetryError { + reason: RetryErrorReason::MaxRetriesExceeded, + errors: vec![attempt.clone()], + }); + let aggregate = AiMuxError::Retry(RetryError { + reason: RetryErrorReason::MaxRetriesExceeded, + errors: vec![attempt, nested], + }); + assert_eq!(aggregate.retry_after_hint(), None); + let AiMuxError::Retry(retry) = &aggregate else { + panic!("expected retry aggregate"); + }; + assert_eq!(retry.errors[0].retry_after_hint(), Some(250)); + assert_eq!(retry.errors[1].retry_after_hint(), None); + let AiMuxError::Retry(nested) = &retry.errors[1] else { + panic!("expected nested retry aggregate"); + }; + assert_eq!(nested.errors[0].retry_after_hint(), Some(250)); +} diff --git a/aimux-ops/tests/runtime.rs b/aimux-ops/tests/runtime.rs new file mode 100644 index 000000000..cb300a2cb --- /dev/null +++ b/aimux-ops/tests/runtime.rs @@ -0,0 +1,258 @@ +//! Runtime family through the real shared dispatch boundary. +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, SystemTime}; + +use aimux_ops::dispatch::{self, Ctx}; +use aimux_ops::envelope::{OpsError, ProtocolError}; +use aimux_ops::handle::{HandleId, Owner}; +use serde_json::{Value, json}; + +fn ctx(owner: Owner) -> Ctx { + Ctx { + owner, + abort: None, + now: SystemTime::now, + } +} + +#[tokio::test] +async fn description_matches_the_real_manifest_and_digest() { + let result = dispatch::call( + ctx(Owner::CProcess), + HandleId(0), + "runtime.describe", + json!({}), + ) + .await + .unwrap(); + let manifest = aimux_ops::manifest::manifest(); + assert_eq!(result["version"], env!("CARGO_PKG_VERSION")); + for field in ["abiVersion", "opsVersion", "manifestRevision"] { + assert_eq!(result[field], manifest[field]); + } + assert_eq!( + result["manifestDigest"], + aimux_ops::manifest::manifest_digest() + ); + for field in ["handleKinds", "packages"] { + assert_eq!(result["capabilities"][field], manifest[field]); + } + let operations: Vec<_> = manifest["ops"] + .as_array() + .unwrap() + .iter() + .map(|op| op["name"].clone()) + .collect(); + assert_eq!(result["capabilities"]["operations"], json!(operations)); + assert_eq!(result.as_object().unwrap().len(), 6); + assert_eq!(result["capabilities"].as_object().unwrap().len(), 3); +} + +#[tokio::test] +async fn strict_controls_fail_before_configuration() { + for (name, params) in [ + ("runtime.describe", json!({"credentials":true})), + ("runtime.describe", Value::Null), + ("runtime.configure", json!({"timeoutMs":1})), + ( + "runtime.configure", + json!({"logging":{"level":"warn", "body":true}}), + ), + ("runtime.configure", json!({"logging":{"level":"verbose"}})), + ("runtime.configure", json!({"proxy":{"unknown":true}})), + ("runtime.configure", json!({"proxy":{"http_url":42}})), + ("runtime.configure", json!({"proxy":[]})), + ("runtime.configure", json!([])), + ("runtime.configure", Value::Null), + ] { + let error = dispatch::call(ctx(Owner::CProcess), HandleId(0), name, params) + .await + .unwrap_err(); + assert!(matches!(error, OpsError::Projected(_))); + assert!(matches!( + error.unprojected(), + OpsError::Protocol(ProtocolError::InvalidParams(_)) + )); + assert_eq!(error.code(), 301); + } +} + +#[tokio::test] +async fn configuration_summary_and_frozen_proxy_preserve_transport_and_privacy() { + let before = aimux_provider_utils::fetch::default_fetch(); + let summary = dispatch::call( + ctx(Owner::CProcess), + HandleId(0), + "runtime.configure", + json!({}), + ) + .await + .unwrap(); + assert_eq!(summary["proxy"]["frozen"], true); + let error = dispatch::call( + ctx(Owner::CProcess), + HandleId(0), + "runtime.configure", + json!({"proxy":{"http_url":"http://user:secret@localhost:8080"}}), + ) + .await + .unwrap_err(); + assert!(matches!( + error.unprojected(), + OpsError::Protocol(ProtocolError::InvalidState(_)) + )); + assert_eq!(error.code(), 304); + assert!( + !error + .envelope(SystemTime::now()) + .error + .to_string() + .contains("secret") + ); + assert!(Arc::ptr_eq( + &before, + &aimux_provider_utils::fetch::default_fetch() + )); +} + +#[tokio::test] +async fn configuration_error_is_projected_once_by_dispatch() { + static CLOCK_READS: AtomicUsize = AtomicUsize::new(0); + fn now() -> SystemTime { + CLOCK_READS.fetch_add(1, Ordering::SeqCst); + SystemTime::UNIX_EPOCH + Duration::from_secs(10) + } + let mut context = ctx(Owner::CProcess); + context.now = now; + let _ = aimux_provider_utils::fetch::default_fetch(); + let error = dispatch::call( + context, + HandleId(0), + "runtime.configure", + json!({"proxy":{"http_url":"http://different.invalid:9999"}}), + ) + .await + .unwrap_err(); + assert!(matches!(error, OpsError::Projected(_))); + assert_eq!(CLOCK_READS.load(Ordering::SeqCst), 1); + assert_eq!( + error.envelope(SystemTime::UNIX_EPOCH), + error.envelope(SystemTime::now()) + ); + assert_eq!(CLOCK_READS.load(Ordering::SeqCst), 1); +} + +#[test] +fn startup_configuration_uses_native_freeze_authorities() { + const CASE: &str = "AIMUX_RUNTIME_TEST_CASE"; + let Ok(case) = std::env::var(CASE) else { + for case in ["startup", "native"] { + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "startup_configuration_uses_native_freeze_authorities", + "--nocapture", + ]) + .env(CASE, case) + .env_remove("AIMUX_LOG") + .env_remove("AIMUX_LOG_LEVEL") + .status() + .unwrap(); + assert!(status.success(), "scenario {case}"); + } + return; + }; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + if case == "native" { + aimux_provider_utils::init_logging("info"); + } + let summary = dispatch::call( + ctx(Owner::CProcess), + HandleId(0), + "runtime.configure", + json!({}), + ) + .await + .unwrap(); + assert_eq!(summary["proxy"]["frozen"], false); + let logging = dispatch::call( + ctx(Owner::CProcess), + HandleId(0), + "runtime.configure", + json!({"logging":{"level":"warn"}}), + ) + .await; + if case != "startup" { + assert_eq!(logging.unwrap_err().code(), 304); + } else { + assert_eq!(logging.unwrap()["logging"]["requestedLevel"], "warn"); + assert!( + dispatch::call( + ctx(Owner::CProcess), + HandleId(0), + "runtime.configure", + json!({"logging":{"level":"warn"}}) + ) + .await + .is_ok() + ); + assert_eq!( + dispatch::call( + ctx(Owner::CProcess), + HandleId(0), + "runtime.configure", + json!({"logging":{"level":"trace"}}) + ) + .await + .unwrap_err() + .code(), + 304 + ); + } + let request = json!({"proxy":{"http_url":"http://user:secret@proxy.invalid:8080"}}); + let summary = dispatch::call( + ctx(Owner::CProcess), + HandleId(0), + "runtime.configure", + request.clone(), + ) + .await + .unwrap(); + assert_eq!(summary["proxy"]["configured"], true); + assert_eq!(summary["proxy"]["frozen"], false); + assert!(!summary.to_string().contains("secret")); + assert!(!summary.to_string().contains("proxy.invalid")); + let first = aimux_provider_utils::fetch::default_fetch(); + assert!( + dispatch::call( + ctx(Owner::CProcess), + HandleId(0), + "runtime.configure", + request + ) + .await + .is_ok() + ); + assert!(Arc::ptr_eq( + &first, + &aimux_provider_utils::fetch::default_fetch() + )); + assert_eq!( + dispatch::call( + ctx(Owner::CProcess), + HandleId(0), + "runtime.configure", + json!({"proxy":{}}) + ) + .await + .unwrap_err() + .code(), + 304 + ); + }); +} diff --git a/aimux-ops/tests/session.rs b/aimux-ops/tests/session.rs new file mode 100644 index 000000000..42083d92e --- /dev/null +++ b/aimux-ops/tests/session.rs @@ -0,0 +1,415 @@ +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use aimux_core::session::{SessionSource, SessionStore}; +use aimux_ops::dispatch::{Ctx, call, descriptor}; +use aimux_ops::envelope::OpsError; +use aimux_ops::handle::{Entry, HandleId, HandleKind, HandleRegistry, Owner}; +use aimux_ops::op::{Cancellation, OpMode, RequestBytePolicy, Scope}; +use serde_json::{Value, json}; + +struct TestOwner(Owner); + +impl TestOwner { + fn new() -> Self { + Self(Owner::new_node().unwrap()) + } + + fn ctx(&self) -> Ctx { + Ctx { + owner: self.0, + abort: None, + now: fixed_now, + } + } + + fn insert(&self, store: Arc) -> HandleId { + HandleRegistry::global() + .insert(self.0, Entry::SessionStore(store)) + .unwrap() + } +} + +impl Drop for TestOwner { + fn drop(&mut self) { + HandleRegistry::global().release_owner(self.0); + } +} + +fn fixed_now() -> SystemTime { + UNIX_EPOCH +} + +fn assert_error(error: OpsError, code: i32, name: &str) -> Value { + assert!(matches!(&error, OpsError::Projected(_))); + assert_eq!(error.code(), code); + let envelope = serde_json::to_value(error.envelope(fixed_now())).unwrap(); + assert_eq!(envelope["error"]["name"], name); + envelope +} + +#[tokio::test] +async fn control_params_are_strict_through_dispatch() { + let owner = TestOwner::new(); + let handle = owner.insert(Arc::new(SessionStore::new())); + let invalid = [ + ("sessionStore.create", HandleId(0), json!({})), + ("sessionStore.create", HandleId(0), json!({"infer": 1})), + ("sessionStore.create", HandleId(0), json!({"infer": null})), + ( + "sessionStore.create", + HandleId(0), + json!({"infer": false, "extra": 1}), + ), + ("sessionStore.attach", handle, json!({"extra": 1})), + ("sessionStore.list", handle, json!({"extra": 1})), + ("sessionStore.calls", handle, json!({})), + ("sessionStore.calls", handle, json!({"sessionId": 1})), + ("sessionStore.calls", handle, json!({"sessionId": null})), + ("sessionStore.calls", handle, json!({"session_id": "s"})), + ( + "sessionStore.calls", + handle, + json!({"sessionId": "s", "extra": 1}), + ), + ]; + for (op, target, params) in invalid { + assert_error( + call(owner.ctx(), target, op, params).await.unwrap_err(), + 301, + "InvalidParams", + ); + } + for (op, target) in [ + ("sessionStore.create", HandleId(0)), + ("sessionStore.attach", handle), + ("sessionStore.list", handle), + ("sessionStore.calls", handle), + ] { + for params in [Value::Null, json!([]), json!("bad")] { + assert_error( + call(owner.ctx(), target, op, params).await.unwrap_err(), + 301, + "InvalidParams", + ); + } + } +} + +#[tokio::test] +async fn queries_use_selected_store_and_preserve_core_wire_and_bounds() { + let owner = TestOwner::new(); + let store = Arc::new(SessionStore::with_capacity(2, 2)); + let handle = owner.insert(store.clone()); + let empty = owner.insert(Arc::new(SessionStore::new())); + assert_eq!( + call(owner.ctx(), handle, "sessionStore.list", json!({})) + .await + .unwrap(), + json!([]) + ); + for id in ["unknown", "", "会话😀"] { + assert_eq!( + call( + owner.ctx(), + handle, + "sessionStore.calls", + json!({"sessionId": id}) + ) + .await + .unwrap(), + json!([]) + ); + } + store.append("会话😀", Some("first"), SessionSource::Explicit); + let second = store.append("会话😀", Some("second"), SessionSource::Explicit); + let third = store.append("会话😀", Some("third"), SessionSource::Explicit); + let expected = json!([ + {"callId":"second", "step":1, "recordedAt":second.recorded_at}, + {"callId":"third", "step":2, "recordedAt":third.recorded_at} + ]); + let actual = call( + owner.ctx(), + handle, + "sessionStore.calls", + json!({"sessionId":"会话😀"}), + ) + .await + .unwrap(); + assert_eq!(actual, expected); + assert_eq!( + actual, + serde_json::to_value(store.session_calls("会话😀")).unwrap() + ); + assert_eq!( + call(owner.ctx(), handle, "sessionStore.list", json!({})) + .await + .unwrap(), + json!([ + {"sessionId":"会话😀", "source":"Explicit", "calls":expected} + ]) + ); + assert_eq!( + call(owner.ctx(), empty, "sessionStore.list", json!({})) + .await + .unwrap(), + json!([]) + ); + store.append("", Some("empty-id"), SessionSource::Inferred); + assert_eq!( + call( + owner.ctx(), + handle, + "sessionStore.calls", + json!({"sessionId":""}) + ) + .await + .unwrap()[0]["callId"], + "empty-id" + ); + store.append("new", Some("new"), SessionSource::Explicit); + assert_eq!( + call( + owner.ctx(), + handle, + "sessionStore.calls", + json!({"sessionId":"会话😀"}) + ) + .await + .unwrap(), + json!([]) + ); + let mut actual = call(owner.ctx(), handle, "sessionStore.list", json!({})) + .await + .unwrap() + .as_array() + .unwrap() + .clone(); + actual.sort_by_key(|view| view["sessionId"].as_str().unwrap().to_owned()); + let mut expected = serde_json::to_value(store.list_sessions()) + .unwrap() + .as_array() + .unwrap() + .clone(); + expected.sort_by_key(|view| view["sessionId"].as_str().unwrap().to_owned()); + assert_eq!(actual, expected); + assert_eq!(actual.len(), 2); +} + +#[tokio::test] +async fn owner_checks_hide_foreign_handles_and_drop_is_idempotent() { + let owner = TestOwner::new(); + let foreign = TestOwner::new(); + let handle = owner.insert(Arc::new(SessionStore::new())); + let wrong_kind = HandleRegistry::global() + .insert( + owner.0, + Entry::Abort(Arc::new(aimux_core::AbortSignal::new())), + ) + .unwrap(); + for (op, params) in [ + ("sessionStore.attach", json!({})), + ("sessionStore.list", json!({})), + ("sessionStore.calls", json!({"sessionId":"s"})), + ] { + let hidden = assert_error( + call(foreign.ctx(), handle, op, params.clone()) + .await + .unwrap_err(), + 203, + "InvalidHandle", + ); + let unknown = assert_error( + call(foreign.ctx(), HandleId(u64::MAX), op, params.clone()) + .await + .unwrap_err(), + 203, + "InvalidHandle", + ); + assert_eq!(hidden, unknown); + assert_eq!(hidden["error"]["actual"], Value::Null); + let wrong = assert_error( + call(owner.ctx(), wrong_kind, op, params.clone()) + .await + .unwrap_err(), + 203, + "InvalidHandle", + ); + assert_eq!(wrong["error"]["actual"], "Abort"); + assert_error( + call(owner.ctx(), HandleId(0), op, params) + .await + .unwrap_err(), + 203, + "InvalidHandle", + ); + } + assert_error( + call( + owner.ctx(), + handle, + "sessionStore.create", + json!({"infer":false}), + ) + .await + .unwrap_err(), + 203, + "InvalidHandle", + ); + call(foreign.ctx(), handle, "handle.drop", json!({})) + .await + .unwrap(); + assert_eq!( + call(owner.ctx(), handle, "sessionStore.list", json!({})) + .await + .unwrap(), + json!([]) + ); + for _ in 0..2 { + assert!( + call(owner.ctx(), handle, "handle.drop", json!({})) + .await + .unwrap() + .is_null() + ); + } + assert_error( + call(owner.ctx(), handle, "sessionStore.list", json!({})) + .await + .unwrap_err(), + 203, + "InvalidHandle", + ); +} + +#[tokio::test] +async fn create_and_attach_preserve_process_global_infer_and_capture_behavior() { + let owner = TestOwner::new(); + let mut previous = None; + for infer in [false, true] { + let result = call( + owner.ctx(), + HandleId(0), + "sessionStore.create", + json!({"infer":infer}), + ) + .await + .unwrap(); + assert_eq!(result.as_object().unwrap().len(), 1); + let text = result["handle"].as_str().unwrap(); + assert!(text.bytes().all(|byte| byte.is_ascii_digit())); + let handle: HandleId = serde_json::from_value(result["handle"].clone()).unwrap(); + assert_ne!(handle, HandleId(0)); + if let Some(previous) = previous { + assert_ne!(handle, previous); + } + previous = Some(handle); + let Entry::SessionStore(store) = HandleRegistry::global() + .get(owner.0, handle, &[HandleKind::SessionStore]) + .unwrap() + else { + unreachable!() + }; + let inferer = aimux_core::session::session_inferer().unwrap(); + let resolved = inferer.lock().unwrap().resolve(None, &Vec::new()); + assert_eq!(resolved.is_some(), infer); + assert!( + !aimux_core::session::session_store() + .is_some_and(|attached| Arc::ptr_eq(&store, &attached)) + ); + store.append("capture", Some("call"), SessionSource::Explicit); + assert!( + call(owner.ctx(), handle, "sessionStore.attach", json!({})) + .await + .unwrap() + .is_null() + ); + assert!(Arc::ptr_eq( + &store, + &aimux_core::session::session_store().unwrap() + )); + assert_eq!( + call( + owner.ctx(), + handle, + "sessionStore.calls", + json!({"sessionId":"capture"}) + ) + .await + .unwrap(), + serde_json::to_value(aimux_core::session::session_calls("capture")).unwrap() + ); + assert_eq!( + call(owner.ctx(), handle, "sessionStore.list", json!({})) + .await + .unwrap(), + serde_json::to_value(aimux_core::session::list_sessions()).unwrap() + ); + call(owner.ctx(), handle, "handle.drop", json!({})) + .await + .unwrap(); + assert!(Arc::ptr_eq( + &store, + &aimux_core::session::session_store().unwrap() + )); + } +} + +#[test] +fn descriptors_publish_concrete_types_and_complete_metadata() { + let manifest = aimux_ops::manifest::manifest(); + for (name, request, result, side_effects) in [ + ( + "sessionStore.create", + "aimux_ops::ops::session::SessionStoreCreateParams", + "aimux_ops::ops::session::SessionStoreHandle", + true, + ), + ("sessionStore.attach", "EmptyParams", "()", true), + ( + "sessionStore.list", + "EmptyParams", + "aimux_ops::ops::session::SessionViews", + false, + ), + ( + "sessionStore.calls", + "aimux_ops::ops::session::SessionStoreCallsParams", + "aimux_ops::ops::session::SessionCalls", + false, + ), + ] { + let op = descriptor(name).unwrap(); + assert_eq!(op.mode, OpMode::Call); + assert_eq!(op.request_type, request); + assert_eq!(op.result_type, result); + assert_eq!(op.scope, Scope::Current); + assert_eq!(op.cancellation, Cancellation::None); + assert_eq!(op.request_byte_policy, RequestBytePolicy::Json); + assert_eq!(op.side_effects, side_effects); + assert_eq!(op.result_ownership, "owned"); + assert_eq!(op.retry_owner, "none"); + assert!(op.runtime_handle_fields.is_empty()); + assert!(op.required_capabilities.is_empty()); + assert!(op.event_type.is_none()); + assert!(op.terminal_type.is_none()); + assert!(!op.source_symbols.is_empty()); + assert_eq!( + op.allowed_handle_kinds, + if name == "sessionStore.create" { + &[][..] + } else { + &[HandleKind::SessionStore][..] + } + ); + for id in [request, result] { + assert!( + manifest["types"] + .as_array() + .unwrap() + .iter() + .any(|ty| ty["id"] == id) + ); + } + } +} diff --git a/aimux-ops/tests/stream_terminal.rs b/aimux-ops/tests/stream_terminal.rs new file mode 100644 index 000000000..4acad3542 --- /dev/null +++ b/aimux-ops/tests/stream_terminal.rs @@ -0,0 +1,554 @@ +//! RFC-0039 shared stream scenarios, at the EventStream -> StreamFrame boundary. +//! +//! The mock source emits already-lifted data events, not provider HTTP chunks. +//! `event` is Ok(Value), `terminalError` is Err(OpsError::AiMux), and `eof` is +//! source exhaustion. `serializationFailure` requires a failing serializer at +//! the native framing seam: a serde_json::Value cannot itself fail serialization. +//! Never substitute a host callback exception (CallbackFailure) for this fault. +//! +//! Run a real dispatch or binding driver with: +//! AIMUX_OPS_SCENARIO_DRIVER=/absolute/path/to/driver \ +//! cargo test -p aimux-ops --test stream_terminal run_shared_driver -- --ignored +//! +//! Driver protocol: one process per scenario, JSON on stdin, one JSON observation +//! on stdout, diagnostics on stderr. The input has no `expected` field. The driver +//! must install a scripted mock EventStream in the REAL framing/queue path and +//! execute `controls` in order. It must not implement a second terminal state +//! machine or derive frames from the script itself. Binding drivers must call +//! their native path, not reproduce native framing in the host language. +//! +//! `wait` parks the source until its named gate is released or cancellation. +//! `waitForSourceGate` and `waitForQueueFull` are instrumentation barriers, never +//! sleeps. `repeatEvent` lazily emits count copies. `read` consumes exactly count +//! frames; `drain` consumes to EOF, including another poll after end; `cancel` +//! signals the shared AbortSignal even after success. `closeReader` invokes the +//! real iterator-close hook, then `drainNative` waits for native cleanup without +//! delivering more frames to the closed consumer. `startProducer` starts the +//! bounded bridge without reading. Queue limits apply to data, not terminal. +//! +//! Observation shape is `expected` in the fixtures. `frames` records all native +//! callbacks/frames, `consumerFrames` only consumer deliveries. accepted means +//! successful preflight, NOT a callback frame. preflightError is null otherwise. +//! abortObserved records the real shared abort signal (null in expectations +//! leaves error/success cleanup signaling unspecified). queueFullObserved must +//! come from capacity instrumentation, and consumerClosed from the close hook. +//! sourceStarted records actual source startup; sourceEventsPolled counts Ok +//! data items pulled from the mock, including any polled but undelivered items. +//! Capture any returned post-acceptance error as a driver FAILURE, not another +//! envelope: accepted stream failures are delivered only by end.error. +//! +//! Errors match their stable kind/code/error.name subset; messages and variant +//! fields must still have the envelope shape. Other event data +//! is compared exactly. Extra native cancellation error detail is permitted. + +use std::collections::BTreeSet; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Scenario { + schema_version: u32, + name: String, + description: String, + boundary: String, + queue_limits: QueueLimits, + script: Vec, + controls: Vec, + fault: Option, + expected: Observation, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct QueueLimits { + items: usize, + bytes: usize, + max_event_bytes: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "action", rename_all = "camelCase", deny_unknown_fields)] +enum SourceStep { + Event { event: Value }, + RepeatEvent { count: usize, event: Value }, + Wait { gate: String }, + TerminalError { kind: String, message: String }, + SerializationFailure { message: String }, + Eof, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "action", rename_all = "camelCase", deny_unknown_fields)] +enum Control { + Read { count: usize }, + Drain, + DrainNative, + Cancel, + CloseReader, + StartProducer, + WaitForQueueFull, + WaitForSourceGate { gate: String }, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Observation { + accepted: bool, + preflight_error: Option, + frames: Vec, + consumer_frames: Vec, + abort_observed: Option, + queue_full_observed: bool, + consumer_closed: bool, + source_started: bool, + source_events_polled: usize, +} + +fn scenarios() -> Vec<(PathBuf, Value, Scenario)> { + let root = std::env::var_os("AIMUX_OPS_SCENARIOS").map_or_else( + || Path::new(env!("CARGO_MANIFEST_DIR")).join("../contract-tests/ops/scenarios"), + PathBuf::from, + ); + let mut paths: Vec<_> = std::fs::read_dir(&root) + .unwrap_or_else(|e| panic!("{}: {e}", root.display())) + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "json")) + .collect(); + paths.sort(); + assert!(!paths.is_empty(), "no scenarios in {}", root.display()); + paths + .into_iter() + .map(|path| { + let raw: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + let scenario = serde_json::from_value(raw.clone()) + .unwrap_or_else(|e| panic!("{}: {e}", path.display())); + (path, raw, scenario) + }) + .collect() +} + +fn validate_envelope(error: &Value, partial: bool) { + let object = error.as_object().expect("error envelope must be an object"); + assert!(matches!( + error["kind"].as_str(), + Some("aimux" | "recording" | "binding" | "protocol") + )); + assert!( + error["code"] + .as_i64() + .is_some_and(|code| i32::try_from(code).is_ok()) + ); + assert!(error["error"]["name"].is_string()); + if !partial { + assert!( + error["message"].is_string(), + "missing envelope message: {error}" + ); + } + assert!(!object.contains_key("retryable")); + assert!(!object.contains_key("type")); + if let Some(retry) = object.get("retryAfterMs") { + assert!(retry.as_u64().is_some()); + } +} + +fn validate_frames(frames: &[Value], terminal: bool, partial: bool) { + let mut ends = 0; + for (index, frame) in frames.iter().enumerate() { + assert_eq!( + frame["seq"], + index.to_string(), + "non-contiguous decimal seq" + ); + match frame["type"].as_str() { + Some("event") => { + assert_eq!(ends, 0, "data after end"); + assert!(frame["event"].is_object()); + assert_eq!(frame.as_object().unwrap().len(), 3); + } + Some("end") => { + ends += 1; + assert_eq!(index + 1, frames.len(), "end must be last"); + assert!(matches!( + frame["outcome"].as_str(), + Some("success" | "error" | "cancelled") + )); + if frame["outcome"] == "error" { + assert!(frame.get("error").is_some(), "error end needs envelope"); + } + if frame["outcome"] == "success" { + assert!( + frame.get("error").is_none(), + "success end cannot carry error" + ); + } + if let Some(error) = frame.get("error") { + validate_envelope(error, partial); + } + assert_eq!( + frame.as_object().unwrap().len(), + if frame.get("error").is_some() { 4 } else { 3 } + ); + } + other => panic!("invalid callback frame type: {other:?}"), + } + } + assert_eq!( + ends, + usize::from(terminal), + "accepted native stream needs exactly one end" + ); +} + +fn validate_observation(observed: &Observation, partial: bool) { + assert_eq!(observed.accepted, observed.preflight_error.is_none()); + assert_eq!(observed.source_started, observed.accepted); + if let Some(error) = &observed.preflight_error { + validate_envelope(error, partial); + assert!(observed.frames.is_empty()); + assert!(observed.consumer_frames.is_empty()); + assert_eq!(observed.abort_observed, Some(false)); + assert!(!observed.queue_full_observed); + assert_eq!(observed.source_events_polled, 0); + } + validate_frames(&observed.frames, observed.accepted, partial); + if observed.consumer_closed { + assert!( + observed.abort_observed == Some(true), + "reader close must reach native abort" + ); + assert_eq!(observed.frames.last().unwrap()["outcome"], "cancelled"); + assert!(observed.consumer_frames.len() < observed.frames.len()); + assert_eq!( + observed.consumer_frames, + observed.frames[..observed.consumer_frames.len()] + ); + validate_frames(&observed.consumer_frames, false, partial); + } else { + assert_eq!(observed.consumer_frames, observed.frames); + } +} + +// Only error envelopes use subset matching; event DTOs remain exact values. +fn error_matches(actual: &Value, expected: &Value) { + if let Some(expected) = expected.as_object() { + for (key, value) in expected { + let actual = actual + .get(key) + .unwrap_or_else(|| panic!("missing error field {key}")); + error_matches(actual, value); + } + } else { + assert_eq!(actual, expected); + } +} + +fn compare_frames(actual: &[Value], expected: &[Value]) { + assert_eq!(actual.len(), expected.len(), "frame count"); + for (actual, expected) in actual.iter().zip(expected) { + let mut actual = actual.clone(); + let mut expected = expected.clone(); + let actual_error = actual.as_object_mut().unwrap().remove("error"); + let expected_error = expected.as_object_mut().unwrap().remove("error"); + if let Some(expected) = expected_error { + error_matches(&actual_error.expect("missing end.error"), &expected); + } else if expected["outcome"] != "cancelled" { + assert!(actual_error.is_none()); + } + assert_eq!(actual, expected); + } +} + +fn compare(actual: &Observation, expected: &Observation) { + validate_observation(actual, false); + assert_eq!(actual.accepted, expected.accepted); + assert_eq!(actual.source_started, expected.source_started); + assert_eq!(actual.source_events_polled, expected.source_events_polled); + match (&actual.preflight_error, &expected.preflight_error) { + (Some(actual), Some(expected)) => error_matches(actual, expected), + (None, None) => {} + _ => panic!("preflight result mismatch"), + } + assert!( + actual.abort_observed.is_some(), + "driver must report actual abort state" + ); + if let Some(expected) = expected.abort_observed { + assert_eq!(actual.abort_observed, Some(expected)); + } + assert_eq!(actual.queue_full_observed, expected.queue_full_observed); + assert_eq!(actual.consumer_closed, expected.consumer_closed); + compare_frames(&actual.frames, &expected.frames); + compare_frames(&actual.consumer_frames, &expected.consumer_frames); +} + +fn run_driver(executable: &Path, mut input: Value) -> Observation { + input.as_object_mut().unwrap().remove("expected"); + let mut child = Command::new(executable) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|e| panic!("cannot start {}: {e}", executable.display())); + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let stderr = child.stderr.take().unwrap(); + let writer = thread::spawn(move || { + serde_json::to_writer(&mut stdin, &input).unwrap(); + stdin.write_all(b"\n").unwrap(); + }); + let output = thread::spawn(move || { + let mut bytes = Vec::new(); + stdout + .take(2 * 1024 * 1024 + 1) + .read_to_end(&mut bytes) + .unwrap(); + bytes + }); + let diagnostics = thread::spawn(move || { + let mut bytes = Vec::new(); + stderr.take(64 * 1024).read_to_end(&mut bytes).unwrap(); + bytes + }); + let started = Instant::now(); + let (status, timed_out) = loop { + if let Some(status) = child.try_wait().unwrap() { + break (status, false); + } + if started.elapsed() > Duration::from_secs(10) { + child.kill().unwrap(); + break (child.wait().unwrap(), true); + } + thread::sleep(Duration::from_millis(10)); + }; + let output = output.join().unwrap(); + let diagnostics = diagnostics.join().unwrap(); + assert!( + !timed_out, + "driver timed out: {}", + String::from_utf8_lossy(&diagnostics) + ); + writer.join().unwrap(); + assert!( + status.success(), + "driver failed ({status}): {}", + String::from_utf8_lossy(&diagnostics) + ); + assert!( + output.len() <= 2 * 1024 * 1024, + "oversized driver transcript" + ); + serde_json::from_slice(&output).expect("driver must return one observation JSON object") +} + +#[test] +fn shared_vectors_are_well_formed() { + let mut names = BTreeSet::new(); + for (path, _, scenario) in scenarios() { + assert_eq!(scenario.schema_version, 1); + assert_eq!(scenario.boundary, "eventStream"); + assert_eq!(path.file_stem().unwrap(), scenario.name.as_str()); + assert!(names.insert(scenario.name)); + assert!(!scenario.description.is_empty()); + assert_eq!(scenario.queue_limits.items, 256); + assert_eq!(scenario.queue_limits.bytes, 8 * 1024 * 1024); + assert_eq!(scenario.queue_limits.max_event_bytes, 1024 * 1024); + assert!(!scenario.script.is_empty()); + assert!(!scenario.controls.is_empty()); + for step in &scenario.script { + match step { + SourceStep::Event { event } => assert!(event["type"].is_string()), + SourceStep::RepeatEvent { count, event } => { + assert!(*count > 0); + assert!(event["type"].is_string()); + } + SourceStep::Wait { gate } => assert!(!gate.is_empty()), + SourceStep::TerminalError { kind, message } => { + assert_eq!(kind, "apiCall"); + assert!(!message.is_empty()); + } + SourceStep::SerializationFailure { message } => assert!(!message.is_empty()), + SourceStep::Eof => {} + } + } + for control in &scenario.controls { + match control { + Control::Read { count } => assert!(*count > 0), + Control::WaitForSourceGate { gate } => assert!(scenario.script.iter().any( + |step| matches!(step, SourceStep::Wait { gate: source } if source == gate) + )), + _ => {} + } + } + assert_eq!(scenario.fault.is_some(), !scenario.expected.accepted); + if scenario.expected.accepted { + let mut events = Vec::new(); + for step in &scenario.script { + match step { + SourceStep::Event { event } => events.push(event.clone()), + SourceStep::RepeatEvent { count, event } => { + events.extend(std::iter::repeat_n(event.clone(), *count)); + } + SourceStep::TerminalError { .. } | SourceStep::SerializationFailure { .. } => { + break; + } + _ => {} + } + } + let framed: Vec<_> = scenario + .expected + .frames + .iter() + .filter(|frame| frame["type"] == "event") + .map(|frame| frame["event"].clone()) + .collect(); + assert_eq!( + events, framed, + "fixture must preserve all pre-terminal source events" + ); + assert_eq!(scenario.expected.source_events_polled, events.len()); + } + validate_observation(&scenario.expected, true); + } + assert_eq!( + names, + BTreeSet::from( + [ + "accepted", + "preflight-failure", + "finish", + "recoverable-error-part", + "terminal-error", + "cancel", + "late-cancel-after-success", + "reader-close", + "duplicate-finish", + "serialization-failure", + "full-queue-cancel", + ] + .map(String::from) + ) + ); +} + +#[test] +fn terminal_checker_rejects_regressions() { + let valid = serde_json::json!([{"type":"end","seq":"0","outcome":"success"}]); + validate_frames(valid.as_array().unwrap(), true, false); + for invalid in [ + serde_json::json!([]), + serde_json::json!([{"type":"end","seq":"0","outcome":"success"}, {"type":"end","seq":"1","outcome":"success"}]), + serde_json::json!([{"type":"end","seq":"0","outcome":"success"}, {"type":"event","seq":"1","event":{}}]), + serde_json::json!([{"type":"end","seq":0,"outcome":"success"}]), + serde_json::json!([{"type":"end","seq":"0","outcome":"error"}]), + serde_json::json!([{"type":"end","seq":"0","outcome":"success","error":{}}]), + ] { + assert!( + std::panic::catch_unwind(|| validate_frames(invalid.as_array().unwrap(), true, false)) + .is_err() + ); + } +} + +#[test] +fn comparator_rejects_lost_data_and_wrong_outcomes() { + for (_, _, scenario) in scenarios() { + if !scenario.expected.accepted { + continue; + } + let mut missing = scenario.expected.frames.clone(); + missing.pop(); + assert!( + std::panic::catch_unwind(|| compare_frames(&missing, &scenario.expected.frames)) + .is_err() + ); + let mut wrong = scenario.expected.frames.clone(); + let end = wrong.last_mut().unwrap(); + end["outcome"] = if end["outcome"] == "success" { + "cancelled" + } else { + "success" + } + .into(); + assert!( + std::panic::catch_unwind(|| compare_frames(&wrong, &scenario.expected.frames)).is_err() + ); + } +} + +#[test] +fn comparator_accepts_complete_envelopes_and_rejects_wrong_codes() { + // Synthetic observations check the comparator, never the runtime. + for (_, raw, scenario) in scenarios() { + let mut raw = raw["expected"].clone(); + if raw["abortObserved"].is_null() { + raw["abortObserved"] = false.into(); + } + if let Some(error) = raw + .get_mut("preflightError") + .filter(|error| !error.is_null()) + { + error["message"] = "driver-specific diagnostic".into(); + } + for frames in ["frames", "consumerFrames"] { + for frame in raw[frames].as_array_mut().unwrap() { + if let Some(error) = frame.get_mut("error") { + error["message"] = "driver-specific diagnostic".into(); + } + } + } + let actual = serde_json::from_value(raw.clone()).unwrap(); + compare(&actual, &scenario.expected); + if !raw["preflightError"].is_null() { + raw["preflightError"]["code"] = 399.into(); + } else if scenario + .expected + .frames + .last() + .is_some_and(|end| end.get("error").is_some()) + { + for frames in ["frames", "consumerFrames"] { + raw[frames].as_array_mut().unwrap().last_mut().unwrap()["error"]["code"] = + 399.into(); + } + } else { + continue; + } + let wrong = serde_json::from_value(raw).unwrap(); + assert!(std::panic::catch_unwind(|| compare(&wrong, &scenario.expected)).is_err()); + } +} + +#[test] +#[ignore = "requires a real dispatch or binding driver in AIMUX_OPS_SCENARIO_DRIVER"] +fn run_shared_driver() { + let executable = std::env::var_os("AIMUX_OPS_SCENARIO_DRIVER") + .expect("set AIMUX_OPS_SCENARIO_DRIVER to a real dispatch or binding driver"); + for (path, raw, scenario) in scenarios() { + eprintln!("scenario: {}", path.display()); + let actual = run_driver(Path::new(&executable), raw); + compare(&actual, &scenario.expected); + } +} + +#[path = "support/native_scenarios.rs"] +mod native_scenarios; + +#[tokio::test] +async fn run_native_shared_vectors() { + for (path, mut input, scenario) in scenarios() { + input.as_object_mut().unwrap().remove("expected"); + eprintln!("native scenario: {}", path.display()); + let actual = tokio::time::timeout(Duration::from_secs(8), native_scenarios::observe(input)) + .await + .expect("native scenario timed out"); + compare(&actual, &scenario.expected); + } +} diff --git a/aimux-ops/tests/support/native_scenarios.rs b/aimux-ops/tests/support/native_scenarios.rs new file mode 100644 index 000000000..54ca63484 --- /dev/null +++ b/aimux-ops/tests/support/native_scenarios.rs @@ -0,0 +1,267 @@ +//! Executes shared inputs through real dispatch/framing and the bounded native queue. +use std::collections::VecDeque; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; +use std::time::{Duration, SystemTime}; + +use aimux_core::error::ApiCallError; +use aimux_core::{AbortSignal, AiMuxError}; +use aimux_ops::dispatch::{Ctx, Dispatcher, EventStream, Handler, Target, serialize_events}; +use aimux_ops::envelope::{OpsError, ProtocolError}; +use aimux_ops::handle::{HandleId, HandleRegistry, Owner}; +use aimux_ops::limits::{QueueEvent, QueueLimits, bounded_queue, check_wire_item}; +use aimux_ops::op::{Cancellation, OpDescriptor, OpMode, RequestBytePolicy, Scope}; +use futures::{Stream, StreamExt}; +use serde::{Serialize, Serializer}; +use serde_json::Value; +use tokio::sync::Notify; + +use super::{Control, Observation, SourceStep}; + +static SOURCE: Mutex> = Mutex::new(None); +static DESCRIPTOR: OpDescriptor = OpDescriptor { + name: "test.scenario", + since_ops_version: "1.0", + mode: OpMode::Stream, + allowed_handle_kinds: &[], + request_type: "Value", + result_type: "()", + event_type: Some("Value"), + scope: Scope::Current, + terminal_type: Some("StreamFrame"), + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::Abort, + side_effects: false, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + retry_owner: "none", + source_symbols: &[], +}; +fn accept(_: Ctx, _: Target, params: Value) -> Result { + if params + .get("fault") + .is_some_and(|fault| fault["stage"] == "preflight") + { + return Err(ProtocolError::InvalidParams("mock invalid params".into()).into()); + } + Ok(SOURCE + .lock() + .unwrap() + .take() + .expect("installed native source")) +} +#[derive(Default)] +struct Stats { + started: AtomicBool, + polled: AtomicUsize, + gate: Mutex>, + full: AtomicBool, + changed: Notify, +} +struct Script { + steps: VecDeque, + stats: Arc, +} +enum Event { + Value(Value), + Broken(String), +} +impl Serialize for Event { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Value(value) => value.serialize(serializer), + Self::Broken(message) => Err(serde::ser::Error::custom(message)), + } + } +} +impl Stream for Script { + type Item = Result; + fn poll_next(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + this.stats.started.store(true, Ordering::SeqCst); + let Some(step) = this.steps.pop_front() else { + return Poll::Ready(None); + }; + let event = + match step { + SourceStep::Event { event } => Event::Value(event), + SourceStep::RepeatEvent { count, event } if count > 0 => { + if count > 1 { + this.steps.push_front(SourceStep::RepeatEvent { + count: count - 1, + event: event.clone(), + }); + } + Event::Value(event) + } + SourceStep::Wait { gate } => { + *this.stats.gate.lock().unwrap() = Some(gate.clone()); + this.steps.push_front(SourceStep::Wait { gate }); + this.stats.changed.notify_waiters(); + return Poll::Pending; + } + SourceStep::TerminalError { kind, message } => { + assert_eq!(kind, "apiCall"); + return Poll::Ready(Some(Err(AiMuxError::ApiCall(Box::new( + ApiCallError::new(message, "https://mock.invalid", serde_json::json!({})), + )) + .into()))); + } + SourceStep::SerializationFailure { message } => { + return Poll::Ready(Some(Ok(Event::Broken(message)))); + } + SourceStep::Eof => return Poll::Ready(None), + SourceStep::RepeatEvent { .. } => panic!("empty repeat"), + }; + this.stats.polled.fetch_add(1, Ordering::SeqCst); + Poll::Ready(Some(Ok(event))) + } +} + +async fn barrier(stats: &Stats, predicate: impl Fn() -> bool) { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let changed = stats.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + if predicate() { + break; + } + changed.await; + } + }) + .await + .expect("native instrumentation barrier stalled"); +} + +pub(super) async fn observe(mut input: Value) -> Observation { + assert!( + input.as_object_mut().unwrap().remove("expected").is_none(), + "driver cannot inspect expected output" + ); + let stats = Arc::new(Stats::default()); + let steps = serde_json::from_value::>(input["script"].clone()).unwrap(); + *SOURCE.lock().unwrap() = Some(serialize_events(Script { + steps: steps.into(), + stats: stats.clone(), + })); + let abort = AbortSignal::new(); + let ctx = Ctx { + owner: Owner::CProcess, + abort: Some(abort.clone()), + now: SystemTime::now, + }; + let registry = HandleRegistry::default(); + let table = [(&DESCRIPTOR, Handler::Stream(accept))]; + let dispatcher = Dispatcher::new(®istry, &table); + let result = dispatcher.stream(ctx, HandleId(0), DESCRIPTOR.name, input.clone()); + let mut observed = Observation { + accepted: result.is_ok(), + preflight_error: None, + frames: Vec::new(), + consumer_frames: Vec::new(), + abort_observed: Some(false), + queue_full_observed: false, + consumer_closed: false, + source_started: false, + source_events_polled: 0, + }; + let mut frames = match result { + Ok(frames) => frames, + Err(error) => { + observed.preflight_error = + Some(serde_json::to_value(error.envelope(SystemTime::UNIX_EPOCH)).unwrap()); + SOURCE.lock().unwrap().take(); + return observed; + } + }; + let limits = &input["queueLimits"]; + let limits = QueueLimits::output() + .lowered( + limits["items"].as_u64().unwrap() as usize, + limits["bytes"].as_u64().unwrap() as usize, + limits["maxEventBytes"].as_u64().unwrap() as usize, + ) + .unwrap(); + let (sender, mut receiver) = bounded_queue::, Value>(limits); + let native_frames = Arc::new(Mutex::new(Vec::new())); + let log = native_frames.clone(); + let producer_stats = stats.clone(); + let producer_abort = abort.clone(); + let producer = tokio::spawn(async move { + while let Some(frame) = frames.next().await { + let terminal = matches!(frame, aimux_ops::StreamFrame::End { .. }); + let value = serde_json::to_value(&frame).unwrap(); + log.lock().unwrap().push(value.clone()); + if terminal { + sender.finish(value); + continue; + } + let size = check_wire_item(&frame, limits.max_item_bytes()).unwrap(); + let permit = sender.reserve(size, Some(&producer_abort)).await.unwrap(); + permit + .send(serde_json::to_vec(&frame).unwrap().into_boxed_slice()) + .unwrap(); + if sender.usage().0 == limits.max_items() { + producer_stats.full.store(true, Ordering::SeqCst); + producer_stats.changed.notify_waiters(); + } + } + }); + let controls = serde_json::from_value::>(input["controls"].clone()).unwrap(); + for control in controls { + match control { + Control::Read { count } => { + for _ in 0..count { + observed.consumer_frames.push( + receive(&mut receiver) + .await + .expect("expected consumer frame"), + ); + } + } + Control::Drain => { + while let Some(frame) = receive(&mut receiver).await { + observed.consumer_frames.push(frame); + } + } + Control::WaitForSourceGate { gate } => { + barrier(&stats, || { + stats.gate.lock().unwrap().as_ref() == Some(&gate) + }) + .await + } + Control::WaitForQueueFull => { + barrier(&stats, || stats.full.load(Ordering::SeqCst)).await; + observed.queue_full_observed = true; + } + Control::Cancel => abort.abort(), + Control::CloseReader => { + observed.consumer_closed = true; + receiver.close(); + abort.abort(); + } + Control::DrainNative | Control::StartProducer => {} + } + } + tokio::time::timeout(Duration::from_secs(3), producer) + .await + .unwrap() + .unwrap(); + observed.frames = native_frames.lock().unwrap().clone(); + observed.abort_observed = Some(abort.is_aborted()); + observed.source_started = stats.started.load(Ordering::SeqCst); + observed.source_events_polled = stats.polled.load(Ordering::SeqCst); + observed +} +async fn receive( + receiver: &mut aimux_ops::limits::QueueReceiver, Value>, +) -> Option { + match receiver.recv().await? { + QueueEvent::Data(data) => Some(serde_json::from_slice(&data).unwrap()), + QueueEvent::Terminal(frame) => Some(frame), + } +} diff --git a/aimux-ops/tests/trace.rs b/aimux-ops/tests/trace.rs new file mode 100644 index 000000000..f5abebb29 --- /dev/null +++ b/aimux-ops/tests/trace.rs @@ -0,0 +1,421 @@ +//! Family gates use real dispatch, owner resolution and canonical core queries. +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, SystemTime}; + +use aimux_core::trace::{RingTraceStore, TraceFilter, TraceRecord, TraceSink}; +use aimux_ops::dispatch::{self, Ctx}; +use aimux_ops::envelope::{BindingError, OpsError, ProtocolError}; +use aimux_ops::handle::{Entry, HandleId, HandleKind, HandleRegistry, Owner}; +use aimux_ops::op::{Cancellation, OpMode, RequestBytePolicy, Scope}; +use serde_json::{Value, json}; + +fn ctx(owner: Owner) -> Ctx { + Ctx { + owner, + abort: None, + now: SystemTime::now, + } +} + +struct Trace { + owner: Owner, + id: HandleId, + store: Arc, +} + +impl Trace { + async fn new(params: Value) -> Self { + let owner = Owner::new_node().unwrap(); + let value = dispatch::call(ctx(owner), HandleId(0), "trace.create", params) + .await + .unwrap(); + assert!(value.is_string(), "owned handles use decimal-string JSON"); + let id: HandleId = serde_json::from_value(value).unwrap(); + let Entry::TraceStore(store) = HandleRegistry::global() + .get(owner, id, &[HandleKind::TraceStore]) + .unwrap() + else { + panic!("trace.create must retain the canonical store"); + }; + Self { owner, id, store } + } + + async fn call(&self, op: &str, params: Value) -> Result { + dispatch::call(ctx(self.owner), self.id, op, params).await + } +} + +impl Drop for Trace { + fn drop(&mut self) { + HandleRegistry::global().release_owner(self.owner); + } +} + +fn record(id: &str, session: &str, provider: &str, sent: i64) -> TraceRecord { + serde_json::from_value(json!({ + "provider": provider, "model": "model", "callId": id, + "sessionId": session, "sentAtUnixMs": sent, + "fingerprint": {"bodyHash": "abc", "lenBytes": 16, + "blockSize": 16, "blockHashes": ["01"], "tokenEstimate": 4}, + "usage": {"inputTotal": 100, "cacheRead": 25} + })) + .unwrap() +} + +#[tokio::test] +async fn create_uses_real_store_capacity_defaults_and_owned_handle() { + let trace = Trace::new(json!({"capacity":2,"perScopeCapacity":1,"auditMode":"off"})).await; + assert_ne!(trace.id, HandleId(0)); + for n in 0..3 { + trace.store.record(record(&n.to_string(), "s", "p", n)); + } + assert_eq!(trace.store.len(), 2); + assert_eq!( + trace + .call("trace.sessionTrajectory", json!({"sessionId":"s"})) + .await + .unwrap()[0]["callId"], + "1" + ); + let defaults = Trace::new(json!({})).await; + assert_eq!( + defaults.call("trace.aggregate", json!({})).await.unwrap(), + json!([]) + ); + assert_eq!(defaults.call("trace.export", json!({})).await.unwrap(), ""); +} + +#[tokio::test] +async fn aggregate_matches_six_canonical_filter_queries() { + let trace = Trace::new(json!({"capacity":4})).await; + trace.store.record(record("a", "session-①", "provider", 10)); + trace.store.record(record("b", "other", "provider", 20)); + trace.store.record(record("c", "session-①", "other", 30)); + for filter in [ + json!({}), + json!({"provider":"provider"}), + json!({"model":"missing"}), + json!({"sessionId":"session-①"}), + json!({"sinceUnixMs":20}), + json!({"provider":"provider","sessionId":"session-①"}), + ] { + let canonical: TraceFilter = serde_json::from_value(filter.clone()).unwrap(); + assert_eq!( + trace + .call("trace.aggregate", json!({"filter":filter})) + .await + .unwrap(), + serde_json::to_value(trace.store.aggregate(&canonical)).unwrap() + ); + } + assert_eq!( + trace + .call("trace.aggregate", json!({})) + .await + .unwrap() + .as_array() + .unwrap() + .len(), + 2 + ); +} + +#[tokio::test] +async fn sessions_export_and_idempotent_clear_preserve_ffi_query_semantics() { + let trace = Trace::new(json!({"capacity":4})).await; + trace.store.record(record("a", "203", "provider", 10)); + trace.store.record(record("b", "203", "provider", 20)); + assert_eq!( + trace + .call("trace.sessionChain", json!({"sessionId":"203"})) + .await + .unwrap(), + serde_json::to_value(trace.store.session_chain("203").unwrap()).unwrap() + ); + let trajectory = trace + .call("trace.sessionTrajectory", json!({"sessionId":"203"})) + .await + .unwrap(); + assert_eq!( + trajectory, + serde_json::to_value(trace.store.session_cache_trajectory("203")).unwrap() + ); + assert_eq!(trajectory[0]["hitRate"], json!(0.25)); + assert_eq!(trajectory[1]["callId"], "b"); + assert_eq!( + trace + .call("trace.sessionTrajectory", json!({"sessionId":"missing"})) + .await + .unwrap(), + json!([]) + ); + let error = trace + .call("trace.sessionChain", json!({"sessionId":"missing"})) + .await + .unwrap_err(); + assert!( + matches!(error.unprojected(), OpsError::AiMux(aimux_core::AiMuxError::InvalidArgument(message)) if message == "unknown session") + ); + let envelope = serde_json::to_value(error.envelope(SystemTime::now())).unwrap(); + assert_eq!(envelope["kind"], "aimux"); + assert_eq!(envelope["code"], 5); + assert_eq!(envelope["error"]["name"], "AI_InvalidArgumentError"); + + let exported = trace.call("trace.export", json!({})).await.unwrap(); + let mut canonical = Vec::new(); + trace.store.export_jsonl(&mut canonical).unwrap(); + assert_eq!(exported, String::from_utf8(canonical).unwrap()); + let lines: Vec = exported + .as_str() + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0]["callId"], "a"); + assert!(lines[0].get("scopeKey").is_none()); + for _ in 0..2 { + assert_eq!( + trace.call("trace.clear", json!({})).await.unwrap(), + Value::Null + ); + } + assert!(trace.store.is_empty()); + assert_eq!(trace.call("trace.export", json!({})).await.unwrap(), ""); + assert_eq!( + trace.call("trace.aggregate", json!({})).await.unwrap(), + json!([]) + ); + assert_eq!( + trace + .call("trace.sessionTrajectory", json!({"sessionId":"203"})) + .await + .unwrap(), + json!([]) + ); +} + +#[tokio::test] +async fn invalid_controls_fail_before_mutation() { + let trace = Trace::new(json!({"capacity":1})).await; + trace.store.record(record("a", "s", "p", 1)); + for (op, params) in [ + ("trace.create", json!({"capacity":0})), + ("trace.create", json!({"perScopeCapacity":0})), + ("trace.create", json!({"capacity":-1})), + ("trace.create", json!({"capacity":1.5})), + ("trace.create", json!({"auditMode":"unknown"})), + ("trace.create", json!({"audit_mode":"strict"})), + ("trace.create", json!({"extra":true})), + ("trace.aggregate", json!({"filter":{"extra":true}})), + ("trace.aggregate", json!({"filter":{"session_id":"s"}})), + ("trace.aggregate", json!({"filter":{"sinceUnixMs":"20"}})), + ("trace.aggregate", json!({"filter":null})), + ("trace.aggregate", json!({"filter":[]})), + ("trace.aggregate", json!({"extra":true})), + ("trace.sessionChain", json!({"sessionId":203})), + ( + "trace.sessionTrajectory", + json!({"sessionId":"s","extra":0}), + ), + ("trace.sessionChain", json!({})), + ("trace.export", json!({"format":"json"})), + ("trace.clear", json!({"sessionId":"s"})), + ("trace.clear", Value::Null), + ] { + let handle = if op == "trace.create" { + HandleId(0) + } else { + trace.id + }; + let error = dispatch::call(ctx(trace.owner), handle, op, params) + .await + .unwrap_err(); + assert!( + matches!( + error.unprojected(), + OpsError::Protocol(ProtocolError::InvalidParams(_)) + ), + "{op}: {error}" + ); + assert_eq!(error.code(), 301); + assert!(matches!(error, OpsError::Projected(_))); + assert_eq!(trace.store.len(), 1, "{op} mutated data on rejection"); + } +} + +#[tokio::test] +async fn trace_store_retains_audit_policy_for_middleware() { + use aimux_core::trace::store::TraceAuditMode; + let owner = Owner::new_node().unwrap(); + for (mode, expected) in [ + ("shared", TraceAuditMode::Shared), + ("strict", TraceAuditMode::Strict), + ] { + let result = dispatch::call( + ctx(owner), + HandleId(0), + "trace.create", + json!({"auditMode":mode}), + ) + .await + .unwrap(); + let handle = serde_json::from_value(result).unwrap(); + let Entry::TraceStore(store) = HandleRegistry::global() + .get(owner, handle, &[HandleKind::TraceStore]) + .unwrap() + else { + panic!("trace store") + }; + assert_eq!(store.audit_mode(), expected); + } + assert_eq!(HandleRegistry::global().release_owner(owner), 2); +} + +#[tokio::test] +async fn trace_ownership_and_drop_use_real_dispatch_without_disclosure() { + let trace = Trace::new(json!({"capacity":2})).await; + trace.store.record(record("a", "203", "p", 1)); + let foreign = Owner::new_python().unwrap(); + let wrong = HandleRegistry::global() + .insert( + trace.owner, + Entry::Abort(Arc::new(aimux_core::AbortSignal::new())), + ) + .unwrap(); + for (op, params) in [ + ("trace.aggregate", json!({})), + ("trace.sessionChain", json!({"sessionId":"203"})), + ("trace.sessionTrajectory", json!({"sessionId":"203"})), + ("trace.export", json!({})), + ("trace.clear", json!({})), + ] { + let foreign_error = dispatch::call(ctx(foreign), trace.id, op, params.clone()) + .await + .unwrap_err(); + let unknown_error = dispatch::call(ctx(foreign), HandleId(u64::MAX), op, params.clone()) + .await + .unwrap_err(); + assert_eq!( + serde_json::to_value(foreign_error.envelope(SystemTime::now())).unwrap(), + serde_json::to_value(unknown_error.envelope(SystemTime::now())).unwrap() + ); + for id in [HandleId(0), wrong] { + let error = dispatch::call(ctx(trace.owner), id, op, params.clone()) + .await + .unwrap_err(); + assert!(matches!( + error.unprojected(), + OpsError::Binding(BindingError::InvalidHandle { .. }) + )); + } + } + assert_eq!(trace.store.len(), 1); + assert_eq!( + dispatch::call(ctx(foreign), trace.id, "handle.drop", json!({})) + .await + .unwrap(), + Value::Null + ); + assert!( + HandleRegistry::global() + .get(trace.owner, trace.id, &[HandleKind::TraceStore]) + .is_ok() + ); + for _ in 0..2 { + assert_eq!( + trace.call("handle.drop", json!({})).await.unwrap(), + Value::Null + ); + } + let error = trace.call("trace.export", json!({})).await.unwrap_err(); + assert_eq!(error.code(), 203); + // The retained Arc survives handle removal; no registry lock is needed. + assert_eq!(trace.store.len(), 1); + HandleRegistry::global().release_owner(foreign); +} + +static CLOCK_CALLS: AtomicUsize = AtomicUsize::new(0); +fn counted_clock() -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(CLOCK_CALLS.fetch_add(1, Ordering::SeqCst) as u64) +} + +#[tokio::test] +async fn trace_errors_are_projected_once_by_dispatch() { + let trace = Trace::new(json!({})).await; + let mut context = ctx(trace.owner); + context.now = counted_clock; + CLOCK_CALLS.store(0, Ordering::SeqCst); + let error = dispatch::call( + context, + trace.id, + "trace.sessionChain", + json!({"sessionId":"missing"}), + ) + .await + .unwrap_err(); + assert!(matches!(error, OpsError::Projected(_))); + assert_eq!(CLOCK_CALLS.load(Ordering::SeqCst), 1); + assert_eq!( + serde_json::to_value(error.envelope(SystemTime::UNIX_EPOCH)).unwrap(), + serde_json::to_value(error.envelope(SystemTime::now())).unwrap() + ); + assert_eq!(CLOCK_CALLS.load(Ordering::SeqCst), 1); +} + +#[test] +fn six_trace_descriptors_publish_concrete_types_and_a5_fields() { + let manifest = aimux_ops::manifest::manifest(); + let names = [ + "trace.create", + "trace.aggregate", + "trace.sessionChain", + "trace.sessionTrajectory", + "trace.export", + "trace.clear", + ]; + assert_eq!( + manifest["ops"] + .as_array() + .unwrap() + .iter() + .filter(|op| op["name"].as_str().unwrap().starts_with("trace.")) + .count(), + names.len() + ); + for (index, name) in names.into_iter().enumerate() { + let descriptor = dispatch::descriptor(name).unwrap(); + assert_eq!(descriptor.mode, OpMode::Call); + assert_eq!(descriptor.scope, Scope::Current); + assert_eq!(descriptor.cancellation, Cancellation::None); + assert_eq!(descriptor.request_byte_policy, RequestBytePolicy::Json); + assert_eq!( + descriptor.allowed_handle_kinds, + if index == 0 { + &[][..] + } else { + &[HandleKind::TraceStore][..] + } + ); + assert!(descriptor.runtime_handle_fields.is_empty()); + assert!(descriptor.required_capabilities.is_empty()); + assert!(descriptor.terminal_type.is_none()); + assert!(descriptor.event_type.is_none()); + assert_eq!( + descriptor.side_effects, + matches!(name, "trace.create" | "trace.clear") + ); + assert!(!descriptor.source_symbols.is_empty()); + for ty in [descriptor.request_type, descriptor.result_type] { + assert_ne!(ty, "serde_json::Value"); + assert!( + manifest["types"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["id"] == ty) + ); + } + } +} diff --git a/aimux-ops/tests/trace_middleware.rs b/aimux-ops/tests/trace_middleware.rs new file mode 100644 index 000000000..3c0c441ce --- /dev/null +++ b/aimux-ops/tests/trace_middleware.rs @@ -0,0 +1,247 @@ +//! Trace decorators reached through model and provider wrapping. +use aimux_core::options::CallOptions; +use aimux_core::result::{GenerateResult, StreamResult}; +use aimux_core::trace::RingTraceStore; +use aimux_core::{AiMuxError, LanguageModel}; +use aimux_ops::{Ctx, Entry, HandleId, HandleKind, HandleRegistry, Owner, dispatch}; +use futures::StreamExt; +use serde_json::{Value, json}; +use std::{future::Future, pin::Pin, sync::Arc, time::SystemTime}; + +struct Model; +impl Model { + fn result() -> GenerateResult { + serde_json::from_value(json!({ + "content":[{"type":"text","text":"traced"}], + "finishReason":{"unified":"stop","raw":"stop"}, + "usage":{"inputTokens":{},"outputTokens":{}}, "warnings":[], + "request":{"body":"{\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]}"} + })) + .unwrap() + } +} +impl LanguageModel for Model { + fn provider(&self) -> &str { + "openai.chat" + } + fn model_id(&self) -> &str { + "trace-test" + } + fn do_generate<'a, 'b, 'f>( + &'a self, + _: &'b CallOptions, + ) -> Pin> + Send + 'f>> + where + 'a: 'f, + 'b: 'f, + Self: 'f, + { + Box::pin(async { Ok(Self::result()) }) + } + fn do_stream<'a, 'b, 'f>( + &'a self, + _: &'b CallOptions, + ) -> Pin> + Send + 'f>> + where + 'a: 'f, + 'b: 'f, + Self: 'f, + { + Box::pin(async { + let result = Self::result(); + Ok(StreamResult { + request: result.request, + response: None, + stream: Box::pin(futures::stream::iter([Ok( + aimux_core::stream_part::StreamPart::Finish { + finish_reason: result.finish_reason, + usage: result.usage, + provider_metadata: None, + }, + )])), + }) + }) + } +} + +fn context(owner: Owner) -> Ctx { + Ctx { + owner, + abort: None, + now: SystemTime::now, + } +} +async fn call(owner: Owner, handle: HandleId, op: &str, params: Value) -> Value { + dispatch::call(context(owner), handle, op, params) + .await + .unwrap() +} +fn model(owner: Owner, id: HandleId) -> Arc { + let Entry::Language(model) = HandleRegistry::global() + .get(owner, id, &[HandleKind::Language]) + .unwrap() + else { + panic!("language model") + }; + model +} +fn store(owner: Owner, id: HandleId) -> Arc { + let Entry::TraceStore(store) = HandleRegistry::global() + .get(owner, id, &[HandleKind::TraceStore]) + .unwrap() + else { + panic!("store") + }; + store +} + +fn records(sink: &RingTraceStore) -> Vec { + let mut bytes = Vec::new(); + sink.export_jsonl(&mut bytes).unwrap(); + String::from_utf8(bytes) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() +} + +#[tokio::test] +async fn model_and_provider_wrap_trace_generation_streams_and_retain_dependencies() { + for mode in ["off", "shared", "strict"] { + let owner = Owner::new_node().unwrap(); + let registry = HandleRegistry::global(); + let trace: HandleId = serde_json::from_value( + call( + owner, + HandleId(0), + "trace.create", + json!({"auditMode":mode}), + ) + .await, + ) + .unwrap(); + let sink = store(owner, trace); + let middleware = call( + owner, + HandleId(0), + "middleware.create", + json!({"kind":"trace","traceStore":trace,"scopeName":"endpoint-a"}), + ) + .await; + let original = registry + .insert(owner, Entry::Language(Arc::new(Model))) + .unwrap(); + let wrapped = call( + owner, + original, + "model.wrap", + json!({"middleware":[middleware["handle"]]}), + ) + .await; + let wrapped_id = serde_json::from_value(wrapped["handle"].clone()).unwrap(); + let provider = aimux_core::custom_provider::custom_provider( + aimux_core::custom_provider::CustomProviderOptions { + language_models: [( + "trace-test".into(), + Arc::new(Model) as Arc, + )] + .into_iter() + .collect(), + ..Default::default() + }, + ); + let provider_id = registry + .insert(owner, Entry::Provider(Arc::new(provider))) + .unwrap(); + let provider = call( + owner, + provider_id, + "provider.wrap", + json!({"languageModelMiddleware":[middleware["handle"]]}), + ) + .await; + let provider_id = serde_json::from_value(provider["handle"].clone()).unwrap(); + let resolved = call( + owner, + provider_id, + "provider.model", + json!({"method":"languageModel","modelId":"trace-test"}), + ) + .await; + let resolved_id = serde_json::from_value(resolved["handle"].clone()).unwrap(); + let middleware_id = serde_json::from_value(middleware["handle"].clone()).unwrap(); + assert!(registry.remove(owner, middleware_id).is_some()); + assert!(registry.remove(owner, trace).is_some()); + assert!(registry.remove(owner, original).is_some()); + assert!(registry.remove(owner, provider_id).is_some()); + let options = CallOptions::new(vec![]); + model(owner, wrapped_id) + .do_generate(&options) + .await + .unwrap(); + let mut stream = model(owner, resolved_id) + .do_stream(&options) + .await + .unwrap() + .stream; + while let Some(part) = stream.next().await { + part.unwrap(); + } + drop(stream); + let records = records(&sink); + assert_eq!(records.len(), 2, "{mode}"); + assert!( + records + .iter() + .all(|record| record.verdict.is_some() == (mode != "off")) + ); + assert!( + records + .iter() + .all(|record| record.provider == "openai.chat") + ); + registry.release_owner(owner); + assert_eq!(Arc::strong_count(&sink), 1); + } +} + +#[tokio::test] +async fn explicit_disabled_audit_overrides_store_policy() { + let owner = Owner::new_node().unwrap(); + let trace = call( + owner, + HandleId(0), + "trace.create", + json!({"auditMode":"strict"}), + ) + .await; + let trace = serde_json::from_value(trace).unwrap(); + let sink = store(owner, trace); + let middleware = call( + owner, + HandleId(0), + "middleware.create", + json!({"kind":"trace","traceStore":trace,"auditMode":"disabled"}), + ) + .await; + let original = HandleRegistry::global() + .insert(owner, Entry::Language(Arc::new(Model))) + .unwrap(); + let wrapped = call( + owner, + original, + "model.wrap", + json!({"middleware":[middleware["handle"]]}), + ) + .await; + model( + owner, + serde_json::from_value(wrapped["handle"].clone()).unwrap(), + ) + .do_generate(&CallOptions::new(vec![])) + .await + .unwrap(); + assert_eq!(records(&sink).len(), 1); + assert!(records(&sink)[0].verdict.is_none()); + HandleRegistry::global().release_owner(owner); +} diff --git a/aimux-provider-utils/src/fetch.rs b/aimux-provider-utils/src/fetch.rs index 7d706bcd7..245291313 100644 --- a/aimux-provider-utils/src/fetch.rs +++ b/aimux-provider-utils/src/fetch.rs @@ -212,36 +212,77 @@ impl fmt::Debug for dyn Fetch { /// A shareable transport, as carried by provider settings and requests. pub type FetchFunction = Arc; -static DEFAULT_TRANSPORT: OnceLock>> = OnceLock::new(); +struct DefaultTransport { + transport: Arc, + frozen: bool, + configured: bool, +} + +static DEFAULT_TRANSPORT: OnceLock> = OnceLock::new(); -fn default_transport_slot() -> &'static RwLock> { - DEFAULT_TRANSPORT.get_or_init(|| RwLock::new(Arc::new(ReqwestFetch::default()))) +fn default_transport_slot() -> &'static RwLock { + DEFAULT_TRANSPORT.get_or_init(|| { + RwLock::new(DefaultTransport { + transport: Arc::new(ReqwestFetch::default()), + frozen: false, + configured: false, + }) + }) } -/// The process default transport, read at request time by every exchange -/// whose provider has no `fetch`. +/// The process default transport, acquired at request time. Acquisition freezes +/// startup proxy configuration for HTTP, WebSocket and guarded downloads alike. #[must_use] pub fn default_fetch() -> FetchFunction { default_transport() } -/// The process default transport as its concrete type: the built-in -/// WebSocket connector and the download hops share its proxy settings. pub(crate) fn default_transport() -> Arc { - default_transport_slot() - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() + let mut slot = default_transport_slot() + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + slot.frozen = true; + slot.transport.clone() } -/// Replace the process default transport. Requests started afterwards by -/// providers without their own `fetch`, built-in WebSocket connects and -/// validated download hops use it; requests in flight keep theirs. A -/// provider given `fetch` is unaffected. +/// Explicit native replacement of the process default transport. In-flight +/// requests retain their transport; startup configuration stays frozen once used. pub fn set_default_transport(transport: ReqwestFetch) { - *default_transport_slot() + let mut slot = default_transport_slot() + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + slot.transport = Arc::new(transport); + slot.configured = true; +} + +/// Configure the startup proxy; repeating the active configuration is idempotent. +/// # Errors +/// Returns an error if a different configuration is requested after acquisition. +pub fn configure_startup_proxy(config: ProxyConfig) -> Result<(), StartupProxyFrozen> { + let mut slot = default_transport_slot() .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Arc::new(transport); + .unwrap_or_else(std::sync::PoisonError::into_inner); + if slot.transport.proxy() != &config { + if slot.frozen { + return Err(StartupProxyFrozen); + } + slot.transport = Arc::new(ReqwestFetch::new(config)); + } + slot.configured = true; + Ok(()) +} + +#[derive(Debug, thiserror::Error)] +#[error("startup proxy is frozen")] +pub struct StartupProxyFrozen; + +/// Inspect startup state without acquiring or freezing the transport. +#[must_use] +pub fn startup_proxy_state() -> (bool, bool) { + let slot = default_transport_slot() + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + (slot.configured, slot.frozen) } /// The real network leaf: reqwest over rustls, routed by its own diff --git a/aimux-provider-utils/src/logging.rs b/aimux-provider-utils/src/logging.rs index 8eaab9110..05b91ef92 100644 --- a/aimux-provider-utils/src/logging.rs +++ b/aimux-provider-utils/src/logging.rs @@ -9,8 +9,7 @@ //! registered yet. Zero-friction for FFI/binding users: set an env var and //! logs appear. //! - **programmatic API** (`init_logging`): idempotent (`Once`-guarded), used -//! by Rust consumers, the bindings' Rust side, and the `aimux_init_logging` -//! C ABI export. +//! by Rust consumers and the shared `runtime.configure` operation. //! //! Privacy rules (§4.3): header *values*, URL query strings, and request/ //! response bodies are **never** logged by default. Bodies only appear when @@ -18,13 +17,14 @@ //! auth-looking JSON fields redacted. use std::io::Write; -use std::sync::Once; +use std::sync::{Once, OnceLock}; use tracing_subscriber::EnvFilter; use tracing_subscriber::fmt; -/// Idempotency guard shared by every entry point (env / API / C ABI). +/// Idempotency guard shared by environment, native API and ops initialization. static INIT: Once = Once::new(); +static REQUESTED_LEVEL: OnceLock = OnceLock::new(); /// Default level: `warn` — only retries and failures are printed, normal /// traffic stays silent. @@ -40,17 +40,26 @@ const BODY_LOG_LIMIT: usize = 4096; /// /// No-ops (without registering anything) when a global subscriber is already /// installed by the consumer — we never override the host's own logger. -fn init_once(level: Option<&str>) { +fn init_once(level: Option<&str>) -> bool { + let mut applied = false; INIT.call_once(|| { if tracing::dispatcher::has_been_set() { return; } let filter = build_filter(level); - let _ = fmt() + if fmt() .with_env_filter(filter) .with_writer(std::io::stderr) - .try_init(); + .try_init() + .is_ok() + { + if let Some(level) = level { + let _ = REQUESTED_LEVEL.set(level.to_owned()); + } + applied = true; + } }); + applied } /// Programmatic init: `aimux_providers::init_logging("debug")`. @@ -61,6 +70,33 @@ pub fn init_logging(level: &str) { init_once(Some(level)); } +/// Whether aimux or the host has initialized the process logger. +#[must_use] +pub fn logging_initialized() -> bool { + INIT.is_completed() || tracing::dispatcher::has_been_set() +} + +/// Programmatic level requested at initialization; environment directives can override it. +#[must_use] +pub fn requested_logging_level() -> Option<&'static str> { + REQUESTED_LEVEL.get().map(String::as_str) +} + +/// Initialize logging through the shared guard, permitting identical requests. +/// # Errors +/// Returns an error when the host/environment or a different level froze logging. +pub fn configure_logging(level: &str) -> Result<(), LoggingFrozen> { + if init_once(Some(level)) || requested_logging_level() == Some(level) { + Ok(()) + } else { + Err(LoggingFrozen) + } +} + +#[derive(Debug, thiserror::Error)] +#[error("logging is frozen")] +pub struct LoggingFrozen; + /// Lazy env-driven auto-init, called by the shared API-call primitive. /// /// Cheap when already initialized (`Once::is_completed` is a single atomic @@ -267,6 +303,65 @@ mod tests { String::from_utf8(captured.lock().unwrap().clone()).unwrap() } + #[test] + fn startup_logging_shares_native_environment_host_and_concurrent_initialization() { + const CASE: &str = "AIMUX_LOGGING_TEST_CASE"; + let Ok(case) = std::env::var(CASE) else { + for case in ["native", "environment", "host", "concurrent"] { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command.args(["--exact", "logging::tests::startup_logging_shares_native_environment_host_and_concurrent_initialization", "--nocapture"]) + .env(CASE, case).env_remove("AIMUX_LOG").env_remove("AIMUX_LOG_LEVEL"); + if case == "environment" { + command.env("AIMUX_LOG_LEVEL", "error"); + } + assert!(command.status().unwrap().success(), "scenario {case}"); + } + return; + }; + assert!(!logging_initialized()); + match case.as_str() { + "native" => { + init_logging("info"); + assert!(configure_logging("info").is_ok()); + assert!(configure_logging("trace").is_err()); + assert_eq!(requested_logging_level(), Some("info")); + } + "environment" => { + auto_init_from_env(); + assert!(logging_initialized()); + assert_eq!(requested_logging_level(), None); + assert!(configure_logging("trace").is_err()); + } + "host" => { + tracing::subscriber::set_global_default( + tracing::subscriber::NoSubscriber::default(), + ) + .unwrap(); + assert!(configure_logging("warn").is_err()); + assert_eq!(requested_logging_level(), None); + } + "concurrent" => { + let barrier = std::sync::Arc::new(std::sync::Barrier::new(8)); + let callers: Vec<_> = (0..8) + .map(|index| { + let barrier = barrier.clone(); + std::thread::spawn(move || { + let level = if index % 2 == 0 { "warn" } else { "trace" }; + barrier.wait(); + (level, configure_logging(level).is_ok()) + }) + }) + .collect(); + for caller in callers { + let (level, applied) = caller.join().unwrap(); + assert_eq!(applied, requested_logging_level() == Some(level)); + } + } + _ => panic!("unknown test scenario"), + } + assert!(logging_initialized()); + } + #[test] fn redact_json_body() { let body = r#"{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],"api_key":"sk-secret","nested":{"authorization":"Bearer xyz","token":"t"}}"#; diff --git a/aimux-providers/src/lib.rs b/aimux-providers/src/lib.rs index 280517f4c..970691328 100644 --- a/aimux-providers/src/lib.rs +++ b/aimux-providers/src/lib.rs @@ -143,9 +143,6 @@ macro_rules! __optional_model { }; } -// Registry-backed provider construction: the embedded `provider_registry.json` -// is parsed once into `preset` descriptors and used by the by-name entry points. -pub mod provider; pub mod replay; pub use preset::{AuthMode, PresetDescriptor, PresetEntry, PresetSettings}; pub use replay::{rebuild_decision_provider, rebuild_provider}; diff --git a/aimux-providers/src/provider.rs b/aimux-providers/src/provider.rs deleted file mode 100644 index b7ffe9453..000000000 --- a/aimux-providers/src/provider.rs +++ /dev/null @@ -1,529 +0,0 @@ -//! By-name creation for the bindings. -//! -//! The Rust surface for creating providers is the factories, -//! [`create_provider`](crate::create_provider) and the registry. The FFI and -//! the Node / Python bindings still expose an older shape (a provider name -//! plus a JSON options object, and providers registered at runtime from a -//! JSON document); this module is that shape, implemented on -//! `create_provider`. It is not re-exported from the crate root and is to be -//! removed in #265 (the ops layer), where the C ABI and the bindings stop -//! calling it. - -use std::collections::HashMap; -use std::sync::{Arc, OnceLock, RwLock}; - -use serde::Deserialize; -use serde_json::Value; - -use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::provider::Provider; -use aimux_provider_utils::{Resolvable, load_api_key, validate_base_url}; - -use crate::openai_compatible::config::{BaseUrl, ChatSettings}; -use crate::openai_compatible::{Assembly, OpenAICompatibleProvider}; -use crate::preset::PresetSettings; -use crate::shared::Credential; - -/// Chat capabilities of an external provider entry, expressed as data -/// (RFC-0020). The `Default` impl and the per-field `#[serde(default = -/// "default_true")]` both yield `true` for the three `supports_*` flags, -/// matching the OpenAI-compatible baseline of the registry presets - so a -/// profile that is omitted entirely (or present but with individual fields -/// missing) stays at full capability. -#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] -pub struct ProviderProfile { - #[serde(default = "default_true")] - pub supports_top_k: bool, - #[serde(default = "default_true")] - pub supports_tools: bool, - #[serde(default = "default_true")] - pub supports_response_format: bool, - /// Streaming usage rides in `chunk[key].usage` instead of `chunk.usage`. - pub stream_usage_key: Option, - /// `"max_tokens"` or `"max_completion_tokens"`: the only max-token key the - /// endpoint accepts. Any other value is rejected at registration. - pub max_tokens_key: Option, -} - -impl Default for ProviderProfile { - fn default() -> Self { - Self { - supports_top_k: true, - supports_tools: true, - supports_response_format: true, - stream_usage_key: None, - max_tokens_key: None, - } - } -} - -fn default_true() -> bool { - true -} - -impl ProviderProfile { - /// The chat behavior this profile describes. - fn chat_settings(&self) -> Result { - let max_tokens_key = match self.max_tokens_key.as_deref() { - None => None, - Some("max_tokens") => Some("max_tokens"), - Some("max_completion_tokens") => Some("max_completion_tokens"), - Some(other) => { - return Err(AiMuxError::InvalidArgument(format!( - "profile.max_tokens_key must be \"max_tokens\" or \"max_completion_tokens\", \ - got {other:?}" - ))); - } - }; - Ok(ChatSettings { - include_usage: true, - supports_structured_outputs: true, - supports_top_k: self.supports_top_k, - supports_tools: self.supports_tools, - supports_response_format: self.supports_response_format, - stream_usage_key: self.stream_usage_key.clone(), - max_tokens_key, - ..ChatSettings::default() - }) - } -} - -/// Per-call construction options for [`provider`] (overrides the registry entry). -/// -/// Deserializing a JSON object that carries `max_retries` or `body_overrides` -/// fails: retry is a call-level option (`CallOptions::max_retries`) and -/// request-body overrides are gone, so neither is silently ignored here. The -/// FFI `config_json` and the Node/Python configs reach this check and report it -/// as `InvalidArgument`. -#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase", try_from = "ProviderOptionsWire")] -pub struct ProviderOptions { - /// Override the registry base URL. - pub base_url: Option, - /// Extra headers merged into every request. - pub headers: Option>, - /// OpenAI organization ID (`OpenAI-Organization` header). - pub organization: Option, - /// OpenAI project ID (`OpenAI-Project` header). - pub project: Option, - /// Values of the preset's template parameters (`account_id`, `region`, - /// `project`, ...); only the ones the registry row declares are accepted. - pub params: Option>, -} - -/// Wire shape of [`ProviderOptions`]: the same keys plus the two removed ones, -/// kept only so their presence can be reported instead of dropped. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ProviderOptionsWire { - #[serde(alias = "base_url")] - base_url: Option, - headers: Option>, - organization: Option, - project: Option, - params: Option>, - #[serde(alias = "max_retries")] - max_retries: Option, - #[serde(alias = "body_overrides")] - body_overrides: Option, -} - -impl TryFrom for ProviderOptions { - type Error = String; - - fn try_from(wire: ProviderOptionsWire) -> Result { - if wire.max_retries.is_some() { - return Err(MAX_RETRIES_REJECTED.to_string()); - } - if wire.body_overrides.is_some() { - return Err(BODY_OVERRIDES_REJECTED.to_string()); - } - Ok(Self { - base_url: wire.base_url, - headers: wire.headers, - organization: wire.organization, - project: wire.project, - params: wire.params, - }) - } -} - -const MAX_RETRIES_REJECTED: &str = "`max_retries` is a call-level option: pass it in the call \ - options (`maxRetries`), not in the provider configuration"; - -const BODY_OVERRIDES_REJECTED: &str = "`body_overrides` is no longer supported: request-body \ - overrides were removed from the provider configuration and the call options"; - -/// Report a provider configuration that still carries `max_retries` or -/// `body_overrides`, for configuration shapes that are not [`ProviderOptions`] -/// JSON (the Node and Python `ProviderConfig`). Pass whether each key was -/// given. -/// -/// # Errors -/// -/// Returns [`AiMuxError::InvalidArgument`] naming the first removed key that -/// was given. -pub fn reject_removed_provider_options( - max_retries: bool, - body_overrides: bool, -) -> Result<(), AiMuxError> { - if max_retries { - return Err(AiMuxError::InvalidArgument(MAX_RETRIES_REJECTED.into())); - } - if body_overrides { - return Err(AiMuxError::InvalidArgument(BODY_OVERRIDES_REJECTED.into())); - } - Ok(()) -} - -/// A language model by provider name: [`provider_handle`] then -/// `language_model(model_id)`. -/// -/// # Errors -/// -/// As [`provider_handle`], plus whatever the provider returns for the id. -pub fn provider( - name: impl AsRef, - api_key: Option, - model_id: &str, - options: Option, -) -> Result, AiMuxError> { - let p = provider_handle(name, api_key, options)?; - p.language_model(model_id) -} - -// ── Runtime overlay layer (RFC-0020) ───────────────────────────────────────── - -/// A provider entry registered at runtime via [`register_provider`] / -/// [`load_providers_from_json`]. Overrides a same-named built-in entry (whole -/// replacement, not deep merge) or adds a new one. -/// -/// Only OpenAI-compatible providers can be registered this way - native -/// protocols (anthropic/google/bedrock...) are code implementations and cannot -/// be described by config data. The JSON form accepts exactly these fields; -/// `max_retries` and `body_overrides` (removed) and any unknown field are -/// `InvalidArgument`. -#[derive(Clone, Deserialize)] -pub struct ExternalProviderEntry { - /// Provider name used for `provider("name", ...)` lookup. Required. - pub name: String, - /// Human-readable name. Defaults to `name` if absent. - pub display: Option, - /// API base URL. Required, must be a valid `http(s)://` URL. - pub base_url: String, - /// Env var name to read the API key from. Optional. - pub env_var: Option, - /// `"env:VAR_NAME"` reference (recommended) or a literal key string - /// (supported but discouraged). Optional. - pub api_key: Option, - /// Protocol kind. Only `"openai_compat"` is accepted; other values error. - #[serde(default = "default_openai_compat")] - pub protocol: String, - /// Provider capability profile. All fields optional, defaults to full. - #[serde(default)] - pub profile: ProviderProfile, - // --- Fields equivalent to ProviderOptions (provider-level config) --- - /// Extra headers merged into every request. - pub headers: Option>, - /// OpenAI organization ID (`OpenAI-Organization` header). - pub organization: Option, - /// OpenAI project ID (`OpenAI-Project` header). - pub project: Option, - /// Free-form note for the user; the library ignores this. - pub comment: Option, -} - -impl std::fmt::Debug for ExternalProviderEntry { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ExternalProviderEntry") - .field("name", &self.name) - .field("display", &self.display) - .field("base_url", &self.base_url) - .field("env_var", &self.env_var) - .field("api_key", &self.api_key.is_some()) - .field("protocol", &self.protocol) - .field("profile", &self.profile) - .field("headers", &self.headers.as_ref().map(HashMap::len)) - .field("organization", &self.organization) - .field("project", &self.project) - .field("comment", &self.comment) - .finish() - } -} - -fn default_openai_compat() -> String { - "openai_compat".to_string() -} - -#[derive(Deserialize)] -struct ProvidersConfig { - providers: Vec, -} - -/// The keys of an external entry's JSON object. -const EXTERNAL_ENTRY_KEYS: [&str; 11] = [ - "name", - "display", - "base_url", - "env_var", - "api_key", - "protocol", - "profile", - "headers", - "organization", - "project", - "comment", -]; - -static OVERLAYS: OnceLock>> = OnceLock::new(); - -fn overlays() -> &'static RwLock> { - OVERLAYS.get_or_init(|| RwLock::new(HashMap::new())) -} - -/// Register (or replace) an external provider entry. -/// -/// Validation failures (empty name, non-`http(s)://` base_url, templated -/// base_url, unsupported protocol, unusable profile) return -/// [`AiMuxError::InvalidArgument`] - they never panic. -/// -/// # Errors -/// -/// Returns [`AiMuxError::InvalidArgument`] when entry validation fails (empty -/// name, non-`http(s)://` base URL, unsupported protocol). -pub fn register_provider(entry: ExternalProviderEntry) -> Result<(), AiMuxError> { - validate_external_entry(&entry)?; - let mut overlays = overlays().write().unwrap(); - overlays.insert(entry.name.clone(), entry); - Ok(()) -} - -/// Whether `name` was registered at runtime via [`register_provider`] / -/// [`load_providers_from_json`] (RFC-0020 overlay). Used by the replay path -/// to recognize externally-registered OpenAI-compatible providers. -#[must_use] -pub fn is_external_provider(name: &str) -> bool { - overlays().read().unwrap().contains_key(name) -} - -/// Load and register multiple external providers from a JSON string -/// (`{ "providers": [ ... ] }`). Useful for binding-layer pass-through. -/// -/// # Errors -/// -/// Returns `AiMuxError::JsonParse` for malformed JSON or a document of the -/// wrong shape, `InvalidArgument` for an entry carrying a removed -/// (`max_retries`, `body_overrides`) or unknown field, and propagates each -/// entry's `register_provider` validation error. -pub fn load_providers_from_json(json: &str) -> Result<(), AiMuxError> { - let value: Value = serde_json::from_str(json).map_err(|e| { - AiMuxError::JsonParse(format!("failed to parse external providers config: {e}")) - })?; - if let Some(entries) = value.get("providers").and_then(Value::as_array) { - for entry in entries.iter().filter_map(Value::as_object) { - let name = entry - .get("name") - .and_then(Value::as_str) - .unwrap_or(""); - reject_removed_provider_options( - entry.contains_key("max_retries"), - entry.contains_key("body_overrides"), - ) - .map_err(|e| AiMuxError::InvalidArgument(format!("external provider '{name}': {e}")))?; - if let Some(unknown) = entry - .keys() - .find(|key| !EXTERNAL_ENTRY_KEYS.contains(&key.as_str())) - { - return Err(AiMuxError::InvalidArgument(format!( - "external provider '{name}' has an unknown field `{unknown}`; accepted \ - fields: {}", - EXTERNAL_ENTRY_KEYS.join(", ") - ))); - } - } - } - let config: ProvidersConfig = serde_json::from_value(value).map_err(|e| { - AiMuxError::JsonParse(format!("failed to parse external providers config: {e}")) - })?; - for entry in config.providers { - register_provider(entry)?; - } - Ok(()) -} - -/// Validate an external entry before inserting it into the overlay. -fn validate_external_entry(entry: &ExternalProviderEntry) -> Result<(), AiMuxError> { - if entry.name.trim().is_empty() { - return Err(AiMuxError::InvalidArgument( - "external provider entry missing `name`".into(), - )); - } - if entry.base_url.trim().is_empty() { - return Err(AiMuxError::InvalidArgument(format!( - "external provider '{}' missing `base_url`", - entry.name - ))); - } - if !(entry.base_url.starts_with("https://") || entry.base_url.starts_with("http://")) { - return Err(AiMuxError::InvalidArgument(format!( - "external provider '{}' base_url must start with http(s)://, got {:?}", - entry.name, entry.base_url - ))); - } - if entry.base_url.contains(['{', '}', '<', '>']) { - return Err(AiMuxError::InvalidArgument(format!( - "external provider '{}' base_url {:?} has a placeholder; an external entry takes a \ - concrete URL", - entry.name, entry.base_url - ))); - } - if entry.protocol != "openai_compat" { - return Err(AiMuxError::InvalidArgument(format!( - "external provider '{}' has unsupported protocol {:?}; only \"openai_compat\" is supported", - entry.name, entry.protocol - ))); - } - entry.profile.chat_settings().map_err(|e| { - AiMuxError::InvalidArgument(format!("external provider '{}': {e}", entry.name)) - })?; - Ok(()) -} - -// ── By-name creation for the bindings ──────────────────────────────────────── - -/// Create a provider by name for a binding: a runtime-registered external -/// entry first, otherwise [`create_provider`](crate::create_provider) (the -/// vendor packages and the registry rows). -/// -/// # Errors -/// -/// [`AiMuxError::NoSuchProvider`] for an unknown name, `InvalidArgument` for -/// invalid options. -pub fn provider_handle( - name: impl AsRef, - api_key: Option, - options: Option, -) -> Result, AiMuxError> { - let name = name.as_ref(); - let external = overlays().read().unwrap().get(name).cloned(); - match external { - Some(entry) => Ok(Arc::new(build_external(&entry, api_key, options)?)), - None => crate::create_provider(name, preset_settings(api_key, options)), - } -} - -/// `PresetSettings` from the by-name arguments. Organization and project -/// become their headers, below the caller's own headers. -fn preset_settings(api_key: Option, options: Option) -> PresetSettings { - let mut settings = PresetSettings { - api_key: api_key.map(Resolvable::Value), - ..PresetSettings::default() - }; - if let Some(options) = options { - let mut headers = aimux_provider_utils::HeaderMapOpt::new(); - if let Some(org) = options.organization { - headers.insert("OpenAI-Organization".to_string(), Some(org)); - } - if let Some(project) = options.project { - headers.insert("OpenAI-Project".to_string(), Some(project)); - } - for (name, value) in options.headers.into_iter().flatten() { - headers.insert(name, Some(value)); - } - settings.base_url = options.base_url; - settings.headers = (!headers.is_empty()).then_some(headers); - settings.params = options.params.unwrap_or_default(); - } - settings -} - -/// An external entry as a compatible provider: its profile, its URL (the -/// per-call override wins), its headers and its key, which is resolved now. -fn build_external( - entry: &ExternalProviderEntry, - api_key: Option, - options: Option, -) -> Result { - let display = entry.display.clone().unwrap_or_else(|| entry.name.clone()); - let options = options.unwrap_or_default(); - if let Some(params) = &options.params - && !params.is_empty() - { - return Err(AiMuxError::InvalidArgument(format!( - "external provider '{}' has no template parameters; `params` does not apply", - entry.name - ))); - } - - let key = resolve_external_key(entry, &display, api_key)?; - - let mut fixed_headers = Vec::new(); - if let Some(org) = options.organization.or_else(|| entry.organization.clone()) { - fixed_headers.push(("OpenAI-Organization".to_string(), org)); - } - if let Some(project) = options.project.or_else(|| entry.project.clone()) { - fixed_headers.push(("OpenAI-Project".to_string(), project)); - } - let headers = options - .headers - .or_else(|| entry.headers.clone()) - .map(|headers| headers.into_iter().map(|(k, v)| (k, Some(v))).collect()); - let base_url = validate_base_url(options.base_url.as_deref().unwrap_or(&entry.base_url))?; - - OpenAICompatibleProvider::assemble(Assembly { - name: entry.name.clone(), - base_url: BaseUrl::Fixed(base_url), - credential: Credential::Explicit(Resolvable::Value(key)), - fixed_headers, - headers, - query_params: None, - fetch: None, - transform_request_body: None, - chat: entry.profile.chat_settings()?, - package: crate::shared::aisdk_pins::OPENAI_COMPATIBLE, - }) -} - -/// Resolve the api key of an external entry. -/// -/// Priority: explicit `api_key` parameter > entry-level `api_key` field -/// (supports `"env:VAR"` references, resolved against the environment) > -/// entry `env_var` (read from the environment via [`load_api_key`]). -fn resolve_external_key( - entry: &ExternalProviderEntry, - display: &str, - api_key: Option, -) -> Result { - if let Some(key) = api_key { - return Ok(key); - } - if let Some(entry_key) = &entry.api_key - && !entry_key.is_empty() - { - // Entry-level key: "env:VAR" → read env; otherwise treat as literal. - if let Some(var) = entry_key.strip_prefix("env:") { - if var.is_empty() { - return Err(AiMuxError::InvalidArgument(format!( - "external provider '{}' has malformed api_key reference {:?} (empty var name)", - entry.name, entry_key - ))); - } - let val = std::env::var(var).map_err(|_| { - AiMuxError::InvalidArgument(format!( - "external provider '{}' references env var `{var}` via api_key, but it is not set", - entry.name - )) - })?; - return Ok(val); - } - return Ok(entry_key.clone()); - } - match entry.env_var.as_deref().filter(|var| !var.is_empty()) { - Some(var) => load_api_key(None, var, display), - None => Err(AiMuxError::InvalidArgument(format!( - "provider '{}' has no api_key parameter, entry-level api_key, or env_var to read from", - entry.name - ))), - } -} diff --git a/aimux-providers/tests/provider_options_json_test.rs b/aimux-providers/tests/provider_options_json_test.rs deleted file mode 100644 index 7ca05e90a..000000000 --- a/aimux-providers/tests/provider_options_json_test.rs +++ /dev/null @@ -1,58 +0,0 @@ -use aimux_providers::provider::ProviderOptions; -use serde_json::json; - -#[test] -fn camel_case_provider_config_preserves_the_base_url_and_serializes_canonically() { - let options: ProviderOptions = serde_json::from_value(json!({ - "baseUrl": "http://127.0.0.1:12345/v1", - "headers": {"x-test": "1"}, - "params": {"account_id": "account"} - })) - .unwrap(); - assert_eq!( - options.base_url.as_deref(), - Some("http://127.0.0.1:12345/v1") - ); - assert_eq!(options.headers.as_ref().unwrap()["x-test"], "1"); - assert_eq!(options.params.as_ref().unwrap()["account_id"], "account"); - - let serialized = serde_json::to_value(&options).unwrap(); - assert_eq!(serialized["baseUrl"], "http://127.0.0.1:12345/v1"); - assert!(serialized.get("base_url").is_none()); -} - -#[test] -fn legacy_base_url_is_read_without_changing_the_canonical_output() { - let options: ProviderOptions = serde_json::from_value(json!({ - "base_url": "http://127.0.0.1:12345/v1" - })) - .unwrap(); - assert_eq!( - options.base_url.as_deref(), - Some("http://127.0.0.1:12345/v1") - ); - assert!( - serde_json::to_value(options) - .unwrap() - .get("baseUrl") - .is_some() - ); -} - -#[test] -fn removed_provider_settings_are_rejected_with_either_spelling() { - for key in [ - "maxRetries", - "max_retries", - "bodyOverrides", - "body_overrides", - ] { - let error = serde_json::from_value::(json!({(key): 0})) - .unwrap_err() - .to_string(); - assert!( - error.contains("call-level option") || error.contains("no longer supported"), - "{key}: {error}" - ); - } -} diff --git a/bindings/c/README.md b/bindings/c/README.md new file mode 100644 index 000000000..b8cc0d5c6 --- /dev/null +++ b/bindings/c/README.md @@ -0,0 +1,68 @@ +# C and C++ primitives + +The examples include the generated `aimux-ffi/include/aimux.h` and use only +ABI primitives. Both examples run offline and print the manifest and +runtime description. `aimux_raii.hpp` owns strings, errors, and handles; +its stream trampoline copies borrowed frame bytes, catches every C++ exception, +requests abort, and rethrows the first exception only after `aimux_stream` returns. +A stream wrapper caller must supply a live Abort handle for cancellation. +Callbacks must return promptly. + +From the repository root: + +```sh +scripts/test-c-abi.sh +scripts/test-c-abi.sh --asan +scripts/test-c-abi.sh --asan-lsan # Linux only +scripts/test-c-abi.sh --compile-only +scripts/test-c-abi.sh --asan-compile-only +``` + +`cargo build -p aimux-ffi` builds the cdylib; its artifact messages locate it +wherever `CARGO_TARGET_DIR` points. The script copies that library into its +temporary run directory before linking. All four +executables run and their exit statuses are reported, even if an earlier one +fails. To test a specific already built cdylib, set +`AIMUX_C_LIB_DIR` to its directory. `CC` and `CXX` select compilers; +`AIMUX_C_HEADER_DIR` explicitly selects a different generated header directory. +Compile-only mode neither links nor runs. Assertions remain enabled in every mode. + +ASan instruments the C/C++ harnesses. A normal Rust cdylib is not itself +ASan-instrumented: full Rust bounds instrumentation requires a separately +instrumented Rust build, and passed through +`AIMUX_C_LIB_DIR`. LeakSanitizer is explicitly requested on Linux; macOS ASan +does not establish an LSan pass. + +The C test exercises ownership on success/error paths, pointer and numeric output +sentinels, NULL arguments, invalid UTF-8, malformed JSON, unknown ops, oversized +binary lengths, an Abort-versus-model/provider/registry/operation wrong-kind +sample, and 100 concurrent drop/signal races. All returned errors and all owned +getter strings are freed. The oversized length test uses an invalid operation +handle, so either length rejection or handle rejection is acceptable; it does +not establish valid-operation backpressure or input-copy behavior. It never +allocates or pretends to supply SIZE_MAX bytes. + +By default the accepted-stream test prints `SKIP`. To enable it, configure an +existing provider (preferably against a local deterministic fixture server): + +```sh +export AIMUX_C_TEST_PROVIDER_PARAMS='' +export AIMUX_C_TEST_MODEL_PARAMS='' +export AIMUX_C_TEST_STREAM_PARAMS='' +scripts/test-c-abi.sh +``` + +The request DTOs come from the generated schema. Tests expect handle-producing +results to contain a decimal-string `handle` field. The stream test creates all +handles in its own process, copies frames while borrowed bytes are valid, +aborts inside the callback, requires exactly one final End frame, and checks +for late callbacks during a short observation interval. That interval is a +bounded check, not a proof against arbitrarily delayed callbacks. Rejection +before acceptance fails this configured test. Never put credentials in committed +fixture files. + +The C++ test exercises move ownership, error unwinding, and the first-failure +latch by directly calling its `extern "C"` trampoline. It does not claim this +local trampoline test is an accepted native stream integration test. All Rust +error domains share one destructor; this suite checks reachable boundary and +protocol errors, not every AiMux/recording payload variant. diff --git a/bindings/c/aimux_raii.hpp b/bindings/c/aimux_raii.hpp new file mode 100644 index 000000000..77e6ae4c9 --- /dev/null +++ b/bindings/c/aimux_raii.hpp @@ -0,0 +1,75 @@ +#ifndef AIMUX_RAII_HPP +#define AIMUX_RAII_HPP +#include "aimux.h" +#include +#include +#include +#include +#include +#include +#include + +namespace aimux { +struct StringDeleter { + void operator()(char *value) const noexcept { aimux_free_string(value); } +}; +struct ErrorDeleter { + void operator()(aimux_error_t *value) const noexcept { if (value) aimux_error_free(value); } +}; +using String = std::unique_ptr; +using Error = std::unique_ptr; + +inline void check(aimux_error_t *raw) { + Error error(raw); + if (!error) return; + String message(aimux_error_message(error.get())); + throw std::runtime_error(message ? message.get() : "aimux call failed"); +} +class Handle { +public: + explicit Handle(std::uint64_t value = 0) noexcept : value_(value) {} + ~Handle() { aimux_drop(value_); } + Handle(const Handle &) = delete; + Handle &operator=(const Handle &) = delete; + Handle(Handle &&other) noexcept : value_(std::exchange(other.value_, 0)) {} + Handle &operator=(Handle &&other) noexcept { + if (this != &other) { + aimux_drop(value_); + value_ = std::exchange(other.value_, 0); + } + return *this; + } + std::uint64_t get() const noexcept { return value_; } +private: + std::uint64_t value_; +}; + +struct StreamContext { + std::function consume; + std::uint64_t abort_handle; + std::exception_ptr failure; +}; +} // namespace aimux + +/* Catch allocation and consumer exceptions before returning to the C ABI. */ +extern "C" inline void aimux_cpp_event(const char *frame, void *opaque) noexcept { + auto &ctx = *static_cast(opaque); + if (ctx.failure) return; + try { + ctx.consume(std::string(frame)); + } catch (...) { + ctx.failure = std::current_exception(); + aimux_abort(ctx.abort_handle); + } +} +namespace aimux { +inline void stream(std::uint64_t model, const char *op, const char *params, + std::uint64_t abort_handle, + std::function consume) { + StreamContext context{std::move(consume), abort_handle, nullptr}; + Error error(aimux_stream(model, op, params, abort_handle, aimux_cpp_event, &context)); + if (context.failure) std::rethrow_exception(context.failure); + check(error.release()); +} +} // namespace aimux +#endif diff --git a/bindings/c/example.c b/bindings/c/example.c index f1957bcfe..3fd6a6d6d 100644 --- a/bindings/c/example.c +++ b/bindings/c/example.c @@ -1,208 +1,32 @@ -// example.c — C example using aimux-ffi -// -// Build: gcc -o example example.c -I../../aimux-ffi -L../../target/release -laimux_ffi -lpthread -ldl -lm -// Run: OPENAI_API_KEY=sk-... ./example -// -// Every fallible call returns aimux_error_t * (NULL = success, result in -// the trailing out-param). Read its unified code and aimux_error_* getters, -// then release it with aimux_error_free(). Codes 200..206 mean the C call -// itself was malformed (NULL argument, malformed JSON text, dead handle). - +/* Offline C11 example. See scripts/test-c-abi.sh. */ +#include "aimux.h" #include -#include -#include - -#include "aimux-error.h" -#include "aimux-ffi.h" - -static void print_owned(const char *label, char *s) { - if (s) { - fprintf(stderr, " %s: %s\n", label, s); - aimux_free_string(s); - } -} - -// Print the failure, release the returned error, and hand back the callee's -// retry verdict: 1 when retrying may help, 0 when it will not. -// -// Payload getters answer only under their own code (NULL / -1 otherwise) — -// read them inside the matching case. -static int report(const char *what, aimux_error_t *e) { - int32_t code = aimux_error_code(e); - if (code >= AIMUX_E_FFI_NULL_POINTER && code <= AIMUX_E_FFI_CALLBACK_FAILURE) { - // Not the model's fault: this call was malformed. Fix the call. - char *message = aimux_error_message(e); - fprintf(stderr, "%s: bad call: %s\n", what, message ? message : ""); - aimux_free_string(message); - aimux_error_free(e); - return 0; - } - char *message = aimux_error_message(e); - int retryable = aimux_error_retryable(e); - switch (code) { - // Every HTTP-shaped failure arrives as AIMUX_E_API_CALL; classify on status. - case AIMUX_E_API_CALL: { - int status = aimux_error_status(e); - int64_t retry_ms = aimux_error_retry_ms(e); - if (status == 429) { - // retry_ms is -1 when the 429 carried no retry-after header — - // fall back to your own exponential backoff. - if (retry_ms >= 0) { - fprintf(stderr, "%s: rate limited, retry in %lldms: %s\n", what, - (long long)retry_ms, message); - } else { - fprintf(stderr, "%s: rate limited, no retry-after hint, back off " - "exponentially: %s\n", - what, message); - } - } else if (status == 401) { - fprintf(stderr, "%s: auth (HTTP 401): %s\n", what, message); - } else if (status == 404) { - fprintf(stderr, "%s: model not found (HTTP 404): %s\n", what, message); - } else if (status >= 0) { - fprintf(stderr, "%s: HTTP %d: %s\n", what, status, message); - } else { - // status == -1: no HTTP response was ever observed — a missing API - // key, an error built without a request, or a transport failure. - // Which one it was is retryable, never the status: those shapes - // both report -1 and disagree about whether a retry helps. - fprintf(stderr, "%s: no HTTP response (%s): %s\n", what, - retryable ? "retryable" : "not retryable", message); - } - // Payload strings are NULL when the provider did not send them. - print_owned("provider code", aimux_error_provider_code(e)); - print_owned("provider message", aimux_error_provider_message(e)); - break; - } - case AIMUX_E_TOKEN_EXPIRED: - fprintf(stderr, "%s: token expired (HTTP %d): %s\n", what, aimux_error_status(e), - message); - break; - case AIMUX_E_NO_SUCH_PROVIDER: { - char *id = aimux_error_provider_id(e); - fprintf(stderr, "%s: no provider \"%s\": %s\n", what, id ? id : "?", message); - aimux_free_string(id); - break; - } - case AIMUX_E_NO_SUCH_MODEL: { - char *id = aimux_error_model_id(e); - char *type = aimux_error_model_type(e); - fprintf(stderr, "%s: no %s model \"%s\": %s\n", what, type ? type : "?", id ? id : "?", - message); - aimux_free_string(id); - aimux_free_string(type); - break; - } - default: - fprintf(stderr, "%s: %s\n", what, message); - break; - } - aimux_free_string(message); - aimux_error_free(e); - return retryable; -} - -static void on_part(const char *json, void *stream_ctx) { - (void)stream_ctx; - printf("PART: %s\n", json); -} - -static void on_done(void *stream_ctx) { - (void)stream_ctx; - printf("\n[stream done]\n"); -} int main(void) { - const char *api_key = getenv("OPENAI_API_KEY"); - if (!api_key) { - fprintf(stderr, "Please set OPENAI_API_KEY\n"); + char *manifest = NULL; + aimux_error_t *error = aimux_manifest_json(&manifest); + if (error) { + char *message = aimux_error_message(error); + char *json = aimux_error_json(error); + fprintf(stderr, "aimux (%d): %s\n%s\n", aimux_error_code(error), + message ? message : "", json ? json : ""); + aimux_free_string(message); + aimux_free_string(json); + aimux_error_free(error); return 1; } - - aimux_error_t *err = NULL; - - // 1. Create model — failure: err != NULL, handle stays 0 - uint64_t handle = 0; - if ((err = aimux_openai_new(api_key, "gpt-4o-mini", &handle))) { - report("openai", err); + puts(manifest); + aimux_free_string(manifest); + char *description = NULL; + error = aimux_call(0, "runtime.describe", "{}", 0, &description); + if (error) { + char *message = aimux_error_message(error); + fprintf(stderr, "%s\n", message ? message : "runtime.describe failed"); + aimux_free_string(message); + aimux_error_free(error); return 1; } - printf("Model created: handle=%lu\n", (unsigned long)handle); - - // 2. Generate text (non-streaming) — failure: err != NULL, result stays NULL - const char *prompt = "\"Explain Rust ownership in one sentence.\""; - char *result = NULL; - if ((err = aimux_generate_text(handle, prompt, NULL, &result)) && - report("generate_text", err)) { - // Retry on the callee's verdict, not on the status sentinel. - if ((err = aimux_generate_text(handle, prompt, NULL, &result))) { - report("generate_text (retry)", err); - } - } - if (result) { - printf("Result: %s\n", result); - aimux_free_string(result); - } - - // 3. Stream — no out-param; NULL after on_done - printf("\n--- Streaming ---\n"); - if ((err = aimux_stream_text(handle, "\"Write a haiku about Rust.\"", NULL, on_part, - on_done, NULL))) { - report("stream", err); - } - - // 3.1 OpenAI-compatible output - printf("\n--- OpenAI-compatible output ---\n"); - char *oai_result = NULL; - if ((err = aimux_generate_text_as_openai(handle, prompt, NULL, &oai_result))) { - report("generate_text_as_openai", err); - } else { - printf("ChatCompletion: %s\n", oai_result); - aimux_free_string(oai_result); - } - - // 3.2 OpenAI-compatible stream - printf("\n--- OpenAI-compatible streaming ---\n"); - if ((err = aimux_stream_text_as_openai(handle, "\"Write a haiku about Rust.\"", NULL, - on_part, on_done, NULL))) { - report("stream_as_openai", err); - } - - // 3.5 Registry provider - uint64_t ds_handle = 0; - if ((err = aimux_provider_new("deepseek", NULL, "deepseek-chat", NULL, &ds_handle))) { - report("provider deepseek", err); - } else { - printf("DeepSeek (registry): handle=%lu\n", (unsigned long)ds_handle); - aimux_drop_handle(ds_handle); - } - - // 3.6 Provider handle (RFC-0027) - uint64_t p_handle = 0; - if ((err = aimux_provider_handle_new("deepseek", NULL, NULL, &p_handle))) { - report("provider_handle", err); - } else { - printf("Provider handle: handle=%lu\n", (unsigned long)p_handle); - char *models_json = NULL; - if ((err = aimux_provider_list_models(p_handle, &models_json))) { - report("list_models", err); - } else { - printf("Models: %s\n", models_json); - aimux_free_string(models_json); - } - uint64_t m_handle = 0; - if ((err = aimux_provider_model(p_handle, "deepseek-chat", &m_handle))) { - report("provider_model", err); - } else { - printf("Model from provider handle: handle=%lu\n", - (unsigned long)m_handle); - aimux_drop_handle(m_handle); - } - aimux_drop_handle(p_handle); - } - - // 4. Cleanup - aimux_drop_handle(handle); - printf("Handle dropped\n"); + puts(description); + aimux_free_string(description); return 0; } diff --git a/bindings/c/example.cpp b/bindings/c/example.cpp index be832c5be..24f3fba93 100644 --- a/bindings/c/example.cpp +++ b/bindings/c/example.cpp @@ -1,222 +1,20 @@ -// example.cpp — C++ RAII wrapper example using aimux-ffi -// -// Build: g++ -std=c++17 -o example_cpp example.cpp -I../../aimux-ffi -L../../target/release -laimux_ffi -lpthread -ldl -lm -// Run: OPENAI_API_KEY=sk-... ./example_cpp - -#include -#include -#include -#include +/* Offline C++17 example: owned values are released on all exception paths. */ +#include "aimux_raii.hpp" #include -#include -#include - -#include "aimux-error.h" -#include "aimux-ffi.h" - -// Own a getter's string (or "" for NULL) and release it. -static std::string take(char *s) { - std::string out = s ? s : ""; - aimux_free_string(s); - return out; -} - -// Thin exception owning a copy of an AiMuxError's facts. -class AimuxException : public std::runtime_error { -public: - // Consumes the returned error: copies every getter, then frees it. - AimuxException(const std::string &context, aimux_error_t *e) - : std::runtime_error(context + ": " + take(aimux_error_message(e))), - code_(static_cast(aimux_error_code(e))), - status_(aimux_error_status(e)), retry_ms_(aimux_error_retry_ms(e)), - retryable_(aimux_error_retryable(e) != 0), - provider_code_(take(aimux_error_provider_code(e))), - provider_message_(take(aimux_error_provider_message(e))), - response_body_(take(aimux_error_response_body(e))), - model_id_(take(aimux_error_model_id(e))), model_type_(take(aimux_error_model_type(e))), - provider_id_(take(aimux_error_provider_id(e))) { - aimux_error_free(e); - } - - aimux_error_code_t code() const { return code_; } - int status() const { return status_; } - int64_t retryMs() const { return retry_ms_; } - /** The callee's verdict: true when retrying may help. Not derivable from - * status() — a transport failure and a missing API key both report -1 - * and disagree here. */ - bool retryable() const { return retryable_; } - // Per-code payload; "" when the code does not carry the field. - const std::string &providerCode() const { return provider_code_; } // API_CALL - const std::string &providerMessage() const { return provider_message_; } // API_CALL - const std::string &responseBody() const { return response_body_; } // API_CALL - const std::string &modelId() const { return model_id_; } // NO_SUCH_MODEL - const std::string &modelType() const { return model_type_; } // NO_SUCH_MODEL - const std::string &providerId() const { return provider_id_; } // NO_SUCH_PROVIDER - -private: - aimux_error_code_t code_ = AIMUX_E_OTHER; - int status_ = -1; - int64_t retry_ms_ = -1; - bool retryable_ = false; - std::string provider_code_, provider_message_, response_body_, model_id_, - model_type_, provider_id_; -}; - -// An AiMuxError code (1..14) becomes AimuxException. Codes 200..206 mean this -// program made a bad call (NULL argument, malformed JSON, dead handle). -static void throw_if_failed(aimux_error_t *err, const std::string &what) { - if (!err) return; - int32_t code = aimux_error_code(err); - if (code >= AIMUX_E_OTHER && code <= AIMUX_E_RETRY) { - throw AimuxException(what, err); - } - std::string msg = take(aimux_error_message(err)); - aimux_error_free(err); - throw std::logic_error(what + ": bad call (fix the caller): " + msg); -} - -// RAII wrapper for model handle -class AimuxModel { -public: - static AimuxModel openai(const std::string &api_key, const std::string &model_id) { - uint64_t h = 0; - throw_if_failed(aimux_openai_new(api_key.c_str(), model_id.c_str(), &h), "openai"); - return AimuxModel(h); - } - - static AimuxModel anthropic(const std::string &api_key, const std::string &model_id) { - uint64_t h = 0; - throw_if_failed(aimux_anthropic_new(api_key.c_str(), model_id.c_str(), &h), "anthropic"); - return AimuxModel(h); - } - - static AimuxModel provider(const std::string &name, const std::string &model_id, - const char *api_key = nullptr, - const char *config_json = nullptr) { - uint64_t h = 0; - throw_if_failed(aimux_provider_new(name.c_str(), api_key, model_id.c_str(), config_json, &h), - "provider '" + name + "'"); - return AimuxModel(h); - } - - ~AimuxModel() { - if (handle_) { - aimux_drop_handle(handle_); - } - } - - AimuxModel(const AimuxModel &) = delete; - AimuxModel &operator=(const AimuxModel &) = delete; - AimuxModel(AimuxModel &&other) noexcept : handle_(other.handle_) { other.handle_ = 0; } - - std::string generate_text(const std::string &prompt_json, - const std::string &opts_json = "") { - require_open("generate_text"); - const char *opts = opts_json.empty() ? nullptr : opts_json.c_str(); - char *result = nullptr; - throw_if_failed(aimux_generate_text(handle_, prompt_json.c_str(), opts, &result), - "generate_text"); - return take(result); - } - - std::string generate_text_as_openai(const std::string &prompt_json, - const std::string &opts_json = "") { - require_open("generate_text_as_openai"); - const char *opts = opts_json.empty() ? nullptr : opts_json.c_str(); - char *result = nullptr; - throw_if_failed(aimux_generate_text_as_openai(handle_, prompt_json.c_str(), opts, &result), - "generate_text_as_openai"); - return take(result); - } - - // Callbacks must not unwind across the Aimux C ABI (Rust cannot reliably - // catch a C++ exception; the process may abort). The trampoline therefore - // catches everything the user's on_part throws, parks it, and rethrows - // after aimux_stream_text has returned. Once an exception is parked the - // remaining parts are dropped on the floor — the stream still runs to its - // end (there is no cancel-from-callback in this ABI); use an abort signal - // if you need to stop early. - struct StreamCtx { - std::function on_part; - std::exception_ptr pending; - }; - - void stream_text(const std::string &prompt_json, - std::function on_part, - const std::string &opts_json = "") { - require_open("stream_text"); - if (!on_part) { - throw std::logic_error("stream_text: on_part must not be empty"); - } - const char *opts = opts_json.empty() ? nullptr : opts_json.c_str(); - StreamCtx ctx{std::move(on_part), nullptr}; - auto part_cb = [](const char *json, void *stream_ctx) noexcept { - auto *c = static_cast(stream_ctx); - if (c->pending) return; // already failed; drain silently - try { - c->on_part(json ? json : ""); - } catch (...) { - c->pending = std::current_exception(); - } - }; - auto done_cb = [](void *) noexcept {}; - throw_if_failed(aimux_stream_text(handle_, prompt_json.c_str(), opts, part_cb, done_cb, &ctx), - "stream_text"); - if (ctx.pending) std::rethrow_exception(ctx.pending); - } - -private: - explicit AimuxModel(uint64_t handle) : handle_(handle) {} - - void require_open(const char *what) { - if (handle_ == 0) { - throw std::logic_error(std::string(what) + ": model handle is closed"); - } - } - - uint64_t handle_ = 0; -}; int main() { try { - const char *api_key = std::getenv("OPENAI_API_KEY"); - if (!api_key) { - std::cerr << "OPENAI_API_KEY is not set\n"; - return 1; - } - - AimuxModel model = AimuxModel::openai(api_key, "gpt-4o-mini"); - std::cout << model.generate_text("\"Explain Rust ownership in one sentence.\"") - << "\n"; - - std::cout << "\n--- Streaming ---\n"; - model.stream_text("\"Write a haiku about Rust.\"", - [](const std::string &p) { std::cout << "PART: " << p << "\n"; }); - - } catch (const AimuxException &e) { - std::cerr << "Error: " << e.what() << "\n"; - if (e.code() == AIMUX_E_NO_SUCH_PROVIDER) { - std::cerr << "unknown provider: " << e.providerId() << "\n"; - } - if (!e.providerCode().empty()) { - std::cerr << "provider code: " << e.providerCode() << "\n"; - } - // Whether to retry is the callee's verdict, not a guess from status: - // a statusless failure is a transport blip (retryable) or a missing - // API key (not), and both report status() == -1. - if (e.retryable()) { - // retryMs() is -1 when no retry-after hint arrived — fall back to - // your own exponential backoff. - if (e.retryMs() >= 0) { - std::cerr << "retryable, retry_ms=" << e.retryMs() << "\n"; - } else { - std::cerr << "retryable, no retry-after hint, back off exponentially\n"; - } - } - return 1; - } catch (const std::exception &e) { - std::cerr << "Error: " << e.what() << "\n"; + char *raw = nullptr; + aimux::check(aimux_manifest_json(&raw)); + aimux::String manifest(raw); + std::cout << manifest.get() << '\n'; + raw = nullptr; + aimux::check(aimux_call(0, "runtime.describe", "{}", 0, &raw)); + aimux::String description(raw); + std::cout << description.get() << '\n'; + return 0; + } catch (const std::exception &error) { + std::cerr << error.what() << '\n'; return 1; } - return 0; } diff --git a/bindings/flutter/CHANGELOG.md b/bindings/flutter/CHANGELOG.md index 4f7772016..44268b809 100644 --- a/bindings/flutter/CHANGELOG.md +++ b/bindings/flutter/CHANGELOG.md @@ -1,5 +1,12 @@ ## Unreleased +- Switch to the versioned ops C ABI with manifest validation. +- Run blocking native work in isolates and bridge streams with bounded credit/ack. +- Add correct allocated-token NativeFinalizer cleanup and asynchronous explicit close. +- Preserve complete error envelopes and expose operation push/poll primitives. +- Remove the unversioned ABI, per-provider constructors and synchronous facades. + DecisionModel now uses the shared Decision ops. + - Breaking: the typed layer and the maps `Model` returns follow the AI SDK JSON of the Rust core: camelCase fields, `type`-tagged unions with kebab-case values (`text-delta`, `tool-call`, ...), optional fields absent diff --git a/bindings/flutter/README.md b/bindings/flutter/README.md index c231335eb..515f1b6cf 100644 --- a/bindings/flutter/README.md +++ b/bindings/flutter/README.md @@ -1,44 +1,119 @@ -# aimux · Flutter/Dart +# aimux for Flutter / Dart -Unified LLM service layer — one API to access 325 AI providers. The Rust -core (`aimux-ffi`) is called directly via `dart:ffi` (C ABI, no platform -channels, no codegen). +The binding uses the versioned C ops ABI. `Aimux.open()` checks the ABI +major, ops major, manifest structure and any required operations before use. +The previous unversioned constructors and per-feature native exports are removed. -| | | -|---|---| -| Platforms | iOS, Android (plugin, native core embedded), plus Linux/macOS/Windows for dev & tests | -| Native core | `libaimux_ffi.so` per ABI (Android) / `aimux_ffi.xcframework` (iOS), shipped inside the package | -| Publisher | [`arcships.ai`](https://pub.dev/publishers/arcships.ai) | - -## Install +```dart +import 'package:aimux/aimux.dart'; -```bash -flutter pub add aimux +final client = await Aimux.open(requiredOps: ['runtime.describe']); +final description = await client.call('runtime.describe'); +print(description); ``` -The native libraries ship inside the package — no extra setup. +`call(op, handle: ..., params: ..., abort: ...)` accepts the canonical request DTO +from the manifest. Global operations omit `handle`. Provider settings, model +methods and request fields come from the runtime descriptor; this package does +not maintain a second provider factory/config table. Adopt each returned handle +exactly once with `client.adopt(result['handle'] as String)` and `await handle.close()` +when finished. All wire handles stay decimal strings, including values above +2^53 and the full uint64 range. Native calls use uint64, never a pointer address. -## Quick Start +Calls, manifest loading, push, poll and resource drop execute on background +isolates. Futures do not implicitly cancel native work: create an `abort.create` +handle, pass it to `call`, and use `client.abort(abort)` to signal cancellation. +Dropping an abort handle does not signal it. Native abort and the small bridge +control functions are nonblocking and may run on the caller isolate. ```dart -import 'package:aimux/aimux.dart'; - -final model = Model.openai('sk-...', 'gpt-4o', baseUrl: 'http://localhost:3000'); -final result = model.generateText('What is Rust?'); -model.close(); +// `model` is an adopted Language handle; params follows text.stream's schema. +final subscription = client.stream('text.stream', handle: model, params: params) + .listen((frame) { + if (frame is EventFrame) print(frame.event); + if (frame is EndFrame) print('${frame.outcome}: ${frame.error}'); +}); +// Pausing withholds native credit; cancelling signals abort even while paused. +subscription.pause(); +subscription.resume(); +await subscription.cancel(); ``` -## Dev / Tests +Each stream owns its bridge and creates an abort handle unless the request +provides `abort` as a decimal handle string. Supplied abort handles remain +caller-owned; cancellation signals the same handle. The native C callback copies a frame and +posts through `NativeCallable.listener` to its owning isolate; it never invokes +a Dart function on a Rust thread. A callback waits for an ack after consumer +delivery. The C bridge holds at most one frame and the isolate transport grants one +event credit (each frame is at most 1 MiB), plus an independent terminal slot. +Pause withholds event credit. Cancel wakes the producer +without needing credit. The bridge and listener remain alive until both native +return and the final queued notification have completed. Single-subscription +streams start only on listen. -Desktop platforms resolve the library from the platform library path -(host builds), which is also how the test suite runs: +A rejected start is a stream error with no frames. After acceptance, raw streams +expose the explicit `EndFrame` (success/error/cancelled); an error in that terminal +is not thrown a second time. `TypedModel` projects events into existing typed +results and throws the terminal error once. Recoverable error *events* stay data. +Host `onData` handlers run under Dart's normal Stream/Zone error rules. Internal +copy/decode failures are latched once, signal abort, and are reported after native +return. No callback exception crosses the C boundary. -```bash -cargo build -p aimux-ffi --release -LD_LIBRARY_PATH=../../target/release dart test # Linux -DYLD_LIBRARY_PATH=../../target/release dart test # macOS -``` +`OpsException` preserves the complete error envelope, including unknown payload +names/fields and optional `retryAfterMs`. AI SDK payloads keep their `name`, flat +fields and `isRetryable`; the binding does not invent HTTP status or retry policy. +`TranscriptionOperation` wraps the new push/next primitives and typed +part/ended/timeout states. `ToolCallRepair` exposes the three pure DTO ops. +`DecisionModel` exposes `decision.decide` and `decision.capabilities` over an +adopted Decision handle. Create its provider through `provider.create` and use +`provider.model` with method `decision`. + +Explicit close clears the local handle, detaches `NativeFinalizer`, frees its +independent token, and schedules drop on a worker isolate. Repeated close returns +the same Future. Forgotten handles use a native `void(void*)` token trampoline: +it queues cleanup to a native reaper thread, without running Dart or blocking +on native drop in the GC callback. Finalization is a leak safeguard; it does not +flush a recorder or complete an operation normally. -## License +## Native packaging + +Android retains the prebuilt `android/src/main/jniLibs//libaimux_ffi.so` +loader and builds `libaimux_dart.so` with the NDK/CMake. iOS retains +`DynamicLibrary.process()` and force-loads the Rust xcframework; the CocoaPods +plugin is static and retains every bridge symbol via explicit linker roots. +The iOS CI `tool/check_symbols.sh` gate verifies all defined ABI and bridge symbols +across app executables, including `Runner.debug.dylib`. + +For desktop use, build `native/CMakeLists.txt` and place the resulting +`libaimux_dart.dylib`, `libaimux_dart.so`, or `aimux_dart.dll` beside the Rust +library on the loader path. Explicit `libraryPath` and `bridgePath` overrides are +available for development/tests; iOS normally uses process lookup for both. +The stream credit transport currently requires POSIX pipes (Android, iOS, macOS +and Linux); Windows streaming is unsupported. + +aimux-codegen owns the packaged `native/aimux_ops.h`. `tool/generate_ffi.py` +generates Dart declarations from that header and checks all 14 ABI primitives, +including the nonblocking native handle finalizer. Dart uses its own allocated +uint64 token trampoline to preserve handles on 32-bit targets. +No old ABI is accepted. + +## Verification + +```sh +python3 tool/generate_ffi.py --check +python3 tool/build_test_fixture.py +mise exec java@temurin-17 gradle@8 flutter@3 -- dart pub get --offline +mise exec java@temurin-17 gradle@8 flutter@3 -- dart analyze lib test +mise exec java@temurin-17 gradle@8 flutter@3 -- dart test +mise exec java@temurin-17 gradle@8 flutter@3 -- flutter test +``` -MIT — see the repository root LICENSE. +The native test double checks isolate responsiveness, arbitrary native callback +threads, lossless ordered streams, pause/resume, cancellation at zero credit, +concurrent streams, rejection/terminal separation, malformed/oversized frames, +uint64 tokens, explicit close, the reaper trampoline, and binary push/poll. It +requires a POSIX C compiler and is not a Rust/provider integration test. +To enable the separate real-library smoke test, set `AIMUX_NATIVE_LIBRARY` and +`AIMUX_DART_BRIDGE` to new-ABI artifacts. Android ABI packaging, iOS simulator +linking and real GC timing need their platform/runtime checks; host fixture tests +do not establish that those pass. diff --git a/bindings/flutter/android/build.gradle b/bindings/flutter/android/build.gradle index 0c8d3a941..b5836377d 100644 --- a/bindings/flutter/android/build.gradle +++ b/bindings/flutter/android/build.gradle @@ -1,10 +1,4 @@ -// Android build for the aimux Flutter plugin. -// -// Pure resource plugin: no native Android code. The Rust core ships as -// `libaimux_ffi.so` per ABI under src/main/jniLibs (produced by the release -// pipeline before `dart pub publish`); the Android build system bundles the -// .so files into the consuming app, and Dart loads them by name via -// `DynamicLibrary.open('libaimux_ffi.so')`. +// Packages the Rust ABI and builds the Flutter credit/finalizer transport. group = 'ai.arcships' version = '0.2.0' @@ -31,8 +25,14 @@ apply plugin: 'com.android.library' android { namespace 'ai.arcships.aimux' compileSdk 34 + externalNativeBuild { + cmake { path '../native/CMakeLists.txt' } + } defaultConfig { minSdk 21 + externalNativeBuild { + cmake { arguments '-DCMAKE_BUILD_TYPE=Release' } + } } } diff --git a/bindings/flutter/example/lib/main.dart b/bindings/flutter/example/lib/main.dart index 4c1e19903..31490e66a 100644 --- a/bindings/flutter/example/lib/main.dart +++ b/bindings/flutter/example/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:aimux/aimux.dart'; +import 'package:aimux/generated/ops.dart' as ops; void main() { runApp(const AimuxDemoApp()); @@ -8,9 +9,7 @@ void main() { /// Minimal demo of the aimux Flutter plugin. /// /// The native core (aimux-ffi) ships inside the package — no extra setup. -/// Replace the fake credentials below with real ones to try a live call, -/// or point [Model.openaiWithBase] at a local mock server (e.g. the -/// contract-test server in this repository). +/// Provider/model request DTOs follow the loaded ops manifest. class AimuxDemoApp extends StatelessWidget { const AimuxDemoApp({super.key}); @@ -32,40 +31,22 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State { - final _apiKeyController = TextEditingController(text: 'sk-test-fake-key'); - final _modelController = TextEditingController(text: 'gpt-4o-mini'); - String _output = 'Press "Generate" to call the model.'; + String _output = 'Press "Describe runtime" to inspect native capabilities.'; bool _busy = false; - @override - void dispose() { - _apiKeyController.dispose(); - _modelController.dispose(); - super.dispose(); - } - - Future _generate() async { + Future _describe() async { setState(() { _busy = true; - _output = 'Generating…'; + _output = 'Loading runtime…'; }); try { - // Local mock by default — swap the base URL for a real provider. - final model = Model.openai( - _apiKeyController.text, - _modelController.text, - baseUrl: 'http://localhost:3000', - ); - try { - final result = model.generateText('What is Rust?'); - setState(() => _output = result.toString()); - } finally { - model.close(); - } + final client = await Aimux.open(requiredOps: [ops.OP_RUNTIME_DESCRIBE]); + final result = await client.call(ops.OP_RUNTIME_DESCRIBE); + if (mounted) setState(() => _output = result.toString()); } catch (e) { - setState(() => _output = 'Error: $e'); + if (mounted) setState(() => _output = 'Error: $e'); } finally { - setState(() => _busy = false); + if (mounted) setState(() => _busy = false); } } @@ -78,25 +59,13 @@ class _HomePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - TextField( - controller: _apiKeyController, - decoration: const InputDecoration(labelText: 'API key'), - ), - const SizedBox(height: 8), - TextField( - controller: _modelController, - decoration: const InputDecoration(labelText: 'Model ID'), - ), - const SizedBox(height: 16), FilledButton( - onPressed: _busy ? null : _generate, - child: const Text('Generate'), + onPressed: _busy ? null : _describe, + child: const Text('Describe runtime'), ), const SizedBox(height: 16), Expanded( - child: SingleChildScrollView( - child: SelectableText(_output), - ), + child: SingleChildScrollView(child: SelectableText(_output)), ), ], ), diff --git a/bindings/flutter/example/pubspec.lock b/bindings/flutter/example/pubspec.lock index 384a94458..e1bb746c9 100644 --- a/bindings/flutter/example/pubspec.lock +++ b/bindings/flutter/example/pubspec.lock @@ -7,7 +7,7 @@ packages: path: ".." relative: true source: path - version: "0.2.1" + version: "0.5.0" async: dependency: transitive description: @@ -134,10 +134,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -150,10 +150,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" path: dependency: transitive description: @@ -211,18 +211,18 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" vector_math: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.3" vm_service: dependency: transitive description: @@ -232,5 +232,5 @@ packages: source: hosted version: "15.2.0" sdks: - dart: ">=3.10.0-0 <4.0.0" + dart: ">=3.11.0-0 <4.0.0" flutter: ">=3.19.0" diff --git a/bindings/flutter/example/test/widget_test.dart b/bindings/flutter/example/test/widget_test.dart index 2dd263572..ae10e95fc 100644 --- a/bindings/flutter/example/test/widget_test.dart +++ b/bindings/flutter/example/test/widget_test.dart @@ -6,6 +6,6 @@ void main() { testWidgets('demo app renders', (WidgetTester tester) async { await tester.pumpWidget(const AimuxDemoApp()); expect(find.text('aimux demo'), findsOneWidget); - expect(find.text('Generate'), findsOneWidget); + expect(find.text('Describe runtime'), findsOneWidget); }); } diff --git a/bindings/flutter/ios/Classes/aimux_dart.c b/bindings/flutter/ios/Classes/aimux_dart.c new file mode 100644 index 000000000..eceb72ce1 --- /dev/null +++ b/bindings/flutter/ios/Classes/aimux_dart.c @@ -0,0 +1,2 @@ +// Compile the same bridge into the CocoaPods plugin; iOS uses process lookup. +#include "../../native/aimux_dart.c" diff --git a/bindings/flutter/ios/aimux.podspec b/bindings/flutter/ios/aimux.podspec index eaba60128..a818d3adc 100644 --- a/bindings/flutter/ios/aimux.podspec +++ b/bindings/flutter/ios/aimux.podspec @@ -13,7 +13,9 @@ dart:ffi. s.author = { 'aimux contributors' => 'ericted8810@gmail.com' } s.source = { :path => '.' } s.source_files = 'Classes/**/*' + s.preserve_paths = '../native/aimux_dart.c' s.swift_version = '5.0' + s.static_framework = true s.dependency 'Flutter' s.platform = :ios, '12.0' @@ -57,6 +59,12 @@ dart:ffi. s.user_target_xcconfig = { 'OTHER_LDFLAGS' => [ '-force_load', '$(PODS_XCFRAMEWORKS_BUILD_DIR)/aimux/aimux_ffi.framework/aimux_ffi', + '-Wl,-u,_aimux_dart_finalize', '-Wl,-u,_aimux_dart_token_new', + '-Wl,-u,_aimux_dart_token_dispose', '-Wl,-u,_aimux_dart_bridge_new', + '-Wl,-u,_aimux_dart_bridge_event', '-Wl,-u,_aimux_dart_bridge_ack', + '-Wl,-u,_aimux_dart_bridge_cancel', '-Wl,-u,_aimux_dart_bridge_pending', + '-Wl,-u,_aimux_dart_bridge_failure', '-Wl,-u,_aimux_dart_bridge_free', + '-Wl,-u,_aimux_dart_frame_free', ], } diff --git a/bindings/flutter/lib/aimux.dart b/bindings/flutter/lib/aimux.dart index 08b41f1c4..ba4b47da6 100644 --- a/bindings/flutter/lib/aimux.dart +++ b/bindings/flutter/lib/aimux.dart @@ -1,1095 +1,427 @@ -// aimux.dart — Flutter/Dart binding for aimux (dart:ffi, C ABI path). -// -// Calls aimux-ffi's C ABI directly via dart:ffi. No flutter_rust_bridge, -// no codegen step. The native library (libaimux_ffi.so / .dylib / .dll) -// must be on the library path or bundled in the Flutter app. -// -// This is the C ABI path (RFC §3.2) — same as Swift and Kotlin. -// -// Error transport (aimux-error.h): every fallible call returns -// `aimux_error_t *` (NULL = success, result in the trailing out-param). -// Call sites hand the pointer to the decoder in errors.dart — expectAimuxError -// (model calls) / expectRecordingError (recorder) / expectFfiError ([C ABI] calls) — -// which throws AimuxException / RecordingException, or StateError('aimux ffi: -// …') for a C ABI failure. Raw JSON string parameters are pre-validated -// with checkJson; JSON this binding builds itself goes through encodeJson; -// every string crossing the ABI goes through toCString / toCStringOrNull. All -// three reject what the Rust side cannot represent (unpaired surrogates, -// interior NULs) as a FormatException here rather than a StateError there. - -// The Flutter tool resolves `dartPluginClass` from the package's main -// library, so the plugin registration class must be visible here. -export 'aimux_plugin.dart'; -export 'decision.dart'; -import 'decision.dart'; -export 'errors.dart' - hide - openAimuxLibrary, - aimuxFreeString, - aimuxDropHandle, - withUtf8, - toCString, - toCStringOrNull, - takeHandle, - takeString, - expectAimuxError, - expectRecordingError, - expectFfiError, - checkJson, - checkPortable, - encodeJson, - construct2; - import 'dart:async'; import 'dart:convert'; import 'dart:ffi'; +import 'dart:isolate'; +import 'dart:typed_data'; + import 'package:ffi/ffi.dart'; +import 'generated/ops.dart' as ops; import 'errors.dart'; +import 'src/bridge.dart'; +import 'src/native.dart'; +import 'src/stream_bridge.dart'; -// ───────────────────────────────────────────────────────────────────────────── -// FFI type aliases. `_Err` is `aimux_error_t *` (opaque; NULL = success); -// results come back through the trailing out-param (`uint64_t *out_handle` / -// `char **out_json`). Pointer types are spelled the same in the C and Dart -// signatures, so a single alias serves both. -// ───────────────────────────────────────────────────────────────────────────── - -typedef _Err = Pointer; - -typedef _NewC = _Err Function( - Pointer apiKey, Pointer modelId, Pointer outHandle); -typedef _NewDart = _Err Function( - Pointer apiKey, Pointer modelId, Pointer outHandle); - -// Constructors with a custom base URL (aimux_*_new_with_base). -typedef _NewWithBaseC = _Err Function(Pointer apiKey, - Pointer modelId, Pointer baseUrl, Pointer outHandle); -typedef _NewWithBaseDart = _Err Function(Pointer apiKey, - Pointer modelId, Pointer baseUrl, Pointer outHandle); - -// Registry provider constructor (aimux_provider_new, RFC-0017 phase 4). -typedef _ProviderNewC = _Err Function( - Pointer name, - Pointer? apiKey, - Pointer modelId, - Pointer? configJson, - Pointer outHandle); -typedef _ProviderNewDart = _Err Function( - Pointer name, - Pointer? apiKey, - Pointer modelId, - Pointer? configJson, - Pointer outHandle); - -// Provider handle constructor (aimux_provider_handle_new, RFC-0027). -typedef _ProviderHandleNewC = _Err Function(Pointer name, - Pointer? apiKey, Pointer? configJson, Pointer outHandle); -typedef _ProviderHandleNewDart = _Err Function(Pointer name, - Pointer? apiKey, Pointer? configJson, Pointer outHandle); - -// Provider handle list_models / model (RFC-0027). -typedef _ProviderListModelsC = _Err Function( - Uint64 handle, Pointer> outModelsJson); -typedef _ProviderListModelsDart = _Err Function( - int handle, Pointer> outModelsJson); - -typedef _ProviderModelC = _Err Function( - Uint64 handle, Pointer modelId, Pointer outHandle); -typedef _ProviderModelDart = _Err Function( - int handle, Pointer modelId, Pointer outHandle); - -typedef _GetModelSpecsC = _Err Function( - Pointer? sourceUrl, Pointer> outSpecsJson); -typedef _GetModelSpecsDart = _Err Function( - Pointer? sourceUrl, Pointer> outSpecsJson); - -typedef _GenerateTextC = _Err Function(Uint64 handle, Pointer promptJson, - Pointer? optsJson, Pointer> outJson); -typedef _GenerateTextDart = _Err Function(int handle, Pointer promptJson, - Pointer? optsJson, Pointer> outJson); - -// (handle, result_json, out_json) → error: aimux_generate_text_result_as_openai. -typedef _ResultAsOpenAIC = _Err Function( - Uint64 handle, Pointer resultJson, Pointer> outJson); -typedef _ResultAsOpenAIDart = _Err Function( - int handle, Pointer resultJson, Pointer> outJson); - -typedef _DropHandleC = Void Function(Uint64); -typedef _DropHandleDart = void Function(int); - -// (const char *) → error: aimux_init_logging, aimux_init_recording, -// aimux_init_proxy. -typedef _StrC = _Err Function(Pointer); -typedef _StrDart = _Err Function(Pointer); - -// Recording + mock replay (RFC-0023). -typedef _RecordingRingC = _Err Function(Uint64 cap); -typedef _RecordingRingDart = _Err Function(int cap); -typedef _VoidNoArgC = Void Function(); -typedef _VoidNoArgDart = void Function(); -typedef _RecordingTryFlushC = _Err Function(); -typedef _RecordingTryFlushDart = _Err Function(); -typedef _MockReplayC = _Err Function( - Pointer recordingsJsonl, Pointer outHandle); -typedef _MockReplayDart = _Err Function( - Pointer recordingsJsonl, Pointer outHandle); - -// Composite models (RFC-0021 / RFC-0022). Take a pointer to an array of -// handles + a length, plus an optional JSON config (nullptr = defaults). -typedef _RouterNewC = _Err Function(Pointer handles, IntPtr len, - Pointer configJson, Pointer outHandle); -typedef _RouterNewDart = _Err Function(Pointer handles, int len, - Pointer configJson, Pointer outHandle); -typedef _MoaNewC = _Err Function(Pointer referenceHandles, IntPtr refLen, - Uint64 aggregator, Pointer configJson, Pointer outHandle); -typedef _MoaNewDart = _Err Function(Pointer referenceHandles, int refLen, - int aggregator, Pointer configJson, Pointer outHandle); - -// Multi-arg constructor C signatures (bedrock/vertex/azure). -typedef _FourStrC = _Err Function(Pointer, Pointer, Pointer, - Pointer, Pointer); -typedef _FourStrDart = _Err Function(Pointer, Pointer, - Pointer, Pointer, Pointer); -typedef _FiveStrC = _Err Function(Pointer, Pointer, Pointer, - Pointer, Pointer, Pointer); -typedef _FiveStrDart = _Err Function(Pointer, Pointer, - Pointer, Pointer, Pointer, Pointer); -// Azure: 3 required strings + nullable api_version + out_handle. -typedef _FourStrOptC = _Err Function(Pointer, Pointer, - Pointer, Pointer?, Pointer); -typedef _FourStrOptDart = _Err Function(Pointer, Pointer, - Pointer, Pointer?, Pointer); - -// Stream callbacks: on_part(json, stream_ctx) / on_done(stream_ctx). -// No on_error — a terminal failure returns a non-NULL error (no on_done); -// NULL after on_done is the clean end. -typedef _OnPartC = Void Function(Pointer json, Pointer streamCtx); -typedef _OnDoneC = Void Function(Pointer streamCtx); - -typedef _StreamTextC = _Err Function( - Uint64, - Pointer, - Pointer?, - Pointer>, - Pointer>, - Pointer); -typedef _StreamTextDart = _Err Function( - int, - Pointer, - Pointer?, - Pointer>, - Pointer>, - Pointer); - -// ───────────────────────────────────────────────────────────────────────────── -// FFI library wrapper (process-wide singleton — dlopen + symbol lookups -// happen once, lazily per symbol) -// ───────────────────────────────────────────────────────────────────────────── - -final class _AimuxFFI { - final DynamicLibrary _lib = openAimuxLibrary(); - - late final openaiNew = - _lib.lookupFunction<_NewC, _NewDart>('aimux_openai_new'); - late final anthropicNew = - _lib.lookupFunction<_NewC, _NewDart>('aimux_anthropic_new'); - late final openaiNewWithBase = _lib - .lookupFunction<_NewWithBaseC, _NewWithBaseDart>( - 'aimux_openai_new_with_base'); - late final anthropicNewWithBase = _lib - .lookupFunction<_NewWithBaseC, _NewWithBaseDart>( - 'aimux_anthropic_new_with_base'); - late final cohereNew = - _lib.lookupFunction<_NewC, _NewDart>('aimux_cohere_new'); - late final cohereNewWithBase = _lib - .lookupFunction<_NewWithBaseC, _NewWithBaseDart>( - 'aimux_cohere_new_with_base'); - late final mistralNew = - _lib.lookupFunction<_NewC, _NewDart>('aimux_mistral_new'); - late final mistralNewWithBase = _lib - .lookupFunction<_NewWithBaseC, _NewWithBaseDart>( - 'aimux_mistral_new_with_base'); - late final xaiNew = _lib.lookupFunction<_NewC, _NewDart>('aimux_xai_new'); - late final xaiNewWithBase = _lib - .lookupFunction<_NewWithBaseC, _NewWithBaseDart>( - 'aimux_xai_new_with_base'); - late final bedrockNew = - _lib.lookupFunction<_FourStrC, _FourStrDart>('aimux_bedrock_new'); - late final bedrockNewWithBase = _lib - .lookupFunction<_FiveStrC, _FiveStrDart>('aimux_bedrock_new_with_base'); - late final vertexNew = - _lib.lookupFunction<_FourStrC, _FourStrDart>('aimux_vertex_new'); - late final vertexNewWithBase = _lib - .lookupFunction<_FiveStrC, _FiveStrDart>('aimux_vertex_new_with_base'); - late final anthropicAwsNew = _lib - .lookupFunction<_NewWithBaseC, _NewWithBaseDart>( - 'aimux_anthropic_aws_new'); - late final anthropicAwsNewWithBase = _lib - .lookupFunction<_FourStrC, _FourStrDart>( - 'aimux_anthropic_aws_new_with_base'); - late final azureNew = - _lib.lookupFunction<_FourStrOptC, _FourStrOptDart>('aimux_azure_new'); - late final azureNewWithBase = _lib - .lookupFunction<_FourStrOptC, _FourStrOptDart>( - 'aimux_azure_new_with_base'); - late final providerNew = _lib - .lookupFunction<_ProviderNewC, _ProviderNewDart>('aimux_provider_new'); - late final providerHandleNew = _lib - .lookupFunction<_ProviderHandleNewC, _ProviderHandleNewDart>( - 'aimux_provider_handle_new'); - late final providerListModels = _lib - .lookupFunction<_ProviderListModelsC, _ProviderListModelsDart>( - 'aimux_provider_list_models'); - late final providerModel = _lib - .lookupFunction<_ProviderModelC, _ProviderModelDart>( - 'aimux_provider_model'); - late final getModelSpecs = _lib - .lookupFunction<_GetModelSpecsC, _GetModelSpecsDart>( - 'aimux_get_model_specs'); - late final generateText = _lib - .lookupFunction<_GenerateTextC, _GenerateTextDart>('aimux_generate_text'); - late final generateObject = _lib - .lookupFunction<_GenerateTextC, _GenerateTextDart>('aimux_generate_object'); - late final consumeStreamText = _lib.lookupFunction<_GenerateTextC, _GenerateTextDart>( - 'aimux_consume_stream_text'); - late final streamText = - _lib.lookupFunction<_StreamTextC, _StreamTextDart>('aimux_stream_text'); - late final generateTextAsOpenAI = _lib - .lookupFunction<_GenerateTextC, _GenerateTextDart>( - 'aimux_generate_text_as_openai'); - late final generateTextResultAsOpenAI = _lib - .lookupFunction<_ResultAsOpenAIC, _ResultAsOpenAIDart>( - 'aimux_generate_text_result_as_openai'); - late final streamTextAsOpenAI = _lib - .lookupFunction<_StreamTextC, _StreamTextDart>( - 'aimux_stream_text_as_openai'); - late final dropHandle = - _lib.lookupFunction<_DropHandleC, _DropHandleDart>('aimux_drop_handle'); - // NativeFinalizer callback pointer: aimux_drop_handle_ptr, declared - // `void aimux_drop_handle_ptr(void *)` — exactly NativeFinalizerFunction - // (Void Function(Pointer)). The handle value is attached as the token - // (its address == the handle), and the finalizer releases the handle when - // a Model/ProviderHandle is GC'd without close(). aimux_drop_handle takes a - // uint64_t and must not be called through this signature: on 32-bit ARM - // (armeabi-v7a) a uint64_t argument does not travel like a pointer, so it - // would receive a garbage high word and leak the handle. - // NativeFinalizerFunction is already a NativeFunction<…>, so it is looked - // up directly (no extra NativeFunction wrapper). - late final dropHandlePtr = - _lib.lookup('aimux_drop_handle_ptr'); - late final initLogging = - _lib.lookupFunction<_StrC, _StrDart>('aimux_init_logging'); - late final initRecording = - _lib.lookupFunction<_StrC, _StrDart>('aimux_init_recording'); - late final initRecordingRing = _lib - .lookupFunction<_RecordingRingC, _RecordingRingDart>( - 'aimux_init_recording_ring'); - late final initRecordingRingDefault = _lib - .lookupFunction<_VoidNoArgC, _VoidNoArgDart>( - 'aimux_init_recording_ring_default'); - late final recordingStop = _lib - .lookupFunction<_VoidNoArgC, _VoidNoArgDart>('aimux_recording_stop'); - late final recordingFlush = _lib - .lookupFunction<_VoidNoArgC, _VoidNoArgDart>('aimux_recording_flush'); - late final recordingTryFlush = _lib - .lookupFunction<_RecordingTryFlushC, _RecordingTryFlushDart>( - 'aimux_recording_try_flush'); - late final mockReplayNew = _lib - .lookupFunction<_MockReplayC, _MockReplayDart>('aimux_mock_replay_new'); - late final initProxy = - _lib.lookupFunction<_StrC, _StrDart>('aimux_init_proxy'); - late final routerNew = _lib - .lookupFunction<_RouterNewC, _RouterNewDart>('aimux_router_new'); - late final moaNew = - _lib.lookupFunction<_MoaNewC, _MoaNewDart>('aimux_moa_new'); -} - -/// Process-wide FFI table. Lazily created on first use; shared by every -/// [Model] / [ProviderHandle] and the top-level functions. -final _AimuxFFI _ffi = _AimuxFFI(); - -/// Process-wide native finalizer that releases a handle when a [Model] or -/// [ProviderHandle] is garbage-collected without [Model.close] / -/// [ProviderHandle.close] (T9). The token attached is the handle value -/// encoded as a `Pointer` (its address == the handle), which the -/// `aimux_drop_handle_ptr` native callback takes as a pointer-sized value. -final NativeFinalizer _handleFinalizer = NativeFinalizer(_ffi.dropHandlePtr); - -// ───────────────────────────────────────────────────────────────────────────── -// Stream callback trampolines (global — safe because aimux_stream_text -// is synchronous, blocks the caller until done). -// ───────────────────────────────────────────────────────────────────────────── - -StreamController>? _currentController; - -void _onPart(Pointer jsonPtr, Pointer streamCtx) { - final controller = _currentController; - if (controller == null || jsonPtr == nullptr) return; - // Never let an exception escape into the C ABI (undefined behavior); - // surface decode failures on the stream instead. - try { - controller.add( - jsonDecode(jsonPtr.toDartString()) as Map); - } catch (e, st) { - controller.addError(e, st); - } -} - -void _onDone(Pointer streamCtx) { - _currentController?.close(); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Constructor helpers -// ───────────────────────────────────────────────────────────────────────────── - -/// Shared constructor for 4-string providers (bedrock/vertex). -int _construct4( - String a, - String b, - String c, - String modelId, - String? baseUrl, - _FourStrDart plain, - _FiveStrDart withBase, -) { - return withUtf8(a, (aPtr) { - return withUtf8(b, (bPtr) { - return withUtf8(c, (cPtr) { - return withUtf8(modelId, (idPtr) { - if (baseUrl == null) { - return takeHandle( - (out) => plain(aPtr, bPtr, cPtr, idPtr, out), 'new'); - } - return withUtf8(baseUrl, (basePtr) { - return takeHandle( - (out) => withBase(aPtr, bPtr, cPtr, idPtr, basePtr, out), - 'new_with_base'); - }); - }); - }); - }); - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Model -// ───────────────────────────────────────────────────────────────────────────── - -/// A model instance backed by a Rust `Arc`. -/// -/// Call [close] to release the native handle. AiMuxError values throw -/// [AimuxException] (or a subclass); using a closed handle throws -/// [StateError]. -/// Implements [Finalizable] so a [NativeFinalizer] can release the handle if -/// [close] is forgotten (T9). -class Model implements Finalizable { - final int _handle; - bool _closed = false; - - Model._(this._handle) { - // Register a native finalizer so the handle is released even if close() - // is never called. The token is the handle value as a Pointer. - _handleFinalizer.attach(this, Pointer.fromAddress(_handle)); - } - - /// Handle read for composite-model factories (router/moa). Throws if closed. - int _requireHandle() { - if (_closed) throw StateError('Model is closed'); - return _handle; - } - - /// Create an OpenAI model instance. - /// - /// Pass [baseUrl] to target an OpenAI-compatible endpoint (Azure, Groq, - /// a local mock server, etc.). When null, the provider's standard URL is - /// used via `aimux_openai_new`. - factory Model.openai(String apiKey, String modelId, {String? baseUrl}) => - Model._(construct2( - apiKey, modelId, baseUrl, _ffi.openaiNew, _ffi.openaiNewWithBase)); - - /// Create an Anthropic model instance. - /// - /// Pass [baseUrl] to target a custom Anthropic-compatible endpoint. When - /// null, the provider's standard URL is used via `aimux_anthropic_new`. - factory Model.anthropic(String apiKey, String modelId, {String? baseUrl}) => - Model._(construct2(apiKey, modelId, baseUrl, _ffi.anthropicNew, - _ffi.anthropicNewWithBase)); - - /// Create a Cohere model instance. - factory Model.cohere(String apiKey, String modelId, {String? baseUrl}) => - Model._(construct2( - apiKey, modelId, baseUrl, _ffi.cohereNew, _ffi.cohereNewWithBase)); - - /// Create a Mistral model instance. - factory Model.mistral(String apiKey, String modelId, {String? baseUrl}) => - Model._(construct2( - apiKey, modelId, baseUrl, _ffi.mistralNew, _ffi.mistralNewWithBase)); - - /// Create an xAI model instance. - factory Model.xai(String apiKey, String modelId, {String? baseUrl}) => - Model._(construct2( - apiKey, modelId, baseUrl, _ffi.xaiNew, _ffi.xaiNewWithBase)); - - /// Create a Bedrock model instance (AWS SigV4 credentials). - factory Model.bedrock( - String accessKeyId, String secretAccessKey, String region, - String modelId, - {String? baseUrl}) => - Model._(_construct4(accessKeyId, secretAccessKey, region, modelId, - baseUrl, _ffi.bedrockNew, _ffi.bedrockNewWithBase)); - - /// Create a Vertex AI model instance (GCP bearer token). - factory Model.vertex( - String accessToken, String project, String location, String modelId, - {String? baseUrl}) => - Model._(_construct4(accessToken, project, location, modelId, baseUrl, - _ffi.vertexNew, _ffi.vertexNewWithBase)); - - /// Create an Anthropic-on-AWS model instance (API key + region). - factory Model.anthropicAws(String apiKey, String region, String modelId, - {String? baseUrl}) { - final handle = withUtf8(apiKey, (keyPtr) { - return withUtf8(region, (rPtr) { - return withUtf8(modelId, (idPtr) { - if (baseUrl == null) { - return takeHandle( - (out) => _ffi.anthropicAwsNew(keyPtr, rPtr, idPtr, out), - 'anthropic_aws_new'); - } - return withUtf8(baseUrl, (basePtr) { - return takeHandle( - (out) => _ffi.anthropicAwsNewWithBase( - keyPtr, rPtr, idPtr, basePtr, out), - 'anthropic_aws_new_with_base'); - }); - }); - }); - }); - return Model._(handle); - } - - /// Create an Azure OpenAI model instance (API key + resource name). - /// The deployment name is passed as [deployment]; [apiVersion] is optional. - factory Model.azure(String apiKey, String resourceName, String deployment, - {String? apiVersion, String? baseUrl}) { - final handle = withUtf8(apiKey, (keyPtr) { - return withUtf8(deployment, (dPtr) { - final vPtr = toCStringOrNull(apiVersion); - try { - if (baseUrl == null) { - return withUtf8(resourceName, (rPtr) { - return takeHandle( - (out) => _ffi.azureNew(keyPtr, rPtr, dPtr, vPtr, out), - 'azure_new'); - }); - } - return withUtf8(baseUrl, (bPtr) { - return takeHandle( - (out) => _ffi.azureNewWithBase(keyPtr, bPtr, dPtr, vPtr, out), - 'azure_new_with_base'); - }); - } finally { - if (vPtr != nullptr) calloc.free(vPtr); - } - }); - }); - return Model._(handle); +export 'aimux_plugin.dart'; +export 'errors.dart' show OpsException; + +/// One version-checked C ABI library. All blocking work runs in worker isolates. +final class Aimux { + final String? _libraryPath, _bridgePath; + final Map manifest; + final NativeApi _native; + final DartBridge _bridge; + final Map> _handles = {}; + + Aimux._(this._libraryPath, this._bridgePath, this.manifest) + : _native = NativeApi(_libraryPath), + _bridge = DartBridge(_bridgePath); + + static Future open({ + String? libraryPath, + String? bridgePath, + Iterable requiredOps = const [], + }) async { + final manifest = await execute( + NativeRequest(libraryPath, 'load'), + ) as Map; + final names = + (manifest['ops'] as List).map((op) => (op as Map)['name']).toSet(); + final missing = requiredOps.where((op) => !names.contains(op)).toList(); + if (missing.isNotEmpty) + throw StateError('Missing required aimux ops: $missing'); + final client = Aimux._(libraryPath, bridgePath, manifest); + // Resolve the bridge entry points at load, rather than at first use. + client._bridge.finalizer; + client._bridge.tokenNew; + client._bridge.tokenDispose; + client._bridge.create; + client._bridge.ack; + client._bridge.cancel; + client._bridge.free; + client._bridge.frameFree; + client._bridge.pending; + client._bridge.failure; + client._bridge.event; + return client; } - /// Create a model from the provider registry by name (RFC-0017 phase 4). - /// - /// [apiKey] may be null to read the provider's env var from the registry - /// entry. [configJson] is an optional JSON object of ProviderOptions - /// (`{"baseUrl": "...", "headers": {...}, "organization": "...", - /// "project": "...", "params": {...}}`). Any other key (`maxRetries` is a - /// per-call option, `bodyOverrides` is gone) is rejected as - /// [InvalidArgumentError]. - factory Model.provider(String name, String modelId, - {String? apiKey, String? configJson}) { - checkJson(configJson, 'config_json', emptyIsDefault: true); - final handle = withUtf8(name, (namePtr) { - return withUtf8(modelId, (idPtr) { - final keyPtr = toCStringOrNull(apiKey); - final cfgPtr = toCStringOrNull(configJson); - try { - return takeHandle( - (out) => _ffi.providerNew(namePtr, keyPtr, idPtr, cfgPtr, out), - 'provider_new'); - } finally { - if (keyPtr != nullptr) calloc.free(keyPtr); - if (cfgPtr != nullptr) calloc.free(cfgPtr); - } - }); - }); - return Model._(handle); + /// Transfers ownership of a decimal handle returned by an op to Dart. + /// Duplicate live wrappers are rejected; close releases the native resource. + OpsHandle adopt(String wireHandle) { + _handles.removeWhere((_, reference) => reference.target == null); + final id = parseHandle(wireHandle); + if (_handles[id]?.target case final handle? when !handle.isClosed) { + throw StateError('Handle already has a live owner'); + } + final handle = OpsHandle._(this, id); + _handles[id] = WeakReference(handle); + return handle; } - /// Generate text (non-streaming). - /// - /// [prompt] — a string or a list of message maps. - /// [options] — optional generation options map. - /// Returns the parsed GenerateTextResult as a Map. - /// Throws [AimuxException] on AiMuxError failure. - Map generateText( - Object prompt, [ - Map? options, - ]) => - _callJson(_ffi.generateText, 'generate_text', prompt, options); - - /// Generate a structured JSON object (M12, RFC-0016). - /// - /// Same signature as [generateText]; returns the parsed - /// GenerateObjectResult as a Map. Pass `'output': {'type': 'object', 'schema': ...}` - /// via [options] to describe the object; the model's text must parse as - /// JSON and match the schema. - /// - /// [prompt] — a string or a list of message maps. - /// [options] — optional generation options map. - /// Throws [AimuxException] on AiMuxError failure. - Map generateObject( - Object prompt, [ - Map? options, - ]) => - _callJson(_ffi.generateObject, 'generate_object', prompt, options); - - /// Consume a stream to completion and return the aggregated result - /// (M11, RFC-0016). Synchronous (blocks until the stream finishes). - /// - /// Same signature as [generateText]; returns the parsed - /// GenerateTextResult as a Map. - /// - /// [prompt] — a string or a list of message maps. - /// [options] — optional generation options map. - /// Throws [AimuxException] on AiMuxError failure. - Map consumeStreamText( - Object prompt, [ - Map? options, - ]) => - _callJson( - _ffi.consumeStreamText, 'consume_stream_text', prompt, options); - - /// Generate text (non-streaming) with OpenAI Chat Completions output - /// (RFC-0026). - /// - /// Same as [generateText], but returns a parsed ChatCompletion map (OpenAI - /// `chat.completion` object). Works with any provider. - Map generateTextAsOpenAI( - Object prompt, [ - Map? options, - ]) => - _callJson(_ffi.generateTextAsOpenAI, 'generate_text_as_openai', prompt, - options); - - /// Convert a GenerateTextResult map into a ChatCompletion map — the - /// conversion half of [generateTextAsOpenAI]. - /// - /// For repairing on the host (RFC-0035): patch the native result, then - /// convert it, so the completion's tool calls are the repaired ones. This - /// model only supplies the fallback model id; nothing is generated. - Map generateTextResultAsOpenAI(Map result) { - _checkOpen(); - final resultJson = encodeJson(result, 'result'); - final completion = withUtf8( - resultJson, - (resultPtr) => takeString( - (out) => _ffi.generateTextResultAsOpenAI(_handle, resultPtr, out), - 'generate_text_result_as_openai')); - return jsonDecode(completion) as Map; + Future call( + String op, { + OpsHandle? handle, + Object? params = const {}, + OpsHandle? abort, + }) { + checkString(op); + return execute( + NativeRequest( + _libraryPath, + 'call', + handle: _id(handle), + abort: _id(abort), + op: op, + params: encodeParams(params), + ), + ); } - /// Shared body of the four `(handle, prompt_json, opts_json, out_json)` - /// calls: encode, call, [expectAimuxError] (via [takeString]), decode. - Map _callJson( - _GenerateTextDart fn, - String context, - Object prompt, - Map? options, - ) { - _checkOpen(); - final promptJson = promptToJson(prompt); - final optsJson = options != null ? encodeJson(options, 'options') : null; - - final resultStr = withUtf8(promptJson, (promptPtr) { - final optsPtr = toCStringOrNull(optsJson); - try { - return takeString( - (out) => fn(_handle, promptPtr, optsPtr, out), context); - } finally { - if (optsPtr != nullptr) calloc.free(optsPtr); - } - }); - - return jsonDecode(resultStr) as Map; + /// Raw frames include exactly one terminal after acceptance. A rejected start + /// is a stream error; terminal failures remain in EndFrame.error, once. + Stream stream( + String op, { + required OpsHandle handle, + Object? params = const {}, + }) { + checkString(op); + return _stream(this, _id(handle), op, encodeParams(params)); } - /// Stream text from the model. - /// - /// Returns a Stream of StreamPart maps. Recoverable frame errors arrive - /// as `error` stream-part maps and the stream continues; only - /// transport/Core failures throw [AimuxException] via [Stream.addError] - /// (and do not call on_done). - /// - /// NOTE: the underlying `aimux_stream_text` call is synchronous — this - /// method blocks the calling isolate until the stream completes, so all - /// parts are effectively buffered and delivered only after completion. Run - /// it in a worker isolate for UI code. Making this truly incremental is - /// follow-up work. - Stream> streamText( - Object prompt, [ - Map? options, - ]) { - _checkOpen(); - return _stream(_ffi.streamText, 'stream_text', prompt, options); - } + /// Nonblocking native signal; independent of stream credit and worker queues. + void abort(OpsHandle handle) => _native.ffi.abort(_id(handle)); - /// Stream text from the model with OpenAI Chat Completions output - /// (RFC-0026). - /// - /// Returns a Stream of ChatCompletionChunk maps (OpenAI - /// `chat.completion.chunk` objects). Stream options (`include_usage`, - /// `include_reasoning`) are passed via - /// `options['providerOptions']['openai']['stream_options']`. - /// - /// NOTE: like [streamText], this blocks the calling isolate until the - /// stream completes and effectively buffers all parts until then. - Stream> streamTextAsOpenAI( - Object prompt, [ - Map? options, - ]) { - _checkOpen(); - return _stream( - _ffi.streamTextAsOpenAI, 'stream_text_as_openai', prompt, options); + Future push(OpsHandle operation, Uint8List bytes) { + final id = _id(operation); + if (bytes.length > 1024 * 1024) { + throw resourceLimit('Audio chunk exceeds 1 MiB'); + } + return execute( + NativeRequest( + _libraryPath, + 'push', + handle: id, + bytes: Uint8List.fromList(bytes), + ), + ).then((_) {}); } - Stream> _stream( - _StreamTextDart streamFn, - String context, - Object prompt, - Map? options, - ) { - final promptJson = promptToJson(prompt); - final optsJson = options != null ? encodeJson(options, 'options') : null; - - final controller = StreamController>(); - - withUtf8(promptJson, (promptPtr) { - final optsPtr = toCStringOrNull(optsJson); - try { - final partCb = Pointer.fromFunction<_OnPartC>(_onPart); - final doneCb = Pointer.fromFunction<_OnDoneC>(_onDone); - - final Pointer e; - _currentController = controller; - try { - e = streamFn(_handle, promptPtr, optsPtr, partCb, doneCb, nullptr); - } finally { - _currentController = null; - } - - // NULL after on_done = clean end. Non-NULL = terminal failure, on_done - // was not called: expectAimuxError throws AimuxException or the native - // StateError for a C ABI failure; either way the error must not be - // swallowed and the controller must not stay open. - try { - expectAimuxError(e, context); - } catch (failure) { - if (controller.isClosed) rethrow; - controller - ..addError(failure) - ..close(); - } - } finally { - if (optsPtr != nullptr) calloc.free(optsPtr); - } + Future next(OpsHandle operation, {int timeoutMs = 0}) { + if (timeoutMs < 0) throw ArgumentError.value(timeoutMs, 'timeoutMs'); + return execute( + NativeRequest( + _libraryPath, + 'poll', + handle: _id(operation), + timeout: timeoutMs, + ), + ).then((value) { + final json = value as Map; + return PollResult._(json['state'] as String, json['part']); }); - - return controller.stream; } - - /// Release the native handle. Safe to call multiple times. - /// - /// Detaches the native finalizer first so the GC cannot double-free the - /// handle after an explicit close. - void close() { - if (_closed) return; - _closed = true; - _handleFinalizer.detach(this); - _ffi.dropHandle(_handle); - } - - void _checkOpen() { - if (_closed) throw StateError('Model is closed'); + int _id(OpsHandle? handle) { + if (handle == null) return 0; + if (!identical(handle._client, this)) { + throw ArgumentError('Handle belongs to a different Aimux instance'); + } + if (handle.isClosed) throw StateError('Handle is closed'); + return handle._id; } } -// ───────────────────────────────────────────────────────────────────────────── -// ProviderConfig (RFC-0027) -// ───────────────────────────────────────────────────────────────────────────── - -/// Per-provider construction options (RFC-0027), mirroring the Rust -/// `ProviderOptions` wire format. Pass to [createProvider] to override -/// individual fields of the registry entry. -/// -/// All fields are optional; null fields are omitted from the serialized JSON -/// so the provider's defaults apply. -class ProviderConfig { - /// Override the registry base URL. - final String? baseUrl; - - /// Extra headers merged into every request. - final Map? headers; - - /// OpenAI organization ID (`OpenAI-Organization` header). - final String? organization; - - /// OpenAI project ID (`OpenAI-Project` header). - final String? project; - - /// Values of a preset's template parameters (`account_id`, `region`, ...); - /// only the ones the preset declares are accepted. - /// - /// Retry is a per-call setting ([GenerateTextOptions.maxRetries]) and - /// request-body overrides no longer exist: a `maxRetries` or - /// `bodyOverrides` key in the config is rejected as [InvalidArgumentError], - /// so this class has neither. - final Map? params; - - const ProviderConfig({ - this.baseUrl, - this.headers, - this.organization, - this.project, - this.params, - }); - - /// Serialize to the `config_json` wire format (camelCase keys, null fields - /// omitted). Returns null when no field is set, so callers can - /// pass null to the FFI for defaults. - String? toJson() { - final map = {}; - if (baseUrl != null) map['baseUrl'] = baseUrl; - if (headers != null) map['headers'] = headers; - if (organization != null) map['organization'] = organization; - if (project != null) map['project'] = project; - if (params != null) map['params'] = params; - if (map.isEmpty) return null; - return encodeJson(map, 'config_json'); +/// NativeFinalizer uses an allocated token, preserving all 64 bits on 32-bit +/// targets. Explicit close takes ownership and detaches before scheduling drop. +final class OpsHandle implements Finalizable { + final Aimux _client; + final Object _detachKey = Object(); + late final Pointer _token; + int _id; + Future? _closing; + + OpsHandle._(this._client, this._id) { + _token = _client._bridge.tokenNew( + _id, + _client._native.ffi.library.lookup>( + 'aimux_drop', + ), + ); + if (_token == nullptr) + throw StateError('Could not allocate finalizer token'); + _client._bridge.finalizer.attach(this, _token, detach: _detachKey); } -} - -// ───────────────────────────────────────────────────────────────────────────── -// ProviderHandle (RFC-0027) -// ───────────────────────────────────────────────────────────────────────────── - -/// A provider handle (RFC-0027) backed by a Rust `Arc`. -/// -/// Created by [createProvider]. Unlike [Model.provider] (which binds to a -/// single model id), a provider handle supports runtime model discovery via -/// [listModels] and building a [Model] from a discovered id via [model]. -/// Call [close] to release the native handle. Implements [Finalizable] so a -/// [NativeFinalizer] can release the handle if [close] is forgotten (T9). -/// -/// ```dart -/// final p = createProvider('deepseek', apiKey); -/// try { -/// final models = p.listModels(); -/// final model = p.model('deepseek-chat'); -/// try { -/// print(model.generateText('Hello')); -/// } finally { -/// model.close(); -/// } -/// } finally { -/// p.close(); -/// } -/// ``` -class ProviderHandle implements Finalizable { - final int _handle; - bool _closed = false; - ProviderHandle._(this._handle) { - // Register a native finalizer so the handle is released even if close() - // is never called. The token is the handle value as a Pointer. - _handleFinalizer.attach(this, Pointer.fromAddress(_handle)); + bool get isClosed => _id == 0; + String get wireId { + if (isClosed) throw StateError('Handle is closed'); + return BigInt.from(_id).toUnsigned(64).toString(); } - /// List models available on this provider (runtime discovery via the - /// provider's `/models` endpoint), enriched with community knowledge - /// (anya2a) when available. - /// - /// Returns a JSON array string of `ResolvedModel` - /// (`[{"id":"...","ownedBy":"...","created":,"spec":{...}}, ...]`). - /// Synchronous: blocks the calling isolate until the network call completes. - String listModels() { - _checkOpen(); - return takeString( - (out) => _ffi.providerListModels(_handle, out), 'provider_list_models'); - } - - /// Build a native decision model with this provider's connection configuration. - DecisionModel decisionModel(String modelId) { - _checkOpen(); - return DecisionModel.fromProviderHandle(_handle, modelId); - } - - /// Build a language [Model] from a discovered [modelId]. - /// - /// The returned [Model] is backed by a fresh native handle (separate from - /// this provider handle) and is usable with `generateText` / `streamText`. - /// The caller owns the returned [Model] and must [Model.close] it. - Model model(String modelId) { - _checkOpen(); - final handle = withUtf8(modelId, (idPtr) { - return takeHandle( - (out) => _ffi.providerModel(_handle, idPtr, out), 'provider_model'); - }); - return Model._(handle); - } - - /// Release the native provider handle. Safe to call multiple times. - /// - /// Detaches the native finalizer first so the GC cannot double-free the - /// handle after an explicit close. - void close() { - if (_closed) return; - _closed = true; - _handleFinalizer.detach(this); - _ffi.dropHandle(_handle); - } - - void _checkOpen() { - if (_closed) throw StateError('ProviderHandle is closed'); + Future close() { + if (_closing != null) return _closing!; + final id = _id; + _id = 0; + _client._handles.remove(id); + _client._bridge.finalizer.detach(_detachKey); + _client._bridge.tokenDispose(_token); + _closing = execute(NativeRequest(_client._libraryPath, 'drop', handle: id)) + .then((_) {}); + return _closing!; } } -/// Create a **provider handle** (RFC-0027) for a registry-backed provider. -/// -/// Unlike [Model.provider] (which binds to a single `modelId`), this returns a -/// [ProviderHandle] supporting runtime model discovery -/// ([ProviderHandle.listModels]) and building a [Model] from a discovered id -/// ([ProviderHandle.model]). -/// -/// [name] is a registry provider name (e.g. `"deepseek"`, `"groq"`). [apiKey] -/// may be null to read the provider's env var from the registry entry. -/// [config] overrides individual fields of the registry entry. -ProviderHandle createProvider( - String name, String? apiKey, ProviderConfig? config) { - final configJson = config?.toJson(); - final handle = withUtf8(name, (namePtr) { - final keyPtr = toCStringOrNull(apiKey); - final cfgPtr = toCStringOrNull(configJson); - try { - return takeHandle( - (out) => _ffi.providerHandleNew(namePtr, keyPtr, cfgPtr, out), - 'provider_handle_new'); - } finally { - if (keyPtr != nullptr) calloc.free(keyPtr); - if (cfgPtr != nullptr) calloc.free(cfgPtr); - } - }); - return ProviderHandle._(handle); +final class PollResult { + final String state; + final Object? part; + const PollResult._(this.state, this.part); } -// ───────────────────────────────────────────────────────────────────────────── -// Model specs (RFC-0027) — get_model_specs -// ───────────────────────────────────────────────────────────────────────────── - -/// Fetch the community model catalogue (anya2a). Returns a JSON-serialized -/// Catalogue string. Thin fetch — no caching. -String getModelSpecs(String? sourceUrl) { - final urlPtr = toCStringOrNull(sourceUrl); - try { - return takeString( - (out) => _ffi.getModelSpecs(urlPtr, out), 'get_model_specs'); - } finally { - if (urlPtr != nullptr) calloc.free(urlPtr); +sealed class StreamFrame { + final String seq; + const StreamFrame(this.seq); + factory StreamFrame.fromJson(Map json) { + final seq = json['seq']; + if (seq is! String || + seq.length > 20 || + !RegExp(r'^(0|[1-9][0-9]*)$').hasMatch(seq) || + BigInt.parse(seq) > (BigInt.one << 64) - BigInt.one) { + throw const FormatException('Invalid stream sequence'); + } + switch (json['type']) { + case 'event': + if (!json.containsKey('event')) + throw const FormatException('Missing event'); + return EventFrame(seq, json['event']); + case 'end': + final outcome = json['outcome']; + if (!const ['success', 'error', 'cancelled'].contains(outcome)) { + throw const FormatException('Invalid stream outcome'); + } + final error = json['error'] == null + ? null + : OpsException.fromJson(json['error'] as Map); + if (outcome == 'error' && error == null || + outcome == 'success' && error != null) { + throw const FormatException('Invalid terminal error'); + } + return EndFrame(seq, outcome as String, error); + default: + throw const FormatException('Unknown stream frame'); + } } } -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -/// The `prompt_json` string every generate/stream call sends: a bare string -/// prompt is encoded as-is, anything else goes inside the `{"prompt": …}` -/// wrapper the C ABI accepts. Public because tool-call repair must pass the -/// same string the call was generated with (RFC-0035 §3.0). -String promptToJson(Object prompt) { - if (prompt is String) return encodeJson(prompt, 'prompt'); - return encodeJson({'prompt': prompt}, 'prompt'); -} - -/// Initialize the global logger (RFC-0014). -/// -/// Idempotent — safe to call any number of times from any thread; only the -/// first call has an effect. If the host already registered its own -/// `tracing` subscriber, this is a no-op (aimux never overrides a consumer's -/// logger). -/// -/// [level]: "off" | "error" | "warn" | "info" | "debug" | "trace"; empty -/// defaults to "warn". The `AIMUX_LOG` / `AIMUX_LOG_LEVEL` environment -/// variables take precedence when set. Logs go to stderr. -void initLogging(String level) { - withUtf8(level.isEmpty ? 'warn' : level, - (p) => expectFfiError(_ffi.initLogging(p), 'init_logging')); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Recording + mock replay (RFC-0023) -// -// Recording is opt-in and global (not tied to a Model instance). -// ───────────────────────────────────────────────────────────────────────────── - -/// Start recording: complete Recording JSONL is written to -/// `{dir}/recordings.jsonl` (dir is auto-created). Calling again replaces the -/// recorder. Throws [RecordingException] with [RecordingException.code] one of -/// [RecordingErrorCode.init] (dir could not be created, also for an empty -/// [dir]), [RecordingErrorCode.openFile] or [RecordingErrorCode.spawn] when -/// the recorder cannot be constructed; on failure the previous recorder (if -/// any) stays in place. -void initRecording(String dir) { - withUtf8(dir, (p) => expectRecordingError(_ffi.initRecording(p), 'init_recording')); -} - -/// Start in-memory bounded recording (ring recorder, FIFO eviction, dropped -/// count queryable). -/// -/// [cap] is optional: omit it (or pass null) to use the library default -/// capacity (FFI `aimux_init_recording_ring_default`). When provided, [cap] -/// must be positive: the C ABI rejects `0`, and a negative Dart int would be -/// reinterpreted as a huge u64 by the FFI. This binding validates up front and -/// throws [ArgumentError] for `cap <= 0`, matching Kotlin/Java. -void initRecordingRing([int? cap]) { - if (cap == null) { - _ffi.initRecordingRingDefault(); - return; - } - if (cap <= 0) { - throw ArgumentError.value(cap, 'cap', 'must be positive'); - } - expectAimuxError(_ffi.initRecordingRing(cap), 'init_recording_ring'); +final class EventFrame extends StreamFrame { + final Object? event; + const EventFrame(super.seq, this.event); } -/// Stop recording: the global recorder becomes None. -void recordingStop() => _ffi.recordingStop(); - -/// Flush the global recorder (blocks until JSONL is on disk; no-op for the -/// ring recorder). Never reports — see [recordingTryFlush]. -void recordingFlush() => _ffi.recordingFlush(); - -/// Flush the global recorder and report failure: throws [RecordingException] -/// with [RecordingException.code] one of [RecordingErrorCode.writerGone], -/// [RecordingErrorCode.flushTimeout] or [RecordingErrorCode.write] (not an -/// [AimuxException] — recording errors are a separate type). Returns normally -/// when data is on disk or nothing is recording. The legacy [recordingFlush] -/// stays and never reports. -void recordingTryFlush() { - expectRecordingError(_ffi.recordingTryFlush(), 'recording_try_flush'); +final class EndFrame extends StreamFrame { + final String outcome; + final OpsException? error; + const EndFrame(super.seq, this.outcome, this.error); } -/// Create a mock replay model from recorded JSONL (one `Recording` per line). -/// -/// Matches recorded inputs and returns the recorded responses — no real API -/// is sent. The returned [Model] works with [Model.generateText] / -/// [Model.streamText]. Call [Model.close] to release the native handle. -Model mockReplay(String recordingsJsonl) { - // Mirror the FFI: blank lines are skipped, every other line is one JSON doc. - for (final line in LineSplitter.split(recordingsJsonl)) { - if (line.trim().isNotEmpty) checkJson(line, 'recordings_jsonl'); - } - final handle = withUtf8(recordingsJsonl, (jsonlPtr) { - return takeHandle( - (out) => _ffi.mockReplayNew(jsonlPtr, out), 'mock_replay_new'); - }); - return Model._(handle); +final class _StreamRequest { + final String? libraryPath, bridgePath; + final int handle, abort; + final String op, params; + const _StreamRequest( + this.libraryPath, + this.bridgePath, + this.handle, + this.abort, + this.op, + this.params, + ); } -/// Replace the default transport with one routed by this proxy configuration -/// (M6, RFC-0016). Requests started afterwards use it; calling it again -/// replaces the previous configuration. -/// -/// `configJSON` shape: `{ "http_url", "https_url", "all_url", "no_proxy" }` -/// (all fields optional). Wrong shape throws [InvalidArgumentError]. -void initProxy(String configJSON) { - checkJson(configJSON, 'config_json'); - withUtf8(configJSON, (p) => expectAimuxError(_ffi.initProxy(p), 'init_proxy')); -} +Future _runStream(_StreamRequest request, int bridgeAddress) => + Isolate.run(() { + final api = NativeApi(request.libraryPath); + final bridge = DartBridge(request.bridgePath); + final op = request.op.toNativeUtf8(); + final params = request.params.toNativeUtf8(); + try { + api.checkError( + api.ffi.stream( + request.handle, + op, + params, + request.abort, + bridge.event, + Pointer.fromAddress(bridgeAddress), + ), + ); + } finally { + calloc.free(op); + calloc.free(params); + } + }); -/// Create a RouterModel (RFC-0021) over the given child models. The returned -/// model routes each call to one child and falls back across the rest on error -/// (per `configJson`). -/// -/// `configJson` (optional): `{"router": "rule"|"weighted", "weights": [...], -/// "fallback": "on_error"|"none", "providerName", "modelId"}`. -Model router(List models, {String? configJson}) { - if (models.isEmpty) { - throw ArgumentError('router: models must be non-empty'); - } - checkJson(configJson, 'config_json', emptyIsDefault: true); - final handles = models.map((m) => m._requireHandle()).toList(); - final ptr = calloc(handles.length); - final int handle; - try { - ptr.asTypedList(handles.length).setAll(0, handles); - Pointer cfgPtr = nullptr; - if (configJson != null) { - cfgPtr = toCString(configJson); +// The listener isolate stays responsive while the C call runs separately. +BlockingStreamProducer _streamProducer(Object? request) => + _NativeStreamProducer(request! as _StreamRequest); + +final class _NativeStreamProducer implements BlockingStreamProducer { + final _StreamRequest request; + _NativeStreamProducer(this.request); + + @override + Future run(StreamBridgeSink sink) async { + final api = NativeApi(request.libraryPath); + final bridge = DartBridge(request.bridgePath); + Pointer context = nullptr; + Object? failure; + StackTrace? failureStack; + String? terminal; + BigInt? lastSeq; + bool cancelled = false; + final drained = Completer(); + bool producerDone = false; + void receive(Pointer pointer) { + try { + if (failure != null || cancelled) return; + final json = pointer.toDartString(); + final frame = StreamFrame.fromJson( + jsonDecode(json) as Map, + ); + final seq = BigInt.parse(frame.seq); + if (terminal != null || lastSeq != null && seq <= lastSeq!) { + throw const FormatException('Invalid stream frame order'); + } + lastSeq = seq; + if (frame is EndFrame) { + terminal = json; + } else if (!sink.add(json)) { + cancelled = true; + bridge.cancel(context); + } + } catch (error, stack) { + failure ??= bindingFailure('Could not deliver native stream: $error'); + failureStack ??= stack; + bridge.cancel(context); + } finally { + bridge.frameFree(pointer); + bridge.ack(context); + if (producerDone && !drained.isCompleted) drained.complete(); + } } + + final listener = NativeCallable.listener(receive); try { - handle = takeHandle( - (out) => _ffi.routerNew(ptr, handles.length, cfgPtr, out), - 'router_new'); - } finally { - if (cfgPtr != nullptr) { - calloc.free(cfgPtr); + context = bridge.create( + listener.nativeFunction, + api.ffi.library.lookup>('aimux_abort'), + request.abort, + ); + if (context == nullptr) + throw StateError('Could not allocate stream bridge'); + try { + await _runStream(request, context.address); + } finally { + producerDone = true; + if (bridge.pending(context) != 0) await drained.future; + } + final code = bridge.failure(context); + if (code != 0) { + failure ??= code == 305 + ? resourceLimit('Stream frame exceeds 1 MiB') + : bindingFailure('Native stream bridge could not copy frame'); } + if (failure != null) + Error.throwWithStackTrace(failure!, failureStack ?? StackTrace.current); + if (terminal != null) { + sink.endFrame(terminal!); + } else if (cancelled) { + sink.end(); + } else { + throw bindingFailure('Native stream ended without terminal'); + } + } finally { + if (context != nullptr) bridge.free(context); + listener.close(); } - } finally { - calloc.free(ptr); } - return Model._(handle); } -/// Create a MoaModel (RFC-0022) over reference models + one aggregator. -/// References fan out in parallel, then the aggregator synthesizes a final -/// answer. -/// -/// `references` may be empty (runs aggregator only). `configJson` (optional) is -/// a serialized MoaConfig. -Model moa(List references, Model aggregator, {String? configJson}) { - checkJson(configJson, 'config_json', emptyIsDefault: true); - final aggregatorHandle = aggregator._requireHandle(); - // Allocate the reference-handle array (NULL when there are no references). - final refPtr = references.isEmpty - ? Pointer.fromAddress(0) - : calloc(references.length); - final shouldFree = !references.isEmpty; - final int handle; - try { - if (shouldFree) { - final handles = references.map((m) => m._requireHandle()).toList(); - refPtr.asTypedList(references.length).setAll(0, handles); - } - Pointer cfgPtr = nullptr; - if (configJson != null) { - cfgPtr = toCString(configJson); - } +Stream _stream(Aimux client, int handle, String op, String params) { + OpsHandle? abort; + bool cancelled = false; + late final StreamController controller; + StreamSubscription? subscription; + final finished = Completer(); + Future start() async { try { - handle = takeHandle( - (out) => _ffi.moaNew( - refPtr, references.length, aggregatorHandle, cfgPtr, out), - 'moa_new'); + final requestParams = jsonDecode(params); + final suppliedAbort = + requestParams is Map ? requestParams['abort'] : null; + late final int abortId; + if (suppliedAbort != null) { + if (suppliedAbort is! String) { + throw const FormatException( + 'Abort must be a decimal handle string'); + } + abortId = parseHandle(suppliedAbort); + } else { + final result = + await client.call(ops.OP_ABORT_CREATE) as Map; + abort = client.adopt(result['handle'] as String); + abortId = client._id(abort); + } + if (cancelled) return; + final bridge = StreamBridge( + producer: _streamProducer, + request: _StreamRequest(client._libraryPath, client._bridgePath, + handle, abortId, op, params), + abort: () => client._native.ffi.abort(abortId), + credits: 1, + ); + subscription = bridge.stream.listen( + (json) => controller.add( + StreamFrame.fromJson(jsonDecode(json) as Map)), + onError: (Object error, StackTrace stack) => + controller.addError(error, stack), + onDone: () => unawaited(controller.close()), + ); + if (controller.isPaused) subscription!.pause(); + await bridge.done; + } catch (error, stack) { + if (!cancelled) { + controller.addError(error, stack); + unawaited(controller.close()); + } } finally { - if (cfgPtr != nullptr) { - calloc.free(cfgPtr); + try { + await abort?.close(); + } finally { + if (!finished.isCompleted) finished.complete(); } } - } finally { - if (shouldFree) { - calloc.free(refPtr); - } } - return Model._(handle); + + controller = StreamController( + sync: true, + onListen: () => unawaited(start()), + onPause: () => subscription?.pause(), + onResume: () => subscription?.resume(), + onCancel: () async { + cancelled = true; + await subscription?.cancel(); + await finished.future; + }, + ); + return controller.stream; } diff --git a/bindings/flutter/lib/aimux_plugin.dart b/bindings/flutter/lib/aimux_plugin.dart index b53999575..4917eec97 100644 --- a/bindings/flutter/lib/aimux_plugin.dart +++ b/bindings/flutter/lib/aimux_plugin.dart @@ -1,18 +1,4 @@ -// aimux_plugin.dart — Flutter plugin registration entry point. -// -// aimux is a Dart-only plugin: it calls the aimux-ffi C ABI directly via -// dart:ffi and uses no platform channels. `registerWith()` is a no-op that -// exists to satisfy Flutter's `dartPluginClass` contract — the flutter tool -// generates a call to it during app startup. -// -// The native libraries themselves ship inside this package: -// - Android: android/src/main/jniLibs//libaimux_ffi.so -// - iOS: ios/libaimux_ffi.a (device + simulator fat archive) - /// Dart-only plugin entry point required by pubspec.yaml's `dartPluginClass`. class AimuxPlugin { - /// Called by the generated plugin registrant at app startup. - static void registerWith() { - // FFI-only — nothing to register. - } + static void registerWith() {} } diff --git a/bindings/flutter/lib/decision.dart b/bindings/flutter/lib/decision.dart index 7e09cb37b..4ec1476cf 100644 --- a/bindings/flutter/lib/decision.dart +++ b/bindings/flutter/lib/decision.dart @@ -1,91 +1,24 @@ -import 'dart:convert'; -import 'dart:ffi'; -import 'package:ffi/ffi.dart'; -import 'errors.dart'; - -typedef _New = Pointer Function(Pointer, Pointer, - Pointer, Pointer, Pointer); -typedef _DecideC = Pointer Function( - Uint64, Pointer, Uint64, Pointer>); -typedef _DecideD = Pointer Function( - int, Pointer, int, Pointer>); -typedef _DescribeC = Pointer Function(Uint64, Pointer>); -typedef _DescribeD = Pointer Function(int, Pointer>); - -typedef _ProviderDecisionC = Pointer Function(Uint64, Pointer, Pointer); -typedef _ProviderDecisionD = Pointer Function(int, Pointer, Pointer); - -final _lib = openAimuxLibrary(); -final _providerDecision = _lib.lookupFunction<_ProviderDecisionC, _ProviderDecisionD>( - 'aimux_provider_decision_model'); -final _newDecision = _lib.lookupFunction<_New, _New>( - 'aimux_jev_decision_new_with_probability_source'); -final _decide = _lib.lookupFunction<_DecideC, _DecideD>('aimux_decide_with_abort'); -final _describe = _lib.lookupFunction<_DescribeC, _DescribeD>( - 'aimux_decision_capabilities'); - -/// A DecisionModelV4 model using the shared core JSON contract: DecideOptions -/// in, DecideResult out. -class DecisionModel { - int _handle; - DecisionModel._(this._handle); - - /// Native interop used by ProviderHandle.decisionModel; owns the returned handle. - factory DecisionModel.fromProviderHandle(int providerHandle, String modelId) { - return withUtf8(modelId, (id) => DecisionModel._(takeHandle( - (out) => _providerDecision(providerHandle, id, out), 'decision model'))); - } - - /// Endpoint is a complete POST URL; null uses the official API. - factory DecisionModel.jev(String apiKey, String modelId, - {String? endpoint, String? probabilitySource}) { - Pointer key = nullptr, id = nullptr, url = nullptr, source = nullptr; - try { - key = toCString(apiKey); - id = toCString(modelId); - url = toCStringOrNull(endpoint); - source = toCStringOrNull(probabilitySource); - return DecisionModel._(takeHandle( - (out) => _newDecision(key, id, url, source, out), 'jev decision')); - } finally { - calloc.free(key); - calloc.free(id); - calloc.free(url); - calloc.free(source); - } - } - - /// Raw JSON API. Optional abort handle uses the existing C ABI lifecycle. - String decideJson(String options, {int abortHandle = 0}) { - _checkOpen(); - checkJson(options, 'options'); - return withUtf8(options, (ptr) => takeString( - (out) => _decide(_handle, ptr, abortHandle, out), 'decide')); - } - - /// Dart maps/lists preserve native structured instructions and criteria. - Map decide(Map options, - {int abortHandle = 0}) => - (jsonDecode(decideJson(encodeJson(options, 'options'), - abortHandle: abortHandle)) as Map).cast(); - - /// The model's DecisionModelV4 properties (`specificationVersion`, - /// `provider`, `modelId`, `supportedQuestionTypes`), without HTTP. - Map describe() { - _checkOpen(); - return (jsonDecode(takeString( - (out) => _describe(_handle, out), 'decision model description')) as Map) - .cast(); - } - - void close() { - if (_handle != 0) { - aimuxDropHandle(_handle); - _handle = 0; - } - } - - void _checkOpen() { - if (_handle == 0) throw StateError('DecisionModel is closed'); - } +import 'generated/ops.dart' as ops; +import 'aimux.dart'; + +/// A Decision handle created with provider.model using method `decision`. +final class DecisionModel { + final Aimux client; + final OpsHandle handle; + DecisionModel(this.client, this.handle); + + Future> decide( + Map options, { + OpsHandle? abort, + }) async => + await client.call(ops.OP_DECISION_DECIDE, + handle: handle, + params: {'options': options}, + abort: abort) as Map; + + Future> capabilities() async => + await client.call(ops.OP_DECISION_CAPABILITIES, handle: handle) + as Map; + + Future close() => handle.close(); } diff --git a/bindings/flutter/lib/errors.dart b/bindings/flutter/lib/errors.dart index bb59e0755..279425277 100644 --- a/bindings/flutter/lib/errors.dart +++ b/bindings/flutter/lib/errors.dart @@ -1,947 +1,42 @@ -// errors.dart — the two aimux error types + the C-error decoder shared by -// aimux.dart and multimodal.dart. -// -// Transport (aimux-error.h): every fallible C call returns -// `aimux_error_t *` — NULL on success (the result is in the trailing -// out-param), non-NULL on failure (out-param at its sentinel: 0 / NULL). The -// unified code selects AiMuxError (1..20), RecordingError (100..105), or a -// C ABI failure (200..206). The last range maps to -// StateError('aimux ffi: …'); Dart does not expose seven additional classes. -// Every field is copied before the error is released with `aimux_error_free` -// exactly once. Errors are not -// handles — never `aimux_drop_handle` them. - -import 'dart:convert'; -import 'dart:ffi'; -import 'dart:io'; - -import 'package:ffi/ffi.dart'; - -// ───────────────────────────────────────────────────────────────────────────── -// Error code constants (match aimux_error_code_t in aimux-error.h) -// ───────────────────────────────────────────────────────────────────────────── - -/// Machine-readable codes. Values match C `aimux_error_code_t` / Go `Code`. -/// 19 variant codes: 1–20 (1 is the catch-all; 4 is retired; 19 / 20 are a -/// missing API key / provider setting). A code outside -/// that set is a header/library mismatch and fails with [StateError], not an -/// error type. Every HTTP-shaped failure -/// arrives as [apiCall], classified -/// by [AimuxException.status] (401 auth, 404 model, 429 rate limit; -/// -1 = no HTTP response was ever observed — a missing API key, an error -/// built without a request, or a transport failure). -abstract final class AimuxErrorCode { - static const int ok = 0; - static const int jsonParse = 2; - static const int invalidResponseData = 3; - static const int invalidArgument = 5; - static const int invalidPrompt = 6; - static const int tokenExpired = 7; - static const int unsupportedFunctionality = 8; - static const int noSuchModel = 9; - static const int noSuchProvider = 10; - static const int apiCall = 11; - static const int timeout = 12; - static const int aborted = 13; - static const int noSuchTool = 15; - static const int invalidToolInput = 16; - static const int toolCallRepair = 17; - static const int noOutputGenerated = 18; - static const int loadApiKey = 19; - static const int loadSetting = 20; - static const int other = 1; - static const int retry = 14; - - static const Map _text = { - ok: 'OK', - jsonParse: 'JsonParse', - invalidResponseData: 'InvalidResponseData', - invalidArgument: 'InvalidArgument', - invalidPrompt: 'InvalidPrompt', - tokenExpired: 'TokenExpired', - unsupportedFunctionality: 'UnsupportedFunctionality', - noSuchModel: 'NoSuchModel', - noSuchProvider: 'NoSuchProvider', - apiCall: 'ApiCall', - timeout: 'Timeout', - aborted: 'Aborted', - noSuchTool: 'NoSuchTool', - invalidToolInput: 'InvalidToolInput', - toolCallRepair: 'ToolCallRepair', - loadApiKey: 'LoadApiKey', - loadSetting: 'LoadSetting', - noOutputGenerated: 'NoOutputGenerated', - other: 'Other', - retry: 'Retry', - }; - - /// Core `error_type()` name. - static String name(int code) => _text[code] ?? 'Code($code)'; -} - -// ───────────────────────────────────────────────────────────────────────────── -// The decoder (aimux-error.h) -// ───────────────────────────────────────────────────────────────────────────── - -/// Decode the `aimux_error_t *` [e] returned by a call that can fail in -/// `AiMuxError` (`[AiMuxError]` in aimux-ffi.h). NULL → returns (success). -/// Codes 1..20 become [AimuxException]; 200..206 become [StateError]. -/// The returned error is always freed. -void expectAimuxError(Pointer e, String context) { - if (e == nullptr) return; - final Object failure; - try { - final code = _errorCode(e); - failure = _isFfiCode(code) - ? _ffiError(e, context) - : AimuxException._decode(e, context); - } finally { - _errorFree(e); - } - throw failure; -} - -/// Same as [expectAimuxError] for calls that can fail in the recorder -/// (`[RecordingError]`): 100..105 → [RecordingException]; 200..206 → -/// invariant [StateError]. -void expectRecordingError(Pointer e, String context) { - if (e == nullptr) return; - final Object failure; - try { - final code = _errorCode(e); - failure = _isFfiCode(code) - ? _ffiError(e, context) - : RecordingException._decode(e); - } finally { - _errorFree(e); - } - throw failure; -} - -/// Same for calls that only expose C ABI failures (`[C ABI]`): message -/// only → invariant [StateError]. -void expectFfiError(Pointer e, String context) { - if (e == nullptr) return; - final Object failure; - try { - final code = _errorCode(e); - failure = _isFfiCode(code) - ? _ffiError(e, context) - : StateError('aimux ffi: $context: expected C ABI failure code, got $code'); - } finally { - _errorFree(e); - } - throw failure; -} - -StateError _ffiError(Pointer e, String context) => - StateError('aimux ffi: $context: ${_errStr(_errorMessage, e) ?? ''}'); - -bool _isFfiCode(int code) => code >= 200 && code <= 206; - -/// Validate a caller-supplied raw JSON string *before* it crosses the C -/// C ABI; throws [FormatException] naming [param] (`source`) on bad -/// syntax. Null passes through (optional parameters); empty/blank is rejected -/// like the FFI does for REQUIRED params, unless [emptyIsDefault] — pass it for -/// every OPTIONAL JSON param (nullable in the Dart signature: `config_json` of -/// provider/router/moa, `opts_json` of embed / file upload / transcription), -/// where the FFI treats NULL/empty as "use defaults". -void checkJson(String? json, String param, {bool emptyIsDefault = false}) { - if (json == null) return; - if (json.trim().isEmpty) { - if (emptyIsDefault) return; - throw FormatException('$param: invalid JSON: empty', param); - } - final Object? value; - try { - value = jsonDecode(json); - } on FormatException catch (e) { - throw FormatException('$param: ${e.message}', param); - } - checkPortable(value, param); -} - -/// [jsonEncode] for a caller-supplied value the binding serializes itself -/// (prompts, call options, [ProviderConfig]) — pre-validated with the same -/// rule [checkJson] applies to caller-supplied JSON *text*, because nothing -/// downstream would check it. -String encodeJson(Object? value, String param) { - checkPortable(value, param); - return jsonEncode(value); -} - -/// Reject a JSON value that `jsonDecode`/`jsonEncode` accept but `serde_json` -/// on the Rust side does not. Divergences (all three verified against -/// serde_json 1.0.151): -/// -/// - **Unpaired surrogate.** A Dart `String` is UTF-16 and may hold one; -/// `jsonDecode` accepts it both raw and as a `\uD800` escape, and -/// `jsonEncode` writes it back out as a `\uD800` escape. `serde_json` -/// rejects that escape (`LoneLeadingSurrogateInHexEscape` / -/// `UnexpectedEndOfHexEscape`). -/// - **Non-finite number.** Dart parses `1e999` to `Infinity`; `serde_json` -/// rejects it (`NumberOutOfRange`). -/// - **Nesting past [_maxJsonDepth].** -/// -/// All three classify as `Category::Syntax`, which aimux-ffi reports as -/// `FfiError::InvalidWireJson` (code 202), so it reaches Dart through -/// [_ffiError] as an invariant [StateError] — but none of it is -/// an invariant: `text.substring(0, n)` cutting an emoji in half is enough to -/// produce the first. Catch them here, where the parameter still has a name -/// and the failure is a catchable [FormatException]. -void checkPortable(Object? value, String param, [int depth = 0]) { - if (depth >= _maxJsonDepth) { - throw FormatException( - '$param: JSON nested deeper than $_maxJsonDepth levels', param); - } - if (value is String) { - _checkSurrogates(value, param); - } else if (value is double && !value.isFinite) { - throw FormatException('$param: number out of range: $value', param); - } else if (value is List) { - for (final element in value) { - checkPortable(element, param, depth + 1); - } - } else if (value is Map) { - value.forEach((key, element) { - checkPortable(key, param, depth + 1); - checkPortable(element, param, depth + 1); - }); - } -} - -/// Matches `serde_json`'s default recursion limit, and keeps [checkPortable]'s -/// own recursion bounded — `jsonDecode` is iterative and happily returns a -/// 5000-deep document that would overflow the stack here. -// ponytail: hard-coded to serde_json's default. If that ever moves, documents -// between the two limits fall back to today's StateError — nothing valid is -// rejected. -const int _maxJsonDepth = 128; - -/// Reject an unpaired UTF-16 surrogate in [s] (not representable in UTF-8). -void _checkSurrogates(String s, String param) { - for (var i = 0; i < s.length; i++) { - final unit = s.codeUnitAt(i); - if (unit < 0xD800 || unit > 0xDFFF) continue; - // A high surrogate must be followed by a low one; a bare low one is - // already wrong. - final low = - (unit < 0xDC00 && i + 1 < s.length) ? s.codeUnitAt(i + 1) : 0x0000; - if (low < 0xDC00 || low > 0xDFFF) { - throw FormatException( - '$param: unpaired surrogate U+${unit.toRadixString(16).toUpperCase()}' - ' at index $i is not representable in UTF-8', - param); - } - i++; // consume the low surrogate of the valid pair - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Shared FFI plumbing (internal — hidden from the package:aimux/aimux.dart -// export; aimux.dart and multimodal.dart both build on these). -// ───────────────────────────────────────────────────────────────────────────── - -/// Opens the aimux-ffi native library for the current platform. -/// -/// The library ships inside this Flutter plugin package: -/// - Android: `libaimux_ffi.so` per ABI under android/src/main/jniLibs, -/// loaded by name (the Android build system bundles it into the APK). -/// - iOS: `libaimux_ffi.a` is statically linked into the app binary -/// via ios/aimux.podspec (vendored + force_load), so symbols resolve -/// from the process itself. -/// - Desktop: looked up on the platform library path (dev/test usage). -DynamicLibrary openAimuxLibrary() { - if (Platform.isAndroid) return DynamicLibrary.open('libaimux_ffi.so'); - if (Platform.isIOS) return DynamicLibrary.process(); - if (Platform.isLinux) return DynamicLibrary.open('libaimux_ffi.so'); - if (Platform.isMacOS) return DynamicLibrary.open('libaimux_ffi.dylib'); - if (Platform.isWindows) return DynamicLibrary.open('aimux_ffi.dll'); - throw StateError('Unsupported platform'); -} - -/// `aimux_free_string` — frees aimux-allocated result strings. Lazily -/// initialized so pure-Dart tests that never produce a native string do not -/// dlopen the library. -final void Function(Pointer) aimuxFreeString = openAimuxLibrary() - .lookupFunction), void Function(Pointer)>( - 'aimux_free_string'); - -/// Lazily opened library shared by the error symbols below. -final DynamicLibrary _errLib = openAimuxLibrary(); - -/// `aimux_drop_handle` — releases a model / provider handle. Lazy, as above. -/// (Never for errors: those go through `aimux_error_free`.) -final void Function(int) aimuxDropHandle = _errLib - .lookupFunction( - 'aimux_drop_handle'); - -// Returned errors (aimux-error.h) are opaque `Pointer` values. -typedef _I32OfPtrC = Int32 Function(Pointer); -typedef _I64OfPtrC = Int64 Function(Pointer); -typedef _StrOfPtrC = Pointer Function(Pointer); -typedef _IntOfPtr = int Function(Pointer); -typedef _StrOfPtr = Pointer Function(Pointer); - -_IntOfPtr _i32Getter(String sym) => - _errLib.lookupFunction<_I32OfPtrC, _IntOfPtr>(sym); -_StrOfPtr _strGetter(String sym) => - _errLib.lookupFunction<_StrOfPtrC, _StrOfPtr>(sym); - -final void Function(Pointer) _errorFree = _errLib.lookupFunction< - Void Function(Pointer), - void Function(Pointer)>('aimux_error_free'); -// `aimux_error_*` getters. Every char* is owned → freed -// via aimuxFreeString by [_errStr]. -final _IntOfPtr _errorCode = _i32Getter('aimux_error_code'); -final _IntOfPtr _errorRetryable = _i32Getter('aimux_error_retryable'); -final _IntOfPtr _errorStatus = _i32Getter('aimux_error_status'); -final _IntOfPtr _errorRetryMs = - _errLib.lookupFunction<_I64OfPtrC, _IntOfPtr>('aimux_error_retry_ms'); -final _StrOfPtr _errorMessage = _strGetter('aimux_error_message'); -final _StrOfPtr _errorProviderCode = _strGetter('aimux_error_provider_code'); -final _StrOfPtr _errorProviderMessage = - _strGetter('aimux_error_provider_message'); -final _StrOfPtr _errorResponseBody = _strGetter('aimux_error_response_body'); -final _StrOfPtr _errorUrl = _strGetter('aimux_error_url'); -final _StrOfPtr _errorRequestBodyValues = - _strGetter('aimux_error_request_body_values'); -final _StrOfPtr _errorResponseHeaders = - _strGetter('aimux_error_response_headers'); -final _StrOfPtr _errorProviderData = _strGetter('aimux_error_provider_data'); -final _StrOfPtr _errorModelId = _strGetter('aimux_error_model_id'); -final _StrOfPtr _errorModelType = _strGetter('aimux_error_model_type'); -final _StrOfPtr _errorProviderId = _strGetter('aimux_error_provider_id'); -final _StrOfPtr _errorRetryReason = _strGetter('aimux_error_retry_reason'); -final _IntOfPtr _errorRetryCount = _i32Getter('aimux_error_retry_count'); -final Pointer Function(Pointer, int) _errorRetryErrorAt = - _errLib.lookupFunction Function(Pointer, Int32), - Pointer Function(Pointer, int)>( - 'aimux_error_retry_error_at'); -final _StrOfPtr _errorToolName = _strGetter('aimux_error_tool_name'); -final _StrOfPtr _errorAvailableTools = _strGetter('aimux_error_available_tools'); -final _StrOfPtr _errorToolInput = _strGetter('aimux_error_tool_input'); -final _StrOfPtr _errorOriginalError = _strGetter('aimux_error_original_error'); - -/// Read an owned getter string for [error]; frees it; null when absent. -String? _errStr(_StrOfPtr getter, Pointer error) { - final p = getter(error); - if (p == nullptr) return null; - try { - return p.toDartString(); - } finally { - aimuxFreeString(p); - } -} - -/// Decode a JSON-string getter value; null when absent or malformed. -Object? _jsonValue(String? json) { - if (json == null) return null; - try { - return jsonDecode(json); - } on FormatException { - return null; - } -} - -/// `aimux_error_response_headers` JSON object → string→string map. -Map? _headerMap(String? json) { - final decoded = _jsonValue(json); - return decoded is Map ? Map.from(decoded) : null; -} - -/// Copy the per-attempt history of an `AIMUX_E_RETRY` error. Each child from -/// `aimux_error_retry_error_at` is a NEW owned error (deep copy, oldest -/// first): decoded with the same getters and freed independently of the -/// parent. Padded so [RetryError.errors] is never empty even for a -/// deserialized value with no recorded attempts. -List _decodeRetryErrors(Pointer error, String context) { - final count = _errorRetryCount(error); - final errors = []; - for (var i = 0; i < count; i++) { - final child = _errorRetryErrorAt(error, i); - if (child == nullptr) continue; - try { - errors.add(AimuxException._decode(child, context)); - } finally { - _errorFree(child); - } - } - return errors.isEmpty ? [OtherError('unknown retry error')] : errors; -} - -/// Run [fn] with a temporary native UTF-8 copy of [s]; always frees it. -T withUtf8(String s, T Function(Pointer) fn) { - final ptr = toCString(s); - try { - return fn(ptr); - } finally { - calloc.free(ptr); - } -} - -/// [toCString] for an optional argument: `nullptr` for null (the FFI reads -/// NULL as "absent"). Callers still free a non-NULL result. -Pointer toCStringOrNull(String? s) => s == null ? nullptr : toCString(s); - -/// `toNativeUtf8` with the two checks a C string needs and a Dart `String` -/// does not enforce, applied to every string parameter that crosses the ABI -/// (model ids, api keys, base URLs, dir paths, base64 payloads, media types, -/// JSON text): -/// -/// - **Interior NUL.** `toNativeUtf8` writes it verbatim, and the C side's -/// `CStr::from_ptr` stops there — the argument is silently truncated at -/// the NUL rather than rejected. -/// - **Unpaired surrogate.** `toNativeUtf8` substitutes U+FFFD (documented in -/// `package:ffi`'s `StringUtf8Pointer.toNativeUtf8`), so the argument is -/// silently corrupted rather than rejected. -/// -/// Both are catchable [FormatException]s here instead of silence there. -Pointer toCString(String s) { - final nul = s.codeUnits.indexOf(0); - if (nul >= 0) { - throw FormatException( - 'argument contains a NUL at index $nul; a C string ends there', s, nul); - } - _checkSurrogates(s, 'argument'); - return s.toNativeUtf8(); -} - -/// Run [fn] with a zeroed `uint64_t *out_handle`, [expectAimuxError] the returned -/// error, and hand back the handle. -int takeHandle( - Pointer Function(Pointer out) fn, String context) { - final out = calloc(); - try { - expectAimuxError(fn(out), context); - return out.value; - } finally { - calloc.free(out); - } -} - -/// Run [fn] with a NULL `char **out_json`, [expectAimuxError] the returned error, -/// copy the owned C string out and free it. -String takeString( - Pointer Function(Pointer> out) fn, String context) { - final out = calloc>(); - try { - expectAimuxError(fn(out), context); - final p = out.value; - if (p == nullptr) throw StateError('aimux ffi: $context: NULL result'); - try { - return p.toDartString(); - } finally { - aimuxFreeString(p); - } - } finally { - calloc.free(out); - } -} - -/// Shared constructor for `(api_key, model_id[, base_url], out_handle)` -/// providers. -int construct2( - String apiKey, - String modelId, - String? baseUrl, - Pointer Function(Pointer, Pointer, Pointer) plain, - Pointer Function( - Pointer, Pointer, Pointer, Pointer) - withBase, -) { - return withUtf8(apiKey, (keyPtr) { - return withUtf8(modelId, (idPtr) { - if (baseUrl == null) { - return takeHandle((out) => plain(keyPtr, idPtr, out), 'new'); - } - return withUtf8(baseUrl, (basePtr) { - return takeHandle( - (out) => withBase(keyPtr, idPtr, basePtr, out), 'new_with_base'); - }); - }); - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Exception hierarchy -// ───────────────────────────────────────────────────────────────────────────── - -/// Base class for aimux AiMuxErrors only. -/// -/// Use subclasses for specific handling (`on APICallError`, -/// `on AimuxTimeoutError`, …). Recording failures use the independent -/// [RecordingException]; C ABI failures surface as native Dart -/// errors (see [expectAimuxError]). -/// HTTP-shaped failures are all [APICallError] — branch on [status] -/// (401 auth, 404 model, 429 rate limit). -/// -/// Fields mirror the C `aimux_error_*` getters / core helpers: -/// - [code]: error code ([AimuxErrorCode]) -/// - [status]: HTTP status, or `-1` -/// - [retryMs]: rate-limit hint, or `-1` (`0` = retry now) -/// - [retryable]: whether retrying may help -/// - [message]: human-readable text -/// -/// Per-code payload lives on the carrying subclass only: [APICallError] -/// (`providerCode`/`providerMessage`/`url`/`requestBodyValues`/ -/// `responseHeaders`/`responseBody`/`data`), [RetryError] (`reason`/`errors`), -/// [NoSuchModelError] (`modelId`/`modelType`), [NoSuchProviderError] -/// (`providerId`). -/// [NoSuchToolError] (`toolName`/`availableTools`), [InvalidToolInputError] -/// (`toolName`/`toolInput`), [ToolCallRepairError] (`originalError`). -class AimuxException implements Exception { - /// Human-readable failure text. - final String message; - - /// Machine-readable error code ([AimuxErrorCode] constants). - final int code; - - /// HTTP status when known; otherwise `-1`. - final int status; - - /// Rate-limit hint in ms; `-1` if none; `0` means retry immediately. - final int retryMs; - - /// Whether retrying may help — the `AiMuxError` verdict, carried across the C - /// ABI. Not derivable from [status]: a transport failure (request went out, - /// connection reset) and a missing API key (request never went out) both - /// report `status == -1` and disagree here. - final bool retryable; - - AimuxException( - this.message, { - this.code = AimuxErrorCode.other, - this.status = -1, - this.retryMs = -1, - this.retryable = false, - }); - - /// Build the typed subclass from a returned `const aimux_error_t *` [error] - /// via the `aimux_error_*` getters (payload getters only under the owning - /// code; getter strings freed here). The caller ([expectAimuxError]) - /// frees it. A code outside 1..20 is a - /// contract violation and throws [StateError]. - factory AimuxException._decode(Pointer error, String context) { - final code = _errorCode(error); - var message = _errStr(_errorMessage, error) ?? ''; - if (message.isEmpty) message = 'aimux: ${AimuxErrorCode.name(code)}'; - - final retryable = _errorRetryable(error) != 0; - switch (code) { - case AimuxErrorCode.apiCall: - return APICallError(message, - status: _errorStatus(error), - retryMs: _errorRetryMs(error), - retryable: retryable, - providerCode: _errStr(_errorProviderCode, error), - providerMessage: _errStr(_errorProviderMessage, error), - url: _errStr(_errorUrl, error), - requestBodyValues: - _jsonValue(_errStr(_errorRequestBodyValues, error)), - responseHeaders: _headerMap(_errStr(_errorResponseHeaders, error)), - responseBody: _errStr(_errorResponseBody, error), - data: _jsonValue(_errStr(_errorProviderData, error))); - case AimuxErrorCode.retry: - return RetryError(message, - reason: - RetryErrorReason.fromWire(_errStr(_errorRetryReason, error)), - errors: _decodeRetryErrors(error, context)); - case AimuxErrorCode.noSuchModel: - return NoSuchModelError(message, - retryable: retryable, - modelId: _errStr(_errorModelId, error) ?? '', - modelType: _errStr(_errorModelType, error) ?? ''); - case AimuxErrorCode.noSuchProvider: - return NoSuchProviderError(message, - retryable: retryable, - providerId: _errStr(_errorProviderId, error) ?? ''); - case AimuxErrorCode.noSuchTool: - // The accessor delivers the tool set as a JSON string array, or NULL - // when no tool set was supplied. - final tools = _errStr(_errorAvailableTools, error); - return NoSuchToolError(message, - retryable: retryable, - toolName: _errStr(_errorToolName, error) ?? '', - availableTools: - tools == null ? null : (jsonDecode(tools) as List).cast()); - case AimuxErrorCode.invalidToolInput: - return InvalidToolInputError(message, - retryable: retryable, - toolName: _errStr(_errorToolName, error) ?? '', - toolInput: _errStr(_errorToolInput, error) ?? ''); - case AimuxErrorCode.toolCallRepair: - final original = _errStr(_errorOriginalError, error); - return ToolCallRepairError(message, - retryable: retryable, - originalError: original == null ? null : jsonDecode(original)); - case AimuxErrorCode.loadApiKey: - // The consulted environment variable rides the provider-code getter. - return LoadAPIKeyError(message, - retryable: retryable, - envVar: _errStr(_errorProviderCode, error) ?? ''); - case AimuxErrorCode.loadSetting: - return LoadSettingError(message, - retryable: retryable, - envVar: _errStr(_errorProviderCode, error) ?? ''); - default: - try { - return AimuxException.fromCode(code, message, retryable: retryable); - } on StateError { - throw StateError('aimux ffi: $context: unknown error code $code'); - } - } - } - - /// Dispatch [code] → concrete subclass (defaults for status when `-1`). - factory AimuxException.fromCode( - int code, - String message, { - int status = -1, - int retryMs = -1, - bool retryable = false, - }) { - switch (code) { - case AimuxErrorCode.jsonParse: - return JSONParseError(message, status: status, retryMs: retryMs, retryable: retryable); - case AimuxErrorCode.invalidResponseData: - return InvalidResponseDataError(message, status: status, retryMs: retryMs, retryable: retryable); - case AimuxErrorCode.invalidArgument: - return InvalidArgumentError(message, status: status, retryMs: retryMs, retryable: retryable); - case AimuxErrorCode.invalidPrompt: - return InvalidPromptError(message, status: status, retryMs: retryMs, retryable: retryable); - case AimuxErrorCode.tokenExpired: - return TokenExpiredError( - message, - status: status == -1 ? 401 : status, - retryMs: retryMs, - retryable: retryable, - ); - case AimuxErrorCode.unsupportedFunctionality: - return UnsupportedFunctionalityError(message, status: status, retryMs: retryMs, retryable: retryable); - case AimuxErrorCode.noSuchModel: - return NoSuchModelError(message, status: status, retryMs: retryMs, retryable: retryable); - case AimuxErrorCode.noSuchProvider: - return NoSuchProviderError(message, status: status, retryMs: retryMs, retryable: retryable); - case AimuxErrorCode.apiCall: - return APICallError(message, status: status, retryMs: retryMs, retryable: retryable); - case AimuxErrorCode.retry: - // The real decode path ([_decode]) rebuilds the attempt history via - // the retry getters; a bare code carries only its message. - return RetryError( - message, - reason: RetryErrorReason.maxRetriesExceeded, - errors: [OtherError(message)], - ); - case AimuxErrorCode.timeout: - return AimuxTimeoutError(message, status: status, retryMs: retryMs, retryable: retryable); - case AimuxErrorCode.aborted: - return RequestAbortedError(message, status: status, retryMs: retryMs, retryable: retryable); - case AimuxErrorCode.noSuchTool: - return NoSuchToolError(message, status: status, retryMs: retryMs, retryable: retryable); - case AimuxErrorCode.invalidToolInput: - return InvalidToolInputError(message, status: status, retryMs: retryMs, retryable: retryable); - case AimuxErrorCode.toolCallRepair: - return ToolCallRepairError(message, status: status, retryMs: retryMs, retryable: retryable); - case AimuxErrorCode.noOutputGenerated: - return NoOutputGeneratedError(message, status: status, retryMs: retryMs, retryable: retryable); - case AimuxErrorCode.loadApiKey: - return LoadAPIKeyError(message, status: status, retryMs: retryMs, retryable: retryable); - case AimuxErrorCode.loadSetting: - return LoadSettingError(message, status: status, retryMs: retryMs, retryable: retryable); - case AimuxErrorCode.other: - return OtherError(message, status: status, retryMs: retryMs, retryable: retryable); - default: - throw StateError('Unknown AimuxErrorCode: $code'); +/// The lossless error envelope. Unknown payload variants are preserved. +final class OpsException implements Exception { + final Map envelope; + + OpsException.fromJson(Map json) + : envelope = Map.unmodifiable(json) { + if (json['kind'] is! String || + json['code'] is! int || + json['message'] is! String || + json['error'] is! Map || + (json.containsKey('retryAfterMs') && + (json['retryAfterMs'] is! int || + (json['retryAfterMs'] as int) < 0))) { + throw const FormatException('Invalid aimux error envelope'); } } - /// Core `error_type()` name for [code]. - String get codeName => AimuxErrorCode.name(code); + String get kind => envelope['kind'] as String; + int get code => envelope['code'] as int; + String get message => envelope['message'] as String; + Map get error => + Map.unmodifiable(envelope['error'] as Map); + int? get retryAfterMs => envelope['retryAfterMs'] as int?; + Map toJson() => Map.of(envelope); @override - String toString() => '$runtimeType: $message'; -} - -// ── Concrete subclasses (one per live AimuxErrorCode) ─────────────────────── - -class NoOutputGeneratedError extends AimuxException { - NoOutputGeneratedError(super.message, {super.status, super.retryMs, super.retryable}) - : super(code: AimuxErrorCode.noOutputGenerated); -} - -/// JSON parse / serialize failure. -class JSONParseError extends AimuxException { - JSONParseError(super.message, {super.status, super.retryMs, super.retryable}) - : super(code: AimuxErrorCode.jsonParse); -} - -/// Invalid / malformed response data (streaming or decode failure). -class InvalidResponseDataError extends AimuxException { - InvalidResponseDataError(super.message, - {super.status, super.retryMs, super.retryable}) - : super(code: AimuxErrorCode.invalidResponseData); -} - -/// The model called a tool that is not in the supplied tool set. -class NoSuchToolError extends AimuxException { - /// The tool name the model called. - final String toolName; - - /// The available tool names, or null when no tool set was supplied. - final List? availableTools; - - NoSuchToolError(super.message, - {super.status, - super.retryMs, - super.retryable, - this.toolName = '', - this.availableTools}) - : super(code: AimuxErrorCode.noSuchTool); -} - -/// The model produced tool arguments that fail to parse or validate. -class InvalidToolInputError extends AimuxException { - /// The tool name the model called. - final String toolName; - - /// The raw argument text the model produced. - final String toolInput; - - InvalidToolInputError(super.message, - {super.status, - super.retryMs, - super.retryable, - this.toolName = '', - this.toolInput = ''}) - : super(code: AimuxErrorCode.invalidToolInput); -} - -/// A `repairToolCall` hook itself failed. -class ToolCallRepairError extends AimuxException { - /// The original lookup/parse/validation error the hook was repairing, - /// decoded from its wire JSON (an `AiMuxError` object keyed by `name`, the - /// same shape as `ToolCall.error`). - final dynamic originalError; - - ToolCallRepairError(super.message, - {super.status, super.retryMs, super.retryable, this.originalError}) - : super(code: AimuxErrorCode.toolCallRepair); -} - -/// Invalid argument (null args, invalid or expired handles, …). -class InvalidArgumentError extends AimuxException { - InvalidArgumentError(super.message, {super.status, super.retryMs, super.retryable}) - : super(code: AimuxErrorCode.invalidArgument); -} - -/// Invalid prompt. -class InvalidPromptError extends AimuxException { - InvalidPromptError(super.message, {super.status, super.retryMs, super.retryable}) - : super(code: AimuxErrorCode.invalidPrompt); -} - -/// No API key was passed and the fallback environment variable is unset (AI SDK -/// `LoadAPIKeyError`). No request was made. -class LoadAPIKeyError extends AimuxException { - /// The environment variable that was consulted, e.g. `OPENAI_API_KEY`. - final String envVar; - - LoadAPIKeyError(super.message, - {super.status, super.retryMs, super.retryable, this.envVar = ''}) - : super(code: AimuxErrorCode.loadApiKey); -} - -/// A required provider setting was not passed and its fallback environment -/// variable is unset (AI SDK `LoadSettingError`). -class LoadSettingError extends AimuxException { - /// The environment variable that was consulted. - final String envVar; - - LoadSettingError(super.message, - {super.status, super.retryMs, super.retryable, this.envVar = ''}) - : super(code: AimuxErrorCode.loadSetting); -} - -/// Access token expired. -class TokenExpiredError extends AimuxException { - TokenExpiredError(super.message, {super.status = 401, super.retryMs, super.retryable}) - : super(code: AimuxErrorCode.tokenExpired); -} - -/// Unsupported functionality. -class UnsupportedFunctionalityError extends AimuxException { - UnsupportedFunctionalityError(super.message, - {super.status, super.retryMs, super.retryable}) - : super(code: AimuxErrorCode.unsupportedFunctionality); -} - -/// No such model in registry / catalogue. -class NoSuchModelError extends AimuxException { - /// The model id that was asked for. - final String modelId; - - /// The model type it was asked for as. - final String modelType; - - NoSuchModelError(super.message, - {super.status, super.retryMs, super.retryable, this.modelId = '', this.modelType = ''}) - : super(code: AimuxErrorCode.noSuchModel); + String toString() => 'OpsException($kind/$code): $message'; } -/// No such provider name. -class NoSuchProviderError extends AimuxException { - /// The provider id that was asked for. - final String providerId; - - NoSuchProviderError(super.message, - {super.status, super.retryMs, super.retryable, this.providerId = ''}) - : super(code: AimuxErrorCode.noSuchProvider); -} - -/// Provider API call failed (AI SDK `APICallError` analogue) — every -/// HTTP-shaped failure. [status] is the classification (401 auth, 404 model, -/// 429 rate limit + [retryMs]); `-1` means no HTTP response was ever observed -/// — a missing API key, an error built without a request, or a transport -/// failure. Read [retryable] to decide on a retry; [status] cannot tell those -/// two apart. -class APICallError extends AimuxException { - /// The provider's own error code, e.g. `insufficient_quota`. - final String? providerCode; - - /// The failure's own text without the composed prefix [message] carries, - /// e.g. `slow down`. - final String? providerMessage; - - /// Sanitized request URL. - final String? url; - - /// Sanitized request body values (decoded JSON; any JSON type). - final dynamic requestBodyValues; - - /// Sanitized response headers. (Request ids live here, not on a field: - /// the core no longer captures a distinct one.) - final Map? responseHeaders; - - /// Raw response body. - final String? responseBody; - - /// Parsed provider error data (the AI SDK's `APICallError.data`), - /// decoded JSON. - final dynamic data; - - APICallError(super.message, - {super.status, - super.retryMs, - super.retryable, - this.providerCode, - this.providerMessage, - this.url, - this.requestBodyValues, - this.responseHeaders, - this.responseBody, - this.data}) - : super(code: AimuxErrorCode.apiCall); -} - -/// Why a [RetryError] stopped retrying (core serde camelCase wire names). -enum RetryErrorReason { - /// Every permitted attempt failed with a retryable error. - maxRetriesExceeded('maxRetriesExceeded'), - - /// A later attempt failed with a non-retryable error. - errorNotRetryable('errorNotRetryable'); - - const RetryErrorReason(this.wireValue); - final String wireValue; - - static RetryErrorReason fromWire(String? value) => - value == errorNotRetryable.wireValue - ? errorNotRetryable - : maxRetriesExceeded; -} - -/// Retrying stopped (`AIMUX_E_RETRY`): [reason] says why, [message] carries -/// the composed summary ("Failed after N attempts…"), and [errors] keeps the -/// per-attempt history, oldest first — each entry is itself a fully decoded -/// [AimuxException] (an [APICallError] keeps its whole detail set; a nested -/// [RetryError] recurses). -class RetryError extends AimuxException { - final RetryErrorReason reason; - - /// Per-attempt errors, oldest first; never empty. - final List errors; - - RetryError(super.message, - {required this.reason, required List errors}) - : assert(errors.isNotEmpty), - errors = List.unmodifiable(errors), - super(code: AimuxErrorCode.retry); - - /// The final attempt. - AimuxException get lastError => errors.last; -} - -/// Request timed out. (Prefixed to avoid shadowing dart:async -/// `TimeoutError`.) -class AimuxTimeoutError extends AimuxException { - AimuxTimeoutError(super.message, {super.status, super.retryMs, super.retryable}) - : super(code: AimuxErrorCode.timeout); -} - -/// Request aborted (not DOM `AbortError`). -class RequestAbortedError extends AimuxException { - RequestAbortedError(super.message, {super.status, super.retryMs, super.retryable}) - : super(code: AimuxErrorCode.aborted); -} - -/// Unclassified failure (`AIMUX_E_OTHER`). -class OtherError extends AimuxException { - OtherError(super.message, {super.status, super.retryMs, super.retryable}) - : super(code: AimuxErrorCode.other); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Recording errors (aimux-error.h aimux_error_code_t) — independent -// of AimuxException, mirroring Rust's separate `recording::RecordingError`. -// ───────────────────────────────────────────────────────────────────────────── - -/// Code of a [RecordingException]. Dart keeps six values independent of the -/// C transport codes 100..105. Only -/// [writerGone], [flushTimeout] and [write] are reachable from a flush. -enum RecordingErrorCode { - init, - openFile, - spawn, - writerGone, - flushTimeout, - write; - - static RecordingErrorCode fromCode(int code) { - if (code < 100 || code >= 100 + values.length) { - throw StateError('Unknown AimuxRecordingErrorCode: $code'); - } - return values[code - 100]; - } -} - -/// Recording failure reported by `initRecording()` / `recordingTryFlush()`. -/// NOT an [AimuxException] — the recorder is a separate subsystem with its -/// own closed error set. -class RecordingException implements Exception { - final RecordingErrorCode code; - final String message; - RecordingException(this.code, this.message); - - /// Decode a returned `const aimux_error_t *` [error]. The caller - /// ([expectRecordingError]) frees it. - factory RecordingException._decode(Pointer error) { - final code = RecordingErrorCode.fromCode(_errorCode(error)); - final message = _errStr(_errorMessage, error) ?? - 'aimux: recording ${code.name}'; - return RecordingException(code, message); - } +OpsException bindingFailure(String message) => OpsException.fromJson({ + 'kind': 'binding', + 'code': 206, + 'message': message, + 'error': {'name': 'CallbackFailure', 'message': message}, + }); - @override - String toString() => 'RecordingException(${code.name}): $message'; -} +OpsException resourceLimit(String message) => OpsException.fromJson({ + 'kind': 'protocol', + 'code': 305, + 'message': message, + 'error': {'name': 'ResourceLimit', 'message': message}, + }); diff --git a/bindings/flutter/lib/generated/ops.dart b/bindings/flutter/lib/generated/ops.dart new file mode 100644 index 000000000..5de266cc8 --- /dev/null +++ b/bindings/flutter/lib/generated/ops.dart @@ -0,0 +1,76 @@ +// Generated by aimux-codegen. Do not edit. Manifest SHA-256: 2bc7cb7ec77a0ed20496411f0dbd199b949b9887cc74d119e11627c8573d82a2 +const int ABI_VERSION = 65536; +const String OPS_VERSION = "1.0"; +const String MANIFEST_DIGEST = "2bc7cb7ec77a0ed20496411f0dbd199b949b9887cc74d119e11627c8573d82a2"; +const String OP_ABORT_CREATE = "abort.create"; +const String OP_ABORT_SIGNAL = "abort.signal"; +const String OP_AUTH_CODEX_REFRESH = "auth.codexRefresh"; +const String OP_CATALOGUE_FETCH = "catalogue.fetch"; +const String OP_COMPOSITE_MOA = "composite.moa"; +const String OP_COMPOSITE_ROUTER = "composite.router"; +const String OP_CREDENTIAL_CREATE = "credential.create"; +const String OP_DECISION_CAPABILITIES = "decision.capabilities"; +const String OP_DECISION_DECIDE = "decision.decide"; +const String OP_EMBEDDING_EMBED = "embedding.embed"; +const String OP_EMBEDDING_EMBED_MANY = "embedding.embedMany"; +const String OP_FILES_UPLOAD = "files.upload"; +const String OP_HANDLE_DROP = "handle.drop"; +const String OP_IMAGE_GENERATE = "image.generate"; +const String OP_MIDDLEWARE_CREATE = "middleware.create"; +const String OP_MODEL_INFO = "model.info"; +const String OP_MODEL_RESOLVE = "model.resolve"; +const String OP_MODEL_WRAP = "model.wrap"; +const String OP_OPERATION_CANCEL = "operation.cancel"; +const String OP_OPERATION_INPUT_DONE = "operation.inputDone"; +const String OP_OPERATION_NEXT = "operation.next"; +const String OP_OPERATION_PUSH = "operation.push"; +const String OP_OPERATION_STATUS = "operation.status"; +const String OP_OUTPUT_TO_OPEN_AI = "output.toOpenAI"; +const String OP_PROVIDER_CREATE = "provider.create"; +const String OP_PROVIDER_CUSTOM = "provider.custom"; +const String OP_PROVIDER_DEFAULT = "provider.default"; +const String OP_PROVIDER_FILES = "provider.files"; +const String OP_PROVIDER_INVOKE = "provider.invoke"; +const String OP_PROVIDER_MODEL = "provider.model"; +const String OP_PROVIDER_WRAP = "provider.wrap"; +const String OP_RECORDER_ATTACH = "recorder.attach"; +const String OP_RECORDER_CREATE = "recorder.create"; +const String OP_RECORDER_EXPORT = "recorder.export"; +const String OP_RECORDER_FLUSH = "recorder.flush"; +const String OP_RECORDER_STATS = "recorder.stats"; +const String OP_RECORDER_STOP = "recorder.stop"; +const String OP_REGISTRY_CREATE = "registry.create"; +const String OP_REGISTRY_PROVIDER = "registry.provider"; +const String OP_REPLAY_CAPABILITIES = "replay.capabilities"; +const String OP_REPLAY_CREATE = "replay.create"; +const String OP_REPLAY_DRY_RUN = "replay.dryRun"; +const String OP_REPLAY_INSPECT = "replay.inspect"; +const String OP_REPLAY_LIVE = "replay.live"; +const String OP_RERANKING_RERANK = "reranking.rerank"; +const String OP_RUNTIME_CONFIGURE = "runtime.configure"; +const String OP_RUNTIME_DESCRIBE = "runtime.describe"; +const String OP_SEARCH_SEARCH = "search.search"; +const String OP_SESSION_STORE_ATTACH = "sessionStore.attach"; +const String OP_SESSION_STORE_CALLS = "sessionStore.calls"; +const String OP_SESSION_STORE_CREATE = "sessionStore.create"; +const String OP_SESSION_STORE_LIST = "sessionStore.list"; +const String OP_SPEECH_GENERATE = "speech.generate"; +const String OP_TEXT_CONSUME = "text.consume"; +const String OP_TEXT_GENERATE = "text.generate"; +const String OP_TEXT_GENERATE_OBJECT = "text.generateObject"; +const String OP_TEXT_GENERATE_OPEN_AI = "text.generateOpenAI"; +const String OP_TEXT_STREAM = "text.stream"; +const String OP_TEXT_STREAM_OPEN_AI = "text.streamOpenAI"; +const String OP_TOOL_REPAIR_APPLY = "toolRepair.apply"; +const String OP_TOOL_REPAIR_APPLY_RESULT = "toolRepair.applyResult"; +const String OP_TOOL_REPAIR_CONTEXT = "toolRepair.context"; +const String OP_TRACE_AGGREGATE = "trace.aggregate"; +const String OP_TRACE_CLEAR = "trace.clear"; +const String OP_TRACE_CREATE = "trace.create"; +const String OP_TRACE_EXPORT = "trace.export"; +const String OP_TRACE_SESSION_CHAIN = "trace.sessionChain"; +const String OP_TRACE_SESSION_TRAJECTORY = "trace.sessionTrajectory"; +const String OP_TRANSCRIPTION_GENERATE = "transcription.generate"; +const String OP_TRANSCRIPTION_START = "transcription.start"; +const String OP_TRANSPORT_CREATE = "transport.create"; +const String OP_VIDEO_GENERATE = "video.generate"; diff --git a/bindings/flutter/lib/multimodal.dart b/bindings/flutter/lib/multimodal.dart index fedc79263..8d67aaf66 100644 --- a/bindings/flutter/lib/multimodal.dart +++ b/bindings/flutter/lib/multimodal.dart @@ -1,769 +1,21 @@ -// multimodal.dart — Flutter/Dart binding for aimux multimodal models -// (dart:ffi, C ABI path). -// -// Mirrors the Go binding's multimodal.go: 8 modality model types -// (Embedding, Speech, Transcription, Image, Video, Reranking, Search, Files), -// each wrapping a native handle acquired via a provider-specific constructor -// and released via [close]. All C ABI data uses JSON strings (base64 -// for binary), matching the C ABI wire format. -// -// This is the C ABI path (RFC §3.2) — same pattern as `aimux.dart`. The native -// library (libaimux_ffi.so / .dylib / .dll) must be on the library path or -// bundled in the Flutter app. All calls are synchronous (the C ABI blocks until -// completion). -// -// AiMuxError values throw [AimuxException] (see errors.dart). - -import 'dart:ffi'; import 'dart:typed_data'; -import 'package:ffi/ffi.dart'; - -import 'errors.dart'; - -// ───────────────────────────────────────────────────────────────────────────── -// FFI type aliases. `_Err` is `aimux_error_t *` (opaque; NULL = success); -// results come back through the trailing out-param. Pointer types are spelled -// the same in the C and Dart signatures, so a single alias serves both. -// ───────────────────────────────────────────────────────────────────────────── - -typedef _Err = Pointer; - -// Constructors taking (api_key, model_id, out_handle). -typedef _NewC = _Err Function( - Pointer apiKey, Pointer modelId, Pointer outHandle); -typedef _NewDart = _Err Function( - Pointer apiKey, Pointer modelId, Pointer outHandle); - -// Constructors taking (api_key, model_id, base_url, out_handle). -typedef _NewWithBaseC = _Err Function(Pointer apiKey, - Pointer modelId, Pointer baseUrl, Pointer outHandle); -typedef _NewWithBaseDart = _Err Function(Pointer apiKey, - Pointer modelId, Pointer baseUrl, Pointer outHandle); - -// Files constructors take only (api_key, out_handle) — no model_id. -typedef _FilesNewC = _Err Function( - Pointer apiKey, Pointer outHandle); -typedef _FilesNewDart = _Err Function( - Pointer apiKey, Pointer outHandle); - -// Files constructors with a custom base URL: (api_key, base_url, out_handle). -typedef _FilesNewWithBaseC = _Err Function( - Pointer apiKey, Pointer baseUrl, Pointer outHandle); -typedef _FilesNewWithBaseDart = _Err Function( - Pointer apiKey, Pointer baseUrl, Pointer outHandle); - -// aimux_embed(handle, values_json, opts_json, out_json) — opts nullable. -typedef _EmbedC = _Err Function(Uint64 handle, Pointer valuesJson, - Pointer? optsJson, Pointer> outJson); -typedef _EmbedDart = _Err Function(int handle, Pointer valuesJson, - Pointer? optsJson, Pointer> outJson); - -// Single-arg generate functions: (handle, opts_json, out_json). -typedef _GenerateOpts1C = _Err Function( - Uint64 handle, Pointer optsJson, Pointer> outJson); -typedef _GenerateOpts1Dart = _Err Function( - int handle, Pointer optsJson, Pointer> outJson); - -// Three-arg generate functions: (handle, a, b, opts_json, out_json). -typedef _GenerateOpts3C = _Err Function( - Uint64 handle, - Pointer a, - Pointer b, - Pointer? optsJson, - Pointer> outJson); -typedef _GenerateOpts3Dart = _Err Function( - int handle, - Pointer a, - Pointer b, - Pointer? optsJson, - Pointer> outJson); - -// Transcription streaming sessions (RFC-0028). -typedef _TranscriptionSessionNewC = _Err Function(Uint64 modelHandle, - Uint64 abortHandle, Pointer optsJson, Pointer outHandle); -typedef _TranscriptionSessionNewDart = _Err Function(int modelHandle, - int abortHandle, Pointer optsJson, Pointer outHandle); -typedef _TranscriptionPushAudioC = _Err Function( - Uint64 session, Pointer data, IntPtr len); -typedef _TranscriptionPushAudioDart = _Err Function( - int session, Pointer data, int len); -typedef _TranscriptionInputDoneC = _Err Function(Uint64 session); -typedef _TranscriptionInputDoneDart = _Err Function(int session); -// out_state is `int32_t *` in the header (holds an -// aimux_transcription_next_part_state_t value), hence Pointer. -typedef _TranscriptionNextPartC = _Err Function(Uint64 session, - Int64 timeoutMs, Pointer> outPart, Pointer outState); -typedef _TranscriptionNextPartDart = _Err Function(int session, - int timeoutMs, Pointer> outPart, Pointer outState); -typedef _VoidU64C = Void Function(Uint64); -typedef _VoidU64Dart = void Function(int); - -// aimux_transcription_next_part_state_t values. -const int _nextPartStatePart = 1; -const int _nextPartStateEnded = 2; -const int _nextPartStateTimeout = 3; - -// Resource management (same as aimux.dart). -typedef _DropHandleC = Void Function(Uint64); -typedef _DropHandleDart = void Function(int); - -// ───────────────────────────────────────────────────────────────────────────── -// FFI library wrapper (process-wide singleton — dlopen + symbol lookups -// happen once, lazily per symbol) -// ───────────────────────────────────────────────────────────────────────────── - -final class _MultimodalFFI { - final DynamicLibrary _lib = openAimuxLibrary(); - - // Constructors — (api_key, model_id, out_handle) - late final openaiEmbeddingNew = - _lib.lookupFunction<_NewC, _NewDart>('aimux_openai_embedding_new'); - late final cohereEmbeddingNew = - _lib.lookupFunction<_NewC, _NewDart>('aimux_cohere_embedding_new'); - late final googleEmbeddingNew = - _lib.lookupFunction<_NewC, _NewDart>('aimux_google_embedding_new'); - late final openaiSpeechNew = - _lib.lookupFunction<_NewC, _NewDart>('aimux_openai_speech_new'); - late final openaiImageNew = - _lib.lookupFunction<_NewC, _NewDart>('aimux_openai_image_new'); - late final googleImageNew = - _lib.lookupFunction<_NewC, _NewDart>('aimux_google_image_new'); - late final openaiTranscriptionNew = - _lib.lookupFunction<_NewC, _NewDart>('aimux_openai_transcription_new'); - late final cohereRerankingNew = - _lib.lookupFunction<_NewC, _NewDart>('aimux_cohere_reranking_new'); - late final googleVideoNew = - _lib.lookupFunction<_NewC, _NewDart>('aimux_google_video_new'); - late final tavilySearchNew = - _lib.lookupFunction<_NewC, _NewDart>('aimux_tavily_search_new'); - - // Constructors — (api_key, model_id, base_url, out_handle) - late final openaiEmbeddingNewWithBase = _lib - .lookupFunction<_NewWithBaseC, _NewWithBaseDart>( - 'aimux_openai_embedding_new_with_base'); - late final cohereEmbeddingNewWithBase = _lib - .lookupFunction<_NewWithBaseC, _NewWithBaseDart>( - 'aimux_cohere_embedding_new_with_base'); - late final googleEmbeddingNewWithBase = _lib - .lookupFunction<_NewWithBaseC, _NewWithBaseDart>( - 'aimux_google_embedding_new_with_base'); - late final openaiSpeechNewWithBase = _lib - .lookupFunction<_NewWithBaseC, _NewWithBaseDart>( - 'aimux_openai_speech_new_with_base'); - late final openaiImageNewWithBase = _lib - .lookupFunction<_NewWithBaseC, _NewWithBaseDart>( - 'aimux_openai_image_new_with_base'); - late final googleImageNewWithBase = _lib - .lookupFunction<_NewWithBaseC, _NewWithBaseDart>( - 'aimux_google_image_new_with_base'); - late final openaiTranscriptionNewWithBase = _lib - .lookupFunction<_NewWithBaseC, _NewWithBaseDart>( - 'aimux_openai_transcription_new_with_base'); - late final cohereRerankingNewWithBase = _lib - .lookupFunction<_NewWithBaseC, _NewWithBaseDart>( - 'aimux_cohere_reranking_new_with_base'); - late final googleVideoNewWithBase = _lib - .lookupFunction<_NewWithBaseC, _NewWithBaseDart>( - 'aimux_google_video_new_with_base'); - late final tavilySearchNewWithBase = _lib - .lookupFunction<_NewWithBaseC, _NewWithBaseDart>( - 'aimux_tavily_search_new_with_base'); - - // Files constructors - late final openaiFilesNew = - _lib.lookupFunction<_FilesNewC, _FilesNewDart>('aimux_openai_files_new'); - late final openaiFilesNewWithBase = _lib - .lookupFunction<_FilesNewWithBaseC, _FilesNewWithBaseDart>( - 'aimux_openai_files_new_with_base'); - - // Generate / embed / rerank / search / upload - late final embed = _lib.lookupFunction<_EmbedC, _EmbedDart>('aimux_embed'); - late final speechGenerate = _lib - .lookupFunction<_GenerateOpts1C, _GenerateOpts1Dart>( - 'aimux_speech_generate'); - late final imageGenerate = _lib - .lookupFunction<_GenerateOpts1C, _GenerateOpts1Dart>( - 'aimux_image_generate'); - late final videoGenerate = _lib - .lookupFunction<_GenerateOpts1C, _GenerateOpts1Dart>( - 'aimux_video_generate'); - late final rerank = - _lib.lookupFunction<_GenerateOpts1C, _GenerateOpts1Dart>('aimux_rerank'); - late final search = - _lib.lookupFunction<_GenerateOpts1C, _GenerateOpts1Dart>('aimux_search'); - late final transcriptionGenerate = _lib - .lookupFunction<_GenerateOpts3C, _GenerateOpts3Dart>( - 'aimux_transcription_generate'); - - // Transcription streaming sessions (RFC-0028). - late final transcriptionSessionNew = _lib.lookupFunction< - _TranscriptionSessionNewC, - _TranscriptionSessionNewDart>('aimux_transcription_session_new'); - late final transcriptionPushAudio = _lib.lookupFunction< - _TranscriptionPushAudioC, - _TranscriptionPushAudioDart>('aimux_transcription_push_audio'); - late final transcriptionInputDone = _lib.lookupFunction< - _TranscriptionInputDoneC, - _TranscriptionInputDoneDart>('aimux_transcription_input_done'); - late final transcriptionNextPart = _lib.lookupFunction< - _TranscriptionNextPartC, - _TranscriptionNextPartDart>('aimux_transcription_next_part'); - late final transcriptionSessionDrop = _lib.lookupFunction< - _VoidU64C, - _VoidU64Dart>('aimux_transcription_session_drop'); - late final fileUpload = _lib - .lookupFunction<_GenerateOpts3C, _GenerateOpts3Dart>('aimux_file_upload'); - - // Resource management - late final dropHandle = - _lib.lookupFunction<_DropHandleC, _DropHandleDart>('aimux_drop_handle'); -} - -/// Process-wide FFI table. Lazily created on first use; shared by every -/// multimodal model instance. -final _MultimodalFFI _ffi = _MultimodalFFI(); - -// ───────────────────────────────────────────────────────────────────────────── -// EmbeddingModel -// ───────────────────────────────────────────────────────────────────────────── - -/// An embedding model backed by a native handle. -/// -/// Call [close] to release the native handle. -class EmbeddingModel { - final int _handle; - bool _closed = false; - - EmbeddingModel._(this._handle); - - /// Create an OpenAI embedding model (e.g. text-embedding-3-small). - /// - /// Pass [baseUrl] to target an OpenAI-compatible endpoint. When null, the - /// provider's standard URL is used via `aimux_openai_embedding_new`. - factory EmbeddingModel.openai(String apiKey, String modelId, - {String? baseUrl}) => - EmbeddingModel._(construct2(apiKey, modelId, baseUrl, - _ffi.openaiEmbeddingNew, _ffi.openaiEmbeddingNewWithBase)); - - /// Create a Cohere embedding model (e.g. embed-english-v3.0). - factory EmbeddingModel.cohere(String apiKey, String modelId, - {String? baseUrl}) => - EmbeddingModel._(construct2(apiKey, modelId, baseUrl, - _ffi.cohereEmbeddingNew, _ffi.cohereEmbeddingNewWithBase)); - - /// Create a Google embedding model (e.g. gemini-embedding-001). - factory EmbeddingModel.google(String apiKey, String modelId, - {String? baseUrl}) => - EmbeddingModel._(construct2(apiKey, modelId, baseUrl, - _ffi.googleEmbeddingNew, _ffi.googleEmbeddingNewWithBase)); - - /// Generate embeddings for [valuesJson] (a JSON array of strings). - /// - /// [optsJson] — optional serialized EmbeddingCallOptions. - /// Returns the JSON-serialized EmbeddingResult, or throws [AimuxException]. - String embed(String valuesJson, [String? optsJson]) { - _checkOpen(); - checkJson(valuesJson, 'values_json'); - checkJson(optsJson, 'opts_json', emptyIsDefault: true); - return withUtf8(valuesJson, (valuesPtr) { - final optsPtr = toCStringOrNull(optsJson); - try { - return takeString( - (out) => _ffi.embed(_handle, valuesPtr, optsPtr, out), 'embed'); - } finally { - if (optsPtr != nullptr) calloc.free(optsPtr); - } - }); - } - - /// Release the native handle. Safe to call multiple times. - void close() { - if (!_closed) { - _ffi.dropHandle(_handle); - _closed = true; - } - } - - void _checkOpen() { - if (_closed) throw StateError('EmbeddingModel is closed'); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// SpeechModel (TTS) -// ───────────────────────────────────────────────────────────────────────────── - -/// A speech (text-to-speech) model backed by a native handle. -/// -/// Call [close] to release the native handle. -class SpeechModel { - final int _handle; - bool _closed = false; - - SpeechModel._(this._handle); - - /// Create an OpenAI speech (TTS) model. - factory SpeechModel.openai(String apiKey, String modelId, - {String? baseUrl}) => - SpeechModel._(construct2(apiKey, modelId, baseUrl, _ffi.openaiSpeechNew, - _ffi.openaiSpeechNewWithBase)); - - /// Generate speech audio from [optsJson] (serialized SpeechCallOptions). - /// - /// Returns the JSON-serialized SpeechResult, or throws [AimuxException]. - String generate(String optsJson) { - _checkOpen(); - checkJson(optsJson, 'opts_json'); - return withUtf8(optsJson, (optsPtr) { - return takeString( - (out) => _ffi.speechGenerate(_handle, optsPtr, out), 'speech_generate'); - }); - } - - /// Release the native handle. Safe to call multiple times. - void close() { - if (!_closed) { - _ffi.dropHandle(_handle); - _closed = true; - } - } - - void _checkOpen() { - if (_closed) throw StateError('SpeechModel is closed'); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// TranscriptionModel (STT) -// ───────────────────────────────────────────────────────────────────────────── - -/// A transcription (speech-to-text) model backed by a native handle. -/// -/// Call [close] to release the native handle. -class TranscriptionModel { - final int _handle; - bool _closed = false; - - TranscriptionModel._(this._handle); - - /// Create an OpenAI transcription (STT) model. - factory TranscriptionModel.openai(String apiKey, String modelId, - {String? baseUrl}) => - TranscriptionModel._(construct2(apiKey, modelId, baseUrl, - _ffi.openaiTranscriptionNew, _ffi.openaiTranscriptionNewWithBase)); - - /// Transcribe audio to text. - /// - /// [audioBase64] — base64-encoded audio data. - /// [mediaType] — media type of the audio (e.g. "audio/wav"). - /// [optsJson] — optional serialized TranscriptionCallOptions. - /// Returns the JSON-serialized TranscriptionResult, or throws [AimuxException]. - String generate(String audioBase64, String mediaType, [String? optsJson]) { - _checkOpen(); - checkJson(optsJson, 'opts_json', emptyIsDefault: true); - return withUtf8(audioBase64, (audioPtr) { - return withUtf8(mediaType, (mediaPtr) { - final optsPtr = toCStringOrNull(optsJson); - try { - return takeString( - (out) => _ffi.transcriptionGenerate( - _handle, audioPtr, mediaPtr, optsPtr, out), - 'transcription_generate'); - } finally { - if (optsPtr != nullptr) calloc.free(optsPtr); - } - }); - }); - } - - /// Start a streaming transcription session (RFC-0028) on this model. - /// Requires a model that supports streaming (realtime models such as - /// gpt-realtime-whisper). - /// - /// [optsJson] — optional session options JSON - /// (`{"inputAudioFormat": {...}, "providerOptions", "headers", - /// "includeRawChunks"}`). - /// [abortHandle] — optional abort handle; firing it aborts the session. - TranscriptionSession startStream({String? optsJson, int abortHandle = 0}) { - _checkOpen(); - checkJson(optsJson, 'opts_json', emptyIsDefault: true); - final optsPtr = toCStringOrNull(optsJson); - final int handle; - try { - handle = takeHandle( - (out) => - _ffi.transcriptionSessionNew(_handle, abortHandle, optsPtr, out), - 'transcription_session_new'); - } finally { - if (optsPtr != nullptr) calloc.free(optsPtr); - } - return TranscriptionSession._(handle); - } - - /// Release the native handle. Safe to call multiple times. - void close() { - if (!_closed) { - _ffi.dropHandle(_handle); - _closed = true; - } - } - - void _checkOpen() { - if (_closed) throw StateError('TranscriptionModel is closed'); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// ImageModel -// ───────────────────────────────────────────────────────────────────────────── - -/// An image generation model backed by a native handle. -/// -/// Call [close] to release the native handle. -class ImageModel { - final int _handle; - bool _closed = false; - - ImageModel._(this._handle); - - /// Create an OpenAI image model (e.g. dall-e-3). - factory ImageModel.openai(String apiKey, String modelId, {String? baseUrl}) => - ImageModel._(construct2(apiKey, modelId, baseUrl, _ffi.openaiImageNew, - _ffi.openaiImageNewWithBase)); - - /// Create a Google image model (e.g. gemini-2.5-flash-image). - factory ImageModel.google(String apiKey, String modelId, {String? baseUrl}) => - ImageModel._(construct2(apiKey, modelId, baseUrl, _ffi.googleImageNew, - _ffi.googleImageNewWithBase)); - - /// Generate images from [optsJson] (serialized ImageCallOptions). - /// - /// Returns the JSON-serialized ImageResult, or throws [AimuxException]. - String generate(String optsJson) { - _checkOpen(); - checkJson(optsJson, 'opts_json'); - return withUtf8(optsJson, (optsPtr) { - return takeString( - (out) => _ffi.imageGenerate(_handle, optsPtr, out), 'image_generate'); - }); - } - - /// Release the native handle. Safe to call multiple times. - void close() { - if (!_closed) { - _ffi.dropHandle(_handle); - _closed = true; - } - } - - void _checkOpen() { - if (_closed) throw StateError('ImageModel is closed'); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// VideoModel -// ───────────────────────────────────────────────────────────────────────────── - -/// A video generation model backed by a native handle. -/// -/// Call [close] to release the native handle. -class VideoModel { - final int _handle; - bool _closed = false; - - VideoModel._(this._handle); - - /// Create a Google video model (e.g. veo-3.0). - factory VideoModel.google(String apiKey, String modelId, {String? baseUrl}) => - VideoModel._(construct2(apiKey, modelId, baseUrl, _ffi.googleVideoNew, - _ffi.googleVideoNewWithBase)); - - /// Generate videos from [optsJson] (serialized VideoCallOptions). - /// - /// Returns the JSON-serialized VideoResult, or throws [AimuxException]. - String generate(String optsJson) { - _checkOpen(); - checkJson(optsJson, 'opts_json'); - return withUtf8(optsJson, (optsPtr) { - return takeString( - (out) => _ffi.videoGenerate(_handle, optsPtr, out), 'video_generate'); - }); - } - - /// Release the native handle. Safe to call multiple times. - void close() { - if (!_closed) { - _ffi.dropHandle(_handle); - _closed = true; - } - } - - void _checkOpen() { - if (_closed) throw StateError('VideoModel is closed'); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// RerankingModel -// ───────────────────────────────────────────────────────────────────────────── - -/// A reranking model backed by a native handle. -/// -/// Call [close] to release the native handle. -class RerankingModel { - final int _handle; - bool _closed = false; - - RerankingModel._(this._handle); - - /// Create a Cohere reranking model (e.g. rerank-v3.0). - factory RerankingModel.cohere(String apiKey, String modelId, - {String? baseUrl}) => - RerankingModel._(construct2(apiKey, modelId, baseUrl, - _ffi.cohereRerankingNew, _ffi.cohereRerankingNewWithBase)); - - /// Rerank documents against a query. - /// - /// [optsJson] — serialized RerankingCallOptions. - /// Returns the JSON-serialized RerankingResult, or throws [AimuxException]. - String rerank(String optsJson) { - _checkOpen(); - checkJson(optsJson, 'opts_json'); - return withUtf8(optsJson, (optsPtr) { - return takeString( - (out) => _ffi.rerank(_handle, optsPtr, out), 'rerank'); - }); - } - - /// Release the native handle. Safe to call multiple times. - void close() { - if (!_closed) { - _ffi.dropHandle(_handle); - _closed = true; - } - } - - void _checkOpen() { - if (_closed) throw StateError('RerankingModel is closed'); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// SearchModel -// ───────────────────────────────────────────────────────────────────────────── - -/// A web search model backed by a native handle. -/// -/// Call [close] to release the native handle. -class SearchModel { - final int _handle; - bool _closed = false; - - SearchModel._(this._handle); - - /// Create a Tavily search model. Tavily uses a fixed endpoint, so no model - /// ID is needed — an empty string is passed (the C ABI ignores it). - factory SearchModel.tavily(String apiKey, {String? baseUrl}) => - SearchModel._(construct2(apiKey, '', baseUrl, _ffi.tavilySearchNew, - _ffi.tavilySearchNewWithBase)); - - /// Perform a web search. - /// - /// [optsJson] — serialized SearchCallOptions. - /// Returns the JSON-serialized SearchResult, or throws [AimuxException]. - String search(String optsJson) { - _checkOpen(); - checkJson(optsJson, 'opts_json'); - return withUtf8(optsJson, (optsPtr) { - return takeString( - (out) => _ffi.search(_handle, optsPtr, out), 'search'); - }); - } - - /// Release the native handle. Safe to call multiple times. - void close() { - if (!_closed) { - _ffi.dropHandle(_handle); - _closed = true; - } - } - - void _checkOpen() { - if (_closed) throw StateError('SearchModel is closed'); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Files -// ───────────────────────────────────────────────────────────────────────────── - -/// A files manager (provider file uploads) backed by a native handle. -/// -/// Call [close] to release the native handle. -class Files { - final int _handle; - bool _closed = false; - - Files._(this._handle); - - /// Create an OpenAI files manager. Files take only an API key (no model ID). - factory Files.openai(String apiKey, {String? baseUrl}) { - final handle = withUtf8(apiKey, (keyPtr) { - if (baseUrl == null) { - return takeHandle( - (out) => _ffi.openaiFilesNew(keyPtr, out), 'openai_files_new'); - } - return withUtf8(baseUrl, (basePtr) { - return takeHandle( - (out) => _ffi.openaiFilesNewWithBase(keyPtr, basePtr, out), - 'openai_files_new_with_base'); - }); - }); - return Files._(handle); - } - - /// Upload a file to the provider. - /// - /// [dataBase64] — base64-encoded file data. - /// [mediaType] — media type of the file (e.g. "application/pdf"). - /// [optsJson] — optional serialized UploadFileCallOptions. - /// Returns the JSON-serialized UploadFileResult, or throws [AimuxException]. - String uploadFile(String dataBase64, String mediaType, [String? optsJson]) { - _checkOpen(); - checkJson(optsJson, 'opts_json', emptyIsDefault: true); - return withUtf8(dataBase64, (dataPtr) { - return withUtf8(mediaType, (mediaPtr) { - final optsPtr = toCStringOrNull(optsJson); - try { - return takeString( - (out) => - _ffi.fileUpload(_handle, dataPtr, mediaPtr, optsPtr, out), - 'file_upload'); - } finally { - if (optsPtr != nullptr) calloc.free(optsPtr); - } - }); - }); - } - - /// Release the native handle. Safe to call multiple times. - void close() { - if (!_closed) { - _ffi.dropHandle(_handle); - _closed = true; - } - } - - void _checkOpen() { - if (_closed) throw StateError('Files is closed'); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// TranscriptionSession (RFC-0028 streaming) -// ───────────────────────────────────────────────────────────────────────────── - -/// Thrown by [TranscriptionSession.nextPart] when the stream ended normally -/// (a `Finish` part was delivered earlier). -class AimuxTranscriptionEndedException implements Exception { - @override - String toString() => 'AimuxTranscriptionEndedException: stream ended'; -} - -/// Thrown by [TranscriptionSession.nextPart] when no part arrived within the -/// timeout. The session stays live — call again. -class AimuxTranscriptionTimeoutException implements Exception { - @override - String toString() => 'AimuxTranscriptionTimeoutException: part timeout'; -} - -/// A live streaming-transcription session (RFC-0028): push audio chunks with -/// [pushAudio], mark end-of-audio with [inputDone], then pull transcription -/// parts (JSON `TranscriptionStreamPart`s) with [nextPart]. -/// -/// Call [close] to terminate (aborts the driver; idempotent). -class TranscriptionSession { - final int _handle; - bool _closed = false; - - TranscriptionSession._(this._handle); - - void _checkOpen() { - if (_closed) throw StateError('TranscriptionSession is closed'); - } - - /// Push one binary audio chunk. Blocks while the internal channel is full - /// (backpressure propagation). - void pushAudio(Uint8List audio) { - _checkOpen(); - final ptr = audio.isEmpty - ? Pointer.fromAddress(0) - : calloc(audio.length); - final allocated = audio.isNotEmpty; - try { - if (allocated) { - ptr.asTypedList(audio.length).setAll(0, audio); - } - expectAimuxError(_ffi.transcriptionPushAudio(_handle, ptr, audio.length), - 'transcription_push_audio'); - } finally { - if (allocated) calloc.free(ptr); - } - } - - /// Signal end-of-audio (idempotent). - void inputDone() { - _checkOpen(); - expectFfiError( - _ffi.transcriptionInputDone(_handle), 'transcription_input_done'); - } - - /// Pull the next transcription part (JSON string). - /// - /// Throws [AimuxTranscriptionEndedException] when the stream finished - /// normally, [AimuxTranscriptionTimeoutException] when no part arrived in - /// time (a poll state, not an error — the session stays live; call again). - /// `timeoutMs`: >0 wait at most; 0 immediate poll; negative = wait - /// indefinitely. - String nextPart(int timeoutMs) { - _checkOpen(); - final outPart = calloc>(); - final outState = calloc(); - try { - expectAimuxError( - _ffi.transcriptionNextPart(_handle, timeoutMs, outPart, outState), - 'transcription_next_part'); - switch (outState.value) { - case _nextPartStatePart: - final p = outPart.value; - if (p == nullptr) { - throw StateError('aimux ffi: transcription_next_part: NULL part'); - } - try { - return p.toDartString(); - } finally { - aimuxFreeString(p); - } - case _nextPartStateEnded: - throw AimuxTranscriptionEndedException(); - case _nextPartStateTimeout: - throw AimuxTranscriptionTimeoutException(); - default: - throw StateError( - 'aimux ffi: transcription_next_part: unknown state ${outState.value}'); - } - } finally { - calloc.free(outPart); - calloc.free(outState); - } - } - /// Terminate and release the session (aborts the driver; idempotent). - void close() { - if (!_closed) { - _ffi.transcriptionSessionDrop(_handle); - _closed = true; - } - } +import 'generated/ops.dart' as ops; +import 'aimux.dart'; + +/// A realtime transcription operation created through transcription.start. +/// Model and modality calls use Aimux.call and the manifest's canonical DTOs. +final class TranscriptionOperation { + final Aimux client; + final OpsHandle handle; + TranscriptionOperation(this.client, this.handle); + + Future push(Uint8List audio) => client.push(handle, audio); + Future inputDone() => + client.call(ops.OP_OPERATION_INPUT_DONE, handle: handle); + Future next({int timeoutMs = 0}) => + client.next(handle, timeoutMs: timeoutMs); + Future status() => client.call(ops.OP_OPERATION_STATUS, handle: handle); + Future cancel() => client.call(ops.OP_OPERATION_CANCEL, handle: handle); + Future close() => handle.close(); } diff --git a/bindings/flutter/lib/src/bridge.dart b/bindings/flutter/lib/src/bridge.dart new file mode 100644 index 000000000..083c146d9 --- /dev/null +++ b/bindings/flutter/lib/src/bridge.dart @@ -0,0 +1,74 @@ +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; + +import 'ffi.g.dart'; +import 'native.dart'; + +typedef Notify = Void Function(Pointer); +typedef ReleaseHandle = Void Function(Uint64); + +final class DartBridge { + final DynamicLibrary library; + DartBridge(String? path) : library = openLibrary(path, bridge: true); + + late final tokenNew = library + .lookupFunction< + Pointer Function(Uint64, Pointer>), + Pointer Function(int, Pointer>) + >('aimux_dart_token_new'); + late final tokenDispose = library + .lookupFunction< + Void Function(Pointer), + void Function(Pointer) + >('aimux_dart_token_dispose'); + // Keep finalizers reachable for the isolate lifetime, even if a client and + // all its forgotten handles become unreachable together. + static final Map _finalizers = {}; + late final finalizer = _finalizers.putIfAbsent( + library.lookup('aimux_dart_finalize').address, + () => NativeFinalizer( + library.lookup('aimux_dart_finalize'), + ), + ); + late final create = library + .lookupFunction< + Pointer Function( + Pointer>, + Pointer>, + Uint64, + ), + Pointer Function( + Pointer>, + Pointer>, + int, + ) + >('aimux_dart_bridge_new'); + late final ack = _voidPointer('aimux_dart_bridge_ack'); + late final cancel = _voidPointer('aimux_dart_bridge_cancel'); + late final free = _voidPointer('aimux_dart_bridge_free'); + late final frameFree = library + .lookupFunction< + Void Function(Pointer), + void Function(Pointer) + >('aimux_dart_frame_free'); + late final pending = library + .lookupFunction< + Int32 Function(Pointer), + int Function(Pointer) + >('aimux_dart_bridge_pending'); + late final failure = library + .lookupFunction< + Int32 Function(Pointer), + int Function(Pointer) + >('aimux_dart_bridge_failure'); + late final event = library.lookup>( + 'aimux_dart_bridge_event', + ); + + void Function(Pointer) _voidPointer(String symbol) => + library.lookupFunction< + Void Function(Pointer), + void Function(Pointer) + >(symbol); +} diff --git a/bindings/flutter/lib/src/ffi.g.dart b/bindings/flutter/lib/src/ffi.g.dart new file mode 100644 index 000000000..38c3e2619 --- /dev/null +++ b/bindings/flutter/lib/src/ffi.g.dart @@ -0,0 +1,25 @@ +// Generated by tool/generate_ffi.py from the shared C header. Do not edit. +import 'dart:ffi'; +import 'package:ffi/ffi.dart'; + +typedef NativeEvent = Void Function(Pointer, Pointer); + +final class OpsBindings { + final DynamicLibrary library; + OpsBindings(this.library); + + late final abiVersion = library.lookupFunction('aimux_abi_version'); + late final manifestJson = library.lookupFunction Function(Pointer>), Pointer Function(Pointer>)>('aimux_manifest_json'); + late final call = library.lookupFunction Function(Uint64, Pointer, Pointer, Uint64, Pointer>), Pointer Function(int, Pointer, Pointer, int, Pointer>)>('aimux_call'); + late final stream = library.lookupFunction Function(Uint64, Pointer, Pointer, Uint64, Pointer>, Pointer), Pointer Function(int, Pointer, Pointer, int, Pointer>, Pointer)>('aimux_stream'); + late final operationPush = library.lookupFunction Function(Uint64, Pointer, UintPtr), Pointer Function(int, Pointer, int)>('aimux_operation_push'); + late final operationNextPart = library.lookupFunction Function(Uint64, Int64, Pointer>, Pointer), Pointer Function(int, int, Pointer>, Pointer)>('aimux_operation_next_part'); + late final handleFinalizer = library.lookupFunction), void Function(Pointer)>('aimux_handle_finalizer'); + late final abort = library.lookupFunction('aimux_abort'); + late final drop = library.lookupFunction('aimux_drop'); + late final freeString = library.lookupFunction), void Function(Pointer)>('aimux_free_string'); + late final errorFree = library.lookupFunction), void Function(Pointer)>('aimux_error_free'); + late final errorCode = library.lookupFunction), int Function(Pointer)>('aimux_error_code'); + late final errorMessage = library.lookupFunction Function(Pointer), Pointer Function(Pointer)>('aimux_error_message'); + late final errorJson = library.lookupFunction Function(Pointer), Pointer Function(Pointer)>('aimux_error_json'); +} diff --git a/bindings/flutter/lib/src/native.dart b/bindings/flutter/lib/src/native.dart new file mode 100644 index 000000000..707cdbb0e --- /dev/null +++ b/bindings/flutter/lib/src/native.dart @@ -0,0 +1,238 @@ +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +import '../generated/ops.dart' as ops; +import '../errors.dart'; +import 'ffi.g.dart'; + +DynamicLibrary openLibrary(String? path, {bool bridge = false}) { + if (path != null) return DynamicLibrary.open(path); + if (Platform.isIOS) return DynamicLibrary.process(); + final name = bridge ? 'aimux_dart' : 'aimux_ffi'; + if (Platform.isAndroid || Platform.isLinux) { + return DynamicLibrary.open('lib$name.so'); + } + if (Platform.isMacOS) return DynamicLibrary.open('lib$name.dylib'); + if (Platform.isWindows) return DynamicLibrary.open('$name.dll'); + throw UnsupportedError('Unsupported native platform'); +} + +// Reject values that dart:ffi would silently truncate or replace. +void checkString(String value, {bool allowNul = false}) { + final units = value.codeUnits; + for (var i = 0; i < units.length; i++) { + final unit = units[i]; + if (unit == 0 && !allowNul) + throw const FormatException('Interior NUL in native string'); + if (unit >= 0xd800 && unit <= 0xdbff) { + if (++i >= units.length || units[i] < 0xdc00 || units[i] > 0xdfff) { + throw const FormatException('Unpaired UTF-16 surrogate'); + } + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + throw const FormatException('Unpaired UTF-16 surrogate'); + } + } +} + +void checkJsonValue(Object? value) { + if (value is String) checkString(value, allowNul: true); + if (value is List) value.forEach(checkJsonValue); + if (value is Map) { + for (final entry in value.entries) { + if (entry.key is! String) + throw const FormatException('JSON key is not a string'); + checkJsonValue(entry.key); + checkJsonValue(entry.value); + } + } +} + +String encodeParams(Object? value) { + checkJsonValue(value); + return jsonEncode(value); +} + +int parseHandle(String wire) { + if (!RegExp(r'^[1-9][0-9]*$').hasMatch(wire)) { + throw const FormatException('Handle must be a nonzero decimal string'); + } + final number = BigInt.parse(wire); + if (number > (BigInt.one << 64) - BigInt.one) { + throw const FormatException('Handle exceeds uint64'); + } + return number.toSigned(64).toInt(); +} + +final class NativeApi { + final OpsBindings ffi; + NativeApi(String? path) : ffi = OpsBindings(openLibrary(path)); + + String takeString(Pointer value) { + if (value == nullptr) + throw const FormatException('Missing native JSON output'); + try { + return value.toDartString(); + } finally { + ffi.freeString(value); + } + } + + void checkError(Pointer value) { + if (value == nullptr) return; + try { + throw OpsException.fromJson( + jsonDecode(takeString(ffi.errorJson(value))) as Map, + ); + } finally { + ffi.errorFree(value); + } + } + + Object? output(Pointer Function(Pointer>) invoke) { + final out = calloc>(); + try { + checkError(invoke(out)); + final ptr = out.value; + out.value = nullptr; + return jsonDecode(takeString(ptr)); + } finally { + if (out.value != nullptr) ffi.freeString(out.value); + calloc.free(out); + } + } + + Object? call(int handle, String op, String params, int abort) { + final name = op.toNativeUtf8(); + final json = params.toNativeUtf8(); + try { + return output((out) => ffi.call(handle, name, json, abort, out)); + } finally { + calloc.free(name); + calloc.free(json); + } + } +} + +// Keep the isolate closure free of UI objects, FFI pointers and Finalizables. +final class NativeRequest { + final String? libraryPath; + final String action; + final int handle, abort, timeout; + final String op, params; + final Uint8List? bytes; + const NativeRequest( + this.libraryPath, + this.action, { + this.handle = 0, + this.abort = 0, + this.timeout = 0, + this.op = '', + this.params = '{}', + this.bytes, + }); +} + +Future execute(NativeRequest request) => + Isolate.run(() => _execute(request)); + +Object? _execute(NativeRequest r) { + final api = NativeApi(r.libraryPath); + switch (r.action) { + case 'load': + // Lookup all mandatory primitives before accepting the library. + [ + api.ffi.call, + api.ffi.stream, + api.ffi.operationPush, + api.ffi.operationNextPart, + api.ffi.abort, + api.ffi.drop, + api.ffi.handleFinalizer, + api.ffi.freeString, + api.ffi.errorFree, + api.ffi.errorCode, + api.ffi.errorMessage, + api.ffi.errorJson, + ]; + final version = api.ffi.abiVersion(); + if (version >> 16 != ops.ABI_VERSION >> 16) + throw StateError('Unsupported aimux ABI: $version'); + final manifest = api.output(api.ffi.manifestJson) as Map; + validateManifest(version, manifest); + return manifest; + case 'call': + return api.call(r.handle, r.op, r.params, r.abort); + case 'drop': + api.ffi.drop(r.handle); + return null; + case 'push': + final bytes = r.bytes!; + final ptr = bytes.isEmpty + ? nullptr.cast() + : calloc(bytes.length); + try { + if (bytes.isNotEmpty) ptr.asTypedList(bytes.length).setAll(0, bytes); + api.checkError(api.ffi.operationPush(r.handle, ptr, bytes.length)); + return null; + } finally { + if (ptr != nullptr) calloc.free(ptr); + } + case 'poll': + final out = calloc>(); + final state = calloc(); + try { + api.checkError( + api.ffi.operationNextPart(r.handle, r.timeout, out, state), + ); + switch (state.value) { + case 1: + final ptr = out.value; + out.value = nullptr; + return {'state': 'part', 'part': jsonDecode(api.takeString(ptr))}; + case 2: + case 3: + if (out.value != nullptr) + throw const FormatException('JSON on empty poll'); + return {'state': state.value == 2 ? 'ended' : 'timeout'}; + default: + throw FormatException('Invalid poll state: ${state.value}'); + } + } finally { + if (out.value != nullptr) api.ffi.freeString(out.value); + calloc.free(out); + calloc.free(state); + } + default: + throw StateError('Unknown native executor action'); + } +} + +void validateManifest(int version, Map manifest) { + final opsVersion = manifest['opsVersion']; + if (manifest['abiVersion'] != version || + opsVersion != ops.OPS_VERSION || + manifest['digest'] != ops.MANIFEST_DIGEST || + manifest['manifestRevision'] == null || + manifest['ops'] is! List || + manifest['handleKinds'] is! List || + manifest['errorCodes'] is! List) { + throw StateError( + 'Incompatible aimux manifest: opsVersion $opsVersion ' + '(expected ${ops.OPS_VERSION}), digest ${manifest['digest']} ' + '(expected ${ops.MANIFEST_DIGEST})', + ); + } + final names = {}; + for (final op in manifest['ops'] as List) { + if (op is! Map || + op['name'] is! String || + !names.add(op['name'] as String)) { + throw StateError('Invalid aimux operation descriptor'); + } + } +} diff --git a/bindings/flutter/lib/src/stream_bridge.dart b/bindings/flutter/lib/src/stream_bridge.dart new file mode 100644 index 000000000..5da019dd0 --- /dev/null +++ b/bindings/flutter/lib/src/stream_bridge.dart @@ -0,0 +1,350 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:ffi/ffi.dart'; + +/// A sendable producer constructed in the worker isolate. [run] may block in +/// aimux_stream. Its synchronous native callback calls [StreamBridgeSink.add] +/// for event JSON and [StreamBridgeSink.end] for the terminal frame. Errors +/// returned before native acceptance may be thrown from [run]. +/// +/// Callback exceptions must be caught inside the FFI callback: call +/// [StreamBridgeSink.fail] and abort native work, without unwinding across C. +abstract class BlockingStreamProducer { + FutureOr run(StreamBridgeSink sink); +} + +typedef StreamProducerFactory = BlockingStreamProducer Function( + Object? request, +); + +/// Lossless single-subscription bridge for the synchronous C stream ABI. +/// +/// The factory and request must be isolate-sendable. [abort] runs in the caller +/// isolate and must be a nonblocking aimux_abort wrapper. Cancellation completes +/// only after the worker returns, keeping callback resources alive until then. +/// Each credit reserves [maxEventBytes], so outstanding data never exceeds +/// [credits] items or 8 MiB. Terminal messages do not consume credits. +/// +/// Uses a POSIX pipe on the binding's Android/iOS targets (also Linux/macOS). +/// Unlike a ReceivePort, the pipe can wake a synchronous native callback while +/// the worker isolate's event loop is blocked. +class StreamBridge { + StreamBridge({ + required StreamProducerFactory producer, + Object? request, + required void Function() abort, + int credits = 8, + int maxEventBytes = 1024 * 1024, + }) : _producer = producer, + _request = request, + _abort = abort, + _credits = credits, + _maxEventBytes = maxEventBytes { + if (credits < 1 || credits > 256) { + throw ArgumentError.value(credits, 'credits', 'must be in 1..256'); + } + if (maxEventBytes < 1 || + maxEventBytes > 1024 * 1024 || + credits * maxEventBytes > 8 * 1024 * 1024) { + throw ArgumentError.value( + maxEventBytes, + 'maxEventBytes', + 'must be in 1..1 MiB with total credit reservation <= 8 MiB', + ); + } + _controller = StreamController( + sync: true, + onListen: _start, + onPause: () => _paused = true, + onResume: _resume, + onCancel: _cancel, + ); + } + + final StreamProducerFactory _producer; + final Object? _request; + final void Function() _abort; + final int _credits; + final int _maxEventBytes; + late final StreamController _controller; + final Completer _stopped = Completer(); + _CreditPipe? _pipe; + ReceivePort? _messages; + bool _paused = false; + bool _cancelled = false; + bool _terminal = false; + int _pendingAcks = 0; + + Stream get stream => _controller.stream; + + /// Completes after worker exit and descriptor cleanup, including cancellation. + Future get done => _stopped.future; + + Future _start() async { + try { + _pipe = _CreditPipe.create(); + _messages = ReceivePort(); + _messages!.listen(_message); + // Share one mailbox for event, terminal, failure and exit messages so + // cleanup cannot overtake accepted events. + await Isolate.spawn<_WorkerRequest>( + _runWorker, + _WorkerRequest( + _producer, + _request, + _pipe!.readFd, + _maxEventBytes, + _messages!.sendPort, + ), + onExit: _messages!.sendPort, + onError: _messages!.sendPort, + errorsAreFatal: true, + ); + if (_stopped.isCompleted) return; + if (_cancelled) { + _pipe!.send(0); + } else { + for (var i = 0; i < _credits; i++) { + _pipe!.send(1); + } + } + } catch (error, stack) { + _finish(error, stack); + _cleanup(); + } + } + + void _message(dynamic message) { + if (message == null) { + if (!_terminal && !_cancelled) { + _finish(StateError('Stream worker exited without a terminal frame')); + } + _cleanup(); + } else if (message is _WorkerEvent) { + if (_cancelled || _terminal) return; + _controller.add(message.json); + // A synchronous listener may pause or cancel while processing this event. + if (!_cancelled && !message.terminal) { + if (_paused) { + _pendingAcks++; + } else { + _pipe!.send(1); + } + } + } else if (message is _WorkerEnd) { + _finish( + message.error, + message.stack == null ? null : StackTrace.fromString(message.stack!), + ); + } else if (message is List) { + _finish(RemoteError('${message[0]}', '${message[1]}')); + } + } + + void _resume() { + _paused = false; + if (_cancelled || _terminal) return; + while (_pendingAcks > 0) { + _pendingAcks--; + _pipe!.send(1); + } + } + + Future _cancel() { + if (!_terminal && !_cancelled) { + _cancelled = true; + try { + _abort(); + } finally { + // Independent of credit count: wake a callback blocked in read(). + _pipe?.send(0); + } + } + return done; + } + + void _finish(Object? error, [StackTrace? stack]) { + if (_terminal || _cancelled) return; + _terminal = true; + if (error != null) _controller.addError(error, stack); + unawaited(_controller.close()); + } + + void _cleanup() { + _messages?.close(); + _pipe?.close(); + _pipe = null; + if (!_stopped.isCompleted) _stopped.complete(); + } +} + +/// Synchronous worker-side callback sink. A false [add] result means the +/// consumer cancelled; the native adapter should stop submitting events. +class StreamBridgeSink { + StreamBridgeSink._(this._port, this._readFd, this._maxEventBytes); + + final SendPort _port; + final int _readFd; + final int _maxEventBytes; + bool _terminal = false; + bool _cancelled = false; + + bool add(String eventJson) { + if (_terminal || _cancelled) return false; + if (utf8.encode(eventJson).length > _maxEventBytes) { + throw StateError( + 'ResourceLimit: stream event exceeds $_maxEventBytes bytes', + ); + } + if (_CreditPipe.receive(_readFd) == 0) { + _cancelled = true; + return false; + } + _port.send(_WorkerEvent(eventJson)); + return true; + } + + /// Deliver one terminal wire frame using the independent terminal slot. + void endFrame(String json) { + if (_terminal) return; + if (utf8.encode(json).length > _maxEventBytes) { + throw StateError( + 'ResourceLimit: terminal frame exceeds $_maxEventBytes bytes'); + } + _port.send(_WorkerEvent(json, terminal: true)); + end(); + } + + /// Call for end(success/cancelled), or pass the original error envelope for + /// end(error). A data part named "error" must still be passed to [add]. + void end([Object? error]) { + if (_terminal) return; + _terminal = true; + _port.send(_WorkerEnd(error, null)); + } + + void fail(Object error, [StackTrace? stack]) { + if (_terminal) return; + _terminal = true; + _port.send(_WorkerEnd(error, stack?.toString())); + } +} + +class _WorkerRequest { + const _WorkerRequest( + this.factory, + this.request, + this.readFd, + this.maxEventBytes, + this.port, + ); + final StreamProducerFactory factory; + final Object? request; + final int readFd; + final int maxEventBytes; + final SendPort port; +} + +class _WorkerEvent { + const _WorkerEvent(this.json, {this.terminal = false}); + final String json; + final bool terminal; +} + +class _WorkerEnd { + const _WorkerEnd(this.error, this.stack); + final Object? error; + final String? stack; +} + +Future _runWorker(_WorkerRequest request) async { + final sink = StreamBridgeSink._( + request.port, + request.readFd, + request.maxEventBytes, + ); + try { + await request.factory(request.request).run(sink); + if (!sink._terminal) { + sink.fail(StateError('Native stream returned without a terminal frame')); + } + } catch (error, stack) { + sink.fail(error, stack); + } +} + +// File descriptors belong to the process, so only the main isolate closes +// them, after worker exit. Keeping the read end open also prevents SIGPIPE +// when cancellation races the final callback. +class _CreditPipe { + _CreditPipe(this.readFd, this.writeFd); + final int readFd; + final int writeFd; + + static final DynamicLibrary _lib = Platform.isWindows + ? throw UnsupportedError('StreamBridge requires POSIX pipes') + : DynamicLibrary.process(); + static final _pipe = _lib.lookupFunction), + int Function(Pointer)>('pipe'); + static final _read = _lib.lookupFunction< + IntPtr Function(Int32, Pointer, IntPtr), + int Function(int, Pointer, int)>('read'); + static final _write = _lib.lookupFunction< + IntPtr Function(Int32, Pointer, IntPtr), + int Function(int, Pointer, int)>('write'); + static final _close = + _lib.lookupFunction('close'); + static final _errno = + _lib.lookupFunction Function(), Pointer Function()>( + Platform.isMacOS || Platform.isIOS + ? '__error' + : (Platform.isAndroid ? '__errno' : '__errno_location'), + ); + + static _CreditPipe create() { + final fds = calloc(2); + try { + if (_pipe(fds) != 0) throw StateError('Cannot create stream credit pipe'); + return _CreditPipe(fds[0], fds[1]); + } finally { + calloc.free(fds); + } + } + + void send(int token) { + final byte = calloc(); + try { + byte.value = token; + int count; + do { + count = _write(writeFd, byte, 1); + } while (count < 0 && _errno().value == 4); // EINTR + if (count != 1) throw StateError('Cannot acknowledge stream credit'); + } finally { + calloc.free(byte); + } + } + + static int receive(int fd) { + final byte = calloc(); + try { + int count; + do { + count = _read(fd, byte, 1); + } while (count < 0 && _errno().value == 4); + if (count != 1) throw StateError('Stream credit pipe closed'); + return byte.value; + } finally { + calloc.free(byte); + } + } + + void close() { + _close(readFd); + _close(writeFd); + } +} diff --git a/bindings/flutter/lib/tool_call_repair.dart b/bindings/flutter/lib/tool_call_repair.dart index 2d8686179..83c8f954c 100644 --- a/bindings/flutter/lib/tool_call_repair.dart +++ b/bindings/flutter/lib/tool_call_repair.dart @@ -1,230 +1,16 @@ -// tool_call_repair.dart — host-side tool-call repair (RFC-0035). -// -// aimux core is single-call: an unparseable tool call never fails generation, -// it comes back as a tool call with `"invalid": true`. Repair is therefore -// post-processing done here: run the normal generate/stream call with no hook, -// find the invalid calls, run the user's [RepairToolCall] on this isolate, and -// hand the reply to three pure native functions that re-validate and patch. -// Only JSON crosses the ABI — no handles, no callbacks registered with native, -// no sessions. -// -// The three functions take data only (no model handle, no tokio runtime, no -// I/O), so they are safe to call anywhere, including from a stream callback. -// -// [TypedModel] wires both helpers up; they are public so a caller driving the -// raw `Model` API can reuse the same loop. - -import 'dart:async'; -import 'dart:convert'; -import 'dart:ffi'; - -import 'package:ffi/ffi.dart'; - -import 'aimux.dart' show promptToJson; -import 'errors.dart'; -import 'types.dart'; - -// ───────────────────────────────────────────────────────────────────────────── -// FFI (aimux-ffi.h §"Host-side tool-call repair") -// ───────────────────────────────────────────────────────────────────────────── - -// (const char *, const char *, const char *, char **out_json) → error: -// aimux_tool_call_repair_context / aimux_apply_tool_call_repair. The trailing -// string arguments are spelled nullable because `opts_json` is — it follows -// the usual NULL/empty = defaults rule. These calls take no handle, so the C -// and Dart signatures are identical and one alias serves both. -typedef _Repair3C = Pointer Function(Pointer, Pointer?, - Pointer?, Pointer>); - -// (result_json, opts_json, tool_call_id, reply_json, char **out_json) → error. -typedef _Repair4C = Pointer Function(Pointer, Pointer?, - Pointer, Pointer, Pointer>); - -final DynamicLibrary _lib = openAimuxLibrary(); - -final _repairContext = - _lib.lookupFunction<_Repair3C, _Repair3C>('aimux_tool_call_repair_context'); -final _applyRepair = - _lib.lookupFunction<_Repair3C, _Repair3C>('aimux_apply_tool_call_repair'); -final _applyRepairToResult = _lib.lookupFunction<_Repair4C, _Repair4C>( - 'aimux_apply_tool_call_repair_to_result'); - -/// `(required, optional, optional) → json` — the shape both three-argument -/// exports share. -String _call3(_Repair3C fn, String a, String? b, String? c, String context) { - return withUtf8(a, (pa) { - final pb = toCStringOrNull(b); - final pc = toCStringOrNull(c); - try { - return takeString((out) => fn(pa, pb, pc, out), context); - } finally { - if (pb != nullptr) calloc.free(pb); - if (pc != nullptr) calloc.free(pc); - } - }); -} - -/// `(result_json, opts_json?, tool_call_id, reply_json) → json`. -String _call4(_Repair4C fn, String a, String? b, String c, String d, - String context) { - return withUtf8(a, (pa) { - return withUtf8(c, (pc) { - return withUtf8(d, (pd) { - final pb = toCStringOrNull(b); - try { - return takeString((out) => fn(pa, pb, pc, pd, out), context); - } finally { - if (pb != nullptr) calloc.free(pb); - } - }); - }); - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// One call: context → user hook → reply -// ───────────────────────────────────────────────────────────────────────────── - -/// The repair argument for one invalid [call], or null when the call was made -/// without a tool set — the AI SDK never repairs such a call, and the core -/// decides it, so null means "skip this one". -ToolCallRepairContext? _contextFor( - Map call, String promptJson, String? optsJson) { - final json = jsonDecode(_call3(_repairContext, jsonEncode(call), promptJson, - optsJson, 'tool_call_repair_context')); - if (json == null) return null; - return ToolCallRepairContext.fromJson(json as Map); -} - -Map _replyFor(RawToolCall? replacement) => replacement == null - ? const {'type': 'unchanged'} - : {'type': 'repaired', 'toolCall': replacement.toJson()}; - -/// A thrown object has no typed counterpart in the core, so it becomes -/// `failed` — the core turns that into `ToolCallRepair{cause: Other(message)}`. -/// -/// The message is the thrown object's `toString()`, which the caller does not -/// control the way it controls a replacement call — so an unpaired surrogate -/// in it is replaced rather than rejected by [encodeJson]: the UTF-8 round -/// trip writes U+FFFD for one. -Map _failedReply(Object error) => - {'type': 'failed', 'message': utf8.decode(utf8.encode('$error'))}; - -/// The synchronous entry points cannot await, so a [Future] return is a -/// programming error rather than a repair failure and must not be swallowed -/// into a `failed` reply. -Never _asyncHookInSyncCall() => throw StateError( - 'repairToolCall returned a Future, but this generate call is synchronous ' - '— use a synchronous hook here, or repair on the streamText path'); - -// ───────────────────────────────────────────────────────────────────────────── -// Non-streaming: patch a whole result document -// ───────────────────────────────────────────────────────────────────────────── - -/// Repair every invalid tool call in a `generate_text` or consumed-stream -/// [result], returning the patched document (a `generate_object` result has -/// no tool calls). -/// -/// Returns [result] unchanged when `options.repairToolCall` is null. [prompt] -/// and [options] must be the ones the result was generated with: the core -/// derives the tool set, messages and instructions from them. -/// -/// Call this on the raw result map *before* decoding it into typed classes — -/// both `toolCalls` and the matching `responseMessages` part are rewritten. -Map repairResultToolCalls( - Map result, - Object prompt, - GenerateTextOptions? options, -) { - final repair = options?.repairToolCall; - if (repair == null) return result; - - final promptJson = promptToJson(prompt); - final optsJson = encodeJson(options!.toJson(), 'options'); - - var current = result; - // Snapshot the invalid calls first: each patch rewrites the document, and a - // repair may even rename its call, but the ids collected here stay the ones - // still to be offered. One attempt per call (AI SDK). - for (final call in _invalidToolCalls(current)) { - final context = _contextFor(call, promptJson, optsJson); - if (context == null) continue; - - // Only what the hook itself throws becomes a `failed` reply; the - // async-hook check below stays a real error, so it runs outside the catch. - Map? reply; - FutureOr replacement; - try { - replacement = repair(context); - } catch (e) { - replacement = null; - reply = _failedReply(e); - } - if (reply == null) { - if (replacement is Future) _asyncHookInSyncCall(); - reply = _replyFor(replacement); - } - - current = jsonDecode(_call4( - _applyRepairToResult, - jsonEncode(current), - optsJson, - call['toolCallId'] as String, - encodeJson(reply, 'repairToolCall reply'), - 'apply_tool_call_repair_to_result')) as Map; - } - return current; -} - -/// The invalid calls of a result document: the top-level `toolCalls` of a -/// text result. A `generate_object` result has none. -List> _invalidToolCalls(Map result) { - final calls = result['toolCalls']; - if (calls is! List) return const []; - return calls - .whereType>() - .where((c) => c['invalid'] == true) - .toList(); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Streaming: patch the tool-call part in flight -// ───────────────────────────────────────────────────────────────────────────── - -/// Replace every invalid `tool-call` part of [parts] with its repaired version. -/// -/// Returns [parts] unchanged when `options.repairToolCall` is null. Every other -/// part — tool-input deltas included — passes through untouched and in order -/// (the AI SDK never holds argument deltas back), which `asyncMap` guarantees -/// by awaiting one element before pulling the next. -Stream> repairStreamToolCalls( - Stream> parts, - Object prompt, - GenerateTextOptions? options, -) { - final repair = options?.repairToolCall; - if (repair == null) return parts; - - final promptJson = promptToJson(prompt); - final optsJson = encodeJson(options!.toJson(), 'options'); - - return parts.asyncMap((part) async { - // `{"type": "tool-call", ...ToolCall}`: the call is the part minus its tag. - if (part['type'] != 'tool-call' || part['invalid'] != true) return part; - final call = {...part}..remove('type'); - - final context = _contextFor(call, promptJson, optsJson); - if (context == null) return part; - - Map reply; - try { - reply = _replyFor(await repair(context)); - } catch (e) { - reply = _failedReply(e); - } - - final patched = _call3(_applyRepair, jsonEncode(call), optsJson, - encodeJson(reply, 'repairToolCall reply'), 'apply_tool_call_repair'); - return {'type': 'tool-call', ...jsonDecode(patched) as Map}; - }); +import 'generated/ops.dart' as ops; +import 'aimux.dart'; + +/// Pure repair DTO operations. Host repair functions remain on the caller's +/// isolate; they are never installed as native callbacks. +final class ToolCallRepair { + final Aimux client; + ToolCallRepair(this.client); + + Future context(Map params) => + client.call(ops.OP_TOOL_REPAIR_CONTEXT, params: params); + Future apply(Map params) => + client.call(ops.OP_TOOL_REPAIR_APPLY, params: params); + Future applyResult(Map params) => + client.call(ops.OP_TOOL_REPAIR_APPLY_RESULT, params: params); } diff --git a/bindings/flutter/lib/typed_model.dart b/bindings/flutter/lib/typed_model.dart index d9149945d..14039676a 100644 --- a/bindings/flutter/lib/typed_model.dart +++ b/bindings/flutter/lib/typed_model.dart @@ -1,187 +1,68 @@ -// typed_model.dart — typed wrapper around the raw dart:ffi `Model`. -// -// Eliminates the JSON/Map boundary at the FFI edge: inputs and outputs are -// typed classes (see `types.dart`). The raw `Model` API in `aimux.dart` is -// left unchanged — this layer only (de)serializes at the boundary, so any -// existing caller using `Model.generateText` / `Model.streamText` keeps -// working byte-for-byte. - -import 'dart:async'; - -import 'package:aimux/aimux.dart'; -import 'package:aimux/tool_call_repair.dart'; -import 'package:aimux/types.dart'; - -/// A typed facade over [Model]. -/// -/// Wraps the raw `Map`-based API with typed classes: -/// [GenerateTextOptions] in, [GenerateTextResult] out, [StreamPart]s out of -/// `streamText`. Construct with a [Model] (e.g. `Model.openai(...)`); call -/// [close] to release the native handle. -/// -/// Example: -/// ```dart -/// final model = Model.openai(apiKey, 'gpt-4o', baseUrl: baseUrl); -/// final typed = TypedModel(model); -/// try { -/// final result = typed.generateText('Hello', GenerateTextOptions(temperature: 0.7)); -/// print(result.text); -/// } finally { -/// typed.close(); -/// } -/// ``` -class TypedModel { - final Model _raw; - - TypedModel(this._raw); - - /// Generate text from a string [prompt]. - /// - /// [options] are serialized and passed to the underlying FFI call. Returns - /// a typed [GenerateTextResult]. Throws if the provider returns an error. - GenerateTextResult generateText( - String prompt, [ - GenerateTextOptions? options, - ]) { - final result = _raw.generateText(prompt, options?.toJson()); - return GenerateTextResult.fromJson( - repairResultToolCalls(result, prompt, options)); - } - - /// Generate text from a list of typed [ModelMessage]s (multi-turn). - /// - /// Each message is serialized with [ModelMessage.toJson] and forwarded as a - /// multi-message prompt (the same shape `Model.generateText` accepts). - GenerateTextResult generateTextMessages( - List messages, [ - GenerateTextOptions? options, - ]) { - final prompt = messages.map((m) => m.toJson()).toList(); - final result = _raw.generateText(prompt, options?.toJson()); - return GenerateTextResult.fromJson( - repairResultToolCalls(result, prompt, options)); - } - - // ── generateObject (M12, RFC-0016) ────────────────────────────────────── - - /// Generate a structured JSON object from a string [prompt] (M12). - /// - /// Same signature as [generateText]; returns a typed - /// [GenerateObjectResult]. Pass `output: {'type': 'object', 'schema': ...}` via - /// [options] to describe the object; the model's text must parse as JSON - /// and match the schema. - GenerateObjectResult generateObject( - String prompt, [ - GenerateTextOptions? options, - ]) { - final result = _raw.generateObject(prompt, options?.toJson()); - return GenerateObjectResult.fromJson( - repairResultToolCalls(result, prompt, options)); - } - - /// Generate a structured JSON object from a list of typed [ModelMessage]s - /// (M12). - GenerateObjectResult generateObjectMessages( - List messages, [ - GenerateTextOptions? options, - ]) { - final prompt = messages.map((m) => m.toJson()).toList(); - final result = _raw.generateObject(prompt, options?.toJson()); - return GenerateObjectResult.fromJson( - repairResultToolCalls(result, prompt, options)); - } - - // ── consumeStreamText (M11, RFC-0016) ─────────────────────────────────── - - /// Consume a stream to completion from a string [prompt] and return the - /// aggregated result (M11). Synchronous (blocks until the stream finishes). - GenerateTextResult consumeStreamText( - String prompt, [ - GenerateTextOptions? options, - ]) { - final result = _raw.consumeStreamText(prompt, options?.toJson()); - return GenerateTextResult.fromJson( - repairResultToolCalls(result, prompt, options)); - } - - /// Consume a stream to completion from a list of typed [ModelMessage]s - /// and return the aggregated result (M11). - GenerateTextResult consumeStreamTextMessages( - List messages, [ - GenerateTextOptions? options, - ]) { - final prompt = messages.map((m) => m.toJson()).toList(); - final result = _raw.consumeStreamText(prompt, options?.toJson()); - return GenerateTextResult.fromJson( - repairResultToolCalls(result, prompt, options)); - } - - /// Stream text, yielding typed [StreamPart]s. - /// - /// [prompt] may be a `String` or a `List>` of messages, - /// matching the raw `Model.streamText` contract. The stream blocks the - /// calling isolate until completion (see `aimux.dart` for FFI semantics). - Stream streamText( - Object prompt, [ - GenerateTextOptions? options, - ]) { - return repairStreamToolCalls( - _raw.streamText(prompt, options?.toJson()), prompt, options) - .map(StreamPart.fromJson); - } - - // ── OpenAI-compatible output (RFC-0026) ───────────────────────────────── - - /// Generate text (non-streaming) with OpenAI Chat Completions output. - /// - /// Returns a typed [ChatCompletion]. Throws if the provider returns an error. - ChatCompletion generateTextAsOpenAI( - String prompt, [ - GenerateTextOptions? options, - ]) { - return _generateTextAsOpenAI(prompt, options); - } - - /// Generate text from a list of typed [ModelMessage]s with OpenAI Chat - /// Completions output. - ChatCompletion generateTextAsOpenAIMessages( - List messages, [ - GenerateTextOptions? options, - ]) { - final prompt = messages.map((m) => m.toJson()).toList(); - return _generateTextAsOpenAI(prompt, options); - } - - /// A [ChatCompletion] has no invalid marker to repair from, so with a - /// [GenerateTextOptions.repairToolCall] the native result is generated, - /// repaired, then converted — its tool calls carry the repaired arguments. - ChatCompletion _generateTextAsOpenAI( - Object prompt, GenerateTextOptions? options) { - if (options?.repairToolCall == null) { - return ChatCompletion.fromJson( - _raw.generateTextAsOpenAI(prompt, options?.toJson())); +import 'generated/ops.dart' as ops; +import 'aimux.dart'; +import 'types.dart'; + +/// Typed results over the shared ops DTOs. Request maps follow the manifest's +/// request schema; no provider field tables are duplicated in this facade. +final class TypedModel { + final Aimux client; + final OpsHandle handle; + TypedModel(this.client, this.handle); + + Future generateText(Map params) async => + GenerateTextResult.fromJson( + await client.call(ops.OP_TEXT_GENERATE, handle: handle, params: params) + as Map, + ); + + Future generateObject( + Map params, + ) async => + GenerateObjectResult.fromJson( + await client.call(ops.OP_TEXT_GENERATE_OBJECT, handle: handle, params: params) + as Map, + ); + + Future consumeStreamText( + Map params, + ) async => + GenerateTextResult.fromJson( + await client.call(ops.OP_TEXT_CONSUME, handle: handle, params: params) + as Map, + ); + + Future generateTextAsOpenAI( + Map params, + ) async => + ChatCompletion.fromJson( + await client.call(ops.OP_TEXT_GENERATE_OPEN_AI, handle: handle, params: params) + as Map, + ); + + Stream streamText(Map params) => + _events(ops.OP_TEXT_STREAM, params).map(StreamPart.fromJson); + + Stream streamTextAsOpenAI(Map params) => + _events(ops.OP_TEXT_STREAM_OPEN_AI, params).map(ChatCompletionChunk.fromJson); + + Stream> _events( + String op, + Map params, + ) async* { + await for (final frame in client.stream( + op, + handle: handle, + params: params, + )) { + if (frame is EventFrame) yield frame.event as Map; + if (frame is EndFrame) { + if (frame.error != null) throw frame.error!; + if (frame.outcome == 'cancelled') { + throw StateError('Operation cancelled'); + } + } } - final result = repairResultToolCalls( - _raw.generateText(prompt, options!.toJson()), prompt, options); - return ChatCompletion.fromJson(_raw.generateTextResultAsOpenAI(result)); - } - - /// Stream text with OpenAI Chat Completions output, yielding typed - /// [ChatCompletionChunk]s (RFC-0026). - /// - /// [prompt] may be a `String` or a `List>` of messages. - /// Stream options (`include_usage`, `include_reasoning`) are passed via - /// `options.providerOptions` → `openai.stream_options` on the wire. - Stream streamTextAsOpenAI( - Object prompt, [ - GenerateTextOptions? options, - ]) { - return _raw - .streamTextAsOpenAI(prompt, options?.toJson()) - .map(ChatCompletionChunk.fromJson); } - /// Release the native handle. Delegates to the wrapped [Model]; safe to - /// call multiple times. - void close() => _raw.close(); + Future close() => handle.close(); } diff --git a/bindings/flutter/lib/types.dart b/bindings/flutter/lib/types.dart index 5205f088e..8793fde95 100644 --- a/bindings/flutter/lib/types.dart +++ b/bindings/flutter/lib/types.dart @@ -45,9 +45,7 @@ List decodeBase64Bytes(Object? json) { return base64.decode(text); } -// ───────────────────────────────────────────────────────────────────────────── // Shared enums -// ───────────────────────────────────────────────────────────────────────────── enum Role { system('system'), @@ -93,9 +91,7 @@ enum ReasoningEffort { String toJson() => wireValue; } -// ───────────────────────────────────────────────────────────────────────────── // Token usage, finish reason -// ───────────────────────────────────────────────────────────────────────────── /// Input token usage detail with cache breakdown. Mirrors `InputTokenUsage.ts`. class InputTokenUsage { @@ -240,9 +236,7 @@ class FinishReason { Json toJson() => {'unified': unified, if (raw != null) 'raw': raw}; } -// ───────────────────────────────────────────────────────────────────────────── // Tool calls -// ───────────────────────────────────────────────────────────────────────────── /// A parsed tool call requested by the model. Mirrors `ToolCall.ts`. /// @@ -311,9 +305,7 @@ Json _checkTypeTag(Json j, String expected) { return j; } -// ───────────────────────────────────────────────────────────────────────────── // Tool-call repair (RFC-0035) -// ───────────────────────────────────────────────────────────────────────────── /// A tool call as the model emitted it: [input] is the provider's raw argument /// **text**, not a decoded object. Mirrors `RawToolCall.ts`. @@ -444,9 +436,7 @@ class ToolCallRepairContext { typedef RepairToolCall = FutureOr Function( ToolCallRepairContext context); -// ───────────────────────────────────────────────────────────────────────────── // Tools -// ───────────────────────────────────────────────────────────────────────────── class FunctionToolInputExample { final Json input; @@ -567,9 +557,7 @@ class ToolChoice { : ToolChoice._('tool', (json as Json)['toolName'] as String); } -// ───────────────────────────────────────────────────────────────────────────── // Request / response metadata -// ───────────────────────────────────────────────────────────────────────────── /// Mirrors `RequestInfo.ts`. class RequestInfo { @@ -638,9 +626,7 @@ class ResponseMetadata { }; } -// ───────────────────────────────────────────────────────────────────────────── // File data, generated files, sources -// ───────────────────────────────────────────────────────────────────────────── /// Raw bytes or a base64 string — `FileBytes.ts` is `string`, the AI SDK's /// `Uint8Array | string` in JSON (`shared-v4-file-data.ts:7-10`). Binary bytes @@ -941,9 +927,7 @@ final class DocumentSource extends Source { }; } -// ───────────────────────────────────────────────────────────────────────────── // ContentPart (message content) — `{"type": "text", ...}` -// ───────────────────────────────────────────────────────────────────────────── /// The output of a tool call in a `tool-result` part. Mirrors /// `ToolResultOutput.ts`: `{"type":"text"|"error-text","value":"…"}`, @@ -1230,9 +1214,7 @@ final class ContentPartUnknown extends ContentPart { Json toJson() => data; } -// ───────────────────────────────────────────────────────────────────────────── // Messages -// ───────────────────────────────────────────────────────────────────────────── /// A single chat message. Mirrors `ModelMessage.ts`. /// @@ -1263,9 +1245,7 @@ class ModelMessage { } } -// ───────────────────────────────────────────────────────────────────────────── // GenerateResult (provider result) and its content -// ───────────────────────────────────────────────────────────────────────────── /// A content item in a [GenerateResult]. Mirrors `GenerateContent.ts` with the /// provider-level parameters (`RawToolCall`, `RawToolApprovalRequest`, @@ -1478,9 +1458,7 @@ class GenerateResult { }; } -// ───────────────────────────────────────────────────────────────────────────── // Results / options -// ───────────────────────────────────────────────────────────────────────────── /// Result of `generate_text` and of a consumed stream (user-facing), the AI /// SDK's `GenerateTextResult`. Mirrors `GenerateTextResult.ts`. @@ -1801,9 +1779,7 @@ class GenerateTextOptions { }; } -// ───────────────────────────────────────────────────────────────────────────── // Streaming -// ───────────────────────────────────────────────────────────────────────────── /// A single part of the stream returned by `stream_text`. Mirrors /// `TextStreamPart.ts`, the AI SDK's full stream: `start`, then per step @@ -2353,14 +2329,12 @@ final class StreamPartUnknown extends StreamPart { Json toJson() => data; } -// ───────────────────────────────────────────────────────────────────────────── // OpenAI Chat Completions output (RFC-0026). // // Mirrors `aimux-core::openai_output`. Field names are camelCase; `@JsonKey` // maps to the wire's snake_case. The `type` field is JSON `"type"` (Rust // `#[serde(rename = "type")]`) → `toolType`. Arbitrary-JSON fields // (`logprobs`, `annotations`) are `dynamic` / `List?`. -// ───────────────────────────────────────────────────────────────────────────── /// A complete Chat Completion response (non-streaming). Mirrors OpenAI /// `chat.completion`. diff --git a/bindings/flutter/native/CMakeLists.txt b/bindings/flutter/native/CMakeLists.txt new file mode 100644 index 000000000..d4f67c6a9 --- /dev/null +++ b/bindings/flutter/native/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.18) +project(aimux_dart C) +add_library(aimux_dart SHARED aimux_dart.c) +set_target_properties(aimux_dart PROPERTIES C_STANDARD 11 C_VISIBILITY_PRESET hidden) +if(NOT WIN32) + find_package(Threads REQUIRED) + target_link_libraries(aimux_dart PRIVATE Threads::Threads) +endif() diff --git a/bindings/flutter/native/aimux_dart.c b/bindings/flutter/native/aimux_dart.c new file mode 100644 index 000000000..60a85b1ef --- /dev/null +++ b/bindings/flutter/native/aimux_dart.c @@ -0,0 +1,187 @@ +/* Flutter-only transport. No provider logic or Dart callbacks on Rust threads. */ +#include +#include +#include +#ifdef _WIN32 +#include +#define EXPORT __declspec(dllexport) +typedef CRITICAL_SECTION mutex_t; +typedef CONDITION_VARIABLE cond_t; +static void mutex_init(mutex_t *m) { InitializeCriticalSection(m); } +static void mutex_destroy(mutex_t *m) { DeleteCriticalSection(m); } +static void lock(mutex_t *m) { EnterCriticalSection(m); } +static void unlock(mutex_t *m) { LeaveCriticalSection(m); } +static void cond_init(cond_t *c) { InitializeConditionVariable(c); } +static void cond_destroy(cond_t *c) { (void)c; } +static void cond_wait(cond_t *c, mutex_t *m) { SleepConditionVariableCS(c, m, INFINITE); } +static void wake(cond_t *c) { WakeAllConditionVariable(c); } +#else +#include +#define EXPORT __attribute__((visibility("default"))) +typedef pthread_mutex_t mutex_t; +typedef pthread_cond_t cond_t; +static void mutex_init(mutex_t *m) { pthread_mutex_init(m, NULL); } +static void mutex_destroy(mutex_t *m) { pthread_mutex_destroy(m); } +static void lock(mutex_t *m) { pthread_mutex_lock(m); } +static void unlock(mutex_t *m) { pthread_mutex_unlock(m); } +static void cond_init(cond_t *c) { pthread_cond_init(c, NULL); } +static void cond_destroy(cond_t *c) { pthread_cond_destroy(c); } +static void cond_wait(cond_t *c, mutex_t *m) { pthread_cond_wait(c, m); } +static void wake(cond_t *c) { pthread_cond_broadcast(c); } +#endif + +typedef void (*drop_fn)(uint64_t); +typedef struct token { + uint64_t handle; + drop_fn drop; + struct token *next; +} token; +static mutex_t queue_mutex; +static cond_t queue_ready; +static token *queue_head, *queue_tail; +static int queue_running; +#ifdef _WIN32 +static DWORD WINAPI reap(void *unused) +#else +static void *reap(void *unused) +#endif +{ + (void)unused; + for (;;) { + lock(&queue_mutex); + while (!queue_head) cond_wait(&queue_ready, &queue_mutex); + token *item = queue_head; + queue_head = item->next; + if (!queue_head) queue_tail = NULL; + unlock(&queue_mutex); + item->drop(item->handle); + free(item); + } + return 0; +} +#ifdef _WIN32 +static INIT_ONCE queue_once = INIT_ONCE_STATIC_INIT; +static BOOL CALLBACK init_queue(PINIT_ONCE once, void *param, void **ctx) { + (void)once; (void)param; (void)ctx; +#else +static pthread_once_t queue_once = PTHREAD_ONCE_INIT; +static void init_queue(void) { +#endif + mutex_init(&queue_mutex); + cond_init(&queue_ready); +#ifdef _WIN32 + HANDLE thread = CreateThread(NULL, 0, reap, NULL, 0, NULL); + queue_running = thread != NULL; + if (thread) CloseHandle(thread); + return TRUE; +#else + pthread_t thread; + queue_running = pthread_create(&thread, NULL, reap, NULL) == 0; + if (queue_running) pthread_detach(thread); +#endif +} +EXPORT void *aimux_dart_token_new(uint64_t handle, drop_fn drop) { +#ifdef _WIN32 + InitOnceExecuteOnce(&queue_once, init_queue, NULL, NULL); +#else + pthread_once(&queue_once, init_queue); +#endif + if (!queue_running || !handle || !drop) return NULL; + token *item = malloc(sizeof(*item)); + if (item) *item = (token){handle, drop, NULL}; + return item; +} +/* Called only after NativeFinalizer.detach, on the owning Dart isolate. */ +EXPORT void aimux_dart_token_dispose(void *ptr) { free(ptr); } +/* Correct NativeFinalizer ABI; queue cleanup without joining on the GC thread. */ +EXPORT void aimux_dart_finalize(void *ptr) { + token *item = ptr; + if (!item) return; + lock(&queue_mutex); + if (queue_tail) queue_tail->next = item; + else queue_head = item; + queue_tail = item; + wake(&queue_ready); + unlock(&queue_mutex); +} + +typedef void (*notify_fn)(char *owned_frame); +typedef struct bridge { + mutex_t mutex; + cond_t ready; + notify_fn notify; + drop_fn abort; + uint64_t abort_handle; + int pending, cancelled, failure; +} bridge; +EXPORT void *aimux_dart_bridge_new(notify_fn notify, drop_fn abort, uint64_t handle) { + bridge *b = calloc(1, sizeof(*b)); + if (!b) return NULL; + mutex_init(&b->mutex); + cond_init(&b->ready); + b->notify = notify; + b->abort = abort; + b->abort_handle = handle; + return b; +} +EXPORT void aimux_dart_bridge_cancel(void *ptr) { + bridge *b = ptr; + lock(&b->mutex); + b->cancelled = 1; + wake(&b->ready); + unlock(&b->mutex); + b->abort(b->abort_handle); +} +EXPORT void aimux_dart_bridge_ack(void *ptr) { + bridge *b = ptr; + lock(&b->mutex); + b->pending = 0; + wake(&b->ready); + unlock(&b->mutex); +} +EXPORT int32_t aimux_dart_bridge_pending(void *ptr) { + bridge *b = ptr; + lock(&b->mutex); + int pending = b->pending; + unlock(&b->mutex); + return pending; +} +EXPORT int32_t aimux_dart_bridge_failure(void *ptr) { + bridge *b = ptr; + lock(&b->mutex); + int failure = b->failure; + unlock(&b->mutex); + return failure; +} +EXPORT void aimux_dart_bridge_event(const char *frame, void *ptr) { + bridge *b = ptr; + size_t length = 0; + if (frame) while (length <= 1048576 && frame[length]) length++; + lock(&b->mutex); + while (b->pending && !b->cancelled) cond_wait(&b->ready, &b->mutex); + if (b->cancelled) { unlock(&b->mutex); return; } + char *copy = frame && length <= 1048576 ? malloc(length + 1) : NULL; + if (!copy) { + b->failure = frame && length > 1048576 ? 305 : 206; + b->cancelled = 1; + wake(&b->ready); + unlock(&b->mutex); + b->abort(b->abort_handle); + return; + } + memcpy(copy, frame, length + 1); + b->pending = 1; /* Exactly one item/1 MiB of credit, including in-flight. */ + unlock(&b->mutex); + b->notify(copy); /* NativeCallable.listener: posts to its owning isolate. */ + lock(&b->mutex); + while (b->pending && !b->cancelled) cond_wait(&b->ready, &b->mutex); + unlock(&b->mutex); +} +EXPORT void aimux_dart_frame_free(char *frame) { free(frame); } +/* Producer has returned AND the outstanding listener message was acknowledged. */ +EXPORT void aimux_dart_bridge_free(void *ptr) { + bridge *b = ptr; + cond_destroy(&b->ready); + mutex_destroy(&b->mutex); + free(b); +} diff --git a/bindings/flutter/native/aimux_ops.h b/bindings/flutter/native/aimux_ops.h new file mode 100644 index 000000000..f991d83d8 --- /dev/null +++ b/bindings/flutter/native/aimux_ops.h @@ -0,0 +1,182 @@ +/* Generated by aimux-codegen. Do not edit. Manifest SHA-256: 2bc7cb7ec77a0ed20496411f0dbd199b949b9887cc74d119e11627c8573d82a2 */ +#ifndef AIMUX_H +#define AIMUX_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define AIMUX_ABI_VERSION UINT32_C(65536) +#define AIMUX_OPS_VERSION "1.0" +#define AIMUX_MANIFEST_DIGEST "2bc7cb7ec77a0ed20496411f0dbd199b949b9887cc74d119e11627c8573d82a2" + +#define AIMUX_OP_ABORT_CREATE "abort.create" +#define AIMUX_OP_ABORT_SIGNAL "abort.signal" +#define AIMUX_OP_AUTH_CODEX_REFRESH "auth.codexRefresh" +#define AIMUX_OP_CATALOGUE_FETCH "catalogue.fetch" +#define AIMUX_OP_COMPOSITE_MOA "composite.moa" +#define AIMUX_OP_COMPOSITE_ROUTER "composite.router" +#define AIMUX_OP_CREDENTIAL_CREATE "credential.create" +#define AIMUX_OP_DECISION_CAPABILITIES "decision.capabilities" +#define AIMUX_OP_DECISION_DECIDE "decision.decide" +#define AIMUX_OP_EMBEDDING_EMBED "embedding.embed" +#define AIMUX_OP_EMBEDDING_EMBED_MANY "embedding.embedMany" +#define AIMUX_OP_FILES_UPLOAD "files.upload" +#define AIMUX_OP_HANDLE_DROP "handle.drop" +#define AIMUX_OP_IMAGE_GENERATE "image.generate" +#define AIMUX_OP_MIDDLEWARE_CREATE "middleware.create" +#define AIMUX_OP_MODEL_INFO "model.info" +#define AIMUX_OP_MODEL_RESOLVE "model.resolve" +#define AIMUX_OP_MODEL_WRAP "model.wrap" +#define AIMUX_OP_OPERATION_CANCEL "operation.cancel" +#define AIMUX_OP_OPERATION_INPUT_DONE "operation.inputDone" +#define AIMUX_OP_OPERATION_NEXT "operation.next" +#define AIMUX_OP_OPERATION_PUSH "operation.push" +#define AIMUX_OP_OPERATION_STATUS "operation.status" +#define AIMUX_OP_OUTPUT_TO_OPEN_AI "output.toOpenAI" +#define AIMUX_OP_PROVIDER_CREATE "provider.create" +#define AIMUX_OP_PROVIDER_CUSTOM "provider.custom" +#define AIMUX_OP_PROVIDER_DEFAULT "provider.default" +#define AIMUX_OP_PROVIDER_FILES "provider.files" +#define AIMUX_OP_PROVIDER_INVOKE "provider.invoke" +#define AIMUX_OP_PROVIDER_MODEL "provider.model" +#define AIMUX_OP_PROVIDER_WRAP "provider.wrap" +#define AIMUX_OP_RECORDER_ATTACH "recorder.attach" +#define AIMUX_OP_RECORDER_CREATE "recorder.create" +#define AIMUX_OP_RECORDER_EXPORT "recorder.export" +#define AIMUX_OP_RECORDER_FLUSH "recorder.flush" +#define AIMUX_OP_RECORDER_STATS "recorder.stats" +#define AIMUX_OP_RECORDER_STOP "recorder.stop" +#define AIMUX_OP_REGISTRY_CREATE "registry.create" +#define AIMUX_OP_REGISTRY_PROVIDER "registry.provider" +#define AIMUX_OP_REPLAY_CAPABILITIES "replay.capabilities" +#define AIMUX_OP_REPLAY_CREATE "replay.create" +#define AIMUX_OP_REPLAY_DRY_RUN "replay.dryRun" +#define AIMUX_OP_REPLAY_INSPECT "replay.inspect" +#define AIMUX_OP_REPLAY_LIVE "replay.live" +#define AIMUX_OP_RERANKING_RERANK "reranking.rerank" +#define AIMUX_OP_RUNTIME_CONFIGURE "runtime.configure" +#define AIMUX_OP_RUNTIME_DESCRIBE "runtime.describe" +#define AIMUX_OP_SEARCH_SEARCH "search.search" +#define AIMUX_OP_SESSION_STORE_ATTACH "sessionStore.attach" +#define AIMUX_OP_SESSION_STORE_CALLS "sessionStore.calls" +#define AIMUX_OP_SESSION_STORE_CREATE "sessionStore.create" +#define AIMUX_OP_SESSION_STORE_LIST "sessionStore.list" +#define AIMUX_OP_SPEECH_GENERATE "speech.generate" +#define AIMUX_OP_TEXT_CONSUME "text.consume" +#define AIMUX_OP_TEXT_GENERATE "text.generate" +#define AIMUX_OP_TEXT_GENERATE_OBJECT "text.generateObject" +#define AIMUX_OP_TEXT_GENERATE_OPEN_AI "text.generateOpenAI" +#define AIMUX_OP_TEXT_STREAM "text.stream" +#define AIMUX_OP_TEXT_STREAM_OPEN_AI "text.streamOpenAI" +#define AIMUX_OP_TOOL_REPAIR_APPLY "toolRepair.apply" +#define AIMUX_OP_TOOL_REPAIR_APPLY_RESULT "toolRepair.applyResult" +#define AIMUX_OP_TOOL_REPAIR_CONTEXT "toolRepair.context" +#define AIMUX_OP_TRACE_AGGREGATE "trace.aggregate" +#define AIMUX_OP_TRACE_CLEAR "trace.clear" +#define AIMUX_OP_TRACE_CREATE "trace.create" +#define AIMUX_OP_TRACE_EXPORT "trace.export" +#define AIMUX_OP_TRACE_SESSION_CHAIN "trace.sessionChain" +#define AIMUX_OP_TRACE_SESSION_TRAJECTORY "trace.sessionTrajectory" +#define AIMUX_OP_TRANSCRIPTION_GENERATE "transcription.generate" +#define AIMUX_OP_TRANSCRIPTION_START "transcription.start" +#define AIMUX_OP_TRANSPORT_CREATE "transport.create" +#define AIMUX_OP_VIDEO_GENERATE "video.generate" + +typedef struct aimux_error aimux_error_t; + +typedef enum aimux_error_code { + AIMUX_ERROR_NONE = 0, + AIMUX_ERROR_AIMUX_OTHER = 1, + AIMUX_ERROR_AIMUX_JSON_PARSE = 2, + AIMUX_ERROR_AIMUX_INVALID_RESPONSE_DATA = 3, + AIMUX_ERROR_AIMUX_INVALID_ARGUMENT = 5, + AIMUX_ERROR_AIMUX_INVALID_PROMPT = 6, + AIMUX_ERROR_AIMUX_TOKEN_EXPIRED = 7, + AIMUX_ERROR_AIMUX_UNSUPPORTED_FUNCTIONALITY = 8, + AIMUX_ERROR_AIMUX_NO_SUCH_MODEL = 9, + AIMUX_ERROR_AIMUX_NO_SUCH_PROVIDER = 10, + AIMUX_ERROR_AIMUX_API_CALL = 11, + AIMUX_ERROR_AIMUX_TIMEOUT = 12, + AIMUX_ERROR_AIMUX_ABORTED = 13, + AIMUX_ERROR_AIMUX_RETRY = 14, + AIMUX_ERROR_AIMUX_NO_SUCH_TOOL = 15, + AIMUX_ERROR_AIMUX_INVALID_TOOL_INPUT = 16, + AIMUX_ERROR_AIMUX_TOOL_CALL_REPAIR = 17, + AIMUX_ERROR_AIMUX_NO_OUTPUT_GENERATED = 18, + AIMUX_ERROR_AIMUX_LOAD_API_KEY = 19, + AIMUX_ERROR_AIMUX_LOAD_SETTING = 20, + AIMUX_ERROR_RECORDING_INIT = 100, + AIMUX_ERROR_RECORDING_OPEN_FILE = 101, + AIMUX_ERROR_RECORDING_SPAWN = 102, + AIMUX_ERROR_RECORDING_WRITER_GONE = 103, + AIMUX_ERROR_RECORDING_FLUSH_TIMEOUT = 104, + AIMUX_ERROR_RECORDING_WRITE = 105, + AIMUX_ERROR_BINDING_NULL_POINTER = 200, + AIMUX_ERROR_BINDING_INVALID_UTF8 = 201, + AIMUX_ERROR_BINDING_INVALID_WIRE_JSON = 202, + AIMUX_ERROR_BINDING_INVALID_HANDLE = 203, + AIMUX_ERROR_BINDING_REENTRANT_CALL = 204, + AIMUX_ERROR_BINDING_RESULT_SERIALIZATION = 205, + AIMUX_ERROR_BINDING_CALLBACK_FAILURE = 206, + AIMUX_ERROR_PROTOCOL_INVALID_OP = 300, + AIMUX_ERROR_PROTOCOL_INVALID_PARAMS = 301, + AIMUX_ERROR_PROTOCOL_UNSUPPORTED_OPS_VERSION = 302, + AIMUX_ERROR_PROTOCOL_CONCURRENT_CONSUMER = 303, + AIMUX_ERROR_PROTOCOL_INVALID_STATE = 304, + AIMUX_ERROR_PROTOCOL_RESOURCE_LIMIT = 305, + AIMUX_ERROR_PROTOCOL_HANDLE_EXHAUSTED = 306 +} aimux_error_code_t; + +typedef enum aimux_poll_state { + AIMUX_POLL_PART = 1, + AIMUX_POLL_ENDED = 2, + AIMUX_POLL_TIMEOUT = 3 +} aimux_poll_state_t; + +/* frame_json is borrowed until the callback returns; copy it to retain it. + * context must remain valid until aimux_stream returns. */ +typedef void (*aimux_event_cb)(const char *frame_json, void *context); + +/* ABI = (major << 16) | minor. Verify ABI and manifest before calling ops. + * JSON inputs are borrowed NUL-terminated UTF-8, never implicit empty objects. + * Handle 0 is global scope; nonzero IDs are process-owned and never reused. + * Fallible calls return NULL on success or an owned error on failure. + * Outputs are reset to NULL/0 before validation, including on failure. + * All output JSON and error getter strings are owned: aimux_free_string. + * Release errors with aimux_error_free. Do not free callback JSON. + * Blocking calls cannot be reentered from a callback (ReentrantCall). */ +uint32_t aimux_abi_version(void); +aimux_error_t *aimux_manifest_json(char **out_json); +aimux_error_t *aimux_call(uint64_t handle, const char *op, + const char *params_json, uint64_t abort, char **out_json); +aimux_error_t *aimux_stream(uint64_t handle, const char *op, + const char *params_json, uint64_t abort, + aimux_event_cb on_event, void *context); +/* Input bytes are copied/consumed before return. NULL is valid only at length 0. */ +aimux_error_t *aimux_operation_push(uint64_t operation, + const uint8_t *data, size_t length); +/* PART allocates out_part; ENDED/TIMEOUT do not. timeout_ms must be nonnegative. */ +aimux_error_t *aimux_operation_next_part(uint64_t operation, int64_t timeout_ms, + char **out_part, int32_t *out_state); +/* Signal only; callback-safe. Use abort.signal to report an invalid handle. */ +void aimux_abort(uint64_t abort); +/* Drop is idempotent, accepts 0, and cancels resources without implying flush. */ +void aimux_drop(uint64_t handle); +/* Non-blocking finalizer; token encodes the handle in uintptr_t (never dereferenced). */ +void aimux_handle_finalizer(void *token); +/* NULL-safe. Each owned allocation must be freed exactly once. */ +void aimux_free_string(char *string); +void aimux_error_free(aimux_error_t *error); +int32_t aimux_error_code(const aimux_error_t *error); +char *aimux_error_message(const aimux_error_t *error); +char *aimux_error_json(const aimux_error_t *error); + +#ifdef __cplusplus +} +#endif + +#endif /* AIMUX_H */ diff --git a/bindings/flutter/test/aimux_test.dart b/bindings/flutter/test/aimux_test.dart index 72f78ee65..1df6e3109 100644 --- a/bindings/flutter/test/aimux_test.dart +++ b/bindings/flutter/test/aimux_test.dart @@ -1,289 +1,380 @@ -// Tests for aimux Flutter/Dart binding. -// -// These tests do NOT make real API calls — they verify the API surface -// and error handling. They require the native library (libaimux_ffi.so) -// to be on the library path. -// -// Run: LD_LIBRARY_PATH=../../target/release dart test +@Timeout(Duration(minutes: 2)) +library; +import 'dart:async'; import 'dart:ffi'; import 'dart:io'; +import 'dart:isolate'; +import 'dart:typed_data'; import 'package:aimux/aimux.dart'; -import 'package:aimux/errors.dart' - show aimuxDropHandle, aimuxFreeString, expectAimuxError, openAimuxLibrary, withUtf8; -import 'package:aimux/multimodal.dart' show EmbeddingModel; -import 'package:ffi/ffi.dart'; +import 'package:aimux/multimodal.dart'; +import 'package:aimux/src/bridge.dart'; +import 'package:aimux/src/native.dart'; import 'package:test/test.dart'; -// Raw C signatures (aimux-ffi.h / aimux-error.h) for C ABI tests. -typedef _OpenaiNewC = Pointer Function( - Pointer, Pointer, Pointer); -typedef _GenerateTextC = Pointer Function( - Uint64, Pointer, Pointer, Pointer>); -typedef _GenerateTextDart = Pointer Function( - int, Pointer, Pointer, Pointer>); -typedef _IntOfPtrC = Int32 Function(Pointer); -typedef _IntOfPtrDart = int Function(Pointer); -typedef _StrOfPtr = Pointer Function(Pointer); +import 'package:aimux/generated/ops.dart' as ops; -void main() { - // Error transport (aimux-error.h): a failed call returns an opaque - // `aimux_error_t *`; the decoder (expectAimuxError / expectRecordingError) - // reads the unified code, copies the fields, frees the returned error once, - // and throws. Codes 200..206 become StateError('aimux ffi: …'). - group('aimux_error_t → AimuxException / native errors', () { - final lib = openAimuxLibrary(); - final errorCode = lib.lookupFunction<_IntOfPtrC, _IntOfPtrDart>( - 'aimux_error_code'); - final ffiMessage = - lib.lookupFunction<_StrOfPtr, _StrOfPtr>('aimux_error_message'); - final openaiNew = - lib.lookupFunction<_OpenaiNewC, _OpenaiNewC>('aimux_openai_new'); - final generateText = lib.lookupFunction<_GenerateTextC, _GenerateTextDart>( - 'aimux_generate_text'); - - test('unknown provider carries the requested provider id', () { - expect( - () => Model.provider('definitely-not-a-provider', 'm', apiKey: 'k'), - throwsA(isA() - .having((e) => e.providerId, 'providerId', - 'definitely-not-a-provider') - .having((e) => e.status, 'status', -1) - .having((e) => e.retryable, 'retryable', isFalse)), - ); - }); - - // Raw-FFI misuse a correct binding never commits: a NULL argument is a - // C ABI failure code 200 maps to StateError. - test('NULL argument at the raw C ABI → code 200 → StateError', - () { - final out = calloc(); - try { - final e = openaiNew(nullptr, nullptr, out); - expect(e, isNot(nullptr)); - expect(out.value, 0); - expect(errorCode(e), 200); - final m = ffiMessage(e); - expect(m, isNot(nullptr)); - expect(m.toDartString(), contains('api_key')); - aimuxFreeString(m); - expect( - () => expectAimuxError(e, 'openai_new'), // frees the owner - throwsA(isA() - .having((e) => e.message, 'message', contains('aimux ffi:')) - .having((e) => e.message, 'message', contains('api_key'))), - ); - } finally { - calloc.free(out); - } - }); - - test('dead handle at the raw C ABI → StateError', () { - final out = calloc>(); - try { - expect( - () => withUtf8('"hi"', (p) { - final e = generateText(0x7FFFFFFFFFFF, p, nullptr, out); - expect(errorCode(e), 203); - expectAimuxError(e, 'generate_text'); - }), - throwsA(isA() - .having((e) => e.message, 'message', contains('model'))), - ); - expect(out.value, nullptr); - } finally { - calloc.free(out); - } - }); - - // The NativeFinalizer callback: aimux_drop_handle_ptr takes the handle - // as a pointer-sized value (NativeFinalizerFunction), so it is safe on - // 32-bit targets where aimux_drop_handle(uint64_t) is not. - test('aimux_drop_handle_ptr releases the handle its address names', () { - final dropHandlePtr = lib.lookupFunction), - void Function(Pointer)>('aimux_drop_handle_ptr'); - expect(() => lib.lookup('aimux_drop_handle_ptr'), - returnsNormally); - final outHandle = calloc(); - final outJson = calloc>(); - try { - final e = withUtf8('sk-test-fake-key', - (k) => withUtf8('gpt-4o-mini', (m) => openaiNew(k, m, outHandle))); - expect(e, nullptr); - final handle = outHandle.value; - expect(handle, isNot(0)); - dropHandlePtr(Pointer.fromAddress(handle)); - // The handle is gone: a call on it is the dead-handle failure (203). - expect( - () => withUtf8('"hi"', (p) { - final e = generateText(handle, p, nullptr, outJson); - expect(errorCode(e), 203); - expectAimuxError(e, 'generate_text'); - }), - throwsA(isA()), - ); - expect(outJson.value, nullptr); - // NULL is handle 0: a no-op. - dropHandlePtr(nullptr); - } finally { - calloc.free(outHandle); - calloc.free(outJson); - } - }); +String fixture([String name = 'ops_fixture']) => + '${Directory.current.path}/.dart_tool/native/$name.${Platform.isMacOS ? 'dylib' : 'so'}'; - // Bad wire JSON is only reachable when checkJson pre-validation was - // bypassed (raw C call here); it is a C ABI failure → StateError. - test('bad wire JSON at the raw C ABI → StateError', () { - final outHandle = calloc(); - final outJson = calloc>(); - try { - final e = withUtf8('sk-test-fake-key', - (k) => withUtf8('gpt-4o-mini', (m) => openaiNew(k, m, outHandle))); - expect(e, nullptr); - final handle = outHandle.value; - expect(handle, isNot(0)); - try { - expect( - () => withUtf8('{not json', (p) { - final e = generateText(handle, p, nullptr, outJson); - expect(errorCode(e), 202); - expectAimuxError(e, 'generate_text'); - }), - throwsA(isA() - .having((e) => e.message, 'message', contains('prompt_json'))), - ); - } finally { - aimuxDropHandle(handle); - } - } finally { - calloc.free(outHandle); - calloc.free(outJson); - } - }); +void main() { + late Aimux client; + late OpsHandle handle; - test('NULL error is success: expectAimuxError returns', () { - expect(() => expectAimuxError(nullptr, 'noop'), returnsNormally); - }); + setUp(() async { + client = await Aimux.open(libraryPath: fixture(), bridgePath: fixture()); + final result = await client.call('test.handle') as Map; + handle = client.adopt(result['handle'] as String); + await client.call('test.reset'); }); + tearDown(() => handle.close()); - test('Model.openai creates an instance', () { - final model = Model.openai('sk-test-fake-key', 'gpt-4o-mini'); - expect(model, isNotNull); - model.close(); - }); + Future stats() async => + await client.call('test.stats', handle: handle) as Map; - test('Model.anthropic creates an instance', () { - final model = Model.anthropic('sk-ant-test-fake-key', 'claude-3-5-sonnet-20241022'); - expect(model, isNotNull); - model.close(); + test('manifest requires exact generated version and digest', () { + validateManifest(ops.ABI_VERSION, client.manifest); + for (final changes in [ + {'opsVersion': '1.1'}, + {'digest': 'wrong-digest'}, + {'digest': null}, + ]) { + final manifest = {...client.manifest, ...changes}; + expect( + () => validateManifest(ops.ABI_VERSION, manifest), + throwsA( + isA() + .having( + (e) => e.message, + 'actual opsVersion', + contains(manifest['opsVersion']), + ) + .having( + (e) => e.message, + 'expected opsVersion', + contains(ops.OPS_VERSION), + ) + .having( + (e) => e.message, + 'actual digest', + contains('${manifest['digest']}'), + ) + .having( + (e) => e.message, + 'expected digest', + contains(ops.MANIFEST_DIGEST), + ), + ), + ); + } }); - test('raw configJson is validated in Dart before the C call', () { - // FormatException naming the parameter, not AiMuxError.jsonParse. - expect( - () => Model.provider('openai', 'gpt-4o-mini', - apiKey: 'k', configJson: '{invalid json}'), - throwsA(isA() - .having((e) => e.source, 'source', 'config_json')), + test('load validates ABI, manifest and required ops', () async { + expect(client.manifest['opsVersion'], ops.OPS_VERSION); + await expectLater( + Aimux.open(libraryPath: fixture('missing_symbol'), bridgePath: fixture()), + throwsArgumentError, ); - }); - - test('multimodal use-after-close throws StateError', () { - final m = EmbeddingModel.openai('sk-test-fake-key', 'text-embedding-3-small'); - m.close(); - expect( - () => m.embed('["a"]'), - throwsA(isA() - .having((e) => e.message, 'message', contains('closed'))), + await expectLater( + Aimux.open( + libraryPath: fixture('missing_finalizer'), bridgePath: fixture()), + throwsArgumentError, + ); + await expectLater( + Aimux.open(libraryPath: fixture('bad_abi'), bridgePath: fixture()), + throwsStateError, + ); + await expectLater( + Aimux.open(libraryPath: fixture('bad_manifest'), bridgePath: fixture()), + throwsStateError, + ); + await expectLater( + Aimux.open( + libraryPath: fixture(), + bridgePath: fixture(), + requiredOps: ['missing.op'], + ), + throwsStateError, + ); + await Aimux.open( + libraryPath: fixture(), + bridgePath: fixture(), + requiredOps: [ops.OP_RUNTIME_DESCRIBE, ops.OP_TEXT_STREAM], ); }); - test('model close is idempotent', () { - final model = Model.openai('sk-test-fake-key', 'gpt-4o-mini'); - model.close(); - model.close(); // should not throw + test('blocking call leaves the caller isolate responsive', () async { + // The fixture sleeps natively for 180 ms. A caller-side timer must fire + // while that call is still pending, which a blocked isolate cannot do. + var done = false; + final call = client.call('test.sleep').whenComplete(() => done = true); + await Future.delayed(const Duration(milliseconds: 20)); + expect(done, isFalse); + expect(await call, {'ok': true}); }); - test('generateText on closed model throws StateError', () { - final model = Model.openai('sk-test-fake-key', 'gpt-4o-mini'); - model.close(); - // Use-after-close is guarded locally: native StateError, not AimuxException. + test( + 'opaque error keeps unknown payload fields and is delivered once', + () async { + await expectLater( + client.call('test.fail'), + throwsA( + isA().having((e) => e.code, 'code', 203).having( + (e) => e.error['futureField'], + 'futureField', + [1, 2], + ), + ), + ); + }, + ); + + test('parameters preserve open JSON, Unicode and escaped NUL', () async { + final params = { + 'providerOptions': { + 'future': ['中文', '\u0000', null], + }, + }; + expect(await client.call('test.echo', params: params), params); + expect(() => client.call('bad\u0000op'), throwsFormatException); expect( - () => model.generateText('"hello"'), - throwsA(isA() - .having((e) => e.message, 'message', contains('closed'))), + () => client.call('test.echo', params: String.fromCharCode(0xd800)), + throwsFormatException, ); }); - // T11/T12: initRecordingRing must validate cap up front. A negative Dart int - // would otherwise be reinterpreted as a huge u64 by the FFI; cap == 0 is - // rejected by the C ABI. Both are caught in Dart with ArgumentError, - // matching Kotlin/Java. - test('initRecordingRing rejects cap <= 0 (T11/T12)', () { - expect(() => initRecordingRing(0), throwsArgumentError); - expect(() => initRecordingRing(-1), throwsArgumentError); - expect(() => initRecordingRing(-9999), throwsArgumentError); - }); - - test('initRecordingRing accepts positive cap', () { - expect(() => initRecordingRing(8), returnsNormally); - recordingStop(); + test('uint64 handles round trip without a pointer-sized token', () async { + final high = client.adopt('18446744073709551615'); + expect(high.wireId, '18446744073709551615'); + await high.close(); + expect(() => client.adopt('18446744073709551616'), throwsFormatException); + expect(() => client.adopt('0'), throwsFormatException); + expect(() => client.adopt('01'), throwsFormatException); }); - // Omitting cap uses the library default capacity (FFI - // aimux_init_recording_ring_default) and must not throw. - test('initRecordingRing with no cap uses library default', () { - expect(() => initRecordingRing(), returnsNormally); - recordingStop(); - }); + test( + 'close detaches once, is idempotent and rejects later admission', + () async { + final id = handle.wireId; + expect(() => client.adopt(id), throwsStateError); + final first = handle.close(); + final second = handle.close(); + expect(identical(first, second), isTrue); + expect(handle.isClosed, isTrue); + expect(() => client.call('test.echo', handle: handle), throwsStateError); + await first; + // Read the native drop counter without acquiring ownership a second time. + final native = NativeApi(fixture()); + final json = native.call(parseHandle(id), 'test.stats', '{}', 0) as Map; + expect(json['drops'], 1); + }, + ); - // aimux_recording_try_flush: NULL = data on disk (also when nothing is - // recording); otherwise an error with the recording view → RecordingException - // (its own type, not an AimuxException). - group('recordingTryFlush', () { - test('returns normally when nothing is recording', () { - recordingStop(); - expect(recordingTryFlush, returnsNormally); - }); + test( + 'typed token trampoline queues cleanup off the finalizer thread', + () async { + final bridge = DartBridge(fixture()); + final native = NativeApi(fixture()); + final token = bridge.tokenNew( + 4000, + native.ffi.library.lookup>('aimux_drop'), + ); + final finalize = bridge.library.lookupFunction< + Void Function(Pointer), + void Function(Pointer)>('aimux_dart_finalize'); + finalize(token); + Map result = {}; + for (var i = 0; i < 100; i++) { + result = native.call(4000, 'test.stats', '{}', 0) as Map; + if (result['drops'] == 1) break; + await Future.delayed(const Duration(milliseconds: 5)); + } + expect(result['drops'], 1); + }, + ); - test('initRecording reports INIT for an unwritable directory', () { - // Parent path is a regular file → the recorder cannot be constructed, - // so init itself fails with code INIT and nothing is installed; a - // try-flush afterwards is a no-op. - final blocker = Directory.systemTemp.createTempSync('aimux-flutter-rec'); - final occupied = File('${blocker.path}/occupied')..writeAsStringSync('x'); + for (final mode in ['leak', 'close']) { + test('NativeFinalizer group shutdown versus explicit $mode releases once', + () async { + final ready = ReceivePort(); + final exited = ReceivePort(); + final errors = ReceivePort(); + final failure = Completer(); + final errorSubscription = errors.listen((error) { + if (!failure.isCompleted) failure.completeError(StateError('$error')); + }); try { - recordingStop(); + final idFuture = ready.first; + final exitFuture = exited.first; + await Isolate.spawnUri( + Uri.file( + '${Directory.current.path}/test/native/finalizer_isolate.dart'), + [fixture(), mode], + ready.sendPort, + onExit: exited.sendPort, + onError: errors.sendPort); + final id = await Future.any([idFuture, failure.future]) as String; + await Future.any([exitFuture, failure.future]); + final native = NativeApi(fixture()); + Map result = {}; + for (var i = 0; i < 100; i++) { + result = native.call(parseHandle(id), 'test.stats', '{}', 0) as Map; + if (result['drops'] == 1) break; + await Future.delayed(const Duration(milliseconds: 5)); + } + expect(result['drops'], 1); + await Future.delayed(const Duration(milliseconds: 25)); expect( - () => initRecording('${occupied.path}/sub'), - throwsA(allOf( - isA() - .having((e) => e.code, 'code', RecordingErrorCode.init), - isNot(isA()))), - ); - expect(recordingTryFlush, returnsNormally); + (native.call(parseHandle(id), 'test.stats', '{}', 0) + as Map)['drops'], + 1); } finally { - recordingStop(); - blocker.deleteSync(recursive: true); + ready.close(); + exited.close(); + await errorSubscription.cancel(); + errors.close(); } + }, + skip: const bool.fromEnvironment('dart.library.ui') + ? 'spawnUri isolate groups require the standalone Dart VM' + : false); + } + + test( + 'native worker thread delivers lossless ordered frames and one terminal', + () async { + final frames = + await client.stream('test.stream', handle: handle).toList(); + final events = frames.whereType().toList(); + expect(events.length, 1000); + expect( + events.map((f) => (f.event as Map)['text']), + List.generate(1000, (i) => '$i'), + ); + expect(frames.whereType().single.outcome, 'success'); + expect((await stats())['completed'], 1); + }, + ); + + test('pause withholds credit, resume is lossless', () async { + final first = Completer(); + final done = Completer(); + final frames = []; + late StreamSubscription sub; + sub = client.stream('test.stream', handle: handle).listen( + (frame) { + frames.add(frame); + if (frames.length == 1) { + sub.pause(); + first.complete(); + } + }, + onError: done.completeError, + onDone: done.complete, + ); + await first.future; + await Future.delayed(const Duration(milliseconds: 50)); + final before = (await stats())['produced']; + await Future.delayed(const Duration(milliseconds: 50)); + expect((await stats())['produced'], before); + // One transport credit plus the next C callback, waiting for credit. + expect(before, 2); + sub.resume(); + await done.future; + expect(frames.whereType().length, 1000); + expect(frames.whereType().length, 1); + }); + + test('cancel wakes producer while credit is exhausted', () async { + final first = Completer(); + late StreamSubscription sub; + sub = client.stream('test.stream', handle: handle).listen((_) { + sub.pause(); + first.complete(); }); + await first.future; + await sub.cancel().timeout(const Duration(seconds: 5)); + expect((await stats())['completed'], 1); + }); + + test( + 'cancel before native startup finishes cleans up without producing', + () async { + final sub = client.stream('test.stream', handle: handle).listen((_) {}); + await sub.cancel().timeout(const Duration(seconds: 5)); + expect((await stats())['produced'], 0); + }, + ); + + test( + 'each subscription has an independent abort and credit bridge', + () async { + final counts = await Future.wait( + List.generate( + 3, + (_) async => + (await client.stream('test.stream', handle: handle).toList()) + .length, + ), + ); + expect(counts, [1001, 1001, 1001]); + }, + ); + + test('pre-acceptance failure has no frame and one error', () async { + final frames = []; + final errors = []; + await client.stream('test.reject', handle: handle).handleError((Object e) { + errors.add(e); + }).forEach(frames.add); + expect(frames, isEmpty); + expect( + errors.single, + isA().having((e) => e.code, 'code', 203), + ); }); - // T9: Model/ProviderHandle register a NativeFinalizer so a forgotten close() - // does not leak the native handle. close() detaches the finalizer first, - // so an explicit close cannot double-free. - test('Model close is idempotent and detaches the finalizer (T9)', () { - final model = Model.openai('sk-test-fake-key', 'gpt-4o-mini'); - model.close(); - model.close(); // second close is a no-op (finalizer already detached) - expect(() => model.generateText('"hi"'), throwsStateError); + test('accepted runtime failure is only in the single EndFrame', () async { + final frames = + await client.stream('test.terminalError', handle: handle).toList(); + final end = frames.whereType().single; + expect(end.outcome, 'error'); + expect(end.error!.code, 11); + expect(frames.length, 1001); }); - test('ProviderHandle close is idempotent and detaches the finalizer (T9)', () { - final p = createProvider('deepseek', 'sk-test-fake-key', null); - p.close(); - p.close(); // should not throw - expect(() => p.listModels(), throwsStateError); + for (final entry in { + 'test.badFrame': 206, + 'test.noTerminal': 206, + 'test.oversize': 305, + }.entries) { + test('${entry.key} reports one failure and frees the bridge', () async { + final errors = []; + await client.stream(entry.key, handle: handle).handleError((Object e) { + errors.add(e); + }).drain(); + expect( + errors.single, + isA().having((e) => e.code, 'code', entry.value), + ); + }); + } + + test('operation push and typed poll states use the new primitives', () async { + final operation = TranscriptionOperation(client, handle); + await operation.push(Uint8List.fromList([1, 2, 3])); + await operation.push(Uint8List(0)); + expect((await operation.next(timeoutMs: 1)).part, {'type': 'transcript'}); + expect((await operation.next(timeoutMs: 2)).state, 'ended'); + expect((await operation.next()).state, 'timeout'); + await expectLater( + operation.next(timeoutMs: 99), + throwsA(isA()), + ); + expect( + () => operation.push(Uint8List(1024 * 1024 + 1)), + throwsA(isA().having((e) => e.code, 'code', 305)), + ); }); } diff --git a/bindings/flutter/test/decision_test.dart b/bindings/flutter/test/decision_test.dart deleted file mode 100644 index d4586d0ce..000000000 --- a/bindings/flutter/test/decision_test.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; -import 'dart:isolate'; -import 'package:aimux/aimux.dart'; -import 'package:test/test.dart'; - -// Keep the worker closure outside the server's lexical scope so Dart cannot -// capture its unsendable HttpServer through a shared closure context. -Future> runDecision( - String endpoint, Map options) => Isolate.run(() { - final model = DecisionModel.jev('test-key', 'jev-latest', endpoint: endpoint); - try { - final description = model.describe(); - if (description['provider'] != 'jev' || - (description['supportedQuestionTypes'] as List).length != 3) { - throw StateError('incorrect description'); - } - return model.decide(options); - } finally { model.close(); } -}); - -Future> runOpenAIDecision( - String baseUrl, Map options) => Isolate.run(() { - final provider = createProvider('openai', 'test-key', ProviderConfig(baseUrl: baseUrl)); - final model = provider.decisionModel('gpt-6-luna'); - try { return model.decide(options); } - finally { model.close(); provider.close(); } -}); - -void main() { - test('OpenAI media and typed values cross the C ABI', () async { - final fixture = jsonDecode(File('../../contract-tests/fixtures/decision-openai-full.json').readAsStringSync()) as Map; - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - final captures = []; - server.listen((request) async { - expect(request.uri.path, '/v1/decisions'); - captures.add(jsonDecode(await utf8.decoder.bind(request).join())); - request.response.headers.contentType = ContentType.json; - request.response.write(jsonEncode(fixture['response'])); - await request.response.close(); - }); - addTearDown(() => server.close(force: true)); - final options = (fixture['options'] as Map).cast(); - final result = await runOpenAIDecision('http://127.0.0.1:${server.port}/v1', options); - expect(captures, [fixture['request']]); - expect(result['answers']['choice']['choice'], 'boolean_true'); - expect(result['providerMetadata']['openai']['answers']['choice']['value'], isTrue); - expect(result['answers']['score']['score'], 0.75); - }); - - test('provider decision model owns its handle', () { - final provider = createProvider('openai', 'test-key', null); - final model = provider.decisionModel('gpt-6-luna'); - addTearDown(provider.close); - addTearDown(model.close); - provider.close(); - expect(model.describe()['specificationVersion'], 'v4'); - expect(model.describe()['provider'], 'openai'); - expect(model.describe()['modelId'], 'gpt-6-luna'); - expect(() => provider.decisionModel('gpt-6-luna'), throwsStateError); - }); - - test('official structured contract and description cross the C ABI', () async { - final fixture = jsonDecode(File('../../contract-tests/fixtures/decision-native.json').readAsStringSync()) as Map; - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - final captures = >[]; - server.listen((request) async { - expect(request.uri.path, '/v1/systemone'); - expect(request.headers.value('authorization'), 'Bearer test-key'); - captures.add((jsonDecode(await utf8.decoder.bind(request).join()) as Map).cast()); - request.response.headers.contentType = ContentType.json; - request.response.write(jsonEncode(fixture['response'])); - await request.response.close(); - }); - addTearDown(() => server.close(force: true)); - final endpoint = 'http://127.0.0.1:${server.port}/v1/systemone'; - final options = (fixture['request'] as Map).cast(); - // The Rust call blocks; the HTTP server remains on this isolate. - final result = await runDecision(endpoint, options); - expect(captures.length, 1); - expect(result['answers']['urgent']['probability'], 0.9); - expect(result['rounding']['scoreDecimals'], 2); - expect(result['usage'], {'inputTokens': 50, 'outputTokens': 10, 'totalTokens': 60}); - expect(captures.single['questions']['severity']['criteria'], options['questions']['severity']['criteria']); - expect(captures.single['questions']['urgent']['criteria'], options['questions']['urgent']['criteria']); - final model = DecisionModel.jev('test-key', 'jev-latest'); - expect(() => model.decideJson('{'), throwsFormatException); - model.close(); - model.close(); - expect(model.describe, throwsStateError); - expect(() => DecisionModel.jev('test', 'jev-latest', probabilitySource: 'unknown'), throwsA(isA())); - }); -} diff --git a/bindings/flutter/test/errors_test.dart b/bindings/flutter/test/errors_test.dart index be832d69c..35ed39d3e 100644 --- a/bindings/flutter/test/errors_test.dart +++ b/bindings/flutter/test/errors_test.dart @@ -1,276 +1,52 @@ -// Pure-Dart tests for the AimuxException hierarchy / code mapping. -// Does not require the native library (the C-error path — expectAimuxError via -// the aimux_error_* getters — is covered in aimux_test.dart, which does). - import 'package:aimux/errors.dart'; import 'package:test/test.dart'; void main() { - group('AimuxException.fromCode', () { - test('rate limiting arrives as APICallError with status 429', () { - final e = AimuxException.fromCode( - AimuxErrorCode.apiCall, - 'API call error: HTTP 429: slow down', - status: 429, - retryMs: 1500, - ); - expect(e, isA()); - expect(e.message, 'API call error: HTTP 429: slow down'); - expect(e.code, AimuxErrorCode.apiCall); - expect(e.status, 429); - expect(e.retryMs, 1500); - expect(e.toString(), contains('APICallError')); - }); - - test('auth failure arrives as APICallError with status 401', () { - final e = AimuxException.fromCode( - AimuxErrorCode.apiCall, - 'API call error: HTTP 401: bad key', - status: 401, - ); - expect(e, isA()); - expect(e.status, 401); - expect(e.codeName, 'ApiCall'); - }); - - test('model-not-found arrives as APICallError with status 404', () { - final e = AimuxException.fromCode( - AimuxErrorCode.apiCall, - 'API call error: HTTP 404: no such model', - status: 404, - ); - expect(e, isA()); - expect(e.status, 404); - }); - - test('transport failure is an APICallError with no status', () { - final e = AimuxException.fromCode( - AimuxErrorCode.apiCall, - 'API call error: connection reset', - ); - expect(e, isA()); - expect(e.status, -1); - expect(e.retryMs, -1); - }); - - test('TokenExpiredError keeps its 401 contract', () { - final e = AimuxException.fromCode(AimuxErrorCode.tokenExpired, 'expired'); - expect(e, isA()); - expect(e.status, 401); - }); - - test('dispatches all known subclasses', () { - final cases = { - AimuxErrorCode.jsonParse: JSONParseError, - AimuxErrorCode.invalidResponseData: InvalidResponseDataError, - AimuxErrorCode.invalidArgument: InvalidArgumentError, - AimuxErrorCode.invalidPrompt: InvalidPromptError, - AimuxErrorCode.tokenExpired: TokenExpiredError, - AimuxErrorCode.unsupportedFunctionality: UnsupportedFunctionalityError, - AimuxErrorCode.noSuchModel: NoSuchModelError, - AimuxErrorCode.noSuchProvider: NoSuchProviderError, - AimuxErrorCode.apiCall: APICallError, - AimuxErrorCode.timeout: AimuxTimeoutError, - AimuxErrorCode.aborted: RequestAbortedError, - AimuxErrorCode.noSuchTool: NoSuchToolError, - AimuxErrorCode.invalidToolInput: InvalidToolInputError, - AimuxErrorCode.toolCallRepair: ToolCallRepairError, - AimuxErrorCode.noOutputGenerated: NoOutputGeneratedError, - AimuxErrorCode.loadApiKey: LoadAPIKeyError, - AimuxErrorCode.loadSetting: LoadSettingError, - AimuxErrorCode.other: OtherError, - AimuxErrorCode.retry: RetryError, - }; - for (final entry in cases.entries) { - final e = AimuxException.fromCode(entry.key, 'msg'); - expect(e.runtimeType, entry.value, reason: 'code ${entry.key}'); - expect(e, isA()); - } - }); - - test('unknown code is rejected with StateError', () { - // A code outside the published table is an ABI mismatch, not an error - // kind. 1 is AIMUX_E_OTHER now because Other inherited the old UNKNOWN - // slot, so it resolves; 4 is retired, 18 is NoOutputGenerated and 21 is - // the first unassigned value. - expect(() => AimuxException.fromCode(999, 'future'), throwsStateError); - expect(() => AimuxException.fromCode(4, 'retired'), throwsStateError); - expect(AimuxException.fromCode(18, 'no output'), isA()); - expect(() => AimuxException.fromCode(21, 'unassigned'), throwsStateError); - }); - - test('bare retry code synthesizes a single-attempt RetryError', () { - final e = AimuxException.fromCode(AimuxErrorCode.retry, 'gave up'); - expect(e, isA()); - final retry = e as RetryError; - expect(retry.reason, RetryErrorReason.maxRetriesExceeded); - expect(retry.errors, hasLength(1)); - expect(retry.lastError, isA()); - }); - }); - - group('RetryError', () { - test('keeps the per-attempt history, oldest first', () { - final e = RetryError( - 'Failed after 2 attempts. Last error: took too long', - reason: RetryErrorReason.maxRetriesExceeded, - errors: [ - APICallError('API call error: HTTP 429: slow down', - status: 429, retryMs: 1500, retryable: true), - AimuxTimeoutError('took too long'), - ], - ); - expect(e.code, AimuxErrorCode.retry); - expect(e.codeName, 'Retry'); - expect(e.errors, hasLength(2)); - expect(e.errors.first, isA()); - expect(e.lastError, isA()); - // The history is a snapshot, not a mutable list. - expect(() => e.errors.add(OtherError('x')), throwsUnsupportedError); - }); - - test('reason decodes from the core wire names', () { - expect(RetryErrorReason.fromWire('maxRetriesExceeded'), - RetryErrorReason.maxRetriesExceeded); - expect(RetryErrorReason.fromWire('errorNotRetryable'), - RetryErrorReason.errorNotRetryable); - // Defensive default for a missing/unknown wire value. - expect( - RetryErrorReason.fromWire(null), RetryErrorReason.maxRetriesExceeded); - }); - }); - - group('APICallError', () { - test('carries the enriched request/response context', () { - final e = APICallError( - 'API call error: HTTP 429: slow down', - status: 429, - retryMs: 1500, - retryable: true, - providerCode: 'rate_limit_exceeded', - providerMessage: 'slow down', - url: 'https://api.example', - requestBodyValues: {'model': 'm'}, - responseHeaders: {'retry-after-ms': '1500'}, - responseBody: '{"error":"slow down"}', - data: {'type': 'rate_limit'}, - ); - expect(e.retryable, isTrue); - expect(e.providerCode, 'rate_limit_exceeded'); - expect(e.url, 'https://api.example'); - expect(e.requestBodyValues, {'model': 'm'}); - expect(e.responseHeaders?['retry-after-ms'], '1500'); - expect(e.data, {'type': 'rate_limit'}); - }); - }); - - group('AimuxErrorCode.name', () { - test('covers the code table', () { - expect(AimuxErrorCode.name(AimuxErrorCode.ok), 'OK'); - expect(AimuxErrorCode.name(AimuxErrorCode.apiCall), 'ApiCall'); - expect(AimuxErrorCode.name(AimuxErrorCode.other), 'Other'); - }); - - test('codes are consecutive and named', () { - expect(AimuxErrorCode.jsonParse, 2); - expect(AimuxErrorCode.name(AimuxErrorCode.jsonParse), 'JsonParse'); - expect(AimuxErrorCode.invalidResponseData, 3); - expect(AimuxErrorCode.name(AimuxErrorCode.invalidResponseData), - 'InvalidResponseData'); - expect(AimuxErrorCode.unsupportedFunctionality, 8); - expect(AimuxErrorCode.name(AimuxErrorCode.unsupportedFunctionality), - 'UnsupportedFunctionality'); - expect(AimuxErrorCode.noSuchProvider, 10); - expect(AimuxErrorCode.name(AimuxErrorCode.noSuchProvider), - 'NoSuchProvider'); - expect(AimuxErrorCode.retry, 14); - expect(AimuxErrorCode.name(AimuxErrorCode.retry), 'Retry'); - // The AIMUX_E_UNKNOWN catch-all is gone and Other took its slot; the - // engine codes are 1–20 (4 retired). - expect(AimuxErrorCode.other, 1); - expect(AimuxErrorCode.aborted, 13); - expect(AimuxErrorCode.noSuchTool, 15); - expect(AimuxErrorCode.name(AimuxErrorCode.noSuchTool), 'NoSuchTool'); - expect(AimuxErrorCode.invalidToolInput, 16); - expect(AimuxErrorCode.name(AimuxErrorCode.invalidToolInput), - 'InvalidToolInput'); - expect(AimuxErrorCode.toolCallRepair, 17); - expect(AimuxErrorCode.name(AimuxErrorCode.toolCallRepair), - 'ToolCallRepair'); - // A missing API key / provider setting have their own codes. - expect(AimuxErrorCode.noOutputGenerated, 18); - expect(AimuxErrorCode.name(AimuxErrorCode.noOutputGenerated), - 'NoOutputGenerated'); - expect(AimuxErrorCode.loadApiKey, 19); - expect(AimuxErrorCode.name(AimuxErrorCode.loadApiKey), 'LoadApiKey'); - expect(AimuxErrorCode.loadSetting, 20); - expect(AimuxErrorCode.name(AimuxErrorCode.loadSetting), 'LoadSetting'); - }); - }); - - test('recording codes outside the Rust enum are rejected', () { - expect(() => RecordingErrorCode.fromCode(999), throwsStateError); - }); - - // Raw JSON parameters are validated in Dart before the C call: bad syntax - // is a native FormatException naming the parameter (pure-Dart, no dlopen). - test('checkJson rejects bad syntax with FormatException naming the param', () { - expect(() => checkJson('{not json', 'opts_json'), throwsA( - isA() - .having((e) => e.source, 'source', 'opts_json') - .having((e) => e.message, 'message', contains('opts_json')))); - expect(() => checkJson(null, 'opts_json'), returnsNormally); - expect(() => checkJson('', 'opts_json'), throwsA( - isA().having((e) => e.source, 'source', 'opts_json'))); - expect(() => checkJson(' ', 'config_json', emptyIsDefault: true), - returnsNormally); - // Optional opts_json (embed / upload / transcription): "" = defaults. - expect(() => checkJson('', 'opts_json', emptyIsDefault: true), - returnsNormally); - expect(() => checkJson('{"a": [1, 2]}', 'opts_json'), returnsNormally); - expect(() => checkJson('"text"', 'prompt_json'), returnsNormally); - }); - - // A Dart String is UTF-16 and may hold an unpaired surrogate; jsonDecode / - // jsonEncode both accept one, serde_json does not. Left unchecked it reaches - // C as FfiError::InvalidWireJson (code 202), which surfaces as an invariant - // StateError — for input a - // `text.substring(0, n)` across an emoji produces. - test('checkJson rejects what serde_json rejects but jsonDecode accepts', () { - for (final bad in [ - '{"text":"\\ud800"}', // lone leading surrogate escape - '"\\udc00"', // lone trailing surrogate escape - '"\\ud800\\ud800"', // leading followed by leading - '{"\\ud800":1}', // in a key - '{"temperature":1e999}', // Dart parses to Infinity - ]) { - expect(() => checkJson(bad, 'opts_json'), - throwsA(isA().having( - (e) => e.message, 'message', contains('opts_json'))), - reason: bad); - } - // Well-formed pairs and finite numbers still pass. - expect(() => checkJson('"\\ud83d\\ude00"', 'prompt_json'), returnsNormally); - expect(() => checkJson('{"a":1e-999}', 'opts_json'), returnsNormally); - // Bounded so the walk cannot outrun the stack on a document jsonDecode - // (which is iterative) happily returns. - expect(() => checkJson('[' * 100 + ']' * 100, 'opts_json'), returnsNormally); - expect(() => checkJson('[' * 5000 + ']' * 5000, 'opts_json'), - throwsA(isA())); - }); - - // The same rule on the JSON the binding builds itself (prompt / options / - // ProviderConfig) — nothing downstream would check that. - test('encodeJson rejects an unpaired surrogate in caller data', () { - final halfEmoji = '\u{1F600}'.substring(0, 1); // split emoji - expect(() => encodeJson(halfEmoji, 'prompt'), - throwsA(isA() - .having((e) => e.message, 'message', contains('prompt')))); - expect(() => encodeJson({'system': [halfEmoji]}, 'options'), - throwsA(isA())); - expect(() => encodeJson({'a': double.infinity}, 'options'), - throwsA(isA())); - expect(encodeJson('\u{1F600}', 'prompt'), '"\u{1F600}"'); - expect(encodeJson({'a': 1}, 'options'), '{"a":1}'); + test('error envelope preserves unknown names, fields and domain', () { + final json = { + 'kind': 'protocol', + 'code': 399, + 'message': 'new error', + 'error': { + 'name': 'FutureError', + 'nested': [1, null], + }, + 'futureField': true, + }; + final error = OpsException.fromJson(json); + expect(error.toJson(), json); + expect(error.retryAfterMs, isNull); + expect(error.kind, 'protocol'); + }); + test('AI SDK payload and optional retry hint remain unchanged', () { + final error = OpsException.fromJson({ + 'kind': 'aimux', + 'code': 11, + 'message': 'try later', + 'retryAfterMs': 2500, + 'error': { + 'name': 'AI_APICallError', + 'isRetryable': true, + 'statusCode': 429, + 'responseHeaders': {'retry-after': '2.5'}, + }, + }); + expect(error.error['isRetryable'], isTrue); + expect(error.retryAfterMs, 2500); + expect(error.error['statusCode'], 429); + expect(error.toJson().containsKey('retryable'), isFalse); + }); + test('malformed envelope is rejected', () { + expect(() => OpsException.fromJson({'code': 11}), throwsFormatException); + expect( + () => OpsException.fromJson({ + 'kind': 'binding', + 'code': 203, + 'message': 'bad', + 'error': {}, + 'retryAfterMs': -1, + }), + throwsFormatException, + ); }); } diff --git a/bindings/flutter/test/multimodal_e2e_test.dart b/bindings/flutter/test/multimodal_e2e_test.dart deleted file mode 100644 index 452df08d8..000000000 --- a/bindings/flutter/test/multimodal_e2e_test.dart +++ /dev/null @@ -1,442 +0,0 @@ -// Multimodal E2E tests for the aimux Flutter/Dart binding. -// -// Drives the real Rust core (via dart:ffi) against a mock OpenAI/compatible -// HTTP server (dart:io HttpServer) for every multimodal modality: Embedding, -// Speech, Image, Transcription, Reranking, Search, Files, and Video. No real -// API calls are made. Mock response shapes mirror the Go E2E tests -// (bindings/go/multimodal_withbase_test.go). -// -// Run: -// export PATH="/home/eric8810/.local/dart-sdk/bin:$PATH" -// export LD_LIBRARY_PATH="/media/eric8810/fast-deliver/code/aimux/target/debug:$LD_LIBRARY_PATH" -// cd bindings/flutter && dart test test/multimodal_e2e_test.dart -// -// ## Why the FFI call runs in a worker isolate -// -// Every multimodal method (embed/generate/rerank/search/uploadFile) is a -// *synchronous* FFI call: it blocks the calling isolate until the Rust core -// finishes, and the Rust core issues an HTTP request that the mock HttpServer -// must serve. If the mock server lived in the *same* isolate, it could never -// accept the request while the isolate is blocked inside FFI → deadlock. So the -// mock server runs in the main test isolate (free event loop) and each blocking -// FFI call runs in a worker isolate via Isolate.run — exactly the pattern from -// structured_e2e_test.dart. - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:isolate'; - -import 'package:aimux/multimodal.dart'; -import 'package:test/test.dart'; - -// ───────────────────────────────────────────────────────────────────────────── -// Mock server -// -// Same pattern as structured_e2e_test.dart, extended to allow a custom response -// content-type so the Speech binary path (audio/mpeg body) can be exercised — -// the stock server hardcodes application/json. -// ───────────────────────────────────────────────────────────────────────────── - -/// A minimal mock server. Captures every request (method, path, parsed JSON -/// body) and replies with a caller-configured response for each request, in FIFO -/// order. -/// -/// Each response map supports: -/// - 'body' : the response body (String) -/// - 'contentType' : optional content-type (defaults to application/json) -/// - 'sse' : if true, reply as text/event-stream -class MockOpenAIServer { - final HttpServer _server; - final List> _responses; - final List<_RecordedRequest> recorded = []; - int _nextResponse = 0; - - MockOpenAIServer._(this._server, this._responses) { - _server.listen(_handleRequest); - } - - String get baseUrl => 'http://${_server.address.host}:${_server.port}'; - - Future close() => _server.close(force: true); - - void _handleRequest(HttpRequest request) async { - final rawBody = await utf8.decoder.bind(request).join(); - dynamic parsed; - try { - parsed = rawBody.isEmpty ? null : jsonDecode(rawBody); - } catch (_) { - parsed = rawBody; - } - recorded.add(_RecordedRequest( - method: request.method, - path: request.uri.path, - body: parsed, - )); - - final resp = _nextResponse < _responses.length - ? _responses[_nextResponse++] - : _responses.last; - final response = request.response; - response.statusCode = HttpStatus.ok; - if (resp['sse'] == true) { - response.headers.contentType = ContentType.parse('text/event-stream'); - response.headers.set('Cache-Control', 'no-cache'); - response.headers.set('Connection', 'keep-alive'); - response.write(resp['body'] as String); - } else { - final ct = resp['contentType'] as String?; - response.headers.contentType = - ct != null ? ContentType.parse(ct) : ContentType.json; - response.write(resp['body'] as String); - } - await response.close(); - } -} - -class _RecordedRequest { - final String method; - final String path; - final dynamic body; - _RecordedRequest( - {required this.method, required this.path, required this.body}); -} - -Future startMockServer( - List> responses) async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - return MockOpenAIServer._(server, responses); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Worker-isolate FFI drivers -// ───────────────────────────────────────────────────────────────────────────── -// -// Each driver constructs the model and invokes the blocking FFI method inside a -// worker isolate, returning the JSON result string to the main isolate. The -// model is closed in the same isolate. Construction + call + close all happen in -// the worker so the main isolate's event loop stays free to serve the mock. -// -// Call options that the Rust core deserializes (speech/image/rerank/search) -// always carry `"providerOptions":{}` — matching the Go wire format, where the -// SharedProviderOptions map must be present (not null). embed/transcription/ -// uploadFile pass no options (null pointer), matching the Go nil-opts path. - -Future runEmbed( - String baseUrl, String apiKey, String modelId, String valuesJson) { - return Isolate.run(() { - final model = EmbeddingModel.openai(apiKey, modelId, baseUrl: baseUrl); - try { - return model.embed(valuesJson); - } finally { - model.close(); - } - }); -} - -Future runSpeechGenerate( - String baseUrl, String apiKey, String modelId, String optsJson) { - return Isolate.run(() { - final model = SpeechModel.openai(apiKey, modelId, baseUrl: baseUrl); - try { - return model.generate(optsJson); - } finally { - model.close(); - } - }); -} - -Future runImageGenerate( - String baseUrl, String apiKey, String modelId, String optsJson) { - return Isolate.run(() { - final model = ImageModel.openai(apiKey, modelId, baseUrl: baseUrl); - try { - return model.generate(optsJson); - } finally { - model.close(); - } - }); -} - -Future runTranscriptionGenerate(String baseUrl, String apiKey, - String modelId, String audioBase64, String mediaType) { - return Isolate.run(() { - final model = TranscriptionModel.openai(apiKey, modelId, baseUrl: baseUrl); - try { - return model.generate(audioBase64, mediaType); - } finally { - model.close(); - } - }); -} - -Future runRerank( - String baseUrl, String apiKey, String modelId, String optsJson) { - return Isolate.run(() { - final model = RerankingModel.cohere(apiKey, modelId, baseUrl: baseUrl); - try { - return model.rerank(optsJson); - } finally { - model.close(); - } - }); -} - -Future runSearch(String baseUrl, String apiKey, String optsJson) { - return Isolate.run(() { - final model = SearchModel.tavily(apiKey, baseUrl: baseUrl); - try { - return model.search(optsJson); - } finally { - model.close(); - } - }); -} - -Future runUploadFile( - String baseUrl, String apiKey, String dataBase64, String mediaType) { - return Isolate.run(() { - final model = Files.openai(apiKey, baseUrl: baseUrl); - try { - return model.uploadFile(dataBase64, mediaType); - } finally { - model.close(); - } - }); -} - -/// Construct (and immediately close) a Google video model. Construction makes no -/// HTTP, so the non-listening base URL is fine; it still goes through a worker -/// isolate to keep all FFI off the main isolate (consistent with the other -/// drivers and with structured_e2e_test.dart). -Future runVideoConstruct() { - return Isolate.run(() { - final model = - VideoModel.google('test', 'veo-3.0', baseUrl: 'http://localhost:9999'); - model.close(); - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Fixture responses (provider wire shapes — same as the Go E2E tests) -// ───────────────────────────────────────────────────────────────────────────── - -const String embeddingResponse = r''' -{"data":[{"embedding":[0.1,0.2,0.3],"index":0}],"model":"text-embedding-3-small","usage":{"prompt_tokens":3,"total_tokens":3}} -'''; - -const String imageResponse = r''' -{"data":[{"b64_json":"aW1hZ2Ux"}]} -'''; - -const String transcriptionResponse = r''' -{"text":"Hello world"} -'''; - -const String rerankingResponse = r''' -{"results":[{"index":1,"relevance_score":0.95},{"index":0,"relevance_score":0.3}]} -'''; - -const String searchResponse = r''' -{"results":[{"title":"Rust","url":"https://rust-lang.org","content":"Rust is..."}],"answer":"Rust is a systems language."} -'''; - -const String filesResponse = r''' -{"id":"file-abc","object":"file","bytes":1024,"created_at":1234,"filename":"test.pdf","purpose":"assistants"} -'''; - -/// Raw binary body returned by the speech mock (base64 of "Hello world"); the -/// Rust core wraps a non-JSON audio response as AudioData::Binary, written as -/// base64. -const String speechAudioBody = 'SGVsbG8gd29ybGQ='; - -/// Parsed-only fixture for the video result wire format. -const String videoResultJson = r''' -{"videos":[{"type":"url","url":"https://example.com/v.mp4","mediaType":"video/mp4"}]} -'''; - -// ───────────────────────────────────────────────────────────────────────────── -// Tests -// ───────────────────────────────────────────────────────────────────────────── - -void main() { - const apiKey = 'sk-test-mock-key'; - - test('Embedding: OpenAI embed via mock', () async { - final server = await startMockServer([ - {'body': embeddingResponse}, - ]); - addTearDown(server.close); - - final result = await runEmbed( - server.baseUrl, apiKey, 'text-embedding-3-small', '["hello"]'); - - final decoded = jsonDecode(result) as Map; - expect(decoded, contains('embeddings')); - final embeddings = decoded['embeddings'] as List; - expect(embeddings, hasLength(1)); - expect(embeddings[0], isA()); - expect((embeddings[0] as List), hasLength(3)); - - // A single POST reached the provider. - expect(server.recorded, hasLength(1)); - expect(server.recorded.first.method, 'POST'); - }); - - test('Speech: OpenAI TTS via mock returns binary audio', () async { - // The mock returns a raw binary body with content-type audio/mpeg (matching - // the Go E2E test). The Rust core wraps non-JSON audio as AudioData::Binary. - final server = await startMockServer([ - {'body': speechAudioBody, 'contentType': 'audio/mpeg'}, - ]); - addTearDown(server.close); - - final optsJson = jsonEncode({ - 'text': 'Hi', - 'voice': 'alloy', - 'outputFormat': 'mp3', - 'providerOptions': {}, - }); - - final result = - await runSpeechGenerate(server.baseUrl, apiKey, 'tts-1', optsJson); - - final decoded = jsonDecode(result) as Map; - expect(decoded, contains('audio')); - // SpeechModelV4Result.audio is a Uint8Array upstream - // (speech-model-v4-result.ts:16): a base64 string in the JSON. - expect(decoded['audio'], base64.encode(utf8.encode(speechAudioBody))); - - expect(server.recorded, hasLength(1)); - expect(server.recorded.first.method, 'POST'); - }); - - test('Image: OpenAI image generation via mock', () async { - final server = await startMockServer([ - {'body': imageResponse}, - ]); - addTearDown(server.close); - - final optsJson = jsonEncode({ - 'prompt': 'otter', - 'n': 1, - 'providerOptions': {}, - }); - - final result = - await runImageGenerate(server.baseUrl, apiKey, 'dall-e-3', optsJson); - - final decoded = jsonDecode(result) as Map; - expect(decoded, contains('images')); - // ImageOutputs is an array of base64 strings (image-model-v4-result.ts:23). - final base64List = decoded['images'] as List; - expect(base64List, hasLength(1)); - expect(base64List[0], 'aW1hZ2Ux'); - - expect(server.recorded, hasLength(1)); - expect(server.recorded.first.method, 'POST'); - }); - - test('Transcription: OpenAI STT via mock', () async { - final server = await startMockServer([ - {'body': transcriptionResponse}, - ]); - addTearDown(server.close); - - final result = await runTranscriptionGenerate( - server.baseUrl, apiKey, 'whisper-1', 'dGVzdA==', 'audio/mp3'); - - final decoded = jsonDecode(result) as Map; - expect(decoded['text'], 'Hello world'); - - expect(server.recorded, hasLength(1)); - expect(server.recorded.first.method, 'POST'); - }); - - test('Reranking: Cohere rerank via mock', () async { - final server = await startMockServer([ - {'body': rerankingResponse}, - ]); - addTearDown(server.close); - - final optsJson = jsonEncode({ - 'query': 'which?', - 'documents': {'type': 'text', 'values': ['doc1', 'doc2']}, - 'topN': 2, - 'providerOptions': {}, - }); - - final result = - await runRerank(server.baseUrl, apiKey, 'rerank-v3.0', optsJson); - - final decoded = jsonDecode(result) as Map; - expect(decoded, contains('ranking')); - final ranking = decoded['ranking'] as List; - expect(ranking, hasLength(2)); - expect((ranking[0] as Map)['relevanceScore'], 0.95); - expect((ranking[0] as Map)['index'], 1); - - expect(server.recorded, hasLength(1)); - expect(server.recorded.first.method, 'POST'); - }); - - test('Search: Tavily search via mock', () async { - final server = await startMockServer([ - {'body': searchResponse}, - ]); - addTearDown(server.close); - - final optsJson = jsonEncode({ - 'query': 'What is Rust?', - 'maxResults': 5, - 'providerOptions': {}, - }); - - final result = await runSearch(server.baseUrl, apiKey, optsJson); - - final decoded = jsonDecode(result) as Map; - expect(decoded, contains('results')); - final results = decoded['results'] as List; - expect(results, hasLength(1)); - expect((results[0] as Map)['title'], 'Rust'); - expect((results[0] as Map)['url'], 'https://rust-lang.org'); - expect(decoded['answer'], 'Rust is a systems language.'); - - expect(server.recorded, hasLength(1)); - expect(server.recorded.first.method, 'POST'); - }); - - test('Files: OpenAI file upload via mock', () async { - final server = await startMockServer([ - {'body': filesResponse}, - ]); - addTearDown(server.close); - - final result = await runUploadFile( - server.baseUrl, apiKey, 'dGVzdA==', 'application/pdf'); - - final decoded = jsonDecode(result) as Map; - expect(decoded, contains('providerReference')); - final providerRef = decoded['providerReference'] as Map; - expect(providerRef['openai'], 'file-abc'); - - expect(server.recorded, hasLength(1)); - expect(server.recorded.first.method, 'POST'); - }); - - test('Video: Google video construction + result parsing', () async { - // Google Video uses a multi-step async API (POST predict → poll operation → - // fetch result). A single-response mock server can't drive the full flow, so - // — like the Go E2E test — we verify construction and result parsing only. - await runVideoConstruct(); - - final decoded = jsonDecode(videoResultJson) as Map; - expect(decoded, contains('videos')); - final videos = decoded['videos'] as List; - expect(videos, hasLength(1)); - final video = videos[0] as Map; - expect(video['type'], 'url'); - expect(video['url'], 'https://example.com/v.mp4'); - expect(video['mediaType'], 'video/mp4'); - }); -} diff --git a/bindings/flutter/test/native/finalizer_isolate.dart b/bindings/flutter/test/native/finalizer_isolate.dart new file mode 100644 index 000000000..85887aff1 --- /dev/null +++ b/bindings/flutter/test/native/finalizer_isolate.dart @@ -0,0 +1,13 @@ +import 'dart:isolate'; +import 'package:aimux/aimux.dart'; + +// spawnUri creates a separate isolate group; NativeFinalizer must run by its +// orderly shutdown. The fixture's native drop counters are process-wide. +void main(List args, SendPort ready) async { + final client = await Aimux.open(libraryPath: args[0], bridgePath: args[0]); + final result = await client.call('test.handle') as Map; + final handle = client.adopt(result['handle'] as String); + final id = handle.wireId; + if (args[1] == 'close') await handle.close(); + ready.send(id); +} diff --git a/bindings/flutter/test/native/ops_fixture.c b/bindings/flutter/test/native/ops_fixture.c new file mode 100644 index 000000000..34d032eff --- /dev/null +++ b/bindings/flutter/test/native/ops_fixture.c @@ -0,0 +1,160 @@ +#define _POSIX_C_SOURCE 200809L +#include +#include "../../native/aimux_ops.h" +#include +#include +#include +#include +#include +#include + +struct aimux_error { int code; const char *json; }; +static atomic_uint_fast64_t next_handle = 1; +static atomic_int aborted[4096], drops[4096]; +static atomic_int produced, completed, pushes; +static void delay_ms(int ms) { + struct timespec t = {ms / 1000, (ms % 1000) * 1000000L}; + /* The Dart VM signals its threads on Linux; resume after EINTR. */ + while (nanosleep(&t, &t) == -1 && errno == EINTR) {} +} +static char *copy(const char *value) { + char *out = malloc(strlen(value) + 1); + strcpy(out, value); + return out; +} +static aimux_error_t *failure(void) { + aimux_error_t *e = malloc(sizeof(*e)); + *e = (aimux_error_t){203, + "{\"kind\":\"binding\",\"code\":203,\"message\":\"bad handle\"," + "\"error\":{\"name\":\"InvalidHandle\",\"futureField\":[1,2]}}"}; + return e; +} +uint32_t aimux_abi_version(void) { +#ifdef BAD_ABI + return 2 << 16; +#else + return AIMUX_ABI_VERSION; +#endif +} +aimux_error_t *aimux_manifest_json(char **out) { + char json[512]; +#ifdef BAD_MANIFEST + snprintf(json, sizeof(json), "{\"abiVersion\":%u,\"opsVersion\":\"2.0\"}", + (unsigned)AIMUX_ABI_VERSION); +#else + snprintf(json, sizeof(json), + "{\"abiVersion\":%u,\"opsVersion\":\"" AIMUX_OPS_VERSION "\",\"digest\":\"" AIMUX_MANIFEST_DIGEST "\",\"manifestRevision\":\"fixture\"," + "\"ops\":[{\"name\":\"" AIMUX_OP_RUNTIME_DESCRIBE "\"},{\"name\":\"" AIMUX_OP_TEXT_STREAM "\"}]," + "\"handleKinds\":[\"Language\",\"Abort\"],\"errorCodes\":[]}", + (unsigned)AIMUX_ABI_VERSION); +#endif + *out = copy(json); + return NULL; +} +aimux_error_t *aimux_call(uint64_t handle, const char *op, const char *params, + uint64_t abort, char **out) { + (void)abort; + *out = NULL; + if (!strcmp(op, "test.fail")) return failure(); + if (!strcmp(op, AIMUX_OP_ABORT_CREATE) || !strcmp(op, "test.handle")) { + char json[96]; + snprintf(json, sizeof(json), "{\"handle\":\"%llu\"}", + (unsigned long long)atomic_fetch_add(&next_handle, 1)); + *out = copy(json); + } else if (!strcmp(op, "test.sleep")) { + delay_ms(180); + *out = copy("{\"ok\":true}"); + } else if (!strcmp(op, "test.stats")) { + char json[200]; + snprintf(json, sizeof(json), + "{\"produced\":%d,\"completed\":%d,\"drops\":%d,\"pushes\":%d}", + atomic_load(&produced), atomic_load(&completed), + handle < 4096 ? atomic_load(&drops[handle]) : 0, atomic_load(&pushes)); + *out = copy(json); + } else if (!strcmp(op, "test.reset")) { + atomic_store(&produced, 0); + atomic_store(&completed, 0); + *out = copy("{}"); + } else { + *out = copy(params); /* Verify bytes, open fields and handle encoding. */ + } + return NULL; +} +struct stream_args { + const char *op; + uint64_t abort; + aimux_event_cb event; + void *ctx; +}; +static void *produce(void *data) { + struct stream_args *a = data; + if (!strcmp(a->op, "test.noTerminal")) return NULL; + if (!strcmp(a->op, "test.oversize")) { + char *frame = malloc(1048578); + memset(frame, 'x', 1048577); + frame[1048577] = 0; + a->event(frame, a->ctx); + free(frame); + } else if (!strcmp(a->op, "test.badFrame")) { + a->event("{", a->ctx); + } else { + for (int i = 0; i < 1000 && !atomic_load(&aborted[a->abort]); i++) { + char frame[180]; + snprintf(frame, sizeof(frame), + "{\"type\":\"event\",\"seq\":\"%d\",\"event\":{\"type\":\"text-delta\",\"text\":\"%d\"}}", i, i); + atomic_fetch_add(&produced, 1); + a->event(frame, a->ctx); + } + } + const char *end = !strcmp(a->op, "test.terminalError") ? + "{\"type\":\"end\",\"seq\":\"1000\",\"outcome\":\"error\",\"error\":{\"kind\":\"aimux\",\"code\":11,\"message\":\"offline\",\"error\":{\"name\":\"AI_APICallError\",\"isRetryable\":false}}}" : + atomic_load(&aborted[a->abort]) ? + "{\"type\":\"end\",\"seq\":\"1000\",\"outcome\":\"cancelled\"}" : + "{\"type\":\"end\",\"seq\":\"1000\",\"outcome\":\"success\"}"; + a->event(end, a->ctx); + atomic_fetch_add(&completed, 1); + return NULL; +} +aimux_error_t *aimux_stream(uint64_t handle, const char *op, const char *params, + uint64_t abort, aimux_event_cb event, void *ctx) { + (void)handle; (void)params; + if (!strcmp(op, "test.reject")) return failure(); + struct stream_args a = {op, abort, event, ctx}; + pthread_t thread; + pthread_create(&thread, NULL, produce, &a); + pthread_join(thread, NULL); + return NULL; +} +aimux_error_t *aimux_operation_push(uint64_t operation, const uint8_t *data, size_t len) { + (void)operation; + if (len && !data) return failure(); + atomic_fetch_add(&pushes, 1); + return NULL; +} +aimux_error_t *aimux_operation_next_part(uint64_t operation, int64_t timeout, + char **out, int32_t *state) { + (void)operation; + *out = NULL; *state = 0; + if (timeout == 99) return failure(); + if (timeout == 1) { *out = copy("{\"type\":\"transcript\"}"); *state = 1; } + else if (timeout == 2) *state = 2; + else *state = 3; + return NULL; +} +void aimux_abort(uint64_t handle) { + if (handle < 4096) atomic_store(&aborted[handle], 1); +} +void aimux_drop(uint64_t handle) { + if (handle && handle < 4096) atomic_fetch_add(&drops[handle], 1); +} +void aimux_free_string(char *value) { free(value); } +void aimux_error_free(aimux_error_t *value) { free(value); } +int32_t aimux_error_code(const aimux_error_t *value) { return value->code; } +#ifndef OMIT_ERROR_MESSAGE +char *aimux_error_message(const aimux_error_t *value) { (void)value; return copy("bad handle"); } +#endif +char *aimux_error_json(const aimux_error_t *value) { return copy(value->json); } + +#ifndef OMIT_HANDLE_FINALIZER +void aimux_handle_finalizer(void *token) { aimux_drop((uint64_t)(uintptr_t)token); } +#endif diff --git a/bindings/flutter/test/native_runtime_test.dart b/bindings/flutter/test/native_runtime_test.dart new file mode 100644 index 000000000..0aeacd0e6 --- /dev/null +++ b/bindings/flutter/test/native_runtime_test.dart @@ -0,0 +1,131 @@ +@Timeout(Duration(minutes: 2)) +library; + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:aimux/aimux.dart'; +import 'package:aimux/decision.dart'; +import 'package:aimux/generated/ops.dart' as ops; +import 'package:test/test.dart'; + +void main() { + final library = Platform.environment['AIMUX_NATIVE_LIBRARY']; + final bridge = Platform.environment['AIMUX_DART_BRIDGE']; + group('real Rust ABI', () { + late Aimux client; + final handles = []; + Future create(String op, + {OpsHandle? handle, Object? params = const {}}) async { + final value = + await client.call(op, handle: handle, params: params) as Map; + final result = client.adopt(value['handle'] as String); + handles.add(result); + return result; + } + + setUp(() async { + client = await Aimux.open( + libraryPath: library, + bridgePath: bridge, + requiredOps: [ + ops.OP_RUNTIME_DESCRIBE, + ops.OP_PROVIDER_CREATE, + ops.OP_PROVIDER_MODEL, + ops.OP_DECISION_DECIDE, + ops.OP_DECISION_CAPABILITIES, + ops.OP_TEXT_STREAM + ]); + }); + tearDown(() async { + for (final handle in handles.reversed) { + await handle.close(); + } + handles.clear(); + }); + + test('manifest and runtime.describe', () async { + expect(client.manifest['handleKinds'], contains('Decision')); + expect(await client.call(ops.OP_RUNTIME_DESCRIBE), isA()); + }); + + test('provider/model handles retain children after provider close', + () async { + final provider = await create(ops.OP_PROVIDER_CREATE, params: { + 'package': 'openai', + 'settings': {'apiKey': 'test'} + }); + final model = await create(ops.OP_PROVIDER_MODEL, + handle: provider, + params: {'method': 'languageModel', 'modelId': 'gpt-test'}); + await provider.close(); + final info = await client.call(ops.OP_MODEL_INFO, handle: model) as Map; + expect(info['modelId'], 'gpt-test'); + await model.close(); + await model.close(); + }); + + test('Decision capabilities use existing provider configuration', () async { + final provider = await create(ops.OP_PROVIDER_CREATE, params: { + 'package': 'jev', + 'settings': {'apiKey': 'test'} + }); + final model = await create(ops.OP_PROVIDER_MODEL, + handle: provider, + params: {'method': 'decision', 'modelId': 'decision-test'}); + final decision = DecisionModel(client, model); + final info = await decision.capabilities(); + expect(info['modelId'], 'decision-test'); + await expectLater(decision.decide({}), + throwsA(isA().having((e) => e.code, 'code', 301))); + }); + + test('wrong-kind binary/poll and protocol errors preserve envelopes', + () async { + final abort = await create(ops.OP_ABORT_CREATE); + final wrongKind = isA() + .having((e) => e.code, 'code', 203) + .having((e) => e.error['name'], 'name', 'InvalidHandle'); + await expectLater(client.push(abort, Uint8List(0)), throwsA(wrongKind)); + await expectLater(client.next(abort), throwsA(wrongKind)); + expect(() => client.next(abort, timeoutMs: -1), throwsArgumentError); + await expectLater(client.call('missing.op'), + throwsA(isA().having((e) => e.code, 'code', 300))); + }); + + test('accepted cancelled stream has one terminal through native callback', + () async { + final provider = await create(ops.OP_PROVIDER_CREATE, params: { + 'package': 'openai', + 'settings': {'apiKey': 'test'} + }); + final model = await create(ops.OP_PROVIDER_MODEL, + handle: provider, + params: {'method': 'languageModel', 'modelId': 'gpt-test'}); + final abort = await create(ops.OP_ABORT_CREATE); + client.abort(abort); + final frames = await client.stream(ops.OP_TEXT_STREAM, + handle: model, + params: {'prompt': 'hello', 'abort': abort.wireId}).toList(); + expect(frames.whereType(), hasLength(1)); + expect(frames.whereType().single.outcome, 'cancelled'); + expect(abort.isClosed, isFalse); + }); + + test('rejected stream returns one error without terminal frames', () async { + final abort = await create(ops.OP_ABORT_CREATE); + final frames = []; + final errors = []; + await client + .stream(ops.OP_TEXT_STREAM, handle: abort, params: {'prompt': 'hello'}) + .handleError((Object e) => errors.add(e)) + .forEach(frames.add); + expect(frames, isEmpty); + expect(errors.single, + isA().having((e) => e.code, 'code', 203)); + }); + }, + skip: library == null + ? 'Set AIMUX_NATIVE_LIBRARY and AIMUX_DART_BRIDGE to the native build' + : false); +} diff --git a/bindings/flutter/test/stream_bridge_test.dart b/bindings/flutter/test/stream_bridge_test.dart new file mode 100644 index 000000000..ebc642c79 --- /dev/null +++ b/bindings/flutter/test/stream_bridge_test.dart @@ -0,0 +1,250 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:aimux/src/stream_bridge.dart'; +import 'package:test/test.dart'; + +BlockingStreamProducer producer(Object? request) => _Producer(request! as List); + +class _Producer implements BlockingStreamProducer { + _Producer(this.request); + final List request; + + @override + void run(StreamBridgeSink sink) { + final count = request[0] as int; + final observations = request[1] as SendPort; + final mode = request[2] as String; + observations.send('started'); + if (mode == 'startup-error') throw StateError('not accepted'); + for (var i = 0; i < count; i++) { + if (mode == 'slow') sleep(const Duration(milliseconds: 15)); + if (!sink.add(mode == 'large' ? 'éé' : '$i')) { + observations.send('cancelled'); + sink.end(); + return; + } + observations.send(i); + } + if (mode == 'error') { + sink.end({ + 'kind': 'protocol', + 'code': 304, + 'message': 'failure', + 'error': {'name': 'InvalidState'}, + }); + sink.end('duplicate'); + throw StateError('after terminal'); + } + if (mode != 'missing-end') sink.end(); + } +} + +void main() { + test( + 'ordered lossless delivery and blocking work leaves main responsive', + () async { + final observations = ReceivePort()..listen((_) {}); + var heartbeats = 0; + final timer = Timer.periodic( + const Duration(milliseconds: 2), + (_) => heartbeats++, + ); + final bridge = StreamBridge( + producer: producer, + request: [20, observations.sendPort, 'slow'], + abort: () {}, + ); + final result = await bridge.stream.toList(); + await bridge.done; + timer.cancel(); + observations.close(); + expect(result, List.generate(20, (i) => '$i')); + expect(heartbeats, greaterThan(5)); + }, + ); + + test('pause exhausts credits, resume preserves every event', () async { + final observations = ReceivePort(); + final produced = []; + final filled = Completer(); + observations.listen((value) { + if (value is int) { + produced.add(value); + if (produced.length == 2) filled.complete(); + } + }); + final bridge = StreamBridge( + producer: producer, + request: [30, observations.sendPort, 'normal'], + abort: () {}, + credits: 2, + ); + final first = Completer(); + final ended = Completer(); + final values = []; + late StreamSubscription subscription; + subscription = bridge.stream.listen((event) { + values.add(event); + if (values.length == 1) { + subscription.pause(); + first.complete(); + } + }, onDone: ended.complete); + await first.future; + await filled.future; + await Future.delayed(const Duration(milliseconds: 50)); + expect(values, ['0']); + expect(produced, [0, 1]); + subscription.resume(); + await ended.future; + await bridge.done; + observations.close(); + expect(values, List.generate(30, (i) => '$i')); + }); + + test('cancel with exhausted credits aborts once and wakes worker', () async { + final observations = ReceivePort(); + final cancelled = Completer(); + observations.listen((value) { + if (value == 'cancelled') cancelled.complete(); + }); + var aborts = 0; + final bridge = StreamBridge( + producer: producer, + request: [100, observations.sendPort, 'normal'], + abort: () => aborts++, + credits: 1, + ); + final first = Completer(); + late StreamSubscription subscription; + subscription = bridge.stream.listen((event) { + subscription.pause(); + first.complete(); + }); + await first.future; + await Future.delayed(const Duration(milliseconds: 30)); + await subscription.cancel().timeout(const Duration(seconds: 3)); + await subscription.cancel(); + await cancelled.future.timeout(const Duration(seconds: 3)); + await bridge.done; + observations.close(); + expect(aborts, 1); + }); + + test( + 'terminal error follows accepted events exactly once while paused', + () async { + final observations = ReceivePort()..listen((_) {}); + var aborts = 0; + final bridge = StreamBridge( + producer: producer, + request: [1, observations.sendPort, 'error'], + abort: () => aborts++, + credits: 1, + ); + final first = Completer(); + final ended = Completer(); + final events = []; + final errors = []; + late StreamSubscription subscription; + subscription = bridge.stream.listen( + (event) { + events.add(event); + subscription.pause(); + first.complete(); + }, + onError: (Object error) => errors.add(error), + onDone: ended.complete, + ); + await first.future; + await bridge.done; + expect(errors, isEmpty); + subscription.resume(); + await ended.future; + observations.close(); + expect(events, ['0']); + expect(errors, hasLength(1)); + expect((errors.single as Map)['code'], 304); + expect(aborts, 0); + }, + ); + + test('missing terminal is an error rather than successful EOF', () async { + final observations = ReceivePort()..listen((_) {}); + final bridge = StreamBridge( + producer: producer, + request: [0, observations.sendPort, 'missing-end'], + abort: () {}, + ); + await expectLater(bridge.stream, emitsError(isA())); + await bridge.done; + observations.close(); + }); + + test('UTF-8 byte limit rejects oversized event', () async { + final observations = ReceivePort()..listen((_) {}); + final bridge = StreamBridge( + producer: producer, + request: [1, observations.sendPort, 'large'], + abort: () {}, + maxEventBytes: 3, + ); + await expectLater(bridge.stream, emitsError(isA())); + await bridge.done; + observations.close(); + }); + + test( + 'cancellation during isolate startup still wakes and joins worker', + () async { + final observations = ReceivePort()..listen((_) {}); + var aborts = 0; + final bridge = StreamBridge( + producer: producer, + request: [100, observations.sendPort, 'normal'], + abort: () => aborts++, + ); + final subscription = bridge.stream.listen((_) {}); + await subscription.cancel().timeout(const Duration(seconds: 3)); + await bridge.done; + observations.close(); + expect(aborts, 1); + }, + ); + + test('pre-acceptance failure is delivered once', () async { + final observations = ReceivePort()..listen((_) {}); + final bridge = StreamBridge( + producer: producer, + request: [0, observations.sendPort, 'startup-error'], + abort: () {}, + ); + final errors = []; + await bridge.stream + .handleError((Object error) => errors.add(error)) + .drain(); + await bridge.done; + observations.close(); + expect(errors, hasLength(1)); + expect(errors.single, isA()); + }); + + test('credit reservations have finite item and byte limits', () { + for (final credits in [0, 257]) { + expect( + () => StreamBridge(producer: producer, abort: () {}, credits: credits), + throwsArgumentError, + ); + } + expect( + () => StreamBridge(producer: producer, abort: () {}, credits: 9), + throwsArgumentError, + ); + expect( + () => StreamBridge(producer: producer, abort: () {}, maxEventBytes: 0), + throwsArgumentError, + ); + }); +} diff --git a/bindings/flutter/test/structured_e2e_test.dart b/bindings/flutter/test/structured_e2e_test.dart deleted file mode 100644 index cd4329e29..000000000 --- a/bindings/flutter/test/structured_e2e_test.dart +++ /dev/null @@ -1,457 +0,0 @@ -// Structured e2e tests for the aimux Flutter/Dart binding. -// -// These tests drive the *real* Rust core (via dart:ffi) against a mock -// OpenAI-compatible HTTP server implemented with `dart:io`'s `HttpServer`. -// No real API calls are made. -// -// Run: -// export PATH="/home/eric8810/.local/dart-sdk/bin:$PATH" -// export LD_LIBRARY_PATH="/media/eric8810/fast-deliver/code/aimux/target/debug:$LD_LIBRARY_PATH" -// cd bindings/flutter && dart test test/structured_e2e_test.dart -// -// ## Why the FFI call runs in a worker isolate -// -// `Model.generateText` / `Model.streamText` are *synchronous* FFI calls: they -// block the calling isolate's thread (and thus its event loop) until the Rust -// core finishes. The Rust core issues an HTTP request via reqwest/tokio and -// blocks until it receives a response. If the mock `HttpServer` lived in the -// *same* isolate, the server could never accept/respond to that request while -// the isolate is blocked inside FFI → deadlock. -// -// The mock server therefore runs in the main test isolate (its event loop is -// free), and the blocking FFI call runs in a worker isolate via `Isolate.run`. -// reqwest drives I/O on its own tokio worker threads; the main isolate serves -// the HTTP request and records the request body. - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:isolate'; - -import 'package:test/test.dart'; - -import 'typed_model_test.dart' show mockChatModel; - -// ───────────────────────────────────────────────────────────────────────────── -// Mock server -// ───────────────────────────────────────────────────────────────────────────── - -/// A minimal OpenAI-compatible mock server. -/// -/// Captures every request (method, path, parsed JSON body) and replies with a -/// caller-configured response for each request, in FIFO order. -class MockOpenAIServer { - final HttpServer _server; - final List> _responses; - final List<_RecordedRequest> recorded = []; - int _nextResponse = 0; - - MockOpenAIServer._(this._server, this._responses) { - _server.listen(_handleRequest); - } - - String get baseUrl => 'http://${_server.address.host}:${_server.port}'; - - Future close() => _server.close(force: true); - - void _handleRequest(HttpRequest request) async { - final rawBody = await utf8.decoder.bind(request).join(); - dynamic parsed; - try { - parsed = rawBody.isEmpty ? null : jsonDecode(rawBody); - } catch (_) { - parsed = rawBody; - } - recorded.add(_RecordedRequest( - method: request.method, - path: request.uri.path, - body: parsed, - )); - - final resp = _nextResponse < _responses.length - ? _responses[_nextResponse++] - : _responses.last; - final response = request.response; - response.statusCode = HttpStatus.ok; - if (resp['sse'] == true) { - response.headers.contentType = - ContentType.parse('text/event-stream'); - response.headers.set('Cache-Control', 'no-cache'); - response.headers.set('Connection', 'keep-alive'); - response.write(resp['body'] as String); - } else { - response.headers.contentType = ContentType.json; - response.write(resp['body'] as String); - } - await response.close(); - } -} - -class _RecordedRequest { - final String method; - final String path; - final dynamic body; - _RecordedRequest({required this.method, required this.path, required this.body}); -} - -Future startMockServer(List> responses) async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - return MockOpenAIServer._(server, responses); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Worker-isolate FFI driver -// ───────────────────────────────────────────────────────────────────────────── - -/// Arguments sent into the worker isolate. Must be sendable (plain JSON-like). -class _GenerateArgs { - final String baseUrl; - final String apiKey; - final String modelId; - final Object prompt; // String or List - final Map? options; - _GenerateArgs(this.baseUrl, this.apiKey, this.modelId, this.prompt, this.options); -} - -/// Run a non-streaming `generateText` in a worker isolate. -Future> runGenerateText(_GenerateArgs args) { - return Isolate.run(() { - final model = mockChatModel(args.baseUrl, args.apiKey, args.modelId); - try { - return model.generateText(args.prompt, args.options); - } finally { - model.close(); - } - }); -} - -/// Run a streaming `streamText` in a worker isolate, returning all parts. -Future>> runStreamText(_GenerateArgs args) { - return Isolate.run(() async { - final model = mockChatModel(args.baseUrl, args.apiKey, args.modelId); - try { - // streamText blocks synchronously until the stream completes, then - // returns an already-closed stream containing the buffered parts. - final parts = await model.streamText(args.prompt, args.options).toList(); - return parts; - } finally { - model.close(); - } - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Fixture responses (real OpenAI API shapes) -// ───────────────────────────────────────────────────────────────────────────── - -const String plainOpenAIResponse = r''' -{"id":"chatcmpl-test","model":"gpt-4o","choices":[{"message":{"role":"assistant","content":"Rust is a systems programming language."},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18}} -'''; - -const String toolCallOpenAIResponse = r''' -{"id":"chatcmpl-tc","model":"gpt-4o","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_abc","type":"function","function":{"name":"get_weather","arguments":"{\"location\":\"Tokyo\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":20,"completion_tokens":10,"total_tokens":30}} -'''; - -/// Final assistant text emitted after the tool result is fed back in. -const String finalTextOpenAIResponse = r''' -{"id":"chatcmpl-2","model":"gpt-4o","choices":[{"message":{"role":"assistant","content":"The weather in Tokyo is sunny."},"finish_reason":"stop"}],"usage":{"prompt_tokens":30,"completion_tokens":8,"total_tokens":38}} -'''; - -/// SSE stream containing a tool call: name then arguments delta, then finish. -String buildToolCallSse() { - final event1 = { - 'id': '1', - 'model': 'gpt-4o', - 'choices': [ - { - 'delta': { - 'role': 'assistant', - 'tool_calls': [ - { - 'index': 0, - 'id': 'call_xyz', - 'type': 'function', - 'function': {'name': 'get_weather', 'arguments': ''}, - } - ], - } - } - ], - }; - final event2 = { - 'id': '1', - 'model': 'gpt-4o', - 'choices': [ - { - 'delta': { - 'tool_calls': [ - { - 'index': 0, - 'function': {'arguments': '{"location":"Tokyo"}'}, - } - ], - } - } - ], - }; - final event3 = { - 'id': '1', - 'model': 'gpt-4o', - 'choices': [ - {'delta': {}, 'finish_reason': 'tool_calls'}, - ], - 'usage': { - 'prompt_tokens': 5, - 'completion_tokens': 2, - 'total_tokens': 7, - }, - }; - final sb = StringBuffer() - ..write('data: ${jsonEncode(event1)}\n\n') - ..write('data: ${jsonEncode(event2)}\n\n') - ..write('data: ${jsonEncode(event3)}\n\n') - ..write('data: [DONE]\n\n'); - return sb.toString(); -} - -Map _weatherTool() { - return { - 'type': 'function', - 'name': 'get_weather', - 'inputSchema': { - 'type': 'object', - 'properties': { - 'location': {'type': 'string'}, - }, - 'required': ['location'], - }, - }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Tests -// ───────────────────────────────────────────────────────────────────────────── - -void main() { - const apiKey = 'sk-test-mock-key'; - const modelId = 'gpt-4o'; - - test('generateText parses toolCalls', () async { - final server = await startMockServer([ - {'body': toolCallOpenAIResponse}, - ]); - addTearDown(server.close); - - final result = await runGenerateText(_GenerateArgs( - server.baseUrl, - apiKey, - modelId, - 'What is the weather in Tokyo?', - {'tools': [_weatherTool()]}, - )); - - // The mock recorded exactly one POST to /chat/completions. - expect(server.recorded, hasLength(1)); - expect(server.recorded.first.method, 'POST'); - expect(server.recorded.first.path, '/chat/completions'); - - // Tool call parsed into the user-facing result. - expect(result, contains('toolCalls')); - final toolCalls = result['toolCalls'] as List; - expect(toolCalls, hasLength(1)); - expect(toolCalls[0]['toolName'], 'get_weather'); - expect(toolCalls[0]['toolCallId'], 'call_abc'); - expect((toolCalls[0]['input'] as Map)['location'], 'Tokyo'); - - // The content contains a tool-call part. - final content = result['content'] as List; - final hasToolCallVariant = - content.any((c) => (c as Map)['type'] == 'tool-call'); - expect(hasToolCallVariant, isTrue); - }); - - test('multi-role messages reach provider', () async { - final server = await startMockServer([ - {'body': plainOpenAIResponse}, - ]); - addTearDown(server.close); - - final messages = [ - {'role': 'system', 'content': 'You are a helpful assistant.'}, - {'role': 'user', 'content': 'Hello'}, - ]; - - final result = await runGenerateText(_GenerateArgs( - server.baseUrl, - apiKey, - modelId, - messages, - null, - )); - - // The request body sent to the provider contains both messages. - expect(server.recorded, hasLength(1)); - final sentBody = server.recorded.first.body as Map; - expect(sentBody['model'], modelId); - final sentMessages = sentBody['messages'] as List; - expect(sentMessages, hasLength(2)); - expect((sentMessages[0] as Map)['role'], 'system'); - expect((sentMessages[0] as Map)['content'], 'You are a helpful assistant.'); - expect((sentMessages[1] as Map)['role'], 'user'); - expect((sentMessages[1] as Map)['content'], 'Hello'); - - // Sanity: generateText still returned text. - expect(result['text'], 'Rust is a systems programming language.'); - }); - - test('tool_choice reaches provider', () async { - final server = await startMockServer([ - {'body': toolCallOpenAIResponse}, - ]); - addTearDown(server.close); - - await runGenerateText(_GenerateArgs( - server.baseUrl, - apiKey, - modelId, - 'What is the weather in Tokyo?', - { - 'tools': [_weatherTool()], - 'toolChoice': 'required', - }, - )); - - expect(server.recorded, hasLength(1)); - final sentBody = server.recorded.first.body as Map; - // tool_choice is only emitted when tools are present; we pass both. - expect(sentBody, contains('tools')); - expect(sentBody['tool_choice'], 'required'); - }); - - test('streamText parses tool-call stream parts', () async { - final server = await startMockServer([ - {'sse': true, 'body': buildToolCallSse()}, - ]); - addTearDown(server.close); - - final parts = await runStreamText(_GenerateArgs( - server.baseUrl, - apiKey, - modelId, - 'What is the weather in Tokyo?', - {'tools': [_weatherTool()]}, - )); - - // The request was a streaming POST. - expect(server.recorded, hasLength(1)); - final sentBody = server.recorded.first.body as Map; - expect(sentBody['stream'], true); - - // The stream delivered tool-call parts. - expect(parts, isNotEmpty); - final hasToolInputDelta = - parts.any((p) => (p as Map)['type'] == 'tool-input-delta'); - final hasToolCall = - parts.any((p) => (p as Map)['type'] == 'tool-call'); - expect(hasToolInputDelta || hasToolCall, isTrue, - reason: 'expected a tool-call stream part; got: $parts'); - - // The assembled ToolCall should carry the get_weather tool + Tokyo input. - final tc = parts - .cast>() - .firstWhere((p) => p['type'] == 'tool-call'); - expect(tc['toolName'], 'get_weather'); - expect((tc['input'] as Map)['location'], 'Tokyo'); - }); - - // Full tool-call round-trip: two generateText calls with a ToolResult fed - // back in between. - // - // The second call's messages carry the assistant's prior tool call and the - // tool's result. These are passed in the binding's content-part shape - // (`content: [{type: 'tool_call' | 'tool_result', ...}]`) — that is what the - // Rust `ModelMessage` deserializes (a message only has `role` + `content`, - // where `content` is a string or a list of typed parts). The OpenAI wire - // shape (`content: null, tool_calls: [...]` at message level) is what the - // core *emits to the provider*; it is reconstructed from these parts, so the - // recorded request body still carries `role: tool` + `tool_call_id`. - test('tool round-trip: generateText → ToolResult → generateText', () async { - final server = await startMockServer([ - {'body': toolCallOpenAIResponse}, - {'body': finalTextOpenAIResponse}, - ]); - addTearDown(server.close); - - const userQuestion = "What's the weather in Tokyo?"; - - // 1. First call: the model requests a tool call. - final firstResult = await runGenerateText(_GenerateArgs( - server.baseUrl, - apiKey, - modelId, - userQuestion, - {'tools': [_weatherTool()]}, - )); - - expect(firstResult, contains('toolCalls')); - final toolCalls = firstResult['toolCalls'] as List; - expect(toolCalls, hasLength(1)); - expect(toolCalls[0]['toolName'], 'get_weather'); - expect(toolCalls[0]['toolCallId'], 'call_abc'); - - // 2. Build the follow-up messages: original user question, the assistant's - // tool call, and the tool result we "executed" and are filling back in. - final messages = [ - {'role': 'user', 'content': userQuestion}, - { - 'role': 'assistant', - 'content': [ - { - 'type': 'tool-call', - 'toolCallId': 'call_abc', - 'toolName': 'get_weather', - 'input': {'location': 'Tokyo'}, - } - ], - }, - { - 'role': 'tool', - 'content': [ - { - 'type': 'tool-result', - 'toolCallId': 'call_abc', - 'toolName': 'get_weather', - 'output': { - 'type': 'json', - 'value': {'temperature': 22, 'condition': 'sunny'}, - }, - } - ], - }, - ]; - - // 3. Second call (same tools): the model produces the final text. - final secondResult = await runGenerateText(_GenerateArgs( - server.baseUrl, - apiKey, - modelId, - messages, - {'tools': [_weatherTool()]}, - )); - - expect(secondResult['text'], 'The weather in Tokyo is sunny.'); - - // 4. Two requests were recorded. - expect(server.recorded, hasLength(2)); - - // 5. The second request carried all three messages, the last one being the - // tool result with the matching tool_call_id. - final secondBody = - server.recorded[1].body as Map; - final sentMessages = secondBody['messages'] as List; - expect(sentMessages, hasLength(3)); - final lastMessage = sentMessages[2] as Map; - expect(lastMessage['role'], 'tool'); - expect(lastMessage['tool_call_id'], 'call_abc'); - }); -} diff --git a/bindings/flutter/test/tool_call_repair_typed_test.dart b/bindings/flutter/test/tool_call_repair_typed_test.dart deleted file mode 100644 index 273e6438c..000000000 --- a/bindings/flutter/test/tool_call_repair_typed_test.dart +++ /dev/null @@ -1,110 +0,0 @@ -// End-to-end repair through [TypedModel] (RFC-0035): the provider returns -// arguments the tool schema rejects, the core marks the call invalid, and the -// hook runs on the Dart side. `tool_call_repair_test.dart` covers the loop -// itself; this file pins its wiring into the typed entry points. -// -// The blocking FFI call runs in a worker isolate so the mock server in this -// isolate can answer (see `typed_model_test.dart`). Everything returned from -// the worker is a plain sendable value. - -import 'dart:convert'; -import 'dart:isolate'; - -import 'package:aimux/typed_model.dart'; -import 'package:aimux/types.dart'; -import 'package:test/test.dart'; - -import 'typed_model_test.dart' - show buildToolCallSse, mockChatModel, startMockServer, toolCallOpenAIResponse; - -/// Accepts `city` only, so the canned `{"location":"Tokyo"}` is invalid. -GenerateTextOptions _strictOptions() => GenerateTextOptions( - tools: [ - Tool.function(FunctionTool( - name: 'get_weather', - inputSchema: { - 'type': 'object', - 'properties': { - 'city': {'type': 'string'}, - }, - 'required': ['city'], - 'additionalProperties': false, - }, - )), - ], - repairToolCall: _toCity, - ); - -RawToolCall? _toCity(ToolCallRepairContext context) => - context.toolCall.copyWith(input: '{"city":"Tokyo"}'); - -void main() { - test('generateText repairs the call and its transcript part', () async { - final server = await startMockServer([ - {'body': toolCallOpenAIResponse}, - ]); - addTearDown(server.close); - final baseUrl = server.baseUrl; - - final result = await Isolate.run(() { - final typed = TypedModel(mockChatModel(baseUrl, 'sk-test', 'gpt-4o')); - try { - return jsonDecode(jsonEncode(typed.generateText('weather?', _strictOptions()))) - as Map; - } finally { - typed.close(); - } - }); - - final call = (result['toolCalls'] as List).single as Map; - expect(call['invalid'], isNull); - expect(call['input'], {'city': 'Tokyo'}); - final part = (result['responseMessages'] as List) - .expand((m) => (m as Map)['content'] as List) - .whereType>() - .singleWhere((p) => p['type'] == 'tool-call'); - expect(part['input'], {'city': 'Tokyo'}); - }); - - test('generateTextAsOpenAI carries the repaired arguments', () async { - final server = await startMockServer([ - {'body': toolCallOpenAIResponse}, - ]); - addTearDown(server.close); - final baseUrl = server.baseUrl; - - final arguments = await Isolate.run(() { - final typed = TypedModel(mockChatModel(baseUrl, 'sk-test', 'gpt-4o')); - try { - final completion = typed.generateTextAsOpenAI('weather?', _strictOptions()); - return completion.choices.first.message.toolCalls!.single.function.arguments; - } finally { - typed.close(); - } - }); - - expect(jsonDecode(arguments), {'city': 'Tokyo'}); - }); - - test('streamText delivers the repaired ToolCall part', () async { - final server = await startMockServer([ - {'sse': true, 'body': buildToolCallSse()}, - ]); - addTearDown(server.close); - final baseUrl = server.baseUrl; - - final input = await Isolate.run(() async { - final typed = TypedModel(mockChatModel(baseUrl, 'sk-test', 'gpt-4o')); - try { - final parts = await typed.streamText('weather?', _strictOptions()).toList(); - final call = parts.whereType().single.call; - return {'invalid': call.invalid, 'input': call.input}; - } finally { - typed.close(); - } - }); - - expect(input['invalid'], isNull); - expect(input['input'], {'city': 'Tokyo'}); - }); -} diff --git a/bindings/flutter/test/typed_model_test.dart b/bindings/flutter/test/typed_model_test.dart deleted file mode 100644 index d69da2951..000000000 --- a/bindings/flutter/test/typed_model_test.dart +++ /dev/null @@ -1,507 +0,0 @@ -// Typed e2e tests for the aimux Flutter/Dart typed wrapper (`TypedModel`). -// -// Drives the *real* Rust core (via dart:ffi) through the typed -// `GenerateTextOptions` → `GenerateTextResult` boundary against a mock -// OpenAI-compatible HTTP server (same mock as `structured_e2e_test.dart`). -// No real API calls are made. -// -// Run: -// export PATH="/home/eric8810/.local/dart-sdk/bin:$PATH" -// export LD_LIBRARY_PATH="/media/eric8810/fast-deliver/code/aimux/target/debug:$LD_LIBRARY_PATH" -// cd bindings/flutter && dart test test/typed_model_test.dart -// -// ## Why the FFI call runs in a worker isolate -// -// Same reason as `structured_e2e_test.dart`: `Model.generateText` / -// `Model.streamText` are *synchronous* FFI calls that block the calling -// isolate's event loop while the Rust core blocks on an HTTP response. If the -// mock `HttpServer` lived in the same isolate it could never answer → -// deadlock. The mock server runs in the main test isolate; the blocking FFI -// call (here wrapped by `TypedModel`) runs in a worker via `Isolate.run`. -// -// `TypedModel` returns typed class instances, which are not directly sendable -// across isolates. The worker therefore round-trips the result through -// `toJson()` (a sendable `Map`) and the main isolate reconstructs it with -// `GenerateTextResult.fromJson`, exercising both directions. - -import 'dart:convert'; -import 'dart:io'; -import 'dart:isolate'; - -import 'package:aimux/aimux.dart'; -import 'package:aimux/typed_model.dart'; -import 'package:aimux/types.dart'; -import 'package:test/test.dart'; - -// ───────────────────────────────────────────────────────────────────────────── -// Mock server (mirrors structured_e2e_test.dart) -// ───────────────────────────────────────────────────────────────────────────── - -/// A chat-completions model against [baseUrl]: the registry's `deepseek` -/// entry with a `baseUrl` setting (`Model.openai` speaks the Responses API; -/// Groq reads stream usage from `x_groq` only, as upstream does). -Model mockChatModel(String baseUrl, String apiKey, String modelId) => - Model.provider('deepseek', modelId, - apiKey: apiKey, configJson: jsonEncode({'baseUrl': baseUrl})); - -class MockOpenAIServer { - final HttpServer _server; - final List> _responses; - final List<_RecordedRequest> recorded = []; - int _nextResponse = 0; - - MockOpenAIServer._(this._server, this._responses) { - _server.listen(_handleRequest); - } - - String get baseUrl => 'http://${_server.address.host}:${_server.port}'; - - Future close() => _server.close(force: true); - - void _handleRequest(HttpRequest request) async { - final rawBody = await utf8.decoder.bind(request).join(); - dynamic parsed; - try { - parsed = rawBody.isEmpty ? null : jsonDecode(rawBody); - } catch (_) { - parsed = rawBody; - } - recorded.add(_RecordedRequest( - method: request.method, - path: request.uri.path, - body: parsed, - )); - - final resp = _nextResponse < _responses.length - ? _responses[_nextResponse++] - : _responses.last; - final response = request.response; - response.statusCode = HttpStatus.ok; - if (resp['sse'] == true) { - response.headers.contentType = ContentType.parse('text/event-stream'); - response.headers.set('Cache-Control', 'no-cache'); - response.headers.set('Connection', 'keep-alive'); - response.write(resp['body'] as String); - } else { - response.headers.contentType = ContentType.json; - response.write(resp['body'] as String); - } - await response.close(); - } -} - -class _RecordedRequest { - final String method; - final String path; - final dynamic body; - _RecordedRequest({required this.method, required this.path, required this.body}); -} - -Future startMockServer(List> responses) async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - return MockOpenAIServer._(server, responses); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Worker-isolate FFI driver (typed) -// ───────────────────────────────────────────────────────────────────────────── - -/// Sendable arguments for the worker isolate. Only plain JSON-like fields, so -/// the instance can cross the isolate boundary (same approach as -/// `structured_e2e_test.dart`'s `_GenerateArgs`). -class _TypedArgs { - final String baseUrl; - final String apiKey; - final String modelId; - final String? prompt; // for generateText (string prompt) - final List>? messages; // for generateTextMessages - final List? tools; - final ToolChoice? toolChoice; - final bool? includeRawChunks; - _TypedArgs({ - required this.baseUrl, - required this.apiKey, - required this.modelId, - this.prompt, - this.messages, - this.tools, - this.toolChoice, - this.includeRawChunks, - }); -} - -/// Run a typed `generateText` in a worker isolate, returning the result as a -/// sendable `Map`. The worker constructs the typed `GenerateTextResult` (so the -/// real wrapper is exercised) then deep-flattens it via a JSON encode/decode -/// round-trip: `toJson()` alone leaves nested `@JsonSerializable` objects as -/// instances (not plain maps), which cannot be re-parsed by `fromJson`. -Future> runTypedGenerateText(_TypedArgs args) { - return Isolate.run(() { - final model = mockChatModel(args.baseUrl, args.apiKey, args.modelId); - final typed = TypedModel(model); - try { - final hasOptions = args.tools != null || args.toolChoice != null; - final options = hasOptions - ? GenerateTextOptions( - tools: args.tools, - toolChoice: args.toolChoice, - ) - : null; - final result = typed.generateText(args.prompt!, options); - return jsonDecode(jsonEncode(result)) as Map; - } finally { - typed.close(); - } - }); -} - -/// Run a typed `generateTextMessages` (multi-turn) in a worker isolate. -Future> runTypedGenerateTextMessages(_TypedArgs args) { - return Isolate.run(() { - final model = mockChatModel(args.baseUrl, args.apiKey, args.modelId); - final typed = TypedModel(model); - try { - final messages = args.messages! - .map((m) => ModelMessage(role: m['role'] as String, content: m['content']!)) - .toList(); - final result = typed.generateTextMessages(messages); - return jsonDecode(jsonEncode(result)) as Map; - } finally { - typed.close(); - } - }); -} - -/// Run a typed `streamText` in a worker isolate, returning the parsed parts. -Future> runTypedStreamText(_TypedArgs args) { - return Isolate.run(() async { - final model = mockChatModel(args.baseUrl, args.apiKey, args.modelId); - final typed = TypedModel(model); - try { - final options = (args.tools != null || args.includeRawChunks != null) - ? GenerateTextOptions( - tools: args.tools, - toolChoice: args.toolChoice, - includeRawChunks: args.includeRawChunks, - ) - : null; - // streamText blocks synchronously, then returns an already-closed - // stream of buffered parts. - final parts = await typed.streamText(args.prompt!, options).toList(); - return parts; - } finally { - typed.close(); - } - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Fixture responses (real OpenAI API shapes — same as structured_e2e_test.dart) -// ───────────────────────────────────────────────────────────────────────────── - -const String plainOpenAIResponse = r''' -{"id":"chatcmpl-test","model":"gpt-4o","choices":[{"message":{"role":"assistant","content":"Rust is a systems programming language."},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18}} -'''; - -const String toolCallOpenAIResponse = r''' -{"id":"chatcmpl-tc","model":"gpt-4o","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_abc","type":"function","function":{"name":"get_weather","arguments":"{\"location\":\"Tokyo\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":20,"completion_tokens":10,"total_tokens":30}} -'''; - -/// SSE stream: a tool-call name → arguments delta → finish. -String buildTextSse() { - // "Hello" -> " world" -> finish + usage; [DONE] sentinel (RFC-0016 M2). - final event1 = { - 'id': '1', - 'model': 'gpt-4o', - 'choices': [ - {'delta': {'role': 'assistant', 'content': 'Hello'}} - ], - }; - final event2 = { - 'id': '1', - 'model': 'gpt-4o', - 'choices': [ - {'delta': {'content': ' world'}} - ], - }; - final event3 = { - 'id': '1', - 'model': 'gpt-4o', - 'choices': [ - {'delta': {}, 'finish_reason': 'stop'} - ], - 'usage': {'prompt_tokens': 3, 'completion_tokens': 2, 'total_tokens': 5}, - }; - final sb = StringBuffer() - ..write('data: ${jsonEncode(event1)}\n\n') - ..write('data: ${jsonEncode(event2)}\n\n') - ..write('data: ${jsonEncode(event3)}\n\n') - ..write('data: [DONE]\n\n'); - return sb.toString(); -} - -String buildToolCallSse() { - final event1 = { - 'id': '1', - 'model': 'gpt-4o', - 'choices': [ - { - 'delta': { - 'role': 'assistant', - 'tool_calls': [ - { - 'index': 0, - 'id': 'call_xyz', - 'type': 'function', - 'function': {'name': 'get_weather', 'arguments': ''}, - } - ], - } - } - ], - }; - final event2 = { - 'id': '1', - 'model': 'gpt-4o', - 'choices': [ - { - 'delta': { - 'tool_calls': [ - { - 'index': 0, - 'function': {'arguments': '{"location":"Tokyo"}'}, - } - ], - } - } - ], - }; - final event3 = { - 'id': '1', - 'model': 'gpt-4o', - 'choices': [ - {'delta': {}, 'finish_reason': 'tool_calls'}, - ], - 'usage': { - 'prompt_tokens': 5, - 'completion_tokens': 2, - 'total_tokens': 7, - }, - }; - final sb = StringBuffer() - ..write('data: ${jsonEncode(event1)}\n\n') - ..write('data: ${jsonEncode(event2)}\n\n') - ..write('data: ${jsonEncode(event3)}\n\n') - ..write('data: [DONE]\n\n'); - return sb.toString(); -} - -Tool _weatherTool() { - return Tool.function(FunctionTool( - name: 'get_weather', - inputSchema: { - 'type': 'object', - 'properties': { - 'location': {'type': 'string'}, - }, - 'required': ['location'], - }, - )); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Tests -// ───────────────────────────────────────────────────────────────────────────── - -void main() { - const apiKey = 'sk-test-mock-key'; - const modelId = 'gpt-4o'; - - - - test('generateText with a plain prompt returns text', () async { - final server = await startMockServer([ - {'body': plainOpenAIResponse}, - ]); - addTearDown(server.close); - - final resultJson = await runTypedGenerateText(_TypedArgs( - baseUrl: server.baseUrl, - apiKey: apiKey, - modelId: modelId, - prompt: 'What is Rust?', - )); - final result = GenerateTextResult.fromJson(resultJson); - - expect(result.text, 'Rust is a systems programming language.'); - expect(result.toolCalls, isEmpty); - expect(result.finishReason, 'stop'); - expect(result.rawFinishReason, 'stop'); - expect(result.usage.inputTokens, 10); - expect(result.usage.outputTokens, 8); - expect(result.usage.totalTokens, 18); - expect(result.steps, hasLength(1)); - }); - - test('generateTextMessages passes multi-role messages to provider', () async { - final server = await startMockServer([ - {'body': plainOpenAIResponse}, - ]); - addTearDown(server.close); - - final resultJson = await runTypedGenerateTextMessages(_TypedArgs( - baseUrl: server.baseUrl, - apiKey: apiKey, - modelId: modelId, - messages: [ - {'role': 'system', 'content': 'You are a helpful assistant.'}, - {'role': 'user', 'content': 'Hello'}, - ], - )); - final result = GenerateTextResult.fromJson(resultJson); - - expect(result.text, 'Rust is a systems programming language.'); - - // Both typed messages reached the provider, preserving role + content. - expect(server.recorded, hasLength(1)); - final sentBody = server.recorded.first.body as Map; - expect(sentBody['model'], modelId); - final sentMessages = sentBody['messages'] as List; - expect(sentMessages, hasLength(2)); - expect((sentMessages[0] as Map)['role'], 'system'); - expect((sentMessages[0] as Map)['content'], 'You are a helpful assistant.'); - expect((sentMessages[1] as Map)['role'], 'user'); - expect((sentMessages[1] as Map)['content'], 'Hello'); - }); - - test('tool_choice object form reaches provider', () async { - final server = await startMockServer([ - {'body': toolCallOpenAIResponse}, - ]); - addTearDown(server.close); - - await runTypedGenerateText(_TypedArgs( - baseUrl: server.baseUrl, - apiKey: apiKey, - modelId: modelId, - prompt: 'What is the weather in Tokyo?', - tools: [_weatherTool()], - toolChoice: ToolChoice.tool('get_weather'), - )); - - expect(server.recorded, hasLength(1)); - final sentBody = server.recorded.first.body as Map; - expect(sentBody, contains('tools')); - // The Rust core maps the `ToolChoice::Tool { toolName }` form onto the - // provider's wire format (OpenAI: {type:function, function:{name}}). - expect(sentBody['tool_choice'], { - 'type': 'function', - 'function': {'name': 'get_weather'}, - }); - }); - - test('streamText yields typed StreamParts with tool-call accessors', () async { - final server = await startMockServer([ - {'sse': true, 'body': buildToolCallSse()}, - ]); - addTearDown(server.close); - - final parts = await runTypedStreamText(_TypedArgs( - baseUrl: server.baseUrl, - apiKey: apiKey, - modelId: modelId, - prompt: 'What is the weather in Tokyo?', - tools: [_weatherTool()], - )); - - expect(parts, isNotEmpty); - // Every part is a `type`-tagged union member the binding models. - expect(parts.every((p) => p.type.isNotEmpty), isTrue); - expect(parts.whereType(), isEmpty); - - // The tool-call part carries the parsed call. - final toolCall = parts.whereType().first; - expect(toolCall.type, 'tool-call'); - expect(toolCall.call.toolName, 'get_weather'); - expect(toolCall.call.toolCallId, 'call_xyz'); - expect((toolCall.call.input as Map)['location'], 'Tokyo'); - - // The stream opens with start and start-step and closes with finish-step - // and a finish part carrying the total usage and finish reason. - expect(parts.first, isA()); - expect(parts[1], isA()); - expect(parts[parts.length - 2], isA()); - final finish = parts.last as StreamPartFinish; - expect(finish.type, 'finish'); - expect(finish.finishReason, 'tool-calls'); - expect(finish.totalUsage, isA()); - expect(finish.totalUsage.inputTokens, 5); - expect(finish.totalUsage.outputTokens, 2); - - // The request was a streaming POST. - expect(server.recorded, hasLength(1)); - final sentBody = server.recorded.first.body as Map; - expect(sentBody['stream'], true); - }); - - test('streamText emits Raw parts when includeRawChunks enabled', () async { - // RFC-0016 M2: includeRawChunks surfaces one Raw part per JSON SSE - // event (parsed payload), before the parsed parts; [DONE] excluded. - final server = await startMockServer([ - {'sse': true, 'body': buildTextSse()}, - ]); - addTearDown(server.close); - - final parts = await runTypedStreamText(_TypedArgs( - baseUrl: server.baseUrl, - apiKey: apiKey, - modelId: modelId, - prompt: 'Say hello', - includeRawChunks: true, - )); - - final raws = parts.whereType().toList(); - // buildTextSse has 3 JSON events (2 content + 1 usage chunk). - expect(raws, hasLength(3)); - final first = raws.first.rawValue as Map; - expect( - (first['choices'] as List).first['delta']['content'], - 'Hello', - ); - - // Raw for the first event precedes its TextDelta. - final firstRawIdx = parts.indexOf(raws.first); - final firstTextIdx = parts.indexWhere((p) => p is StreamPartTextDelta); - expect(firstRawIdx, lessThan(firstTextIdx)); - - // Parsed text is unaffected. - final text = parts - .whereType() - .map((p) => p.text) - .join(); - expect(text, 'Hello world'); - }); - - test('streamText parses non-empty start-step warnings', () async { - // RFC-0016 M9: start-step carries the call's warnings; a non-empty array - // (e.g. from a provider that rejects top_k) must decode without breaking - // the stream. The openai full profile produces no warning, so the - // assertion is decode-ability + field presence (the warning itself is - // covered by groq_test.rs in Rust). - final server = await startMockServer([ - {'sse': true, 'body': buildTextSse()}, - ]); - addTearDown(server.close); - - final parts = await runTypedStreamText(_TypedArgs( - baseUrl: server.baseUrl, - apiKey: apiKey, - modelId: modelId, - prompt: 'Say hello', - )); - - final start = parts.whereType().first; - expect(start.type, 'start-step'); - expect(start.warnings, isA>()); - }); -} diff --git a/bindings/flutter/tool/build_test_fixture.py b/bindings/flutter/tool/build_test_fixture.py new file mode 100644 index 000000000..dd6838399 --- /dev/null +++ b/bindings/flutter/tool/build_test_fixture.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Build a deterministic new-ABI test double, never a substitute for Rust QA.""" +import pathlib +import subprocess +import sys + +root = pathlib.Path(__file__).resolve().parents[1] +output = root / '.dart_tool/native' +output.mkdir(parents=True, exist_ok=True) +suffix = '.dylib' if sys.platform == 'darwin' else '.so' +shared = '-dynamiclib' if sys.platform == 'darwin' else '-shared' +for name, defines in [('ops_fixture', []), ('bad_abi', ['-DBAD_ABI']), + ('bad_manifest', ['-DBAD_MANIFEST']), + ('missing_symbol', ['-DOMIT_ERROR_MESSAGE']), + ('missing_finalizer', ['-DOMIT_HANDLE_FINALIZER'])]: + command = ['cc', '-std=c11', '-Wall', '-Wextra', '-Werror', '-fPIC', shared, + '-pthread', *defines, str(root / 'native/aimux_dart.c'), + str(root / 'test/native/ops_fixture.c'), '-o', str(output / (name + suffix))] + subprocess.run(command, check=True) + print(output / (name + suffix)) diff --git a/bindings/flutter/tool/check_symbols.sh b/bindings/flutter/tool/check_symbols.sh new file mode 100755 index 000000000..9c812dcbb --- /dev/null +++ b/bindings/flutter/tool/check_symbols.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Check defined global symbols across every executable in an iOS app bundle. +set -euo pipefail +app=${1:?usage: check_symbols.sh path/to/Runner.app} +native=$(cd "$(dirname "$0")/../native" && pwd) +# The ABI is what the generated header declares plus the transport's EXPORTs. +required=($(sed -n 's/.*\(aimux_[a-z0-9_]*\)(.*/\1/p' "$native/aimux_ops.h" + sed -n '/^EXPORT/s/.*\(aimux_dart_[a-z0-9_]*\)(.*/\1/p' "$native/aimux_dart.c")) +symbols=$(mktemp) +trap 'rm -f "$symbols" "$symbols.names"' EXIT +while IFS= read -r -d '' file; do + # Undefined imports do not establish that force_load retained the ABI. + nm -gU "$file" >> "$symbols" 2>/dev/null || true +done < <(find "$app" -type f -perm -111 -print0) +# Keep only the names, so grep -q reads a file and no pipe writer sees SIGPIPE. +awk '{print $NF}' "$symbols" > "$symbols.names" +for symbol in "${required[@]}"; do + if ! grep -qx "_\{0,1\}$symbol" "$symbols.names"; then + echo "FAIL: defined symbol $symbol missing from $app" >&2 + exit 1 + fi +done +echo "OK: all ${#required[@]} required aimux symbols present" diff --git a/bindings/flutter/tool/generate_ffi.py b/bindings/flutter/tool/generate_ffi.py new file mode 100644 index 000000000..a62e34bf6 --- /dev/null +++ b/bindings/flutter/tool/generate_ffi.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Generate Dart ABI primitives from aimux-codegen's packaged header; reject unexpected layouts.""" +import argparse +import pathlib +import re + +ROOT = pathlib.Path(__file__).resolve().parents[1] +parser = argparse.ArgumentParser() +parser.add_argument('header', nargs='?', type=pathlib.Path, default=ROOT / 'native/aimux_ops.h') +parser.add_argument('--check', action='store_true') +args = parser.parse_args() +source = re.sub(r'/\*.*?\*/|//[^\n]*', '', args.header.read_text(), flags=re.S) +types = { + 'void': ('Void', 'void'), 'uint32_t': ('Uint32', 'int'), + 'uint64_t': ('Uint64', 'int'), 'int64_t': ('Int64', 'int'), + 'int32_t': ('Int32', 'int'), 'size_t': ('UintPtr', 'int'), + 'char*': ('Pointer', 'Pointer'), + 'char**': ('Pointer>', 'Pointer>'), + 'void*': ('Pointer', 'Pointer'), + 'uint8_t*': ('Pointer', 'Pointer'), + 'int32_t*': ('Pointer', 'Pointer'), + 'aimux_error_t*': ('Pointer', 'Pointer'), + 'aimux_event_cb': ('Pointer>', 'Pointer>'), +} +expected = { + 'abi_version': ('uint32_t', []), + 'manifest_json': ('aimux_error_t*', ['char**']), + 'call': ('aimux_error_t*', ['uint64_t', 'char*', 'char*', 'uint64_t', 'char**']), + 'stream': ('aimux_error_t*', ['uint64_t', 'char*', 'char*', 'uint64_t', 'aimux_event_cb', 'void*']), + 'operation_push': ('aimux_error_t*', ['uint64_t', 'uint8_t*', 'size_t']), + 'operation_next_part': ('aimux_error_t*', ['uint64_t', 'int64_t', 'char**', 'int32_t*']), + 'handle_finalizer': ('void', ['void*']), + 'abort': ('void', ['uint64_t']), 'drop': ('void', ['uint64_t']), + 'free_string': ('void', ['char*']), 'error_free': ('void', ['aimux_error_t*']), + 'error_code': ('int32_t', ['aimux_error_t*']), + 'error_message': ('char*', ['aimux_error_t*']), + 'error_json': ('char*', ['aimux_error_t*']), +} + +def normalized(value): + return re.sub(r'\s+', '', value.replace('const', '')) + +found = {} +for match in re.finditer(r'\b(uint32_t|int32_t|void|char\s*\*|aimux_error_t\s*\*)\s*(aimux_\w+)\s*\((.*?)\)\s*;', source, re.S): + name = match[2].removeprefix('aimux_') + if name not in expected: + continue + params = [] if match[3].strip() in ('void', '') else match[3].split(',') + params = [normalized(re.sub(r'\b\w+\s*$', '', p.strip())) for p in params] + found[name] = (normalized(match[1]), params) +for name, signature in expected.items(): + if found.get(name) != signature: + raise SystemExit(f'header mismatch for aimux_{name}: expected {signature}, got {found.get(name)}') +lines = [ + '// Generated by tool/generate_ffi.py from the shared C header. Do not edit.', + "import 'dart:ffi';", "import 'package:ffi/ffi.dart';", '', + 'typedef NativeEvent = Void Function(Pointer, Pointer);', '', + 'final class OpsBindings {', ' final DynamicLibrary library;', + ' OpsBindings(this.library);', '', +] +for name, (result, params) in expected.items(): + dart_name = re.sub(r'_([a-z])', lambda m: m[1].upper(), name) + native = f'{types[result][0]} Function({", ".join(types[p][0] for p in params)})' + dart = f'{types[result][1]} Function({", ".join(types[p][1] for p in params)})' + lines.append(f" late final {dart_name} = library.lookupFunction<{native}, {dart}>('aimux_{name}');") +lines.extend(['}', '']) +output = '\n'.join(lines) +target = ROOT / 'lib/src/ffi.g.dart' +if args.check: + if target.read_text() != output: + raise SystemExit('generated declarations differ; run tool/generate_ffi.py') +else: + target.write_text(output) diff --git a/bindings/go/README.md b/bindings/go/README.md index 7db593930..cd5b06cdd 100644 --- a/bindings/go/README.md +++ b/bindings/go/README.md @@ -1,71 +1,92 @@ # aimux-go -Go bindings for [aimux](https://github.com/arcships/aimux) — a unified LLM -access layer for 325 AI providers, built on a Rust core. - -## Install - -```bash -go get github.com/arcships/aimux/bindings/go -go generate github.com/arcships/aimux/bindings/go # downloads libaimux_ffi.a for your platform -go build ./... -``` - -The binding links `libaimux_ffi.a` (static archive) via cgo. Every release -builds the archive for 4 platforms (linux x64, macOS x64/arm64, Windows x64) -on CI and ships them on the [GitHub Releases](https://github.com/arcships/aimux/releases) -page. `go generate` fetches the right one for your machine — a few MB, no -Rust toolchain, no compilation. Pin a specific version with -`AIMUX_FFI_VERSION=v0.1.0 go generate github.com/arcships/aimux/bindings/go`. - -> Platforms without a prebuilt archive (e.g. FreeBSD, RISC-V): clone the -> repo and run `cargo build -p aimux-ffi --release` locally, then build -> your Go code from that checkout. -> The archive lands in `/.../aimux/target/release/`; a `go -> clean -modcache` or Go upgrade removes it — re-run `go generate`. - -## Quick start +Go bindings for the operation C ABI. Requires cgo, Go 1.23+, the matching +generated `aimux-ffi/include/aimux.h`, and a matching Rust library. +Provider-specific constructors have been replaced by operation calls. ```go -package main - -import ( - "fmt" - "log" - - aimux "github.com/arcships/aimux/bindings/go" -) - -func main() { - p, err := aimux.NewOpenAI("sk-...", "gpt-4o") - if err != nil { - log.Fatal(err) - } - defer p.Close() - - result, err := p.Generate("Explain Rust ownership in one sentence.", nil) - if err != nil { - log.Fatal(err) - } - fmt.Println(result.Text) -} +ctx := context.Background() +provider, err := aimux.Create(ctx, nil, "provider.create", map[string]any{ + "package": "openai", + "settings": map[string]any{"apiKey": os.Getenv("OPENAI_API_KEY")}, +}) +if err != nil { return err } +defer provider.Close() +model, err := aimux.Create(ctx, provider, "provider.model", map[string]any{ + "method": "languageModel", "modelId": "gpt-4o-mini", +}) +if err != nil { return err } +defer model.Close() +result, err := aimux.CallInto[aimux.GenerateTextResult](ctx, model, "text.generate", map[string]any{ + "prompt": "Hello", "options": aimux.GenerateTextOptions{}, +}) +if err != nil { return err } +fmt.Println(result.Text) ``` -`GenerateText` / `StreamText` take and return raw JSON strings; `Generate` / -`Stream` are the typed wrappers. The JSON is the AI SDK JSON: camelCase field -names, unions tagged by `type` (`"tool-call"`, `"text-delta"`, ...), optional -fields absent. A tool result is a `tool-result` content part -(`toolCallId`, `toolName`, `output`). See [docs/api/go.md](../../docs/api/go.md). - -## Features - -- Typed API for text, streaming, embeddings, tools, images, speech, and more -- One API for 325 providers (OpenAI, Anthropic, Google, Bedrock, …) -- Single static binary — the Rust core is linked in, no runtime deps - -See the [API docs](https://github.com/arcships/aimux/blob/master/docs/API.md) -for the full API surface. - -## License +`Call` invokes a global operation; `Handle.Call` invokes one on an owned +handle. `Create` owns the returned entry, whether the operation returns a +decimal handle string or an object containing `handle`. Names are available +in `generated/ops.go` and `Manifest()`. The first operation checks ABI major +and the exact generated operation version. Manifest metadata remains available +to callers; per-type schema revision/hash metadata is not currently generated. + +Calls accept typed values or `json.RawMessage` and return canonical raw JSON. +`CallInto[T]` decodes a typed result. `Params` preserves absent keys versus +explicit null. `WireDTO[T]` retains the complete immutable JSON document, +including unknown fields and large integers; `Value()` decodes a separate +typed view and `Field()` reports presence. For example, +`CallInto[aimux.WireDTO[aimux.GenerateTextResult]]` provides lossless storage. +Nil parameters encode `null`; use `struct{}{}` for `{}`. Handle IDs encode as +decimal strings, including above 2^53. Never adopt an ID twice. + +Context cancellation signals native Abort. `StartOperation` retains its +context signal through the transcription operation lifetime. `Push` submits +binary input and may return ResourceLimit when native admission is full. +`InputDone`, `NextPart`, `Status`, `Cancel` and `Close` expose operation +controls. `NextPart` distinguishes Part, Ended and PollTimeout; its timeout +must be nonnegative, with zero polling immediately. A terminal failure is +delivered once by polling and remains available through Status. + +`Handle.Stream` returns copied event JSON through `Events()`. The queue holds +seven events plus one pending producer, with at most 1 MiB per event and +bounded frame overhead. The producer alone closes the channel after callbacks +return. `Cancel` releases a full queue even without a consumer. `Err` and +`Terminal` wait for producer completion; drain Events first or cancel when +abandoning consumption. Terminal control state is stored separately from data. +A late cancellation preserves the recorded native outcome. Always close an +abandoned stream. + +Native errors support `errors.As` with `*aimux.Error`. `Envelope` contains the +domain, code, message, complete error payload and optional retry hint; +`RawEnvelope` also retains unknown envelope fields. The binding adds no retry +policy. Handles support concurrent calls and idempotent Close. Use Close for +normal cleanup; the fallback finalizer only enqueues native cleanup. + +## Build and test + +Build `aimux-ffi` with the matching source and header. By default cgo searches +`target/release` for `libaimux_ffi`; a shared library can also be selected with +`CGO_LDFLAGS` and the platform loader's library path. `go generate ./...` +downloads the platform archive and header from one release; pin it with +`AIMUX_FFI_VERSION`. + +For a built shared library: + +```sh +AIMUX_LIBRARY_DIR=/absolute/path/to/target/debug ./test-native.sh -count=1 +``` -MIT +This runs `go test -race -timeout 60s ./...` against Rust, including manifest +checks, provider/model/composite ownership, concurrent Close/call dispatch, +stream admission and terminal errors, and persistent operation errors. Tests +use local validation and unsupported operations without network I/O. + +`./test-abi.sh -count=1` builds a deterministic C double against the production +header in a temporary directory. It tests cgo ownership, borrowed-memory +copies, malformed callbacks, full-queue cancellation, operation lifetime, +large handle IDs and callback-token cleanup. These tests skip against Rust; +Rust integration tests skip against the double. Both suites include bridge +race tests and typed DTO round trips. Live provider calls, successful audio +transport, cross-platform packaging and Go 1.23 itself require separate +verification. diff --git a/bindings/go/abi_test.go b/bindings/go/abi_test.go new file mode 100644 index 000000000..618285aff --- /dev/null +++ b/bindings/go/abi_test.go @@ -0,0 +1,544 @@ +package aimux + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/arcships/aimux/bindings/go/generated" +) + +func fixture(t *testing.T) { + t.Helper() + if os.Getenv("AIMUX_TEST_ABI") != "1" { + t.Skip("deterministic C fixture only; run ./test-abi.sh") + } +} + +func fixtureHandle(t *testing.T) *Handle { + t.Helper() + fixture(t) + h, err := Create(context.Background(), nil, "test.create", struct{}{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { h.Close() }) + return h +} + +func await(t *testing.T, done <-chan struct{}) { + t.Helper() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("native producer failed to stop") + } +} + +func awaitNativeWait(t *testing.T, field string) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + stats, err := CallInto[map[string]int](context.Background(), nil, "test.stats", struct{}{}) + if err != nil { + t.Fatal(err) + } + if (*stats)[field] != 0 { + return + } + runtime.Gosched() + } + t.Fatalf("native did not enter %s", field) +} + +func checkCode(t *testing.T, err error, kind string, code int32) *Error { + t.Helper() + var native *Error + if !errors.As(err, &native) { + t.Fatalf("expected structured error, got %T: %v", err, err) + } + if native.Envelope.Kind != kind || native.Envelope.Code != code { + t.Fatalf("unexpected envelope: %+v", native.Envelope) + } + return native +} + +func TestManifestRequiresExactGeneratedVersionAndDigest(t *testing.T) { + for _, tc := range []struct { + ops, digest string + valid bool + }{ + {generated.OPS_VERSION, generated.MANIFEST_DIGEST, true}, + {"1.1", generated.MANIFEST_DIGEST, false}, + {generated.OPS_VERSION, "wrong-digest", false}, + {generated.OPS_VERSION, "", false}, + } { + raw, err := json.Marshal(map[string]any{"abiVersion": ABIVersion, "opsVersion": tc.ops, "digest": tc.digest}) + if err != nil { + t.Fatal(err) + } + err = validateManifest(ABIVersion, raw) + if tc.valid { + if err != nil { + t.Fatal(err) + } + continue + } + checkCode(t, err, "protocol", 302) + for _, value := range []string{tc.ops, generated.OPS_VERSION, tc.digest, generated.MANIFEST_DIGEST} { + if !strings.Contains(err.Error(), value) { + t.Fatalf("mismatch omits %q: %v", value, err) + } + } + } +} + +func TestRuntimeManifest(t *testing.T) { + raw, err := Manifest() + if err != nil { + t.Fatal(err) + } + var manifest struct { + ABI uint32 `json:"abiVersion"` + Ops string `json:"opsVersion"` + } + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatal(err) + } + if manifest.ABI>>16 != generated.ABI_VERSION>>16 || manifest.Ops != generated.OPS_VERSION { + t.Fatalf("unexpected manifest: %s", raw) + } +} + +func TestNativeErrorEnvelopeDomainsAndOwnership(t *testing.T) { + fixture(t) + for _, c := range []struct { + op, kind string + code int32 + }{ + {"test.error", "aimux", 11}, {"test.recording", "recording", 100}, + {"test.binding", "binding", 204}, {"test.protocol", "protocol", 303}, + } { + _, err := Call(context.Background(), c.op, struct{}{}) + native := checkCode(t, fmt.Errorf("wrapped: %w", err), c.kind, c.code) + if native.Envelope.RetryAfterMs == nil || *native.Envelope.RetryAfterMs != 0 { + t.Fatal("lost explicit zero retry hint") + } + var payload map[string]json.RawMessage + if err := json.Unmarshal(native.Envelope.Error, &payload); err != nil { + t.Fatal(err) + } + if string(payload["data"]) != "null" || string(payload["nested"]) != `{"kept":7}` { + t.Fatalf("lost payload: %s", native.Envelope.Error) + } + var all map[string]json.RawMessage + if err := json.Unmarshal(native.RawEnvelope, &all); err != nil { + t.Fatal(err) + } + if string(all["future"]) != "true" { + t.Fatal("unknown envelope field lost") + } + } + stats, err := CallInto[struct{ Strings, Errors int }](context.Background(), nil, "test.stats", struct{}{}) + if err != nil { + t.Fatal(err) + } + if stats.Strings != 0 || stats.Errors != 0 { + t.Fatalf("native owned pointers leaked: %+v", stats) + } +} + +func TestNativeParamsKeepNullMissingAndLargeHandle(t *testing.T) { + h := fixtureHandle(t) + id, err := h.ID() + if err != nil { + t.Fatal(err) + } + if uint64(id) <= 1<<53 { + t.Fatal("fixture must use handles beyond JSON number precision") + } + params := Params{"null": json.RawMessage(`null`), "handle": json.RawMessage(fmt.Sprintf(`"%d"`, id)), "providerOptions": json.RawMessage(`{"vendor":{"x":null,"ordered":7}}`)} + result, err := h.Call(context.Background(), "test.echo", params) + if err != nil { + t.Fatal(err) + } + var got Params + if err := json.Unmarshal(result, &got); err != nil { + t.Fatal(err) + } + if _, present := got["missing"]; present { + t.Fatal("absent field became present") + } + if string(got["null"]) != "null" || string(got["handle"]) != string(params["handle"]) { + t.Fatalf("wire changed: %s", result) + } + for _, input := range []any{nil, json.RawMessage(`{}`), json.RawMessage(`null`)} { + result, err := h.Call(context.Background(), "test.echo", input) + if err != nil { + t.Fatal(err) + } + want, _ := json.Marshal(input) + if string(result) != string(want) { + t.Fatalf("normalization: got %s want %s", result, want) + } + } +} + +func TestCallCancellationBeforeStartAndDuringWait(t *testing.T) { + h := fixtureHandle(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := h.Call(ctx, "test.wait", struct{}{}); !errors.Is(err, context.Canceled) { + t.Fatalf("pre-cancel: %v", err) + } + ctx, cancel = context.WithCancel(context.Background()) + defer cancel() + done := make(chan struct{}) + var err error + go func() { _, err = h.Call(ctx, "test.wait", struct{}{}); close(done) }() + awaitNativeWait(t, "waitingCalls") + cancel() + await(t, done) + if !errors.Is(err, context.Canceled) { + checkCode(t, err, "aimux", 13) + } +} + +func TestCloseCallRace(t *testing.T) { + fixture(t) + for i := 0; i < 40; i++ { + h, err := Create(context.Background(), nil, "test.create", struct{}{}) + if err != nil { + t.Fatal(err) + } + start := make(chan struct{}) + errs := make(chan error, 8) + var wg sync.WaitGroup + for j := 0; j < 8; j++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, err := h.Call(context.Background(), generated.OP_TEXT_GENERATE, struct{}{}) + errs <- err + }() + } + close(start) + h.Close() + h.Close() + wg.Wait() + close(errs) + for err := range errs { + if err != nil && !errors.Is(err, ErrClosed) { + checkCode(t, err, "binding", 203) + } + } + } +} + +func TestStreamCopiesBorrowedFramesAndLateCancel(t *testing.T) { + h := fixtureHandle(t) + s, err := h.Stream(context.Background(), generated.OP_TEXT_STREAM, struct{}{}) + if err != nil { + t.Fatal(err) + } + count := 0 + for event := range s.Events() { + var payload struct { + Text string `json:"text"` + } + if err := json.Unmarshal(event, &payload); err != nil { + t.Fatal(err) + } + if payload.Text != "copied" { + t.Fatalf("borrowed callback buffer escaped: %s", event) + } + count++ + } + if count != 1 { + t.Fatalf("event count = %d", count) + } + if err := s.Err(); err != nil { + t.Fatal(err) + } + s.Cancel() + terminal, err := s.Terminal() + if err != nil || terminal == nil || terminal.Outcome != "success" { + t.Fatalf("late cancel rewrote terminal: %+v, %v", terminal, err) + } + if n := activeCallbacks.Load(); n != 0 { + t.Fatalf("cgo handles leaked: %d", n) + } +} + +func TestStreamCancellationFullQueueNoConsumer(t *testing.T) { + h := fixtureHandle(t) + for i := 0; i < 40; i++ { + ctx, cancel := context.WithCancel(context.Background()) + s, err := h.Stream(ctx, "test.flood", struct{}{}) + if err != nil { + t.Fatal(err) + } + // The producer blocks only after filling the bounded output queue. + deadline := time.Now().Add(time.Second) + for len(s.events) < cap(s.events) && time.Now().Before(deadline) { + runtime.Gosched() + } + if len(s.events) != cap(s.events) { + t.Fatal("fixture did not fill queue") + } + errDone := make(chan struct{}) + var streamErr error + go func() { streamErr = s.Err(); close(errDone) }() + select { + case <-errDone: + t.Fatal("Err read producer state before close") + default: + } + if i%2 == 0 { + cancel() + } else { + s.Cancel() + cancel() + } + await(t, s.Done()) + await(t, errDone) + checkCode(t, streamErr, "aimux", 13) + terminal, _ := s.Terminal() + if terminal == nil || terminal.Outcome != "cancelled" { + t.Fatalf("lost terminal with full queue: %+v", terminal) + } + for range s.Events() { + } + s.Close() + if n := activeCallbacks.Load(); n != 0 { + t.Fatalf("cgo handles leaked: %d", n) + } + } +} + +func TestStreamPreCancelAndAdmissionRejection(t *testing.T) { + h := fixtureHandle(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s, err := h.Stream(ctx, generated.OP_TEXT_STREAM, struct{}{}) + if s != nil || !errors.Is(err, context.Canceled) { + t.Fatalf("pre-cancel: %v, %v", s, err) + } + s, err = h.Stream(context.Background(), "test.reject", struct{}{}) + if s != nil { + t.Fatal("rejected stream escaped") + } + checkCode(t, err, "protocol", 301) + if activeCallbacks.Load() != 0 { + t.Fatal("rejection leaked callback context") + } +} + +func TestStreamCancellationDuringStartup(t *testing.T) { + h := fixtureHandle(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan struct{}) + var s *Stream + var err error + go func() { s, err = h.Stream(ctx, "test.waitStream", struct{}{}); close(done) }() + awaitNativeWait(t, "waitingStreams") + cancel() + await(t, done) + if err != nil { + if !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + } else { + await(t, s.Done()) + checkCode(t, s.Err(), "aimux", 13) + } +} + +func TestStreamTerminalErrorAndCallbackFailureLatch(t *testing.T) { + h := fixtureHandle(t) + for _, c := range []struct { + op, kind string + code int32 + }{ + {"test.terminalError", "aimux", 11}, {"test.bad", "binding", 206}, + {"test.null", "binding", 206}, {"test.large", "protocol", 305}, + {"test.sequence", "binding", 206}, {"test.eventTooLarge", "protocol", 305}, {"test.noend", "binding", 206}, + } { + t.Run(c.op, func(t *testing.T) { + s, err := h.Stream(context.Background(), c.op, struct{}{}) + if err == nil { + for range s.Events() { + } + err = s.Err() + } + checkCode(t, err, c.kind, c.code) + if activeCallbacks.Load() != 0 { + t.Fatal("callback failure leaked context") + } + }) + } +} + +func TestStreamTerminalCancelRace(t *testing.T) { + h := fixtureHandle(t) + for i := 0; i < 100; i++ { + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { runtime.Gosched(); cancel(); close(done) }() + s, err := h.Stream(ctx, generated.OP_TEXT_STREAM, struct{}{}) + if err != nil { + if !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + } else { + for range s.Events() { + } + terminal, err := s.Terminal() + if terminal == nil { + t.Fatal("lost race terminal") + } + if terminal.Outcome == "success" { + if err != nil { + t.Fatal(err) + } + } else { + checkCode(t, err, "aimux", 13) + } + } + await(t, done) + } +} + +func TestOperationPushPollAndCancellationLifetime(t *testing.T) { + h := fixtureHandle(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + o, err := h.StartOperation(ctx, struct{}{}) + if err != nil { + t.Fatal(err) + } + defer o.Close() + if err := o.Push(nil); err != nil { + t.Fatal(err) + } + if err := o.Push([]byte("audio")); err != nil { + t.Fatal(err) + } + checkCode(t, o.Push(make([]byte, maxFrameBytes+1)), "protocol", 305) + result, err := o.NextPart(0) + if err != nil || result.State != PollTimeout || result.Part != nil { + t.Fatalf("timeout: %+v, %v", result, err) + } + result, err = o.NextPart(time.Second) + if err != nil || result.State != Part || !json.Valid(result.Part) { + t.Fatalf("part: %+v, %v", result, err) + } + // Unlike a Call-scoped abort, the operation abort still exists here. + done := make(chan struct{}) + var pushErr error + go func() { pushErr = o.Push([]byte{255}); close(done) }() + awaitNativeWait(t, "waitingPushes") + cancel() + await(t, done) + checkCode(t, pushErr, "aimux", 13) + _, err = o.NextPart(time.Hour) + checkCode(t, err, "aimux", 13) + result, err = o.NextPart(0) + if err != nil || result.State != Ended { + t.Fatalf("terminal error repeated: %+v, %v", result, err) + } + o.Close() + o.Close() + if !errors.Is(o.Push(nil), ErrClosed) { + t.Fatal("push after close") + } +} + +func TestOperationInputDoneAndCancelWithoutConsumer(t *testing.T) { + h := fixtureHandle(t) + o, err := h.StartOperation(context.Background(), struct{}{}) + if err != nil { + t.Fatal(err) + } + defer o.Close() + if err := o.InputDone(context.Background()); err != nil { + t.Fatal(err) + } + if err := o.InputDone(context.Background()); err != nil { + t.Fatal(err) + } + checkCode(t, o.Push([]byte("late")), "protocol", 304) + result, err := o.NextPart(time.Second) + if err != nil || result.State != Ended { + t.Fatalf("inputDone: %+v, %v", result, err) + } + o.Cancel() +} + +func TestCompositeConstructionPreservesHandleStrings(t *testing.T) { + child := fixtureHandle(t) + childID, _ := child.ID() + params := struct { + Children []HandleID `json:"children"` + }{[]HandleID{childID}} + composite, err := Create(context.Background(), nil, generated.OP_COMPOSITE_ROUTER, params) + if err != nil { + t.Fatal(err) + } + defer composite.Close() + child.Close() + if _, err := composite.Call(context.Background(), generated.OP_TEXT_GENERATE, struct{}{}); err != nil { + t.Fatal(err) + } +} + +func TestOperationCancellationInterruptsPoll(t *testing.T) { + h := fixtureHandle(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + o, err := h.StartOperation(ctx, struct{}{}) + if err != nil { + t.Fatal(err) + } + defer o.Close() + if _, err := o.NextPart(time.Second); err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + var pollErr error + go func() { _, pollErr = o.NextPart(time.Hour); close(done) }() + awaitNativeWait(t, "waitingPolls") + cancel() + await(t, done) + checkCode(t, pollErr, "aimux", 13) +} + +func TestStreamSingleEventLimitIncludesFrameOverhead(t *testing.T) { + h := fixtureHandle(t) + s, err := h.Stream(context.Background(), "test.limit", struct{}{}) + if err != nil { + t.Fatal(err) + } + count := 0 + for event := range s.Events() { + if len(event) != maxItemBytes { + t.Fatalf("event length = %d", len(event)) + } + count++ + } + if err := s.Err(); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("event count = %d", count) + } +} diff --git a/bindings/go/aimux.go b/bindings/go/aimux.go index 0267c4318..ec8bf2d89 100644 --- a/bindings/go/aimux.go +++ b/bindings/go/aimux.go @@ -1,1324 +1,226 @@ -// Package aimux provides Go bindings for the aimux unified LLM service layer -// (325 AI providers via a single API). -// -// This is the C ABI path (RFC-0001 §3.2) — same as Swift/Kotlin/Flutter/C. -// Go calls aimux-ffi via cgo, statically linking libaimux_ffi.a. The result is -// a single binary with the Rust core compiled in (no .so/.dll/.dylib to ship). -// -// Design doc: rfc/0011-golang-bindings.md +// Package aimux binds the RFC-0039 C ABI. Operation names and wire DTOs are +// defined by the runtime manifest; the binding does not add a retry layer. package aimux -/* -#cgo CFLAGS: -I${SRCDIR}/../../aimux-ffi -#cgo linux LDFLAGS: -L${SRCDIR}/../../target/release -Wl,-Bstatic -laimux_ffi -Wl,-Bdynamic -lpthread -ldl -lm -#cgo darwin LDFLAGS: -L${SRCDIR}/../../target/release -laimux_ffi -#cgo windows LDFLAGS: -L${SRCDIR}/../../target/release -Wl,-Bstatic -laimux_ffi -Wl,-Bdynamic -lws2_32 -lbcrypt -lntdll -luserenv - -#include -#include - -#include "aimux-ffi.h" - -// Go-side callback trampolines (//export below). stream_ctx is the stream id. -extern void goStreamPart(uintptr_t id, char* json); - -static void trampoline_part(const char* json, void* stream_ctx) { - uintptr_t id = (uintptr_t)stream_ctx; - if (id) goStreamPart(id, (char*)json); -} -static void trampoline_done(void* stream_ctx) { - (void)stream_ctx; -} - -// do_stream: blocking cancelable stream; id is passed as stream_ctx. -// NULL return after on_done = clean end; non-NULL = failure (no on_done). -static aimux_error_t* do_stream(uint64_t handle, uint64_t abort_handle, - const char* prompt, const char* opts, uintptr_t id) { - return aimux_stream_text_with_abort( - handle, abort_handle, prompt, opts, - trampoline_part, trampoline_done, - (void*)id); -} - -static aimux_error_t* do_stream_openai(uint64_t handle, uint64_t abort_handle, - const char* prompt, const char* opts, uintptr_t id) { - return aimux_stream_text_as_openai_with_abort( - handle, abort_handle, prompt, opts, - trampoline_part, trampoline_done, - (void*)id); -} - -*/ -import "C" - import ( + "bytes" "context" "encoding/json" - "errors" "fmt" - "io" "runtime" - "runtime/cgo" - "strings" + "sync" "sync/atomic" - "unsafe" -) - -// ── Model handle wrapper ─────────────────────────────────────────────────── - -// Model is a model instance backed by a Rust Arc. -// -// It implements io.Closer — you MUST call Close (or use defer) to release the -// native handle and avoid memory leaks. -// -// Model must not be copied after first use. Its methods and Close are safe to -// call concurrently. Close prevents future calls but does not wait for calls -// that have already entered the Rust registry; those calls own a cloned Arc -// and may finish normally. -type Model struct { - id atomic.Uint64 // 0 means closed - // traced is true only for handles produced by Trace/TraceAudited, the - // only ones with a trace store behind them. Set at construction and never - // mutated, so the trace guard reads it without synchronization. - traced bool -} - -// Close releases the native handle. Safe to call multiple times. -// It never waits for in-flight model calls or streams. -func (m *Model) Close() error { - if m == nil { - return nil - } - if id := m.id.Swap(0); id != 0 { - C.aimux_drop_handle(C.uint64_t(id)) - runtime.SetFinalizer(m, nil) - } - return nil -} - -// handle returns a snapshot of the native handle. Callers must keep m alive -// until after the C call (runtime.KeepAlive) so its finalizer cannot drop the -// registry entry between this load and Rust's Arc clone. -func (m *Model) handle() (uint64, error) { - if m != nil { - if id := m.id.Load(); id != 0 { - return id, nil - } - } - return 0, fmt.Errorf("%w: model", ErrClosed) -} - -// modelHandles snapshots models in caller order without taking Go locks. -// A concurrent Close races only with Rust's registry lookup: either the call -// clones the Arc or it receives an invalid-handle error, never a use-after-free. -func modelHandles(models []*Model) ([]uint64, error) { - out := make([]uint64, len(models)) - for i, m := range models { - if m == nil { - return nil, fmt.Errorf("model[%d] is nil", i) - } - id, err := m.handle() - if err != nil { - return nil, err - } - out[i] = id - } - return out, nil -} - -func keepModelsAlive(models []*Model) { - for _, m := range models { - runtime.KeepAlive(m) - } -} - -// ── Provider constructors ─────────────────────────────────────────────────── - -// NewOpenAI creates an OpenAI model instance. -// -// m, err := aimux.NewOpenAI("sk-...", "gpt-4o") -// if err != nil { ... } -// defer m.Close() -func NewOpenAI(apiKey, modelID string) (*Model, error) { - return newModel(apiKey, modelID, "", "openai") -} - -// NewOpenAIWithBase creates an OpenAI model with a custom base URL -// (for Ollama, OpenRouter, local proxies, etc.). -func NewOpenAIWithBase(apiKey, modelID, baseURL string) (*Model, error) { - return newModel(apiKey, modelID, baseURL, "openai") -} - -// NewAnthropic creates an Anthropic model instance. -func NewAnthropic(apiKey, modelID string) (*Model, error) { - return newModel(apiKey, modelID, "", "anthropic") -} - -// NewAnthropicWithBase creates an Anthropic model with a custom base URL. -func NewAnthropicWithBase(apiKey, modelID, baseURL string) (*Model, error) { - return newModel(apiKey, modelID, baseURL, "anthropic") -} - -// NewCohere creates a Cohere model instance. -func NewCohere(apiKey, modelID string) (*Model, error) { - return newModel(apiKey, modelID, "", "cohere") -} - -// NewCohereWithBase creates a Cohere model with a custom base URL. -func NewCohereWithBase(apiKey, modelID, baseURL string) (*Model, error) { - return newModel(apiKey, modelID, baseURL, "cohere") -} - -// NewMistral creates a Mistral model instance. -func NewMistral(apiKey, modelID string) (*Model, error) { - return newModel(apiKey, modelID, "", "mistral") -} - -// NewMistralWithBase creates a Mistral model with a custom base URL. -func NewMistralWithBase(apiKey, modelID, baseURL string) (*Model, error) { - return newModel(apiKey, modelID, baseURL, "mistral") -} - -// NewXai creates an xAI model instance. -func NewXai(apiKey, modelID string) (*Model, error) { - return newModel(apiKey, modelID, "", "xai") -} - -// NewXaiWithBase creates an xAI model with a custom base URL. -func NewXaiWithBase(apiKey, modelID, baseURL string) (*Model, error) { - return newModel(apiKey, modelID, baseURL, "xai") -} - -// NewBedrock creates a Bedrock model instance (AWS SigV4 credentials). -func NewBedrock(accessKeyID, secretAccessKey, region, modelID string) (*Model, error) { - return newBedrockModel(accessKeyID, secretAccessKey, region, modelID, "") -} - -// NewBedrockWithBase creates a Bedrock model with a custom base URL. -func NewBedrockWithBase(accessKeyID, secretAccessKey, region, modelID, baseURL string) (*Model, error) { - return newBedrockModel(accessKeyID, secretAccessKey, region, modelID, baseURL) -} - -// NewVertex creates a Vertex AI model instance (GCP bearer token). -func NewVertex(accessToken, project, location, modelID string) (*Model, error) { - return newVertexModel(accessToken, project, location, modelID, "") -} - -// NewVertexWithBase creates a Vertex AI model with a custom base URL. -func NewVertexWithBase(accessToken, project, location, modelID, baseURL string) (*Model, error) { - return newVertexModel(accessToken, project, location, modelID, baseURL) -} - -// NewAnthropicAws creates an Anthropic-on-AWS model instance (API key + region). -func NewAnthropicAws(apiKey, region, modelID string) (*Model, error) { - return newAnthropicAwsModel(apiKey, region, modelID, "") -} - -// NewAnthropicAwsWithBase creates an Anthropic-on-AWS model with a custom base URL. -func NewAnthropicAwsWithBase(apiKey, region, modelID, baseURL string) (*Model, error) { - return newAnthropicAwsModel(apiKey, region, modelID, baseURL) -} - -// NewAzure creates an Azure OpenAI model instance (API key + resource name). -// The deployment name is passed as modelID; apiVersion "" uses the default. -func NewAzure(apiKey, resourceName, deployment string) (*Model, error) { - return newAzureModel(apiKey, resourceName, deployment, "", false) -} - -// NewAzureWithVersion creates an Azure OpenAI model with an explicit api-version. -func NewAzureWithVersion(apiKey, resourceName, deployment, apiVersion string) (*Model, error) { - return newAzureModel(apiKey, resourceName, deployment, apiVersion, false) -} - -// NewAzureWithBase creates an Azure OpenAI model with a custom base URL. -func NewAzureWithBase(apiKey, baseURL, deployment string) (*Model, error) { - return newAzureModel(apiKey, baseURL, deployment, "", true) -} - -// OpenAI creates an OpenAI model instance. Must-style (regexp.MustCompile): it -// PANICS on any failure, including invalid input — an apiKey or modelID that is -// not valid UTF-8 or contains a NUL. Use NewOpenAI for anything caller-supplied. -func OpenAI(apiKey, modelID string) *Model { - return mustNew(NewOpenAI(apiKey, modelID)) -} - -// OpenAIWithBase creates an OpenAI model with a custom base URL. Must-style: it -// PANICS on any failure, including an apiKey / modelID / baseURL that is not -// valid UTF-8 or contains a NUL. Use NewOpenAIWithBase to get an error instead. -func OpenAIWithBase(apiKey, modelID, baseURL string) *Model { - return mustNew(NewOpenAIWithBase(apiKey, modelID, baseURL)) -} - -// InitLogging initializes the global logger (RFC-0014). Idempotent — safe to -// call any number of times; no-op if the host already registered its own -// subscriber. level: "off"|"error"|"warn"|"info"|"debug"|"trace" (empty -// defaults to "warn"). The AIMUX_LOG / AIMUX_LOG_LEVEL env vars take -// precedence. Logs go to stderr. -// -// An unusable level — empty, not valid UTF-8, or containing a NUL — falls back -// to "warn" rather than failing: this has no error channel, and aimux-core -// already treats every unparseable level as "use the default". That matters -// because the obvious call is InitLogging(os.Getenv("AIMUX_LOG_LEVEL")), and -// os.Getenv hands back raw bytes on POSIX. -func InitLogging(level string) { - if level == "" || checkUTF8("level", level) != nil { - level = "warn" - } - cLevel := C.CString(level) - defer C.free(unsafe.Pointer(cLevel)) - if err := expectFfiError(C.aimux_init_logging(cLevel)); err != nil { - // Unreachable: level is now non-NULL, valid UTF-8 and NUL-free, which - // is every failure aimux_init_logging documents. A non-nil error here - // is a header/library mismatch, like the enum panics below. - panic(err) - } -} -// InitRecording starts RFC-0023 recording: complete Recording JSONL is written -// to {dir}/recordings.jsonl (dir auto-created). Recording is opt-in; calling -// again replaces the recorder. Construction failures are reported as -// *RecordingError (Code RecordingErrorInit when dir cannot be created, -// RecordingErrorOpenFile, RecordingErrorSpawn); on failure the previous -// recorder, if any, stays in place. -func InitRecording(dir string) error { - if err := checkUTF8("dir", dir); err != nil { - return err - } - cDir := C.CString(dir) - defer C.free(unsafe.Pointer(cDir)) - return expectRecordingError(C.aimux_init_recording(cDir)) -} - -// InitRecordingRing starts in-memory bounded recording (RFC-0023 P6): FIFO -// ring with the given capacity. -// -// cap is optional (variadic): omit it to use the library default capacity, -// which calls the FFI aimux_init_recording_ring_default entry point. When -// provided, cap must be > 0 — the C ABI returns `AiMuxError::InvalidArgument` -// for cap == 0. Unlike the previous behavior, this binding no longer -// silently rewrites cap == 0 to 2048; callers must omit cap for the default or -// pass an explicit > 0 capacity. This matches Kotlin/Java (which throw) and -// Swift/Flutter (which surface the C error). Returns an error when cap == 0, -// more than one cap is supplied, or the C call fails. -func InitRecordingRing(cap ...uint64) error { - if len(cap) == 0 { - // No cap: library default capacity (FFI default entry point). - C.aimux_init_recording_ring_default() - return nil - } - if len(cap) > 1 { - return fmt.Errorf("aimux: InitRecordingRing accepts at most one cap, got %d", len(cap)) - } - c := cap[0] - if c == 0 { - return fmt.Errorf("aimux: InitRecordingRing requires cap > 0 (got 0)") - } - return expectAimuxError(C.aimux_init_recording_ring(C.uint64_t(c))) -} + "github.com/arcships/aimux/bindings/go/generated" +) -// RecordingStop stops recording: the global recorder becomes None. -func RecordingStop() { - C.aimux_recording_stop() -} +const ABIVersion uint32 = generated.ABI_VERSION -// RecordingFlush flushes the global recorder (blocks until JSONL is on disk; -// no-op for the ring recorder). -func RecordingFlush() { - C.aimux_recording_flush() +var abiCheck struct { + sync.Once + err error } -// RecordingTryFlush flushes the global recorder and reports failures as -// *RecordingError (Code RecordingErrorWriterGone, RecordingErrorFlushTimeout -// or RecordingErrorWrite); nil when the JSONL is on disk (or nothing is -// recording). The legacy RecordingFlush stays and never reports. -func RecordingTryFlush() error { - return expectRecordingError(C.aimux_recording_try_flush()) -} +// Handle owns one native registry entry. Do not copy it. Close is idempotent +// and safe alongside calls: native lookup either clones the entry or reports +// InvalidHandle. Close does not cancel calls that already cloned a model. +type Handle struct{ id atomic.Uint64 } -// MockReplay creates a mock replay model from recorded JSONL (RFC-0023 P3): -// it returns recorded responses by input match — no real API is sent. The -// returned model works with GenerateText / StreamText. -func MockReplay(recordingsJsonl string) (*Model, error) { - // checkJSON per line: UTF-8 / NUL / json.Valid / surrogate pairing, with - // "" (a blank line) skipped as before. - for _, line := range strings.Split(recordingsJsonl, "\n") { - if err := checkJSON("recordings_jsonl", strings.TrimSpace(line)); err != nil { - return nil, err - } +// AdoptHandle takes ownership of a nonzero handle returned by this C runtime. +// Never adopt the same ID twice or a handle from a different owner/runtime. +func AdoptHandle(id HandleID) (*Handle, error) { + if id == 0 { + return nil, localError("binding", 203, "InvalidHandle", "cannot own global handle 0") } - cJsonl := C.CString(recordingsJsonl) - defer C.free(unsafe.Pointer(cJsonl)) - var h C.uint64_t - return wrapHandle(&h, C.aimux_mock_replay_new(cJsonl, &h)) + h := &Handle{} + h.id.Store(uint64(id)) + runtime.SetFinalizer(h, finalizeHandle) + return h, nil } -// InitProxy replaces the default transport with one routed by this proxy -// configuration (M6, RFC-0016). Requests started afterwards use it; calling it -// again replaces the previous configuration. configJSON is required (pass "{}" -// for defaults); shape: -// { "http_url": "...", "https_url": "...", "all_url": "...", "no_proxy": "..." } -// (all fields optional). -func InitProxy(configJSON string) error { - if err := requireJSON("config_json", configJSON); err != nil { - return err +func finalizeHandle(h *Handle) { + if id := h.id.Swap(0); id != 0 { + nativeFinalize(id) } - cJSON := C.CString(configJSON) - defer C.free(unsafe.Pointer(cJSON)) - return expectAimuxError(C.aimux_init_proxy(cJSON)) } -// NewRouter builds a RouterModel (RFC-0021) over the given child models. The -// returned model routes each call to one child and falls back across the rest -// on error (per configJSON). models must be non-empty. -// -// configJSON (optional): {"router": "rule"|"weighted", "weights": [...], -// "fallback": "on_error"|"none", "providerName", "modelId"}. -// -// The same model may appear several times. A nil entry returns an error -// ("aimux: router: models[i] is nil"), never a panic. Concurrent Close is -// resolved by the Rust registry: construction either clones every live child -// it reaches or returns an invalid-handle error. -func NewRouter(models []*Model, configJSON string) (*Model, error) { - if len(models) == 0 { - return nil, errors.New("aimux: router needs at least one model") - } - if err := checkJSON("config_json", configJSON); err != nil { - return nil, err - } - for i, m := range models { - if m == nil { - return nil, fmt.Errorf("aimux: router: models[%d] is nil", i) +func (h *Handle) Close() error { + if h != nil { + if id := h.id.Swap(0); id != 0 { + runtime.SetFinalizer(h, nil) + nativeDrop(id) } + runtime.KeepAlive(h) } - handles, err := modelHandles(models) - if err != nil { - return nil, err - } - defer keepModelsAlive(models) - - var cJSON *C.char - if configJSON != "" { - cJSON = C.CString(configJSON) - defer C.free(unsafe.Pointer(cJSON)) - } - var h C.uint64_t - return wrapHandle(&h, C.aimux_router_new( - (*C.uint64_t)(unsafe.Pointer(&handles[0])), - C.size_t(len(handles)), - cJSON, - &h, - )) + return nil } -// NewMoa builds a MoaModel (RFC-0022) over reference models + one aggregator. -// References fan out in parallel, then the aggregator synthesizes a final -// answer. references may be empty (runs aggregator only). -// -// configJSON (optional) is a serialized MoaConfig. -// -// The aggregator is allowed to appear in references too, and references may -// repeat. No Go locks are taken; Rust clones every model Arc it resolves. A -// nil aggregator or reference returns an error, never a panic. -func NewMoa(references []*Model, aggregator *Model, configJSON string) (*Model, error) { - if err := checkJSON("config_json", configJSON); err != nil { - return nil, err - } - if aggregator == nil { - return nil, errors.New("aimux: moa: aggregator is nil") - } - for i, m := range references { - if m == nil { - return nil, fmt.Errorf("aimux: moa: references[%d] is nil", i) +func (h *Handle) ID() (HandleID, error) { + if h != nil { + if id := h.id.Load(); id != 0 { + return HandleID(id), nil } } - models := append(append(make([]*Model, 0, len(references)+1), references...), aggregator) - handles, err := modelHandles(models) - if err != nil { - return nil, err - } - defer keepModelsAlive(models) - aggHandle := handles[len(references)] - - // No references: NULL pointer + 0 length. - var refPtr *C.uint64_t - if len(references) > 0 { - refPtr = (*C.uint64_t)(unsafe.Pointer(&handles[0])) - } - var cJSON *C.char - if configJSON != "" { - cJSON = C.CString(configJSON) - defer C.free(unsafe.Pointer(cJSON)) - } - var h C.uint64_t - return wrapHandle(&h, C.aimux_moa_new( - refPtr, - C.size_t(len(references)), - C.uint64_t(aggHandle), - cJSON, - &h, - )) -} - -// Anthropic creates an Anthropic model instance. Must-style: it PANICS on any -// failure, including an apiKey / modelID that is not valid UTF-8 or contains a -// NUL. Use NewAnthropic to get an error instead. -func Anthropic(apiKey, modelID string) *Model { - return mustNew(NewAnthropic(apiKey, modelID)) -} - -// AnthropicWithBase creates an Anthropic model with a custom base URL. -// Must-style: it PANICS on any failure, including an apiKey / modelID / -// baseURL that is not valid UTF-8 or contains a NUL. Use NewAnthropicWithBase -// to get an error instead. -func AnthropicWithBase(apiKey, modelID, baseURL string) *Model { - return mustNew(NewAnthropicWithBase(apiKey, modelID, baseURL)) + return 0, ErrClosed } -// mustNew is the Must-style constructor helper shared by OpenAI / OpenAIWithBase / -// Anthropic / AnthropicWithBase / DeepSeek: the NewXxx twin's error becomes a -// panic. Invalid input panics too — that is the documented Go convention -// (regexp.MustCompile), so these five are for compile-time-known arguments. -func mustNew(m *Model, err error) *Model { - if err != nil { - panic(err) - } - return m -} - -// wrapHandle turns an [AiMuxError] constructor's out-handle + error into *Model. -// It takes the out-handle by pointer so it can be written in the same -// expression: wrapHandle(&h, C.aimux_x_new(..., &h)). -func wrapHandle(h *C.uint64_t, e *C.aimux_error_t) (*Model, error) { - if err := expectAimuxError(e); err != nil { - return nil, err - } - m := &Model{} - m.id.Store(uint64(*h)) - runtime.SetFinalizer(m, func(m *Model) { m.Close() }) - return m, nil -} - -func newModel(apiKey, modelID, baseURL, provider string) (*Model, error) { - if err := checkUTF8("api_key", apiKey, "model_id", modelID, "base_url", baseURL); err != nil { - return nil, err - } - cKey := C.CString(apiKey) - cModel := C.CString(modelID) - defer C.free(unsafe.Pointer(cKey)) - defer C.free(unsafe.Pointer(cModel)) - - var h C.uint64_t - var e *C.aimux_error_t - if baseURL == "" { - switch provider { - case "anthropic": - e = C.aimux_anthropic_new(cKey, cModel, &h) - case "cohere": - e = C.aimux_cohere_new(cKey, cModel, &h) - case "mistral": - e = C.aimux_mistral_new(cKey, cModel, &h) - case "xai": - e = C.aimux_xai_new(cKey, cModel, &h) - default: - e = C.aimux_openai_new(cKey, cModel, &h) +// CheckABI checks the ABI major and exact generated operation version and digest once, +// before any operation is executed. +func CheckABI() error { + abiCheck.Do(func() { + actual := nativeABIVersion() + if actual>>16 != ABIVersion>>16 { + abiCheck.err = localError("protocol", 302, "UnsupportedOpsVersion", fmt.Sprintf("incompatible ABI version %#x", actual)) + return } - } else { - cBase := C.CString(baseURL) - defer C.free(unsafe.Pointer(cBase)) - switch provider { - case "anthropic": - e = C.aimux_anthropic_new_with_base(cKey, cModel, cBase, &h) - case "cohere": - e = C.aimux_cohere_new_with_base(cKey, cModel, cBase, &h) - case "mistral": - e = C.aimux_mistral_new_with_base(cKey, cModel, cBase, &h) - case "xai": - e = C.aimux_xai_new_with_base(cKey, cModel, cBase, &h) - default: - e = C.aimux_openai_new_with_base(cKey, cModel, cBase, &h) + raw, err := nativeManifest() + if err != nil { + abiCheck.err = err + return } - } - return wrapHandle(&h, e) -} - -func newBedrockModel(accessKeyID, secretAccessKey, region, modelID, baseURL string) (*Model, error) { - if err := checkUTF8("access_key_id", accessKeyID, "secret_access_key", secretAccessKey, - "region", region, "model_id", modelID, "base_url", baseURL); err != nil { - return nil, err - } - cAccess := C.CString(accessKeyID) - cSecret := C.CString(secretAccessKey) - cRegion := C.CString(region) - cModel := C.CString(modelID) - defer C.free(unsafe.Pointer(cAccess)) - defer C.free(unsafe.Pointer(cSecret)) - defer C.free(unsafe.Pointer(cRegion)) - defer C.free(unsafe.Pointer(cModel)) - - var h C.uint64_t - if baseURL == "" { - return wrapHandle(&h, C.aimux_bedrock_new(cAccess, cSecret, cRegion, cModel, &h)) - } - cBase := C.CString(baseURL) - defer C.free(unsafe.Pointer(cBase)) - return wrapHandle(&h, C.aimux_bedrock_new_with_base(cAccess, cSecret, cRegion, cModel, cBase, &h)) -} - -func newVertexModel(accessToken, project, location, modelID, baseURL string) (*Model, error) { - if err := checkUTF8("access_token", accessToken, "project", project, - "location", location, "model_id", modelID, "base_url", baseURL); err != nil { - return nil, err - } - cToken := C.CString(accessToken) - cProject := C.CString(project) - cLocation := C.CString(location) - cModel := C.CString(modelID) - defer C.free(unsafe.Pointer(cToken)) - defer C.free(unsafe.Pointer(cProject)) - defer C.free(unsafe.Pointer(cLocation)) - defer C.free(unsafe.Pointer(cModel)) - - var h C.uint64_t - if baseURL == "" { - return wrapHandle(&h, C.aimux_vertex_new(cToken, cProject, cLocation, cModel, &h)) - } - cBase := C.CString(baseURL) - defer C.free(unsafe.Pointer(cBase)) - return wrapHandle(&h, C.aimux_vertex_new_with_base(cToken, cProject, cLocation, cModel, cBase, &h)) -} - -func newAnthropicAwsModel(apiKey, region, modelID, baseURL string) (*Model, error) { - if err := checkUTF8("api_key", apiKey, "region", region, - "model_id", modelID, "base_url", baseURL); err != nil { - return nil, err - } - cKey := C.CString(apiKey) - cRegion := C.CString(region) - cModel := C.CString(modelID) - defer C.free(unsafe.Pointer(cKey)) - defer C.free(unsafe.Pointer(cRegion)) - defer C.free(unsafe.Pointer(cModel)) - - var h C.uint64_t - if baseURL == "" { - return wrapHandle(&h, C.aimux_anthropic_aws_new(cKey, cRegion, cModel, &h)) - } - cBase := C.CString(baseURL) - defer C.free(unsafe.Pointer(cBase)) - return wrapHandle(&h, C.aimux_anthropic_aws_new_with_base(cKey, cRegion, cModel, cBase, &h)) + abiCheck.err = validateManifest(actual, raw) + }) + return abiCheck.err } -func newAzureModel(apiKey, resourceOrBase, deployment, apiVersion string, useBase bool) (*Model, error) { - resourceParam := "resource_name" - if useBase { - resourceParam = "base_url" +func validateManifest(actual uint32, raw []byte) error { + var manifest struct { + ABI uint32 `json:"abiVersion"` + Ops string `json:"opsVersion"` + Digest string `json:"digest"` } - if err := checkUTF8("api_key", apiKey, resourceParam, resourceOrBase, - "deployment", deployment, "api_version", apiVersion); err != nil { - return nil, err - } - cKey := C.CString(apiKey) - cResource := C.CString(resourceOrBase) - cDeployment := C.CString(deployment) - cVersion := C.CString(apiVersion) - defer C.free(unsafe.Pointer(cKey)) - defer C.free(unsafe.Pointer(cResource)) - defer C.free(unsafe.Pointer(cDeployment)) - defer C.free(unsafe.Pointer(cVersion)) - - var h C.uint64_t - if useBase { - return wrapHandle(&h, C.aimux_azure_new_with_base(cKey, cResource, cDeployment, cVersion, &h)) - } - return wrapHandle(&h, C.aimux_azure_new(cKey, cResource, cDeployment, cVersion, &h)) -} - -// Provider creates a model from the built-in registry by provider name -// (RFC-0017 phase 4). apiKey may be "" to read the provider's env var. -// -// m, err := aimux.Provider("groq", "", "llama-3.3-70b") -func Provider(name, apiKey, modelID string) (*Model, error) { - return ProviderWithBase(name, apiKey, modelID, "") -} - -// ProviderWithBase is Provider with a base URL override (config_json). -func ProviderWithBase(name, apiKey, modelID, baseURL string) (*Model, error) { - if baseURL == "" { - return ProviderWithConfig(name, apiKey, modelID, nil) + if err := json.Unmarshal(raw, &manifest); err != nil { + return localError("binding", 205, "ResultSerialization", err.Error()) + } else if manifest.ABI != actual || manifest.Ops != generated.OPS_VERSION || manifest.Digest != generated.MANIFEST_DIGEST { + return localError("protocol", 302, "UnsupportedOpsVersion", fmt.Sprintf("incompatible runtime manifest: opsVersion %q (expected %q), digest %q (expected %q)", manifest.Ops, generated.OPS_VERSION, manifest.Digest, generated.MANIFEST_DIGEST)) } - return ProviderWithConfig(name, apiKey, modelID, &ProviderConfig{BaseURL: baseURL}) -} - -// ProviderConfig is the config_json accepted by aimux_provider_new and -// aimux_provider_handle_new (RFC-0017 §3.4). Zero-value fields are omitted. -// Retry is a per-call setting (GenerateTextOptions.MaxRetries), and request-body -// overrides no longer exist: any other key in config_json is rejected as -// CodeInvalidArgument, so this struct has neither. -type ProviderConfig struct { - BaseURL string `json:"baseUrl,omitempty"` - Headers map[string]string `json:"headers,omitempty"` - Organization string `json:"organization,omitempty"` - Project string `json:"project,omitempty"` - // Params are the values of a preset's template parameters (account_id, - // region, ...); only the ones the preset declares are accepted. - Params map[string]string `json:"params,omitempty"` + return nil } -// ProviderWithConfig is Provider with the full ProviderOptions config. -// cfg may be nil for defaults. -func ProviderWithConfig(name, apiKey, modelID string, cfg *ProviderConfig) (*Model, error) { - if err := checkUTF8("name", name, "api_key", apiKey, "model_id", modelID); err != nil { +func Manifest() (json.RawMessage, error) { + if err := CheckABI(); err != nil { return nil, err } - cName := C.CString(name) - cModel := C.CString(modelID) - defer C.free(unsafe.Pointer(cName)) - defer C.free(unsafe.Pointer(cModel)) - - var cKey *C.char - if apiKey != "" { - cKey = C.CString(apiKey) - defer C.free(unsafe.Pointer(cKey)) - } - - var cConfig *C.char - if cfg != nil { - buf, err := marshalJSON("config_json", cfg) - if err != nil { - return nil, err - } - cConfig = C.CString(buf) - defer C.free(unsafe.Pointer(cConfig)) - } - - var h C.uint64_t - return wrapHandle(&h, C.aimux_provider_new(cName, cKey, cModel, cConfig, &h)) + return nativeManifest() } -// ── Provider handles (RFC-0027) ───────────────────────────────────────────── - -// ProviderHandle is a provider handle created by CreateProvider. It supports -// ListModels (runtime discovery) and Model (build a model from a discovered id). -// It must not be copied after first use. -type ProviderHandle struct { - id atomic.Uint64 // 0 means closed +// Call invokes a global operation. Pass an explicit object, e.g. struct{}{}, +// for no parameters. Nil encodes JSON null and is never normalized to {}. +func Call(ctx context.Context, op string, params any) (json.RawMessage, error) { + return call(ctx, nil, op, params) } -// Close releases the native handle. Safe to call multiple times. -func (p *ProviderHandle) Close() error { - if p == nil { - return nil +func (h *Handle) Call(ctx context.Context, op string, params any) (json.RawMessage, error) { + if h == nil { + return nil, ErrClosed } - if id := p.id.Swap(0); id != 0 { - C.aimux_drop_handle(C.uint64_t(id)) - runtime.SetFinalizer(p, nil) - } - return nil + return call(ctx, h, op, params) } -func (p *ProviderHandle) handle() (uint64, error) { - if p != nil { - if id := p.id.Load(); id != 0 { - return id, nil - } - } - return 0, fmt.Errorf("%w: provider", ErrClosed) -} - -// ListModels lists models available on this provider (runtime discovery via -// the provider's /models endpoint), enriched with community knowledge (anya2a) -// when available. Returns a JSON array of RuntimeModel. -func (p *ProviderHandle) ListModels() (string, error) { - id, err := p.handle() - if err != nil { - return "", err - } - defer runtime.KeepAlive(p) - return ffiString(func(out **C.char) *C.aimux_error_t { - return C.aimux_provider_list_models(C.uint64_t(id), out) - }) -} - -// Model builds a language model from a discovered model id. -func (p *ProviderHandle) Model(modelID string) (*Model, error) { - if err := checkUTF8("model_id", modelID); err != nil { +func call(ctx context.Context, h *Handle, op string, params any) (json.RawMessage, error) { + if err := ctx.Err(); err != nil { return nil, err } - id, err := p.handle() + data, err := encodeParams(op, params) if err != nil { return nil, err } - defer runtime.KeepAlive(p) - cModel := C.CString(modelID) - defer C.free(unsafe.Pointer(cModel)) - var h C.uint64_t - return wrapHandle(&h, C.aimux_provider_model(C.uint64_t(id), cModel, &h)) -} - -// CreateProvider creates a provider handle for a registry-backed provider. -// Unlike Provider (which binds to a single modelID), this returns a handle -// that supports ListModels() and Model(). -func CreateProvider(name, apiKey string, cfg *ProviderConfig) (*ProviderHandle, error) { - if err := checkUTF8("name", name, "api_key", apiKey); err != nil { - return nil, err - } - cName := C.CString(name) - defer C.free(unsafe.Pointer(cName)) - - var cKey *C.char - if apiKey != "" { - cKey = C.CString(apiKey) - defer C.free(unsafe.Pointer(cKey)) - } - - var cConfig *C.char - if cfg != nil { - buf, err := marshalJSON("config_json", cfg) + var id HandleID + if h != nil { + id, err = h.ID() if err != nil { return nil, err } - cConfig = C.CString(buf) - defer C.free(unsafe.Pointer(cConfig)) } - - var h C.uint64_t - if err := expectAimuxError(C.aimux_provider_handle_new(cName, cKey, cConfig, &h)); err != nil { + defer runtime.KeepAlive(h) + if err := CheckABI(); err != nil { return nil, err } - p := &ProviderHandle{} - p.id.Store(uint64(h)) - runtime.SetFinalizer(p, func(p *ProviderHandle) { p.Close() }) - return p, nil -} - -// ── Model specs (RFC-0027) ────────────────────────────────────────────────── - -// GetModelSpecs fetches the community model catalogue (anya2a). Returns a JSON -// string representing the Catalogue (provider → model_id → ModelSpec). -// Thin fetch — no caching. sourceURL may be "" for the default endpoint. -func GetModelSpecs(sourceURL string) (string, error) { - if err := checkUTF8("source_url", sourceURL); err != nil { - return "", err - } - var cURL *C.char - if sourceURL != "" { - cURL = C.CString(sourceURL) - defer C.free(unsafe.Pointer(cURL)) - } - return ffiString(func(out **C.char) *C.aimux_error_t { - return C.aimux_get_model_specs(cURL, out) - }) -} - -// ── Non-streaming generation ──────────────────────────────────────────────── - -// GenerateText performs non-streaming text generation. -// -// Parameters: -// - promptJson: JSON prompt (bare value like "\"text\"" or "[{...}] messages array", -// or {"prompt": }). -// - optsJson: JSON-serialized GenerateTextOptions ("" or null for defaults). -// -// Returns the JSON-serialized GenerateTextResult, or an error if the FFI call failed. -// -// result, err := model.GenerateText(`"What is Rust?"`, "") -func (m *Model) GenerateText(promptJson, optsJson string) (string, error) { - if err := checkPromptOpts(promptJson, optsJson); err != nil { - return "", err - } - handle, err := m.handle() + abort, err := createAbort() if err != nil { - return "", err - } - defer runtime.KeepAlive(m) - - cPrompt := C.CString(promptJson) - defer C.free(unsafe.Pointer(cPrompt)) - - var cOpts *C.char - if optsJson != "" { - cOpts = C.CString(optsJson) - defer C.free(unsafe.Pointer(cOpts)) + return nil, err } - - return ffiString(func(out **C.char) *C.aimux_error_t { - return C.aimux_generate_text(C.uint64_t(handle), cPrompt, cOpts, out) - }) + defer abort.Close() + stop := watchAbort(ctx, abort) + defer stop() + return nativeCall(uint64(id), op, data, abort.id.Load()) } -// GenerateObject generates a structured JSON object (M12, RFC-0016). -// -// Same signature as GenerateText; returns the JSON-serialized -// GenerateObjectResult. Pass "output": {"type": "object", "schema": {...}} via -// optsJson to describe the object; the model's text must parse as JSON and -// match the schema. -func (m *Model) GenerateObject(promptJson, optsJson string) (string, error) { - if err := checkPromptOpts(promptJson, optsJson); err != nil { - return "", err - } - handle, err := m.handle() +// CallInto decodes any canonical result DTO. A nil handle selects global scope. +// Use Call when unknown fields or absent/null distinctions must be retained. +func CallInto[T any](ctx context.Context, h *Handle, op string, params any) (*T, error) { + raw, err := call(ctx, h, op, params) if err != nil { - return "", err + return nil, err } - defer runtime.KeepAlive(m) - - cPrompt := C.CString(promptJson) - defer C.free(unsafe.Pointer(cPrompt)) - - var cOpts *C.char - if optsJson != "" { - cOpts = C.CString(optsJson) - defer C.free(unsafe.Pointer(cOpts)) + var result T + if err := json.Unmarshal(raw, &result); err != nil { + return nil, localError("binding", 205, "ResultSerialization", err.Error()) } - - return ffiString(func(out **C.char) *C.aimux_error_t { - return C.aimux_generate_object(C.uint64_t(handle), cPrompt, cOpts, out) - }) + return &result, nil } -// ConsumeStreamText consumes a stream to completion and returns the -// aggregated result (M11, RFC-0016). Synchronous (blocks until the stream -// finishes). -// -// Same signature as GenerateText; returns the JSON-serialized -// GenerateTextResult. -func (m *Model) ConsumeStreamText(promptJson, optsJson string) (string, error) { - if err := checkPromptOpts(promptJson, optsJson); err != nil { - return "", err - } - handle, err := m.handle() - if err != nil { - return "", err - } - defer runtime.KeepAlive(m) - - cPrompt := C.CString(promptJson) - defer C.free(unsafe.Pointer(cPrompt)) - - var cOpts *C.char - if optsJson != "" { - cOpts = C.CString(optsJson) - defer C.free(unsafe.Pointer(cOpts)) - } - - return ffiString(func(out **C.char) *C.aimux_error_t { - return C.aimux_consume_stream_text(C.uint64_t(handle), cPrompt, cOpts, out) - }) +// HandleResult is returned by constructors that include kind metadata. +type HandleResult struct { + Handle HandleID `json:"handle"` + Kind string `json:"kind"` } -// GenerateTextAsOpenAI performs non-streaming text generation and returns the -// result as an OpenAI Chat Completion JSON string (RFC-0026). -// -// Same as GenerateText, but the returned JSON is a serialized ChatCompletion -// (OpenAI "chat.completion" object) rather than a GenerateTextResult. Works -// with any provider. -func (m *Model) GenerateTextAsOpenAI(promptJson, optsJson string) (string, error) { - if err := checkPromptOpts(promptJson, optsJson); err != nil { - return "", err - } - handle, err := m.handle() +// Create invokes a handle-producing operation in global scope (parent nil) +// or on an existing parent. The new entry owns its native dependencies. +func Create(ctx context.Context, parent *Handle, op string, params any) (*Handle, error) { + raw, err := call(ctx, parent, op, params) if err != nil { - return "", err - } - defer runtime.KeepAlive(m) - - cPrompt := C.CString(promptJson) - defer C.free(unsafe.Pointer(cPrompt)) - - var cOpts *C.char - if optsJson != "" { - cOpts = C.CString(optsJson) - defer C.free(unsafe.Pointer(cOpts)) + return nil, err } - - return ffiString(func(out **C.char) *C.aimux_error_t { - return C.aimux_generate_text_as_openai(C.uint64_t(handle), cPrompt, cOpts, out) - }) + return handleFromResult(raw) } -// GenerateTextResultAsOpenAI converts a GenerateTextResult JSON string into a -// ChatCompletion JSON string: the conversion half of GenerateTextAsOpenAI. -// -// For repairing on the host (RFC-0035): patch the native result, then convert -// it, so the completion's tool calls are the repaired ones. The model only -// supplies the fallback model id; no generation happens. -func (m *Model) GenerateTextResultAsOpenAI(resultJson string) (string, error) { - if err := requireJSON("result_json", resultJson); err != nil { - return "", err +func handleFromResult(raw []byte) (*Handle, error) { + // Both shapes are current operation results: for example provider.model + // returns an object, while transcription.start returns a decimal string. + raw = bytes.TrimSpace(raw) + var result HandleResult + var err error + if len(raw) > 0 && raw[0] == '"' { + err = json.Unmarshal(raw, &result.Handle) + } else { + err = json.Unmarshal(raw, &result) } - handle, err := m.handle() if err != nil { - return "", err - } - defer runtime.KeepAlive(m) - - cResult := C.CString(resultJson) - defer C.free(unsafe.Pointer(cResult)) - - return ffiString(func(out **C.char) *C.aimux_error_t { - return C.aimux_generate_text_result_as_openai(C.uint64_t(handle), cResult, out) - }) -} - -// ── Streaming generation ───────────────────────────────────────────────────── - -// Stream is a handle to an in-progress or completed stream. It must be fully -// drained or explicitly cancelled. The producer goroutine is the only writer -// of err and the only goroutine that closes parts; observing parts close -// publishes err under the Go memory model. -type Stream struct { - parts chan string - cancelCtx context.Context - cancelFn context.CancelCauseFunc - err error - abortHandle uint64 -} - -// Parts returns a receive-only channel of StreamPart JSON strings. -// The channel is closed when the stream ends (normally or on error). -func (s *Stream) Parts() <-chan string { return s.parts } - -// Err returns any error that occurred during streaming. -// It must be called only after Parts() has closed. -func (s *Stream) Err() error { return s.err } - -// Cancel stops this stream. It is safe to call more than once. -func (s *Stream) Cancel() { s.cancel(context.Canceled) } - -// cancel uses Context's first-cause-wins, concurrency-safe cancellation. The -// native abort operation is idempotent, so repeated calls need no Go lock. -func (s *Stream) cancel(cause error) { - if cause == nil { - cause = context.Canceled + return nil, localError("binding", 205, "ResultSerialization", err.Error()) } - s.cancelFn(cause) - if s.abortHandle != 0 { - C.aimux_abort_signal_abort(C.uint64_t(s.abortHandle)) + if result.Handle == 0 { + return nil, localError("binding", 205, "ResultSerialization", "constructor returned no owned handle") } + return AdoptHandle(result.Handle) } -// finish is called exactly once by the producer after the synchronous C -// stream call has returned and all callbacks have left the call stack. -func (s *Stream) finish(err error) { - if cause := context.Cause(s.cancelCtx); cause != nil { - err = cause +func createAbort() (*Handle, error) { + raw, err := nativeCall(0, generated.OP_ABORT_CREATE, []byte(`{}`), 0) + if err != nil { + return nil, err } - s.err = err - close(s.parts) -} - -// StreamText performs streaming text generation. -// -// It returns immediately with a *Stream. Consume parts via stream.Parts(): -// -// stream := model.StreamText(`"Write a haiku"`, "") -// for part := range stream.Parts() { -// fmt.Println(part) // StreamPart JSON -// } -// if err := stream.Err(); err != nil { -// log.Fatal(err) -// } -// -// Drain the Parts channel or call Cancel when you stop reading. Use -// StreamTextContext to connect cancellation to a context. -func (m *Model) StreamText(promptJson, optsJson string) *Stream { - return m.StreamTextContext(context.Background(), promptJson, optsJson) -} - -// StreamTextContext performs streaming text generation. Context cancellation -// stops the native request and makes Err return the context error. -func (m *Model) StreamTextContext(ctx context.Context, promptJson, optsJson string) *Stream { - return m.startStream(ctx, promptJson, optsJson, false) -} - -// StreamTextAsOpenAI performs streaming text generation with OpenAI Chat -// Completion output (RFC-0026). -// -// Same as StreamText, but each part in the Parts() channel is a serialized -// ChatCompletionChunk (OpenAI "chat.completion.chunk" object). Works with any -// provider. -// -// Opts may carry providerOptions.openai.streamOptions with includeUsage -// (bool, default true) and includeReasoning (bool, default true). -func (m *Model) StreamTextAsOpenAI(promptJson, optsJson string) *Stream { - return m.StreamTextAsOpenAIContext(context.Background(), promptJson, optsJson) + return handleFromResult(raw) } -// StreamTextAsOpenAIContext performs streaming OpenAI-compatible generation. -// Context cancellation stops the native request. -func (m *Model) StreamTextAsOpenAIContext(ctx context.Context, promptJson, optsJson string) *Stream { - return m.startStream(ctx, promptJson, optsJson, true) -} - -func (m *Model) startStream(ctx context.Context, promptJson, optsJson string, openAI bool) *Stream { - if ctx == nil { - ctx = context.Background() - } - abortHandle := uint64(C.aimux_abort_signal_new()) - cancelCtx, cancelFn := context.WithCancelCause(context.Background()) - stream := &Stream{ - parts: make(chan string, 256), - cancelCtx: cancelCtx, - cancelFn: cancelFn, - abortHandle: abortHandle, - } - callbackHandle := cgo.NewHandle(stream) - - if cause := context.Cause(ctx); cause != nil { - stream.cancel(cause) - } - stopContext := func() bool { return true } - if ctx.Done() != nil { - stopContext = context.AfterFunc(ctx, func() { stream.cancel(context.Cause(ctx)) }) - } - +// The stop function joins the watcher before the abort handle can be dropped. +func watchAbort(ctx context.Context, abort *Handle) func() { + stop, done := make(chan struct{}), make(chan struct{}) go func() { - defer callbackHandle.Delete() - defer C.aimux_abort_signal_drop(C.uint64_t(abortHandle)) - err := m.runStream(stream, callbackHandle, promptJson, optsJson, openAI) - // Do not depend on the AfterFunc goroutine winning a scheduling race at - // the exact moment the native call returns. - if cause := context.Cause(ctx); cause != nil { - stream.cancel(cause) + defer close(done) + select { + case <-ctx.Done(): + signalAbort(abort) + case <-stop: } - stopContext() - stream.finish(err) }() - - return stream + return func() { close(stop); <-done } } -func (m *Model) runStream(stream *Stream, callbackHandle cgo.Handle, promptJson, optsJson string, openAI bool) error { - if err := checkPromptOpts(promptJson, optsJson); err != nil { - return err - } - select { - case <-stream.cancelCtx.Done(): - return context.Cause(stream.cancelCtx) - default: - } - - handle, err := m.handle() - if err != nil { - return err +func signalAbort(abort *Handle) { + if abort != nil { + nativeAbort(abort.id.Load()) + runtime.KeepAlive(abort) } - defer runtime.KeepAlive(m) - - cPrompt := C.CString(promptJson) - defer C.free(unsafe.Pointer(cPrompt)) - - var cOpts *C.char - if optsJson != "" { - cOpts = C.CString(optsJson) - defer C.free(unsafe.Pointer(cOpts)) - } - - var ffiErr *C.aimux_error_t - if openAI { - ffiErr = C.do_stream_openai( - C.uint64_t(handle), C.uint64_t(stream.abortHandle), - cPrompt, cOpts, C.uintptr_t(callbackHandle), - ) - } else { - ffiErr = C.do_stream( - C.uint64_t(handle), C.uint64_t(stream.abortHandle), - cPrompt, cOpts, C.uintptr_t(callbackHandle), - ) - } - return expectAimuxError(ffiErr) } - -// cstr copies and frees an aimux-allocated C string; "" for NULL. -func cstr(p *C.char) string { - if p == nil { - return "" - } - defer C.aimux_free_string(p) - return C.GoString(p) -} - -// ── Error decoding ─────────────────────────────────────────────────────────── -// -// Every fallible C call returns *C.aimux_error_t: nil = success, non-nil -// = failure. The unified code space distinguishes AiMuxError (1..20), -// RecordingError (100..105), and failures detected by the C ABI (200..206). -// The latter collapse to a plain error in Go; no public Go error type is added -// for those implementation failures. Every helper frees the pointer once. - -// ffiError reads the owner's message into a plain error; the caller has -// deferred aimux_error_free. -func ffiError(e *C.aimux_error_t) error { - return fmt.Errorf("aimux: %s", cstr(C.aimux_error_message(e))) -} - -// expectFfiError decodes a [C ABI] call: nil is success; anything else is a -// C ABI failure. -func expectFfiError(e *C.aimux_error_t) error { - if e == nil { - return nil - } - defer C.aimux_error_free(e) - code := int(C.aimux_error_code(e)) - if !ffiCodeFromC(code) { - panic(fmt.Sprintf("aimux: expected C ABI failure code, got %d", code)) - } - return ffiError(e) -} - -// expectAimuxError decodes an [AiMuxError] call: nil → nil; 1..20 → -// *Error; 200..206 → plain C ABI error. Any other code is an ABI contract -// violation. -func expectAimuxError(e *C.aimux_error_t) error { - if e == nil { - return nil - } - defer C.aimux_error_free(e) - codeValue := int(C.aimux_error_code(e)) - if ffiCodeFromC(codeValue) { - return ffiError(e) - } - return aimuxErrorFromC(e) -} - -// aimuxErrorFromC reads a non-nil AiMuxError-coded error into *Error via the -// per-variant getters. It does not free e — the caller owns the pointer — so -// it can recurse into the owned children aimux_error_retry_error_at returns. -// A non-AiMuxError code is an ABI contract violation and panics. -func aimuxErrorFromC(e *C.aimux_error_t) *Error { - str := cstr - codeValue := int(C.aimux_error_code(e)) - code, ok := codeFromC(codeValue) - if !ok { - panic(fmt.Sprintf("aimux: unknown aimux_error_code_t: %d", codeValue)) - } - msg := str(C.aimux_error_message(e)) - if msg == "" { - msg = fmt.Sprintf("aimux: %s", code.String()) - } - err := &Error{ - Code: code, - Message: msg, - Status: -1, - RetryMs: -1, - Retryable: C.aimux_error_retryable(e) != 0, - } - switch code { - case CodeAPICall: - err.Status = int(C.aimux_error_status(e)) - err.RetryMs = int64(C.aimux_error_retry_ms(e)) - err.ProviderCode = str(C.aimux_error_provider_code(e)) - err.ProviderMessage = str(C.aimux_error_provider_message(e)) - err.ResponseBody = str(C.aimux_error_response_body(e)) - err.URL = str(C.aimux_error_url(e)) - // The JSON-string getters carry serde-produced JSON; an absent - // payload is NULL ("" here), never invalid JSON, so a failed - // Unmarshal just leaves the field at its zero value. - if s := str(C.aimux_error_request_body_values(e)); s != "" { - _ = json.Unmarshal([]byte(s), &err.RequestBodyValues) - } - if s := str(C.aimux_error_response_headers(e)); s != "" { - _ = json.Unmarshal([]byte(s), &err.ResponseHeaders) - } - // Kept as raw JSON so a null payload stays apart from a missing one - // (the getter returns NULL only for a missing one). - if s := str(C.aimux_error_provider_data(e)); s != "" { - err.Data = json.RawMessage(s) - } - case CodeNoSuchModel: - err.ModelID = str(C.aimux_error_model_id(e)) - err.ModelType = str(C.aimux_error_model_type(e)) - case CodeNoSuchProvider: - err.ProviderID = str(C.aimux_error_provider_id(e)) - case CodeRetry: - err.Reason = RetryErrorReason(str(C.aimux_error_retry_reason(e))) - // Each attempt is a NEW owned error (deep copy) read with the same - // getters — recursion also covers a nested Retry — and freed - // independently of the parent, right after decoding. - count := int(C.aimux_error_retry_count(e)) - for i := 0; i < count; i++ { - child := C.aimux_error_retry_error_at(e, C.int32_t(i)) - if child == nil { - continue - } - err.Errors = append(err.Errors, aimuxErrorFromC(child)) - C.aimux_error_free(child) - } - case CodeNoSuchTool: - err.ToolName = str(C.aimux_error_tool_name(e)) - // NULL (→ "") means the core did not report the list; the string - // itself is serde-serialized JSON, so Unmarshal cannot fail short - // of an ABI mismatch, and nil is the right fallback either way. - if tools := str(C.aimux_error_available_tools(e)); tools != "" { - _ = json.Unmarshal([]byte(tools), &err.AvailableTools) - } - case CodeInvalidToolInput: - err.ToolInput = str(C.aimux_error_tool_input(e)) - case CodeToolCallRepair: - if orig := str(C.aimux_error_original_error(e)); orig != "" { - err.OriginalError = json.RawMessage(orig) - } - case CodeLoadAPIKey, CodeLoadSetting: - // The consulted environment variable travels on the provider-code getter. - err.EnvVar = str(C.aimux_error_provider_code(e)) - } - // TokenExpired carries a 401 by contract even if C reports -1; every - // other status is the observed one (ApiCall without a status = no HTTP - // response was observed). See bindings/node src/error.ts. - err.Status = defaultStatus(code, err.Status) - return err -} - -// expectRecordingError decodes a [RecordingError] call: nil → nil; 100..105 → -// *RecordingError; 200..206 → plain C ABI error. Any other code is an ABI -// contract violation. -func expectRecordingError(e *C.aimux_error_t) error { - if e == nil { - return nil - } - defer C.aimux_error_free(e) - codeValue := int(C.aimux_error_code(e)) - if ffiCodeFromC(codeValue) { - return ffiError(e) - } - code, ok := recordingErrorCodeFromC(codeValue) - if !ok { - panic(fmt.Sprintf("aimux: unknown aimux_error_code_t: %d", codeValue)) - } - return &RecordingError{Code: code, Message: cstr(C.aimux_error_message(e))} -} - -// ffiString runs an [AiMuxError] call that writes an aimux-allocated C string to -// its out-parameter, freeing it and mapping the error through expectAimuxError. -func ffiString(call func(out **C.char) *C.aimux_error_t) (string, error) { - var out *C.char - if err := expectAimuxError(call(&out)); err != nil { - return "", err - } - return cstr(out), nil -} - -// ffiStringWithFfiError is ffiString for a call that only reports C ABI codes. -func ffiStringWithFfiError(call func(out **C.char) *C.aimux_error_t) (string, error) { - var out *C.char - if err := expectFfiError(call(&out)); err != nil { - return "", err - } - return cstr(out), nil -} - -// ── C→Go callback trampolines (called by trampoline_part/done) ───────── - -//export goStreamPart -func goStreamPart(id C.uintptr_t, json *C.char) { - if json == nil { - return - } - s := cgo.Handle(id).Value().(*Stream) - select { - case s.parts <- C.GoString(json): - case <-s.cancelCtx.Done(): - } -} - -// Ensure io.Closer interface is satisfied. -var _ io.Closer = (*Model)(nil) diff --git a/bindings/go/aimux_test.go b/bindings/go/aimux_test.go deleted file mode 100644 index ec51b2994..000000000 --- a/bindings/go/aimux_test.go +++ /dev/null @@ -1,657 +0,0 @@ -// Unit tests for the aimux Go binding — constructor, Close, invalid input. -// -// These mirror the Kotlin ModelTest.kt: they verify the binding's lifecycle -// behavior without needing network access. Even with a fake API key, the -// provider constructs (the Rust side doesn't validate keys until a request -// is actually made). - -package aimux - -import ( - "context" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "os" - "strings" - "sync" - "sync/atomic" - "testing" - "time" -) - -// cancelOnDoneContext models cancellation racing exactly between the -// context.Cause and context.AfterFunc checks in StreamTextContext. -type cancelOnDoneContext struct { - done chan struct{} - cancelled atomic.Bool - once sync.Once -} - -func newCancelOnDoneContext() *cancelOnDoneContext { - return &cancelOnDoneContext{done: make(chan struct{})} -} - -func (c *cancelOnDoneContext) Deadline() (time.Time, bool) { return time.Time{}, false } -func (c *cancelOnDoneContext) Done() <-chan struct{} { - c.once.Do(func() { - c.cancelled.Store(true) - close(c.done) - }) - return c.done -} -func (c *cancelOnDoneContext) Err() error { - if c.cancelled.Load() { - return context.Canceled - } - return nil -} -func (*cancelOnDoneContext) Value(any) any { return nil } - -func TestOpenAI(t *testing.T) { - m := OpenAI("sk-test-fake-key", "gpt-4o-mini") - if m == nil { - t.Fatal("expected non-nil model") - } - defer m.Close() - if m.id.Load() == 0 { - t.Fatal("expected non-zero handle") - } -} - -func TestAnthropic(t *testing.T) { - m := Anthropic("sk-ant-test-fake-key", "claude-3-5-sonnet-20241022") - if m == nil { - t.Fatal("expected non-nil model") - } - defer m.Close() - if m.id.Load() == 0 { - t.Fatal("expected non-zero handle") - } -} - -func TestOpenAIWithBase(t *testing.T) { - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o-mini", "http://localhost:11434") - if m == nil { - t.Fatal("expected non-nil model") - } - defer m.Close() - if m.id.Load() == 0 { - t.Fatal("expected non-zero handle") - } -} - -func TestModelClose(t *testing.T) { - m := OpenAI("sk-test-fake-key", "gpt-4o-mini") - m.Close() - // Double-close should not crash. - if err := m.Close(); err != nil { - t.Fatalf("double close should be safe: %v", err) - } -} - -func TestGenerateTextRejectsInvalidPrompt(t *testing.T) { - m := OpenAI("sk-test-fake-key", "gpt-4o-mini") - defer m.Close() - - // Malformed prompt JSON is rejected by the binding before the C call - // (plain error naming the parameter; see error_test.go). - if _, err := m.GenerateText("{invalid json}", ""); err == nil { - t.Fatal("expected error for malformed prompt_json") - } -} - -func TestStreamTextReturnsStream(t *testing.T) { - m := OpenAI("sk-test-fake-key", "gpt-4o-mini") - defer m.Close() - - // We don't consume the stream (would need network), but verify it - // can be created without panicking. - s := m.StreamText(`"hello"`, "") - if s == nil { - t.Fatal("expected non-nil stream") - } - // Parts() should return a channel. - ch := s.Parts() - if ch == nil { - t.Fatal("expected non-nil parts channel") - } - // Drain to avoid leaking the goroutine (the fake-key stream will error - // quickly since no real API is reachable). - for range ch { - } - // Err() should be safe to call after drain. - _ = s.Err() -} - -func TestStreamTextContextAlreadyCanceled(t *testing.T) { - m := OpenAI("sk-test-fake-key", "gpt-4o-mini") - if err := m.Close(); err != nil { - t.Fatalf("Close failed: %v", err) - } - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - stream := m.StreamTextContext(ctx, `"hello"`, "") - - done := make(chan struct{}) - go func() { - for range stream.Parts() { - } - close(done) - }() - - select { - case <-done: - case <-time.After(2 * time.Second): - t.Fatal("cancelled stream did not stop") - } - if !errors.Is(stream.Err(), context.Canceled) { - t.Fatalf("expected context.Canceled, got %v", stream.Err()) - } - - stream.Cancel() - stream.Cancel() -} - -func TestStreamTextContextPreservesCancelCause(t *testing.T) { - m := OpenAI("sk-test-fake-key", "gpt-4o-mini") - defer m.Close() - - cause := errors.New("caller stopped consuming") - ctx, cancel := context.WithCancelCause(context.Background()) - cancel(cause) - stream := m.StreamTextContext(ctx, `"hello"`, "") - for range stream.Parts() { - } - if !errors.Is(stream.Err(), cause) { - t.Fatalf("expected custom cancellation cause, got %v", stream.Err()) - } -} - -func TestStreamTextContextDoesNotLoseRacingCancellation(t *testing.T) { - requestStarted := make(chan struct{}, 1) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requestStarted <- struct{}{} - <-r.Context().Done() - })) - defer server.Close() - - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o-mini", server.URL) - defer m.Close() - - stream := m.StreamTextContext(newCancelOnDoneContext(), `"hello"`, "") - done := make(chan struct{}) - go func() { - for range stream.Parts() { - } - close(done) - }() - - select { - case <-done: - case <-requestStarted: - stream.Cancel() - <-done - t.Fatal("request started after the context was cancelled") - case <-time.After(2 * time.Second): - stream.Cancel() - <-done - t.Fatal("racing cancellation did not stop the stream") - } - if !errors.Is(stream.Err(), context.Canceled) { - t.Fatalf("expected context.Canceled, got %v", stream.Err()) - } -} - -func TestProviderWithConfigFullOptions(t *testing.T) { - m, err := ProviderWithConfig("groq", "sk-test-fake-key", "llama-3.3-70b", &ProviderConfig{ - BaseURL: "https://example.com/v1", - Headers: map[string]string{"X-Custom": "1"}, - Organization: "org-1", - Project: "proj-1", - }) - if err != nil { - t.Fatalf("expected success, got %v", err) - } - defer m.Close() - if m.id.Load() == 0 { - t.Fatal("expected non-zero handle") - } -} - -// config_json is ProviderOptions: params fill a preset's template parameters. -func TestProviderWithConfigParams(t *testing.T) { - m, err := ProviderWithConfig("cloudflare_workers_ai", "sk-test-fake-key", "m", &ProviderConfig{ - Params: map[string]string{"account_id": "acct123"}, - }) - if err != nil { - t.Fatalf("expected success, got %v", err) - } - defer m.Close() - - // A parameter the preset does not declare is an invalid argument. - _, err = ProviderWithConfig("groq", "sk-test-fake-key", "m", &ProviderConfig{ - Params: map[string]string{"account_id": "acct123"}, - }) - var e *Error - if !errors.As(err, &e) || e.Code != CodeInvalidArgument { - t.Fatalf("want CodeInvalidArgument, got %v", err) - } -} - -// A registry provider with no key and no env var fails with CodeLoadAPIKey and -// names the variable it consulted. The key is read when a call is made (as in -// the AI SDK), not when the model is built. -func TestMissingAPIKeyIsLoadAPIKey(t *testing.T) { - if old, ok := os.LookupEnv("GROQ_API_KEY"); ok { - os.Unsetenv("GROQ_API_KEY") - defer os.Setenv("GROQ_API_KEY", old) - } - m, err := Provider("groq", "", "llama-3.3-70b") - if err != nil { - t.Fatalf("Provider: %v", err) - } - defer m.Close() - _, err = m.GenerateText(`"hi"`, `{"maxRetries":0}`) - var e *Error - if !errors.As(err, &e) || e.Code != CodeLoadAPIKey { - t.Fatalf("want CodeLoadAPIKey, got %v", err) - } - if e.EnvVar != "GROQ_API_KEY" { - t.Fatalf("EnvVar = %q, want GROQ_API_KEY", e.EnvVar) - } -} - -func TestProviderWithConfigNil(t *testing.T) { - m, err := ProviderWithConfig("groq", "sk-test-fake-key", "llama-3.3-70b", nil) - if err != nil { - t.Fatalf("expected success, got %v", err) - } - defer m.Close() -} - -func TestProviderWithBaseQuotedURLDoesNotInjectJSON(t *testing.T) { - // A baseURL containing a quote must not produce malformed config JSON - // (the old string concatenation would). The provider layer may reject - // the URL itself, but the error must not be a JSON parse failure. - _, err := ProviderWithBase("groq", "sk-test-fake-key", "llama-3.3-70b", `https://example.com/"v1`) - if err != nil && strings.Contains(err.Error(), "invalid provider config JSON") { - t.Fatalf("config JSON injection: %v", err) - } -} - -// TestInitRecordingRingRejectsZeroCap verifies D7: cap == 0 is rejected with -// an error instead of being silently rewritten to 2048. The check happens in -// Go before any FFI call, so this does not touch the global recorder state. -func TestInitRecordingRingRejectsZeroCap(t *testing.T) { - err := InitRecordingRing(0) - if err == nil { - t.Fatal("expected error for cap == 0, got nil") - } - if !strings.Contains(err.Error(), "cap > 0") { - t.Fatalf("expected cap error message, got: %v", err) - } -} - -// TestInitRecordingRingAcceptsPositiveCap verifies a positive cap does not -// return an error (the C ABI accepts it and returns 0). -func TestInitRecordingRingAcceptsPositiveCap(t *testing.T) { - if err := InitRecordingRing(8); err != nil { - t.Fatalf("expected success for cap=8, got: %v", err) - } - // Reset global recorder state so this doesn't leak into other tests. - RecordingStop() -} - -// TestInitRecordingRingDefaultNoArg verifies the no-arg form uses the library -// default capacity (FFI aimux_init_recording_ring_default) and returns nil. -func TestInitRecordingRingDefaultNoArg(t *testing.T) { - if err := InitRecordingRing(); err != nil { - t.Fatalf("expected success for no-arg default, got: %v", err) - } - // Reset global recorder state so this doesn't leak into other tests. - RecordingStop() -} - -// TestInitProxyRejectsEmpty verifies "" is rejected in Go (C requires JSON; -// letting it through used to surface as a boundary panic). -func TestInitProxyRejectsEmpty(t *testing.T) { - err := InitProxy("") - if err == nil || !strings.Contains(err.Error(), "config_json") { - t.Fatalf("expected config_json error, got: %v", err) - } -} - -// TestTraceAggregateEmptyFilter verifies the documented "" = all filter works -// (lowered to "{}" before the C call). Empty store → JSON array. -func TestTraceAggregateEmptyFilter(t *testing.T) { - m, err := NewOpenAIWithBase("sk-test-fake-key", "gpt-4o", "http://127.0.0.1:1") - if err != nil { - t.Fatal(err) - } - defer m.Close() - tr, err := m.Trace() - if err != nil { - t.Fatal(err) - } - defer tr.Close() - out, err := tr.TraceAggregate("") - if err != nil { - t.Fatalf("TraceAggregate(\"\") failed: %v", err) - } - if !strings.HasPrefix(strings.TrimSpace(out), "[") { - t.Fatalf("expected JSON array, got: %q", out) - } -} - -// TestTraceQueriesOnUntracedModel verifies every trace query on a plain model -// returns ErrNotTraced instead of crashing the process: the C side only knows -// "no trace store for this handle" and its [C ABI] error would panic. -func TestTraceQueriesOnUntracedModel(t *testing.T) { - m, err := NewOpenAIWithBase("sk-test-fake-key", "gpt-4o", "http://127.0.0.1:1") - if err != nil { - t.Fatal(err) - } - defer m.Close() - - for name, call := range map[string]func() error{ - "TraceAggregate": func() error { _, e := m.TraceAggregate(""); return e }, - "TraceSessionChain": func() error { _, e := m.TraceSessionChain("sess-1"); return e }, - "TraceExportJsonl": func() error { _, e := m.TraceExportJsonl(); return e }, - "TraceClear": m.TraceClear, - } { - if err := call(); !errors.Is(err, ErrNotTraced) { - t.Errorf("%s: expected ErrNotTraced, got %v", name, err) - } - } - - // Closed still wins over the traced check for a real trace handle. - tr, err := m.Trace() - if err != nil { - t.Fatal(err) - } - tr.Close() - if _, err := tr.TraceExportJsonl(); !errors.Is(err, ErrClosed) { - t.Errorf("closed trace handle: expected ErrClosed, got %v", err) - } -} - -// ── Composite constructors: atomic snapshots, close races, nil elements ──── - -// runConcurrent starts every task at one barrier and requires every task — -// including Close — to return. The atomic handle design has no Go lock order -// and therefore no waiting edge between these tasks. -func runConcurrent(t *testing.T, what string, tasks ...func()) { - t.Helper() - start := make(chan struct{}) - var wg sync.WaitGroup - wg.Add(len(tasks)) - for _, task := range tasks { - go func() { - defer wg.Done() - <-start - task() - }() - } - close(start) - - done := make(chan struct{}) - go func() { wg.Wait(); close(done) }() - select { - case <-done: - case <-time.After(10 * time.Second): - t.Fatalf("%s did not return within 10s — deadlocked", what) - } -} - -// dup returns m repeated n times. -func dup(m *Model, n int) []*Model { - out := make([]*Model, n) - for i := range out { - out[i] = m - } - return out -} - -func TestNewRouterRepeatedModelRacingClose(t *testing.T) { - m := OpenAI("sk-test-fake-key", "gpt-4o-mini") - if r, err := NewRouter(dup(m, 8), ""); err != nil { - t.Fatalf("duplicate router input: %v", err) - } else { - r.Close() - } - m.Close() - - for i := 0; i < 25; i++ { - m = OpenAI("sk-test-fake-key", "gpt-4o-mini") - tasks := []func(){func() { m.Close() }} - for j := 0; j < 16; j++ { - tasks = append(tasks, func() { - if r, err := NewRouter(dup(m, 8), ""); err == nil { - r.Close() - } - }) - } - runConcurrent(t, "NewRouter over a repeated model", tasks...) - } -} - -func TestNewMoaAggregatorAlsoReferenceRacingClose(t *testing.T) { - m := OpenAI("sk-test-fake-key", "gpt-4o-mini") - if r, err := NewMoa(dup(m, 7), m, ""); err != nil { - t.Fatalf("duplicate MoA input: %v", err) - } else { - r.Close() - } - m.Close() - - for i := 0; i < 25; i++ { - m = OpenAI("sk-test-fake-key", "gpt-4o-mini") - tasks := []func(){func() { m.Close() }} - for j := 0; j < 16; j++ { - tasks = append(tasks, func() { - if r, err := NewMoa(dup(m, 7), m, ""); err == nil { - r.Close() - } - }) - } - runConcurrent(t, "NewMoa with the aggregator also in references", tasks...) - } -} - -// TestCompositeConstructorsOppositeOrdersRacingClose is the end-to-end half: -// two groups of goroutines build routers over the same models in opposite -// orders while every model is being closed. -func TestCompositeConstructorsOppositeOrdersRacingClose(t *testing.T) { - const ( - iterations = 30 - fanout = 8 - pairs = 8 - ) - for i := 0; i < iterations; i++ { - models := make([]*Model, fanout) - for j := range models { - models[j] = OpenAI("sk-test-fake-key", "gpt-4o-mini") - } - reversed := make([]*Model, fanout) - for j, m := range models { - reversed[fanout-1-j] = m - } - - tasks := make([]func(), 0, pairs*2+fanout) - build := func(order []*Model) func() { - return func() { - if r, err := NewRouter(order, ""); err == nil { - r.Close() - } - } - } - for j := 0; j < pairs; j++ { - tasks = append(tasks, build(models), build(reversed)) - } - for _, m := range models { - model := m - tasks = append(tasks, func() { model.Close() }) - } - runConcurrent(t, fmt.Sprintf("opposite-order iteration %d", i), tasks...) - } -} - -func TestZeroValueModelIsClosed(t *testing.T) { - var m Model - if _, err := m.GenerateText(`"hello"`, ""); !errors.Is(err, ErrClosed) { - t.Fatalf("zero-value Model: expected ErrClosed, got %v", err) - } - if err := m.Close(); err != nil { - t.Fatalf("zero-value Close: %v", err) - } -} - -func TestModelConcurrentCloseIsIdempotent(t *testing.T) { - m := OpenAI("sk-test-fake-key", "gpt-4o-mini") - tasks := make([]func(), 64) - for i := range tasks { - tasks[i] = func() { - if err := m.Close(); err != nil { - t.Errorf("Close: %v", err) - } - } - } - runConcurrent(t, "concurrent Model.Close", tasks...) - if got := m.id.Load(); got != 0 { - t.Fatalf("handle after Close = %d, want 0", got) - } - if err := m.Close(); err != nil { - t.Fatalf("repeated Close: %v", err) - } -} - -func TestCompositeConstructorsRejectNilModels(t *testing.T) { - m := OpenAI("sk-test-fake-key", "gpt-4o-mini") - defer m.Close() - - cases := []struct { - name string - want string - call func() (*Model, error) - }{ - {"router nil child", "aimux: router: models[1] is nil", - func() (*Model, error) { return NewRouter([]*Model{m, nil, m}, "") }}, - {"router only child nil", "aimux: router: models[0] is nil", - func() (*Model, error) { return NewRouter([]*Model{nil}, "") }}, - {"moa nil aggregator", "aimux: moa: aggregator is nil", - func() (*Model, error) { return NewMoa([]*Model{m}, nil, "") }}, - {"moa nil reference", "aimux: moa: references[1] is nil", - func() (*Model, error) { return NewMoa([]*Model{m, nil}, m, "") }}, - {"moa nil aggregator, no references", "aimux: moa: aggregator is nil", - func() (*Model, error) { return NewMoa(nil, nil, "") }}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - defer func() { - if r := recover(); r != nil { - t.Fatalf("panicked instead of returning an error: %v", r) - } - }() - got, err := tc.call() - if got != nil { - got.Close() - t.Fatal("expected a nil model alongside the error") - } - if err == nil { - t.Fatal("expected an error for a nil element") - } - if err.Error() != tc.want { - t.Fatalf("error = %q, want %q", err, tc.want) - } - }) - } -} - -func newBlockedHTTPServer(t *testing.T, contentType, body string) (*httptest.Server, <-chan struct{}, func()) { - t.Helper() - requestStarted := make(chan struct{}) - releaseRequest := make(chan struct{}) - var startedOnce sync.Once - var releaseOnce sync.Once - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - startedOnce.Do(func() { close(requestStarted) }) - <-releaseRequest - if contentType != "" { - w.Header().Set("Content-Type", contentType) - } - _, _ = fmt.Fprint(w, body) - })) - release := func() { releaseOnce.Do(func() { close(releaseRequest) }) } - t.Cleanup(func() { - release() - server.Close() - }) - return server, requestStarted, release -} - -func TestGenerateTextAfterClose(t *testing.T) { - m := OpenAI("sk-test-fake-key", "gpt-4o-mini") - m.Close() - - if _, err := m.GenerateText(`"hello"`, ""); !errors.Is(err, ErrClosed) { - t.Fatalf("expected ErrClosed after close, got %v", err) - } -} - -func TestProviderCloseDoesNotWaitForInFlightListModels(t *testing.T) { - server, requestStarted, releaseRequest := newBlockedHTTPServer( - t, "application/json", `{"data":[]}`, - ) - defer releaseRequest() - - provider, err := CreateProvider("groq", "sk-test-fake-key", &ProviderConfig{ - BaseURL: server.URL, - }) - if err != nil { - t.Fatalf("CreateProvider failed: %v", err) - } - defer provider.Close() - - listDone := make(chan error, 1) - go func() { - _, err := provider.ListModels() - listDone <- err - }() - select { - case <-requestStarted: - case <-time.After(2 * time.Second): - t.Fatal("ListModels request did not start") - } - - closes := make([]func(), 32) - for i := range closes { - closes[i] = func() { - if err := provider.Close(); err != nil { - t.Errorf("Close: %v", err) - } - } - } - runConcurrent(t, "ProviderHandle.Close during ListModels", closes...) - if got := provider.id.Load(); got != 0 { - t.Fatalf("provider handle after Close = %d, want 0", got) - } - - // The call already entered the native registry, so dropping the Go owner - // must not invalidate its Arc. Let the provider response complete now. - releaseRequest() - select { - case err := <-listDone: - if err != nil { - t.Fatalf("in-flight ListModels failed after Close: %v", err) - } - case <-time.After(2 * time.Second): - t.Fatal("in-flight ListModels did not finish after releasing the response") - } - - if _, err = provider.ListModels(); !errors.Is(err, ErrClosed) { - t.Fatalf("ListModels after Close: expected ErrClosed, got %v", err) - } -} diff --git a/bindings/go/batch1_e2e_test.go b/bindings/go/batch1_e2e_test.go deleted file mode 100644 index d826cf598..000000000 --- a/bindings/go/batch1_e2e_test.go +++ /dev/null @@ -1,136 +0,0 @@ -package aimux - -// RFC-0016 第一批 e2e tests (Go shell): -// M2 includeRawChunks -> "raw" stream part emission -// M10 Usage.raw preservation -// M9 StartStep non-empty warnings -// -// Mirrors the Rust provider tests (openai_model_test.rs / groq_test.rs) at -// the Go binding level, driving the real FFI path against a mock SSE server. - -import ( - "encoding/json" - "strings" - "testing" -) - -func boolPtr(b bool) *bool { return &b } -func f64Ptr(f float64) *float64 { return &f } - -// RFC-0016 M2: include_raw_chunks=true yields one Raw part per JSON SSE event, -// before the parsed parts ([DONE] excluded). -func TestE2E_StreamTextEmitsRawWhenEnabled(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetContentType("text/event-stream") - srv.SetResponse(buildTextDeltaSSE()) - - m := chatModel(t, srv.URL) - defer m.Close() - - stream := m.StreamText(`"Say hello"`, mustMarshalOptions(t, &GenerateTextOptions{ - IncludeRawChunks: boolPtr(true), - })) - - var raws []json.RawMessage - rawBeforeFirstDelta := false - sawDelta := false - var textBuilder strings.Builder - for part := range stream.Parts() { - sp, err := ParseStreamPart(part) - if err != nil { - t.Fatalf("failed to parse: %v", err) - } - switch sp.Type { - case "raw": - raws = append(raws, sp.Raw) - if !sawDelta { - rawBeforeFirstDelta = true - } - case "text-delta": - sawDelta = true - if !rawBeforeFirstDelta { - t.Error("Raw must precede the first parsed text-delta") - } - var td TextDeltaPayload - json.Unmarshal(sp.Raw, &td) - textBuilder.WriteString(td.Text) - } - } - if err := stream.Err(); err != nil { - t.Fatalf("stream error: %v", err) - } - - // buildTextDeltaSSE has 3 JSON events (2 content + 1 usage chunk); - // [DONE] emits no Raw. - if len(raws) != 3 { - t.Fatalf("expected 3 Raw parts, got %d", len(raws)) - } - var first map[string]any - if err := json.Unmarshal(raws[0], &first); err != nil { - t.Fatalf("Raw payload not an object: %v", err) - } - // A raw part is {"type":"raw","rawValue": {chunk}}. - inner, ok := first["rawValue"].(map[string]any) - if !ok { - t.Fatalf("raw part missing rawValue, got %#v", first) - } - delta, ok := inner["choices"].([]any)[0].(map[string]any)["delta"].(map[string]any)["content"].(string) - if !ok || delta != "Hello" { - t.Errorf("first Raw should carry the 'Hello' chunk, got %#v", inner) - } - if textBuilder.String() != "Hello world" { - t.Errorf("text deltas still parsed: expected 'Hello world', got %q", textBuilder.String()) - } -} - -func TestE2E_StartStepCarriesNonEmptyWarnings(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetContentType("text/event-stream") - srv.SetResponse(buildTextDeltaSSE()) - - m := chatModel(t, srv.URL) - defer m.Close() - - // DeepSeek does not support topK, so its warning must cross the Go stream - // boundary while the text continues to parse normally. - stream := m.StreamText(`"Say hello"`, mustMarshalOptions(t, &GenerateTextOptions{ - TopK: f64Ptr(0.5), - })) - - sawStartStep := false - for part := range stream.Parts() { - sp, err := ParseStreamPart(part) - if err != nil { - t.Fatalf("failed to parse: %v", err) - } - if sp.Type == "start-step" { - sawStartStep = true - var start struct { - Warnings []json.RawMessage `json:"warnings"` - } - if err := json.Unmarshal(sp.Raw, &start); err != nil { - t.Fatalf("failed to decode start-step part: %v", err) - } - if len(start.Warnings) == 0 { - t.Error("expected an unsupported topK warning") - } - } - } - if err := stream.Err(); err != nil { - t.Fatalf("stream error: %v", err) - } - if !sawStartStep { - t.Fatal("no start-step part received") - } -} - -func mustMarshalOptions(t *testing.T, opts *GenerateTextOptions) string { - t.Helper() - s, err := MarshalOptions(opts) - if err != nil { - t.Fatalf("MarshalOptions: %v", err) - } - return s -} diff --git a/bindings/go/cmd/ffidownload/main.go b/bindings/go/cmd/ffidownload/main.go index 29fc073e0..95386c999 100644 --- a/bindings/go/cmd/ffidownload/main.go +++ b/bindings/go/cmd/ffidownload/main.go @@ -1,6 +1,5 @@ -// Command ffidownload downloads the prebuilt libaimux_ffi.a for the current -// platform from the GitHub Release assets and places it where the cgo -// LDFLAGS expect it (/target/release/libaimux_ffi.a). +// Command ffidownload downloads the matching native archive and generated +// header from one GitHub Release into the paths expected by cgo. // // Invoked automatically by `go generate ./...` — pure standard library so it // works on Linux / macOS / Windows without bash or curl. @@ -44,7 +43,10 @@ func main() { version = "latest" } asset := "libaimux_ffi-" + triple + ".a" - url := fmt.Sprintf("https://github.com/arcships/aimux/releases/%s/download/%s", version, asset) + releaseURL := "https://github.com/arcships/aimux/releases/latest/download/" + if version != "latest" { + releaseURL = "https://github.com/arcships/aimux/releases/download/" + version + "/" + } // go generate runs with cwd = the package directory; the cgo LDFLAGS use // ${SRCDIR}/../../target/release, so write exactly there (works both in @@ -57,28 +59,48 @@ func main() { dest := filepath.Join(destDir, "libaimux_ffi.a") fmt.Printf("ffidownload: downloading %s (%s)\n", asset, version) + if err := download(releaseURL+"aimux.h", filepath.Join(cwd, "..", "..", "aimux-ffi", "include", "aimux.h")); err != nil { + fatal(err) + } + if err := download(releaseURL+asset, dest); err != nil { + fatal(err) + } +} + +func download(url, dest string) error { resp, err := http.Get(url) if err != nil { - fatal(fmt.Errorf("download failed: %w", err)) + return fmt.Errorf("download failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - fatal(fmt.Errorf("download failed: HTTP %d for %s", resp.StatusCode, url)) + return fmt.Errorf("download failed: HTTP %d for %s", resp.StatusCode, url) } - if err := os.MkdirAll(destDir, 0o755); err != nil { - fatal(err) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err } - f, err := os.Create(dest) + f, err := os.CreateTemp(filepath.Dir(dest), ".aimux-download-*") if err != nil { - fatal(err) + return err } defer f.Close() + defer os.Remove(f.Name()) if _, err := io.Copy(f, resp.Body); err != nil { - fatal(err) + return err + } + if err := f.Close(); err != nil { + return err + } + if err := os.Chmod(f.Name(), 0o644); err != nil { + return err + } + if err := os.Rename(f.Name(), dest); err != nil { + return err } fmt.Printf("ffidownload: wrote %s\n", dest) + return nil } func fatal(err error) { diff --git a/bindings/go/decision.go b/bindings/go/decision.go deleted file mode 100644 index a540ef152..000000000 --- a/bindings/go/decision.go +++ /dev/null @@ -1,135 +0,0 @@ -package aimux - -/* -#cgo CFLAGS: -I${SRCDIR}/../../aimux-ffi -#include -#include "aimux-ffi.h" -*/ -import "C" - -import ( - "context" - "encoding/json" - "runtime" - "unsafe" -) - -// DecisionModel is a DecisionModelV4 model. Close releases its handle. -// Like other model handles, it must not be copied after first use. -type DecisionModel struct{ h multimodalHandle } - -// NewJevDecision uses the official TypeSafe endpoint. -func NewJevDecision(apiKey, modelID string) (*DecisionModel, error) { - return NewJevDecisionWithEndpoint(apiKey, modelID, "", "") -} - -// NewJevDecisionWithEndpoint accepts a complete POST URL and optional provenance. -// Empty endpoint/source selects the official endpoint and native probabilities. -func NewJevDecisionWithEndpoint(apiKey, modelID, endpoint, probabilitySource string) (*DecisionModel, error) { - if err := checkUTF8("api_key", apiKey, "model_id", modelID, "endpoint", endpoint, - "probability_source", probabilitySource); err != nil { - return nil, err - } - key, id, cleanup := cstringPair(apiKey, modelID) - defer cleanup() - var url, source *C.char - if endpoint != "" { - url = C.CString(endpoint) - defer C.free(unsafe.Pointer(url)) - } - if probabilitySource != "" { - source = C.CString(probabilitySource) - defer C.free(unsafe.Pointer(source)) - } - var handle C.uint64_t - if err := expectAimuxError(C.aimux_jev_decision_new_with_probability_source(key, id, url, source, &handle)); err != nil { - return nil, err - } - model := &DecisionModel{} - model.h.handle.Store(uint64(handle)) - runtime.SetFinalizer(model, func(m *DecisionModel) { m.Close() }) - return model, nil -} - -// DecisionModel builds the provider's decision model with its existing -// configuration. A provider without decision models returns a NoSuchModel error. -func (p *ProviderHandle) DecisionModel(modelID string) (*DecisionModel, error) { - if err := checkUTF8("model_id", modelID); err != nil { - return nil, err - } - handle, err := p.handle() - if err != nil { - return nil, err - } - defer runtime.KeepAlive(p) - id := C.CString(modelID) - defer C.free(unsafe.Pointer(id)) - var out C.uint64_t - if err := expectAimuxError(C.aimux_provider_decision_model(C.uint64_t(handle), id, &out)); err != nil { - return nil, err - } - model := &DecisionModel{} - model.h.handle.Store(uint64(out)) - runtime.SetFinalizer(model, func(m *DecisionModel) { m.Close() }) - return model, nil -} - -func (m *DecisionModel) Close() error { closeMultimodal(m, &m.h); return nil } - -// Describe returns the model's DecisionModelV4 properties as JSON -// ({specificationVersion, provider, modelId, supportedQuestionTypes}), -// without a network request. -func (m *DecisionModel) Describe() (string, error) { - return callFFIString(m, &m.h, func(handle C.uint64_t, out **C.char) *C.aimux_error_t { - return C.aimux_decision_capabilities(handle, out) - }) -} - -// Decide accepts a value serializable as DecideOptions (state, a question map -// keyed by ID, and optional maxRetries, timeout, headers and -// providerOptions: the AI SDK's camelCase keys), preserving structured -// instructions and criteria. It returns DecideResult JSON (usage with -// inputTokens, outputTokens and totalTokens, an unknown count absent; -// providerMetadata). -func (m *DecisionModel) Decide(options any) (string, error) { - return m.DecideContext(context.Background(), options) -} - -func (m *DecisionModel) DecideContext(ctx context.Context, options any) (string, error) { - payload, err := json.Marshal(options) - if err != nil { - return "", err - } - return m.DecideJSONContext(ctx, string(payload)) -} - -// DecideJSONContext is the raw JSON boundary with context cancellation. -func (m *DecisionModel) DecideJSONContext(ctx context.Context, optionsJSON string) (string, error) { - if err := checkUTF8("options", optionsJSON); err != nil { - return "", err - } - if err := ctx.Err(); err != nil { - return "", err - } - ptr := C.CString(optionsJSON) - defer C.free(unsafe.Pointer(ptr)) - abort := C.aimux_abort_signal_new() - done := make(chan struct{}) - stop := context.AfterFunc(ctx, func() { - C.aimux_abort_signal_abort(abort) - close(done) - }) - defer func() { - if !stop() { - <-done - } - C.aimux_abort_signal_drop(abort) - }() - result, err := callFFIString(m, &m.h, func(handle C.uint64_t, out **C.char) *C.aimux_error_t { - return C.aimux_decide_with_abort(handle, ptr, abort, out) - }) - if err != nil && ctx.Err() != nil { - return "", ctx.Err() - } - return result, err -} diff --git a/bindings/go/decision_test.go b/bindings/go/decision_test.go deleted file mode 100644 index b1c9162cc..000000000 --- a/bindings/go/decision_test.go +++ /dev/null @@ -1,209 +0,0 @@ -package aimux - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "os" - "reflect" - "testing" - "time" -) - -func TestProviderDecisionModelOwnsHandle(t *testing.T) { - provider, err := CreateProvider("openai", "test-key", nil) - if err != nil { - t.Fatal(err) - } - defer provider.Close() - model, err := provider.DecisionModel("gpt-6-luna") - if err != nil { - t.Fatal(err) - } - defer model.Close() - provider.Close() - description, err := model.Describe() - if err != nil { - t.Fatal(err) - } - var decoded map[string]any - if err := json.Unmarshal([]byte(description), &decoded); err != nil { - t.Fatal(err) - } - if decoded["specificationVersion"] != "v4" || decoded["provider"] != "openai" || decoded["modelId"] != "gpt-6-luna" { - t.Fatal(description) - } - if _, err := provider.DecisionModel("gpt-6-luna"); !errors.Is(err, ErrClosed) { - t.Fatalf("closed provider: %v", err) - } -} - -func TestDecisionNativeContract(t *testing.T) { - data, err := os.ReadFile("../../contract-tests/fixtures/decision-native.json") - if err != nil { - t.Fatal(err) - } - var fixture map[string]json.RawMessage - if err = json.Unmarshal(data, &fixture); err != nil { - t.Fatal(err) - } - requests := make(chan map[string]any, 1) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/systemone" || r.Header.Get("Authorization") != "Bearer test-key" { - t.Errorf("unexpected request path/auth: %s", r.URL.Path) - } - var request map[string]any - if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - t.Error(err) - } - requests <- request - w.Header().Set("Content-Type", "application/json") - w.Write(fixture["response"]) - })) - defer server.Close() - model, err := NewJevDecisionWithEndpoint("test-key", "jev-latest", server.URL+"/v1/systemone", "native") - if err != nil { - t.Fatal(err) - } - defer model.Close() - description, err := model.Describe() - if err != nil { - t.Fatal(err) - } - var properties map[string]any - json.Unmarshal([]byte(description), &properties) - if !reflect.DeepEqual(properties["supportedQuestionTypes"], []any{"choice", "score", "boolean"}) || properties["provider"] != "jev" { - t.Fatalf("unexpected description: %s", description) - } - if len(requests) != 0 { - t.Fatal("describe made an HTTP request") - } - result, err := model.DecideJSONContext(context.Background(), string(fixture["request"])) - if err != nil { - t.Fatal(err) - } - var decoded map[string]any - json.Unmarshal([]byte(result), &decoded) - answers := decoded["answers"].(map[string]any) - if answers["urgent"].(map[string]any)["probability"] != 0.9 { - t.Fatal(result) - } - if decoded["rounding"].(map[string]any)["probabilityDecimals"] != float64(2) { - t.Fatal(result) - } - // decide's usage is the AI SDK's camelCase DecisionResult usage - // (decision-result.ts:27-31). - if !reflect.DeepEqual(decoded["usage"], map[string]any{"inputTokens": float64(50), "outputTokens": float64(10), "totalTokens": float64(60)}) { - t.Fatal(result) - } - var original map[string]any - json.Unmarshal(fixture["request"], &original) - questions := original["questions"].(map[string]any) - wire := (<-requests)["questions"].(map[string]any) - if !reflect.DeepEqual(wire["urgent"].(map[string]any)["criteria"], questions["urgent"].(map[string]any)["criteria"]) { - t.Fatal("boolean criteria changed") - } - if !reflect.DeepEqual(wire["severity"].(map[string]any)["criteria"], questions["severity"].(map[string]any)["criteria"]) { - t.Fatal("structured score levels changed") - } - model.Close() - if _, err := model.Describe(); !errors.Is(err, ErrClosed) { - t.Fatalf("closed: %v", err) - } - if _, err := NewJevDecisionWithEndpoint("test-key", "jev-latest", "", "unknown"); err == nil { - t.Fatal("unknown provenance accepted") - } -} - -func TestDecisionContextCancelsNativeRequest(t *testing.T) { - reached := make(chan struct{}) - released := make(chan struct{}) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - close(reached) - <-released - })) - defer server.Close() - defer close(released) - model, err := NewJevDecisionWithEndpoint("test", "jev-latest", server.URL+"/v1/systemone", "") - if err != nil { - t.Fatal(err) - } - defer model.Close() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - result := make(chan error, 1) - go func() { - _, err := model.DecideContext(ctx, map[string]any{"state": "test", "questions": map[string]any{ - "q": map[string]any{"type": "boolean", "instructions": "Urgent?"}}}) - result <- err - }() - select { - case <-reached: - case <-time.After(3 * time.Second): - t.Fatal("request did not start") - } - cancel() - select { - case err := <-result: - if !errors.Is(err, context.Canceled) { - t.Fatal(err) - } - case <-time.After(3 * time.Second): - t.Fatal("cancellation hung") - } -} - -func TestOpenAIDecisionMediaAndNativeValues(t *testing.T) { - data, err := os.ReadFile("../../contract-tests/fixtures/decision-openai-full.json") - if err != nil { - t.Fatal(err) - } - var fixture map[string]json.RawMessage - if err := json.Unmarshal(data, &fixture); err != nil { - t.Fatal(err) - } - requests := make(chan map[string]any, 1) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/decisions" { - t.Errorf("wrong decision route: %s", r.URL.Path) - } - var request map[string]any - if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - t.Error(err) - } - requests <- request - w.Header().Set("Content-Type", "application/json") - w.Write(fixture["response"]) - })) - defer server.Close() - provider, err := CreateProvider("openai", "test-key", &ProviderConfig{BaseURL: server.URL + "/v1"}) - if err != nil { - t.Fatal(err) - } - defer provider.Close() - model, err := provider.DecisionModel("gpt-6-luna") - if err != nil { - t.Fatal(err) - } - defer model.Close() - result, err := model.DecideJSONContext(context.Background(), string(fixture["options"])) - if err != nil { - t.Fatal(err) - } - var decoded, expected map[string]any - json.Unmarshal([]byte(result), &decoded) - json.Unmarshal(fixture["request"], &expected) - if !reflect.DeepEqual(<-requests, expected) { - t.Fatal("native request changed across Go boundary") - } - choice := decoded["answers"].(map[string]any)["choice"].(map[string]any) - if choice["choice"] != "boolean_true" { - t.Fatal(result) - } - metadata := decoded["providerMetadata"].(map[string]any)["openai"].(map[string]any) - if metadata["answers"].(map[string]any)["choice"].(map[string]any)["value"] != true { - t.Fatal(result) - } -} diff --git a/bindings/go/e2e_test.go b/bindings/go/e2e_test.go deleted file mode 100644 index 31a823511..000000000 --- a/bindings/go/e2e_test.go +++ /dev/null @@ -1,872 +0,0 @@ -// Structured end-to-end tests for the Go binding. -// -// These spin up a local mock HTTP server speaking the OpenAI chat-completions -// wire format, point a chat-completions Model at it via a base URL, -// and assert that the binding correctly: -// 1. parses text from a provider response, -// 2. parses tool_calls out of a provider response, -// 3. forwards multi-role messages, -// 4. forwards tool_choice, -// 5. parses tool-call stream parts from an SSE stream, -// 6. yields text-delta stream parts. -// -// No real network access is performed — every request hits 127.0.0.1. -package aimux - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "reflect" - "strings" - "sync" - "testing" - "time" -) - -// ── Mock provider server ────────────────────────────────────────────────────── - -// mockProviderServer is a tiny HTTP server that records the last request body -// and replays a preset response. Listens on a random loopback port. -type mockProviderServer struct { - *httptest.Server - - mu sync.Mutex - lastRequestBody string - lastMethod string - - status int - contentType string - responseBody string - queue []string -} - -func newMockServer() *mockProviderServer { - s := &mockProviderServer{ - status: http.StatusOK, - contentType: "application/json", - } - s.Server = httptest.NewServer(http.HandlerFunc(s.handle)) - return s -} - -func (s *mockProviderServer) handle(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - body = nil - } - - s.mu.Lock() - s.lastRequestBody = string(body) - s.lastMethod = r.Method - status := s.status - ct := s.contentType - var resp string - if len(s.queue) > 0 { - resp = s.queue[0] - s.queue = s.queue[1:] - } else { - resp = s.responseBody - } - s.mu.Unlock() - - w.Header().Set("Content-Type", ct) - if strings.Contains(ct, "text/event-stream") { - // SSE: use chunked transfer (Flusher) so the streaming reader sees - // events as they arrive. - w.WriteHeader(status) - flusher, _ := w.(http.Flusher) - fmt.Fprint(w, resp) - if flusher != nil { - flusher.Flush() - } - } else { - w.WriteHeader(status) - w.Write([]byte(resp)) - } -} - -func (s *mockProviderServer) setResponses(responses ...string) { - s.mu.Lock() - defer s.mu.Unlock() - s.queue = append(s.queue[:0], responses...) -} - -func (s *mockProviderServer) LastRequestBody() string { - s.mu.Lock() - defer s.mu.Unlock() - return s.lastRequestBody -} - -// SetResponse sets the response body and content type (thread-safe). -func (s *mockProviderServer) SetResponse(body string) { - s.mu.Lock() - defer s.mu.Unlock() - s.responseBody = body -} - -// SetContentType sets the response content type (thread-safe). -func (s *mockProviderServer) SetContentType(ct string) { - s.mu.Lock() - defer s.mu.Unlock() - s.contentType = ct -} - -// newBackpressureSSEServer sends enough parts to fill the Go channel. It then -// keeps the HTTP request open until the client cancels it. -func newBackpressureSSEServer(chunks int) (*httptest.Server, <-chan struct{}, <-chan struct{}) { - allSent := make(chan struct{}) - requestDone := make(chan struct{}) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer close(requestDone) - _, _ = io.ReadAll(r.Body) - w.Header().Set("Content-Type", "text/event-stream") - w.WriteHeader(http.StatusOK) - flusher, _ := w.(http.Flusher) - for i := 0; i < chunks; i++ { - chunk := `{"id":"1","model":"gpt-4o","choices":[{"delta":{"content":"x"}}]}` - _, _ = fmt.Fprintf(w, "data: %s\n\n", chunk) - if flusher != nil { - flusher.Flush() - } - } - close(allSent) - select { - case <-r.Context().Done(): - case <-time.After(5 * time.Second): - } - })) - return server, allSent, requestDone -} - -func waitForChannelFull[T any](t *testing.T, channel chan T) { - t.Helper() - deadline := time.Now().Add(2 * time.Second) - for len(channel) < cap(channel) { - if time.Now().After(deadline) { - t.Fatalf("channel did not fill: got %d of %d", len(channel), cap(channel)) - } - time.Sleep(5 * time.Millisecond) - } -} - -// chatModel is a model that speaks the OpenAI chat-completions wire format at -// baseURL: the registry's OpenAI-compatible preset (the OpenAI constructors -// talk to the Responses API). -func chatModel(t *testing.T, baseURL string) *Model { - t.Helper() - m, err := ProviderWithBase("deepseek", "sk-test-fake-key", "gpt-4o", baseURL) - if err != nil { - t.Fatalf("ProviderWithBase: %v", err) - } - return m -} - -// ── Canned OpenAI responses ──────────────────────────────────────────────────── - -// plainOpenAIResponse is a plain OpenAI text response (no tool calls). -const plainOpenAIResponse = `{ - "id": "chatcmpl-test", - "model": "gpt-4o", - "choices": [{ - "message": {"role": "assistant", "content": "Rust is a systems programming language."}, - "finish_reason": "stop" - }], - "usage": {"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18} -}` - -// toolCallOpenAIResponse is an OpenAI response that requests a tool call. -const toolCallOpenAIResponse = `{ - "id": "chatcmpl-tc", - "model": "gpt-4o", - "choices": [{ - "message": { - "role": "assistant", - "content": null, - "tool_calls": [{ - "id": "call_abc", - "type": "function", - "function": {"name": "get_weather", "arguments": "{\"location\":\"Tokyo\"}"} - }] - }, - "finish_reason": "tool_calls" - }], - "usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30} -}` - -// weatherToolsOpts builds a JSON options string with a get_weather tool. -func weatherToolsOpts(toolChoice string) string { - opts := map[string]any{ - "tools": []map[string]any{ - { - "type": "function", - "name": "get_weather", - "inputSchema": map[string]any{ - "type": "object", - "properties": map[string]any{ - "location": map[string]any{"type": "string"}, - }, - }, - }, - }, - } - if toolChoice != "" { - opts["toolChoice"] = toolChoice - } - b, _ := json.Marshal(opts) - return string(b) -} - -// ── Tests ─────────────────────────────────────────────────────────────────── - -func TestE2E_GenerateTextParsesText(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetResponse(plainOpenAIResponse) - - m := chatModel(t, srv.URL) - defer m.Close() - - result, err := m.GenerateText(`"What is Rust?"`, "") - if err != nil { - t.Fatalf("generate_text failed: %v", err) - } - - // Parse the typed result. - parsed, err := ParseGenerateTextResult(result) - if err != nil { - t.Fatalf("parse failed: %v", err) - } - if parsed.Text != "Rust is a systems programming language." { - t.Errorf("expected text, got %q", parsed.Text) - } - if len(parsed.ToolCalls) != 0 { - t.Errorf("expected no tool calls, got %d", len(parsed.ToolCalls)) - } - // ProviderWithBase marshals the public baseUrl option through the FFI. The - // loopback server must receive this call, rather than the vendor's default URL. - var request struct { - Model string `json:"model"` - Messages []struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"messages"` - } - if err := json.Unmarshal([]byte(srv.LastRequestBody()), &request); err != nil { - t.Fatalf("custom baseUrl did not receive a valid request: %v", err) - } - if request.Model != "gpt-4o" || len(request.Messages) != 1 { - t.Fatalf("unexpected request at custom baseUrl: %#v", request) - } - if request.Messages[0].Role != "user" || request.Messages[0].Content != "What is Rust?" { - t.Errorf("prompt did not reach custom baseUrl: %#v", request.Messages[0]) - } -} - -// The AI SDK parses tool input with JSON.parse (ai -// generate-text/parse-tool-call.ts:256; provider-utils secure-json-parse.ts:31), -// so JSON.stringify writes an integral 1e2 or 3.0 as 100 or 3. -func TestE2E_IntegralToolInputNumbersAreIntegers(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetResponse(strings.Replace(toolCallOpenAIResponse, `{\"location\":\"Tokyo\"}`, - `{\"location\":\"Tokyo\",\"days\":3.0,\"n\":1e2,\"t\":1.5}`, 1)) - - m := chatModel(t, srv.URL) - defer m.Close() - - result, err := m.GenerateText(`"What is the weather in Tokyo?"`, weatherToolsOpts("")) - if err != nil { - t.Fatalf("generate_text failed: %v", err) - } - parsed, err := ParseGenerateTextResult(result) - if err != nil { - t.Fatalf("parse failed: %v", err) - } - if got := string(parsed.ToolCalls[0].Input); got != `{"location":"Tokyo","days":3,"n":100,"t":1.5}` { - t.Errorf("tool input: %s", got) - } -} - -func TestE2E_GenerateTextParsesToolCalls(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetResponse(toolCallOpenAIResponse) - - m := chatModel(t, srv.URL) - defer m.Close() - - result, err := m.GenerateText(`"What is the weather in Tokyo?"`, weatherToolsOpts("")) - if err != nil { - t.Fatalf("generate_text failed: %v", err) - } - - parsed, err := ParseGenerateTextResult(result) - if err != nil { - t.Fatalf("parse failed: %v", err) - } - if len(parsed.ToolCalls) == 0 { - t.Fatal("expected tool calls") - } - tc := parsed.ToolCalls[0] - if tc.ToolName != "get_weather" { - t.Errorf("expected tool name 'get_weather', got %q", tc.ToolName) - } - if tc.ToolCallID != "call_abc" { - t.Errorf("expected tool call id 'call_abc', got %q", tc.ToolCallID) - } - - // Verify input contains location: Tokyo. - var input map[string]any - json.Unmarshal(tc.Input, &input) - if input["location"] != "Tokyo" { - t.Errorf("expected location Tokyo, got %v", input["location"]) - } - - // content should contain a "tool-call" part. - var contentWrap struct { - Content []struct { - Type string `json:"type"` - } `json:"content"` - } - json.Unmarshal([]byte(result), &contentWrap) - hasToolCallPart := false - for _, part := range contentWrap.Content { - if part.Type == "tool-call" { - hasToolCallPart = true - } - } - if !hasToolCallPart { - t.Error("expected content to contain a tool-call part") - } -} - -func TestE2E_MultiRoleMessagesReachProvider(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetResponse(plainOpenAIResponse) - - m := chatModel(t, srv.URL) - defer m.Close() - - msgs := []ModelMessage{ - {Role: RoleSystem, Content: "You are a helpful assistant."}, - {Role: RoleUser, Content: "What is Rust?"}, - } - prompt, _ := MarshalMessages(msgs) - - result, err := m.GenerateText(prompt, "") - if err != nil { - t.Fatalf("generate_text failed: %v", err) - } - - parsed, _ := ParseGenerateTextResult(result) - if parsed.Text != "Rust is a systems programming language." { - t.Errorf("expected text, got %q", parsed.Text) - } - - // Verify the provider received both messages. - var reqBody map[string]any - json.Unmarshal([]byte(srv.LastRequestBody()), &reqBody) - if reqBody["model"] != "gpt-4o" { - t.Errorf("expected model gpt-4o, got %v", reqBody["model"]) - } - messages := reqBody["messages"].([]any) - if len(messages) != 2 { - t.Fatalf("expected 2 messages, got %d", len(messages)) - } - first := messages[0].(map[string]any) - if first["role"] != "system" { - t.Errorf("expected first role 'system', got %v", first["role"]) - } - if first["content"] != "You are a helpful assistant." { - t.Errorf("expected first content, got %v", first["content"]) - } - second := messages[1].(map[string]any) - if second["role"] != "user" { - t.Errorf("expected second role 'user', got %v", second["role"]) - } -} - -func TestE2E_ToolChoiceReachesProvider(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetResponse(toolCallOpenAIResponse) - - m := chatModel(t, srv.URL) - defer m.Close() - - _, err := m.GenerateText(`"What is the weather in Tokyo?"`, weatherToolsOpts("required")) - if err != nil { - t.Fatalf("generate_text failed: %v", err) - } - - var reqBody map[string]any - json.Unmarshal([]byte(srv.LastRequestBody()), &reqBody) - if reqBody["tool_choice"] != "required" { - t.Errorf("expected tool_choice 'required', got %v", reqBody["tool_choice"]) - } - if reqBody["tools"] == nil { - t.Error("expected tools to be forwarded") - } -} - -func TestE2E_StreamTextParsesToolCallStreamParts(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetContentType("text/event-stream") - - // SSE stream: tool_calls delta (name) -> arguments delta -> finish_reason. - sseResponse := buildToolCallSSE() - srv.SetResponse(sseResponse) - - m := chatModel(t, srv.URL) - defer m.Close() - - stream := m.StreamText(`"What is the weather in Tokyo?"`, weatherToolsOpts("")) - - var parts []string - for part := range stream.Parts() { - parts = append(parts, part) - } - if err := stream.Err(); err != nil { - t.Fatalf("stream error: %v", err) - } - if len(parts) == 0 { - t.Fatal("expected at least one stream part") - } - - // The stream opens with Start and StartStep parts. - var gotToolCall, gotFinish bool - for _, p := range parts { - sp, err := ParseStreamPart(p) - if err != nil { - t.Errorf("failed to parse stream part: %v", err) - continue - } - // Types we expect: stream-start, text-delta, tool-call, finish, etc. - if sp.Type == "" { - t.Error("expected non-empty stream part type") - } - switch sp.Type { - case "tool-call": - gotToolCall = true - var call ToolCall - if err := json.Unmarshal(sp.Raw, &call); err != nil { - t.Fatalf("decode tool call: %v", err) - } - if call.ToolName != "get_weather" || call.ToolCallID != "call_xyz" { - t.Errorf("unexpected tool call: %#v", call) - } - var input map[string]any - if err := json.Unmarshal(call.Input, &input); err != nil || input["location"] != "Tokyo" { - t.Errorf("unexpected tool arguments: %q (%v)", call.Input, err) - } - case "finish": - gotFinish = true - } - } - if !gotToolCall || !gotFinish { - t.Errorf("expected completed tool call and finish; gotToolCall=%t gotFinish=%t", gotToolCall, gotFinish) - } -} - -func TestE2E_StreamTextYieldsTextDeltas(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetContentType("text/event-stream") - - // SSE stream: "Hello" -> " world" -> finish. - sseResponse := buildTextDeltaSSE() - srv.SetResponse(sseResponse) - - m := chatModel(t, srv.URL) - defer m.Close() - - stream := m.StreamText(`"Say hello"`, "") - - var textBuilder strings.Builder - var gotFinish bool - for part := range stream.Parts() { - sp, err := ParseStreamPart(part) - if err != nil { - t.Errorf("failed to parse: %v", err) - continue - } - switch sp.Type { - case "text-delta": - var td TextDeltaPayload - json.Unmarshal(sp.Raw, &td) - textBuilder.WriteString(td.Text) - case "finish": - gotFinish = true - } - } - if err := stream.Err(); err != nil { - t.Fatalf("stream error: %v", err) - } - - text := textBuilder.String() - if text != "Hello world" { - t.Errorf("expected 'Hello world', got %q", text) - } - if !gotFinish { - t.Error("expected a finish part") - } -} - -func TestStreamTextContextCancelsInFlightRequest(t *testing.T) { - started := make(chan struct{}) - requestDone := make(chan struct{}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer close(requestDone) - _, _ = io.ReadAll(r.Body) - w.Header().Set("Content-Type", "text/event-stream") - w.WriteHeader(http.StatusOK) - if flusher, ok := w.(http.Flusher); ok { - flusher.Flush() - } - close(started) - select { - case <-r.Context().Done(): - case <-time.After(3 * time.Second): - } - })) - defer srv.Close() - - m := chatModel(t, srv.URL) - defer m.Close() - ctx, cancel := context.WithCancel(context.Background()) - stream := m.StreamTextContext(ctx, `"wait"`, "") - - select { - case <-started: - case <-time.After(2 * time.Second): - t.Fatal("stream request did not start") - } - cancel() - - drained := make(chan struct{}) - go func() { - for range stream.Parts() { - } - close(drained) - }() - select { - case <-drained: - case <-time.After(2 * time.Second): - t.Fatal("in-flight stream did not stop after cancellation") - } - if !errors.Is(stream.Err(), context.Canceled) { - t.Fatalf("expected context.Canceled, got %v", stream.Err()) - } - select { - case <-requestDone: - case <-time.After(2 * time.Second): - t.Fatal("provider request stayed open after cancellation") - } -} - -func TestStreamCancelUnblocksFullPartsChannel(t *testing.T) { - srv, allSent, requestDone := newBackpressureSSEServer(800) - defer srv.Close() - - m := chatModel(t, srv.URL) - defer m.Close() - stream := m.StreamText(`"fill the channel"`, "") - defer stream.Cancel() - - select { - case <-allSent: - case <-time.After(2 * time.Second): - t.Fatal("provider did not send the SSE burst") - } - waitForChannelFull(t, stream.parts) - - tasks := []func(){func() { m.Close() }} - for i := 0; i < 32; i++ { - tasks = append(tasks, stream.Cancel) - } - runConcurrent(t, "full stream buffer Cancel + Model.Close", tasks...) - drained := make(chan struct{}) - go func() { - for range stream.Parts() { - } - close(drained) - }() - select { - case <-drained: - case <-time.After(2 * time.Second): - t.Fatal("full parts channel stayed blocked after cancellation") - } - if !errors.Is(stream.Err(), context.Canceled) { - t.Fatalf("expected context.Canceled, got %v", stream.Err()) - } - select { - case <-requestDone: - case <-time.After(2 * time.Second): - t.Fatal("provider request stayed open after backpressure cancellation") - } -} - -// ── SSE builders ─────────────────────────────────────────────────────────────── - -func buildTextDeltaSSE() string { - chunk1 := `{"id":"1","model":"gpt-4o","choices":[{"delta":{"role":"assistant","content":"Hello"}}]}` - chunk2 := `{"id":"1","model":"gpt-4o","choices":[{"delta":{"content":" world"}}]}` - chunk3 := `{"id":"1","model":"gpt-4o","choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}` - var sb strings.Builder - sb.WriteString("data: " + chunk1 + "\n\n") - sb.WriteString("data: " + chunk2 + "\n\n") - sb.WriteString("data: " + chunk3 + "\n\n") - sb.WriteString("data: [DONE]\n\n") - return sb.String() -} - -func buildToolCallSSE() string { - chunk1 := `{"id":"1","model":"gpt-4o","choices":[{"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_xyz","type":"function","function":{"name":"get_weather","arguments":""}}]}}]}` - chunk2 := `{"id":"1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"location\":\"Tokyo\"}"}}]}}]}` - chunk3 := `{"id":"1","model":"gpt-4o","choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}` - var sb strings.Builder - sb.WriteString("data: " + chunk1 + "\n\n") - sb.WriteString("data: " + chunk2 + "\n\n") - sb.WriteString("data: " + chunk3 + "\n\n") - sb.WriteString("data: [DONE]\n\n") - return sb.String() -} - -// ── Tool-call full round-trip ───────────────────────────────────────────────── - -// TestE2E_ToolCallFullRoundTrip: the model requests a tool call, the client sends the tool result -// back as a multi-part tool message, and the model produces a final answer. -// This verifies ModelMessage multi-part content reaches the provider correctly. -func TestE2E_ToolCallFullRoundTrip(t *testing.T) { - srv := newMockServer() - defer srv.Close() - - // Queue: first call returns a tool_call, second call returns the final text. - srv.setResponses(toolCallOpenAIResponse, plainOpenAIResponse) - - m := chatModel(t, srv.URL) - defer m.Close() - - // 1. First call: model requests get_weather tool call. - result1, err := m.GenerateText(`"What is the weather in Tokyo?"`, weatherToolsOpts("")) - if err != nil { - t.Fatalf("first generate failed: %v", err) - } - parsed1, _ := ParseGenerateTextResult(result1) - if len(parsed1.ToolCalls) == 0 { - t.Fatal("expected tool calls in first response") - } - call := parsed1.ToolCalls[0] - if call.ToolName != "get_weather" { - t.Fatalf("expected tool name 'get_weather', got %q", call.ToolName) - } - - // 2. Build a multi-part tool-result message using ModelMessage's Content - // (any) field. - toolResultMsg := ModelMessage{ - Role: RoleTool, - Content: []map[string]any{ - { - "type": "tool-result", - "toolCallId": call.ToolCallID, - "toolName": call.ToolName, - "output": map[string]any{"type": "json", "value": map[string]any{"temperature": "20C"}}, - }, - }, - } - conversation := []ModelMessage{ - NewTextMessage(RoleUser, "What is the weather in Tokyo?"), - {Role: RoleAssistant, Content: []map[string]any{ - {"type": "tool-call", "toolCallId": call.ToolCallID, "toolName": call.ToolName, "input": map[string]any{"location": "Tokyo"}}, - }}, - toolResultMsg, - } - prompt, _ := MarshalMessages(conversation) - - // 3. Second call: model produces final text from the tool result. - result2, err := m.GenerateText(prompt, "") - if err != nil { - t.Fatalf("second generate failed: %v", err) - } - parsed2, _ := ParseGenerateTextResult(result2) - if parsed2.Text == "" { - t.Error("expected non-empty final text") - } - - // Verify the second request carried the tool-result message correctly. - // The engine converts a tool-result part into OpenAI's wire - // format: {"role":"tool","tool_call_id":"...","content":""}. - reqBody := srv.LastRequestBody() - var body struct { - Messages []struct { - Role string `json:"role"` - Content json.RawMessage `json:"content"` - ToolCallID string `json:"tool_call_id,omitempty"` - ToolCalls []struct { - ID string `json:"id"` - Function struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` - } `json:"tool_calls"` - } `json:"messages"` - } - if err := json.Unmarshal([]byte(reqBody), &body); err != nil { - t.Fatalf("decode request: %v", err) - } - if len(body.Messages) != 3 { - t.Fatalf("expected 3 messages, got %d", len(body.Messages)) - } - second := body.Messages[1] - if second.Role != "assistant" || len(second.ToolCalls) != 1 { - t.Fatalf("assistant tool call did not round-trip: %#v", second) - } - priorCall := second.ToolCalls[0] - if priorCall.ID != call.ToolCallID || priorCall.Function.Name != call.ToolName { - t.Errorf("assistant tool call identity did not round-trip: %#v", priorCall) - } - var arguments map[string]any - if err := json.Unmarshal([]byte(priorCall.Function.Arguments), &arguments); err != nil || arguments["location"] != "Tokyo" { - t.Errorf("assistant arguments did not round-trip: %q (%v)", priorCall.Function.Arguments, err) - } - // The third message (tool result) in OpenAI format. - third := body.Messages[2] - if third.Role != "tool" { - t.Errorf("expected third role 'tool', got %q", third.Role) - } - if third.ToolCallID != call.ToolCallID { - t.Errorf("expected tool_call_id %q, got %q", call.ToolCallID, third.ToolCallID) - } - var content string - if err := json.Unmarshal(third.Content, &content); err != nil { - t.Fatalf("decode tool content: %v", err) - } - var output map[string]any - if err := json.Unmarshal([]byte(content), &output); err != nil || output["temperature"] != "20C" { - t.Errorf("tool output did not round-trip: %q (%v)", content, err) - } -} - -func TestE2E_FilePartsAreTheAiSdkFilePart(t *testing.T) { - // FilePart.data is FileData | DataContent | URL | ProviderReference upstream - // (provider-utils types/content-part.ts:75): a data: URL's media type wins - // (ai prompt/file-part-data.ts:31-41) and a bare reference is a reference - // (:133-138). There is no file-base64 / file-url / file-reference part. - srv := newMockServer() - defer srv.Close() - srv.SetResponse(plainOpenAIResponse) - m := chatModel(t, srv.URL) - defer m.Close() - - prompt := `[{"role":"user","content":[ - {"type":"file","data":{"type":"url","url":"https://example.com/a.png"},"mediaType":"image/png"}, - {"type":"file","data":"data:image/png;base64,iVBORw==","mediaType":"image"}, - {"type":"file","data":{"deepseek":"file-abc"},"mediaType":"image/png"}]}]` - if _, err := m.GenerateText(prompt, ""); err != nil { - t.Fatalf("generate_text failed: %v", err) - } - var request struct { - Messages []struct { - Content []map[string]any `json:"content"` - } `json:"messages"` - } - if err := json.Unmarshal([]byte(srv.LastRequestBody()), &request); err != nil { - t.Fatalf("request body: %v", err) - } - content := request.Messages[0].Content - if got := content[0]["image_url"].(map[string]any)["url"]; got != "https://example.com/a.png" { - t.Errorf("url file part: %v", content[0]) - } - if got := content[1]["image_url"].(map[string]any)["url"]; got != "data:image/png;base64,iVBORw==" { - t.Errorf("data URL file part: %v", content[1]) - } - if content[2]["type"] != "file" || content[2]["file_id"] != "file-abc" { - t.Errorf("bare reference file part: %v", content[2]) - } - - for _, removed := range []string{"file-base64", "file-url", "file-reference"} { - part := `{"type":"` + removed + `","data":"aGk=","url":"https://example.com/a.png","mediaType":"image/png"}` - if _, err := m.GenerateText(`[{"role":"user","content":[`+part+`]}]`, ""); err == nil { - t.Errorf("%s was accepted", removed) - } - } -} - -func TestE2E_GeneratedFilesAreBase64AndMediaType(t *testing.T) { - // A generated file is GeneratedFile upstream, whose JSON is - // {base64, mediaType} (ai generate-text/generated-file.ts:10-32); the - // content part wraps it as {type: "file", file} - // (generate-text/convert-language-model-content.ts:55-70). - srv := newMockServer() - defer srv.Close() - srv.SetResponse(`{"candidates":[{"content":{"role":"model","parts":[ - {"text":"Here:"},{"inlineData":{"mimeType":"image/png","data":"iVBORw=="}}]}, - "finishReason":"STOP"}], - "usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":2,"totalTokenCount":5}}`) - m, err := NewVertexWithBase("token", "project", "us-central1", "gemini-2.0-flash", srv.URL) - if err != nil { - t.Fatalf("NewVertexWithBase: %v", err) - } - defer m.Close() - - out, err := m.GenerateText(`"draw"`, "") - if err != nil { - t.Fatalf("generate_text failed: %v", err) - } - var wire struct { - Files []map[string]any `json:"files"` - Content []map[string]any `json:"content"` - } - if err := json.Unmarshal([]byte(out), &wire); err != nil { - t.Fatalf("result: %v", err) - } - want := map[string]any{"base64": "iVBORw==", "mediaType": "image/png"} - if len(wire.Files) != 1 || !reflect.DeepEqual(wire.Files[0], want) { - t.Fatalf("files: %v", wire.Files) - } - if !reflect.DeepEqual(wire.Content[1], map[string]any{"type": "file", "file": want}) { - t.Errorf("file content part: %v", wire.Content[1]) - } - - var typed GenerateTextResult - if err := json.Unmarshal([]byte(out), &typed); err != nil { - t.Fatalf("typed result: %v", err) - } - if bytes, err := typed.Files[0].Bytes(); err != nil || typed.Files[0].MediaType != "image/png" || - !reflect.DeepEqual(bytes, []byte{0x89, 'P', 'N', 'G'}) { - t.Errorf("typed file: %+v %v %v", typed.Files[0], bytes, err) - } -} - -func TestE2E_ResponseTimestampIsToISOString(t *testing.T) { - // The response timestamp is a Date (provider - // language-model-v4-response-metadata.ts:10), built as - // new Date(created * 1000); its JSON is toISOString(). - srv := newMockServer() - defer srv.Close() - srv.SetResponse(strings.Replace(plainOpenAIResponse, `"id": "chatcmpl-test",`, `"id": "chatcmpl-test", "created": 1741269019,`, 1)) - m := chatModel(t, srv.URL) - defer m.Close() - - out, err := m.GenerateText(`"What is Rust?"`, "") - if err != nil { - t.Fatalf("generate_text failed: %v", err) - } - parsed, err := ParseGenerateTextResult(out) - if err != nil { - t.Fatalf("parse failed: %v", err) - } - if parsed.Response.Timestamp != "2025-03-06T13:50:19.000Z" { - t.Fatalf("timestamp: %v", parsed.Response.Timestamp) - } - if _, err := time.Parse(time.RFC3339, parsed.Response.Timestamp); err != nil { - t.Errorf("not RFC 3339: %v", err) - } -} diff --git a/bindings/go/error.go b/bindings/go/error.go index c0c322aa1..a483366e8 100644 --- a/bindings/go/error.go +++ b/bindings/go/error.go @@ -4,432 +4,58 @@ import ( "encoding/json" "errors" "fmt" - "strconv" - "strings" - "unicode/utf8" ) -// Code is the machine-readable Aimux error code. Values match -// aimux-ffi aimux_error_code_t (1..3, 5..20; 4 is retired). Retry is 14; -// tool-call errors occupy 15..17; NoOutputGenerated is 18; a missing API key / -// provider setting are 19 / 20. A code outside that set is a header/library -// mismatch and expectAimuxError panics rather than inventing an "unknown" -// variant. Recording failures are -// a different type: see RecordingError. -// -// Every HTTP-shaped failure arrives as CodeAPICall, classified by Status -// (401 auth, 404 model, 429 rate limit; Status -1 = no HTTP response was ever -// observed — a missing API key, an error built without a request, or a -// transport failure — which says nothing about whether a retry would help). -type Code int - -const ( - CodeOK Code = 0 - CodeOther Code = 1 - CodeJSONParse Code = 2 - CodeInvalidResponseData Code = 3 - // 4 is retired (the legacy Tool variant). - CodeInvalidArgument Code = 5 - CodeInvalidPrompt Code = 6 - CodeTokenExpired Code = 7 - CodeUnsupportedFunctionality Code = 8 - CodeNoSuchModel Code = 9 - CodeNoSuchProvider Code = 10 - CodeAPICall Code = 11 - CodeTimeout Code = 12 - CodeAborted Code = 13 - CodeRetry Code = 14 - CodeNoSuchTool Code = 15 - CodeInvalidToolInput Code = 16 - CodeToolCallRepair Code = 17 - CodeNoOutputGenerated Code = 18 - // CodeLoadAPIKey: no API key was passed and the fallback environment - // variable is unset (the AI SDK's LoadAPIKeyError); no request was made. - CodeLoadAPIKey Code = 19 - // CodeLoadSetting: a required provider setting was not passed and its - // fallback environment variable is unset (LoadSettingError). - CodeLoadSetting Code = 20 +var ( + ErrClosed = errors.New("aimux: use of closed handle") ) -// String returns the core error_type name (e.g. "ApiCall", "TokenExpired"). -// Unmapped codes render as "Code(n)". -func (c Code) String() string { - switch c { - case CodeOK: - return "OK" - case CodeJSONParse: - return "JsonParse" - case CodeInvalidResponseData: - return "InvalidResponseData" - case CodeInvalidArgument: - return "InvalidArgument" - case CodeInvalidPrompt: - return "InvalidPrompt" - case CodeTokenExpired: - return "TokenExpired" - case CodeUnsupportedFunctionality: - return "UnsupportedFunctionality" - case CodeNoSuchModel: - return "NoSuchModel" - case CodeNoSuchProvider: - return "NoSuchProvider" - case CodeAPICall: - return "ApiCall" - case CodeTimeout: - return "Timeout" - case CodeAborted: - return "Aborted" - case CodeNoSuchTool: - return "NoSuchTool" - case CodeInvalidToolInput: - return "InvalidToolInput" - case CodeNoOutputGenerated: - return "NoOutputGenerated" - case CodeToolCallRepair: - return "ToolCallRepair" - case CodeLoadAPIKey: - return "LoadApiKey" - case CodeLoadSetting: - return "LoadSetting" - case CodeOther: - return "Other" - case CodeRetry: - return "Retry" - default: - return fmt.Sprintf("Code(%d)", int(c)) - } +// ErrorEnvelope is the canonical envelope. Error preserves the complete +// AI SDK payload, including nested errors, provider data and isRetryable. +type ErrorEnvelope struct { + Kind string `json:"kind"` + Code int32 `json:"code"` + Message string `json:"message"` + Error json.RawMessage `json:"error"` + RetryAfterMs *uint64 `json:"retryAfterMs,omitempty"` } -// codeFromC maps a C aimux_error_code_t (1..3, 5..20); false for any other -// value, including retired code 4. -func codeFromC(code int) (Code, bool) { - if code >= int(CodeOther) && code <= int(CodeLoadSetting) && code != 4 { - return Code(code), true - } - return 0, false -} - -// Error is the structured aimux failure type for Go (openai-go style: one -// struct implementing error, not a class tree). -// -// Transport: returned as the error value of (T, error). -// Inspection: -// -// var e *aimux.Error -// if errors.As(err, &e) { -// // e.Code, e.Status, e.RetryMs, e.Retryable, e.Message -// // e.Code == aimux.CodeAPICall && e.Status == 429 → rate limited -// // e.Code == aimux.CodeRetry → e.Reason, e.Errors, e.LastError() -// } -// -// Fields mirror the aimux-ffi aimux_error_* getters / core helpers: -// - Code: error code (ApiCall, TokenExpired, …) -// - Status: HTTP status, or -1 — the classification for CodeAPICall -// (401 auth, 404 model, 429 rate limit) -// - RetryMs: rate-limit hint, or -1 (0 = retry now) -// - Retryable: the core's retry verdict, carried across the ABI -// - Message: human-readable text -// - ProviderCode / ProviderMessage / ResponseBody / URL / -// RequestBodyValues / ResponseHeaders / Data: CodeAPICall payload -// - ModelID / ModelType: CodeNoSuchModel payload -// - ProviderID: CodeNoSuchProvider payload -// - Reason / Errors: CodeRetry payload — why retrying stopped, and the -// per-attempt history -// - ToolName / AvailableTools: CodeNoSuchTool payload (ToolName is shared -// with CodeInvalidToolInput) -// - ToolInput: CodeInvalidToolInput payload -// - OriginalError: CodeToolCallRepair payload -// - EnvVar: CodeLoadAPIKey / CodeLoadSetting payload — the environment -// variable that was consulted +// Error supports errors.As across all four domains. RawEnvelope retains all +// fields, including fields unknown to this version of the Go binding. type Error struct { - Code Code - Message string - Status int // HTTP status or -1 - RetryMs int64 // retry hint or -1; 0 = retry immediately - // Retryable is the core's verdict on whether retrying may help. It - // rides the ABI as its own field and is never derived from Status: a - // transport failure (request went out, connection reset) is retryable - // and a missing API key (request never went out) is not, yet both - // report Status -1. - Retryable bool - // Per-code payload, mirroring aimux-core's AiMuxError variants. Each - // field is set only under the Code named here and empty otherwise. - ProviderCode string // CodeAPICall: provider's own error code, e.g. "insufficient_quota" - ProviderMessage string // CodeAPICall: the failure's own text without the composed prefix Message carries, e.g. "slow down" - ResponseBody string // CodeAPICall: raw response body - URL string // CodeAPICall: sanitized request URL - // RequestBodyValues is the sanitized request body of the failed call, - // decoded from JSON (any JSON type). CodeAPICall only. - RequestBodyValues any - // ResponseHeaders is the sanitized response headers of the failed call. - // Provider request-id evidence, when present, is a header here — there - // is no separate request-id field. CodeAPICall only. - ResponseHeaders map[string]string - // Data is the provider's parsed error data as raw JSON, the AI SDK's - // `APICallError.data` (`unknown`): nil when the error carries none, and - // the JSON `null` when the data was null. CodeAPICall only. - Data json.RawMessage - ModelID string // CodeNoSuchModel: the model id asked for - ModelType string // CodeNoSuchModel: the model type it was asked for as - ProviderID string // CodeNoSuchProvider: the provider id asked for - // Reason says why retrying stopped; Errors preserves each attempt as a - // concrete *Error (oldest first; an attempt can itself be any Code, - // with the full per-code payload). CodeRetry only. - Reason RetryErrorReason - Errors []*Error - ToolName string // CodeNoSuchTool / CodeInvalidToolInput: tool name - AvailableTools []string - ToolInput string // CodeInvalidToolInput: raw argument text - OriginalError json.RawMessage // CodeToolCallRepair: unrepaired error - EnvVar string // CodeLoadAPIKey / CodeLoadSetting: the environment variable consulted, e.g. "OPENAI_API_KEY" + Envelope ErrorEnvelope + RawEnvelope json.RawMessage } -// RetryErrorReason explains why operation retry stopped. Values are the -// core's serde camelCase wire names, as aimux_error_retry_reason returns them. -type RetryErrorReason string - -const ( - // RetryMaxRetriesExceeded: every permitted attempt failed with a - // retryable error. - RetryMaxRetriesExceeded RetryErrorReason = "maxRetriesExceeded" - // RetryErrorNotRetryable: an attempt failed with a non-retryable error. - RetryErrorNotRetryable RetryErrorReason = "errorNotRetryable" -) - -// LastError returns the final attempt error, or nil for a non-retry error. -func (e *Error) LastError() *Error { - if e == nil || len(e.Errors) == 0 { - return nil - } - return e.Errors[len(e.Errors)-1] -} - -// Error implements the error interface. func (e *Error) Error() string { - if e == nil { - return "aimux: " - } - if e.Message != "" { - return e.Message - } - return fmt.Sprintf("aimux: %s", e.Code.String()) + return fmt.Sprintf("aimux %s (%d): %s", e.Envelope.Kind, e.Envelope.Code, e.Envelope.Message) } -// defaultStatus fills the status TokenExpired carries by contract (401) when -// the C ABI reports -1. CodeAPICall statuses are observed, never invented: a -// missing status there means no HTTP response was ever observed (a missing API -// key, an error built without a request, or a transport failure), so it stays -// -1. -func defaultStatus(code Code, status int) int { - if status == -1 && code == CodeTokenExpired { - return 401 +func decodeError(data []byte) (*Error, error) { + if err := validateJSON("error envelope", data); err != nil { + return nil, err } - return status -} - -// RecordingErrorCode mirrors aimux-core recording::RecordingError variants. -// Go keeps compact values 1..6; the C ABI transports them as 100..105. Only WriterGone, -// FlushTimeout and Write are reachable from RecordingTryFlush. -type RecordingErrorCode int - -const ( - RecordingErrorInit RecordingErrorCode = 1 - RecordingErrorOpenFile RecordingErrorCode = 2 - RecordingErrorSpawn RecordingErrorCode = 3 - RecordingErrorWriterGone RecordingErrorCode = 4 - RecordingErrorFlushTimeout RecordingErrorCode = 5 - RecordingErrorWrite RecordingErrorCode = 6 -) - -// recordingErrorCodeFromC maps C codes 100..105 to Go values 1..6; false -// for any other value. -func recordingErrorCodeFromC(code int) (RecordingErrorCode, bool) { - const recordingCodeBase = 99 - if code < recordingCodeBase+int(RecordingErrorInit) || code > recordingCodeBase+int(RecordingErrorWrite) { - return 0, false + var envelope ErrorEnvelope + if err := json.Unmarshal(data, &envelope); err != nil { + return nil, err } - return RecordingErrorCode(code - recordingCodeBase), true -} - -// ffiCodeFromC reports whether code is one of the implementation failures -// detected by the C ABI. Go intentionally maps all of them to a plain error. -func ffiCodeFromC(code int) bool { - return code >= 200 && code <= 206 -} - -// String returns the Rust variant name; unmapped codes render as -// "RecordingErrorCode(n)". -func (c RecordingErrorCode) String() string { - switch c { - case RecordingErrorInit: - return "Init" - case RecordingErrorOpenFile: - return "OpenFile" - case RecordingErrorSpawn: - return "Spawn" - case RecordingErrorWriterGone: - return "WriterGone" - case RecordingErrorFlushTimeout: - return "FlushTimeout" - case RecordingErrorWrite: - return "Write" + switch envelope.Kind { + case "aimux", "recording", "binding", "protocol": default: - return fmt.Sprintf("RecordingErrorCode(%d)", int(c)) - } -} - -// RecordingError is a recording (RFC-0023) failure, returned by -// RecordingTryFlush. It is independent of *Error — the two share only the -// error interface, as Rust's RecordingError and AiMuxError share only -// std::error::Error. -type RecordingError struct { - Code RecordingErrorCode - Message string -} - -// Error implements the error interface. -func (e *RecordingError) Error() string { - if e == nil { - return "aimux recording: " - } - if e.Message != "" { - return e.Message - } - return fmt.Sprintf("aimux recording: %s", e.Code.String()) -} - -// ErrClosed is returned (wrapped, so test with errors.Is) when a Model, -// ProviderHandle, multimodal model or transcription session is used after -// Close. It is the binding's own guard, evaluated before any C call. -var ErrClosed = errors.New("aimux: use of closed handle") - -// ErrNotTraced is returned (test with errors.Is) when a trace query runs on a -// model that never went through Trace/TraceAudited. The trace store is keyed -// on the wrapper handle, so a plain model simply has none — the same class of -// misuse as ErrClosed, and guarded in Go before any C call. -var ErrNotTraced = errors.New("aimux: model is not traced; call Trace() first") - -// checkUTF8 validates string parameters (param, value pairs) that cross to C. -// A Go string is an arbitrary byte sequence and C.CString passes it through -// verbatim, but the Rust side requires UTF-8 and stops at the first NUL — both -// would come back as a C ABI failure code, or (for the NUL) as a -// silently truncated argument. Cheaper to name the parameter here. -func checkUTF8(pairs ...string) error { - for i := 0; i+1 < len(pairs); i += 2 { - v := pairs[i+1] - if !utf8.ValidString(v) { - return fmt.Errorf("aimux: %s: must be valid UTF-8", pairs[i]) - } - if strings.IndexByte(v, 0) >= 0 { - return fmt.Errorf("aimux: %s: must not contain NUL", pairs[i]) - } - } - return nil -} - -// checkJSON validates optional raw JSON string parameters (param, value -// pairs) before they cross the C ABI, so malformed opts/config JSON never -// reaches aimux-ffi. "" means "default" and is skipped. -func checkJSON(pairs ...string) error { - if err := checkUTF8(pairs...); err != nil { - return err - } - for i := 0; i+1 < len(pairs); i += 2 { - v := pairs[i+1] - if v == "" { - continue - } - if !json.Valid([]byte(v)) { - return fmt.Errorf("aimux: %s: invalid JSON", pairs[i]) - } - if err := checkSurrogates(pairs[i], v); err != nil { - return err - } - } - return nil -} - -// checkSurrogates rejects the \uXXXX escapes serde_json rejects but -// encoding/json accepts: an unpaired surrogate half (json.Valid passes it, -// then serde fails in C with a C ABI failure code). Runs after json.Valid, so -// every backslash here is a well-formed escape inside a string. -func checkSurrogates(param, s string) error { - for i := 0; i+1 < len(s); { - if s[i] != '\\' { - i++ - continue - } - if s[i+1] != 'u' { - i += 2 // \" \\ \/ \b \f \n \r \t — skip the escaped byte - continue - } - hi, ok := hex4(s, i) - if !ok { - i += 2 - continue - } - i += 6 - switch { - case hi >= 0xDC00 && hi <= 0xDFFF: - return fmt.Errorf("aimux: %s: invalid JSON: lone low surrogate \\u%04X", param, hi) - case hi >= 0xD800 && hi <= 0xDBFF: - lo, ok := hex4(s, i) - if !ok || lo < 0xDC00 || lo > 0xDFFF { - return fmt.Errorf("aimux: %s: invalid JSON: unpaired high surrogate \\u%04X", param, hi) - } - i += 6 - } + return nil, fmt.Errorf("aimux: invalid error domain %q", envelope.Kind) } - return nil -} - -// hex4 reads the code point of a \uXXXX escape starting at s[i]; false when -// s[i:] is not such an escape. -func hex4(s string, i int) (int, bool) { - if i+6 > len(s) || s[i] != '\\' || s[i+1] != 'u' { - return 0, false + if envelope.Code == 0 || len(envelope.Error) == 0 || string(envelope.Error) == "null" { + return nil, errors.New("aimux: incomplete error envelope") } - n, err := strconv.ParseUint(s[i+2:i+6], 16, 32) - if err != nil { - return 0, false - } - return int(n), true + return &Error{Envelope: envelope, RawEnvelope: append(json.RawMessage(nil), data...)}, nil } -// marshalJSON serializes v for a C string parameter and validates the result. -// -// json.Marshal is NOT self-validating here. It coerces invalid UTF-8 in a Go -// *string* to U+FFFD and escapes NUL, but a json.RawMessage field (or any -// MarshalJSON, e.g. jsonObj) is passed through compact(), which checks JSON -// *syntax* only. So raw non-UTF-8 bytes and lone \uD800–\uDFFF escapes survive -// into the output — the first fails CStr::to_str on the Rust side, the second -// fails serde_json (LoneLeadingSurrogateInHexEscape). Every option struct here -// has such a field (ProviderOptions, BodyOverrides, Audio, Documents, …), so -// the marshalled bytes get the same checkJSON the raw-string entry points get. -func marshalJSON(param string, v any) (string, error) { - b, err := json.Marshal(v) - if err != nil { - return "", fmt.Errorf("aimux: failed to marshal %s: %w", param, err) - } - if err := checkJSON(param, string(b)); err != nil { - return "", err - } - return string(b), nil -} - -// requireJSON is checkJSON for a mandatory parameter: "" is rejected too. -func requireJSON(param, v string) error { - if v == "" { - return fmt.Errorf("aimux: %s: invalid JSON: empty", param) - } - return checkJSON(param, v) -} - -// checkPromptOpts validates the (prompt_json, opts_json) pair every generate / -// stream entry point takes: the prompt is required, the opts default on "". -func checkPromptOpts(promptJson, optsJson string) error { - if err := requireJSON("prompt_json", promptJson); err != nil { - return err - } - return checkJSON("opts_json", optsJson) +func localError(kind string, code int32, name, message string) *Error { + payload, _ := json.Marshal(struct { + Name string `json:"name"` + Message string `json:"message"` + }{name, message}) + envelope := ErrorEnvelope{Kind: kind, Code: code, Message: message, Error: payload} + raw, _ := json.Marshal(envelope) + return &Error{Envelope: envelope, RawEnvelope: raw} } diff --git a/bindings/go/error_test.go b/bindings/go/error_test.go deleted file mode 100644 index 5c8707042..000000000 --- a/bindings/go/error_test.go +++ /dev/null @@ -1,572 +0,0 @@ -package aimux - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -// A 429 arrives as CodeAPICall; the classification is Status, and the hint -// rides along in RetryMs. -func TestErrorAs(t *testing.T) { - inner := &Error{ - Code: CodeAPICall, - Message: "API call error: HTTP 429: slow down", - Status: 429, - RetryMs: 1000, - } - wrapped := fmt.Errorf("call failed: %w", inner) - - var e *Error - if !errors.As(wrapped, &e) { - t.Fatal("errors.As(*Error) failed") - } - if e.Code != CodeAPICall { - t.Fatalf("Code: got %v", e.Code) - } - if e.Status != 429 || e.RetryMs != 1000 { - t.Fatalf("Status/RetryMs: got %d / %d", e.Status, e.RetryMs) - } - if e.Code.String() != "ApiCall" { - t.Fatalf("Code.String: %s", e.Code.String()) - } -} - -// Auth (401) and model-not-found (404) share CodeAPICall and are distinguished -// apart by Status alone. -func TestAPICallClassification(t *testing.T) { - for _, c := range []struct { - status int - msg string - }{ - {401, "API call error: HTTP 401: invalid api key"}, - {404, "API call error: HTTP 404: model not found"}, - {-1, "API call error: connection reset"}, // transport: no response - } { - e := &Error{Code: CodeAPICall, Message: c.msg, Status: c.status, RetryMs: -1} - if got := defaultStatus(e.Code, e.Status); got != c.status { - t.Errorf("defaultStatus(ApiCall, %d) = %d; want %d", c.status, got, c.status) - } - if e.Error() != c.msg { - t.Errorf("Error() = %q; want %q", e.Error(), c.msg) - } - } -} - -// An AiMuxError carries its per-code payload in the matching field. -func TestPayloadEngineFailure(t *testing.T) { - _, err := Provider("no-such-provider", "sk-test-fake-key", "some-model") - if err == nil { - t.Fatal("expected error for unknown provider") - } - var e *Error - if !errors.As(err, &e) { - t.Fatalf("expected *Error, got %T: %v", err, err) - } - if e.Code != CodeNoSuchProvider { - t.Fatalf("Code: got %v, want NoSuchProvider", e.Code) - } - if e.ProviderID != "no-such-provider" { - t.Fatalf("ProviderID: got %q", e.ProviderID) - } - if e.ProviderCode != "" || e.ProviderMessage != "" || e.ResponseBody != "" || e.URL != "" || e.ModelID != "" || e.ModelType != "" { - t.Fatalf("payload fields of other codes must be empty: %+v", e) - } - if e.Reason != "" || len(e.Errors) != 0 || e.LastError() != nil { - t.Fatalf("retry payload of other codes must be empty: %+v", e) - } -} - -// Use-after-close is the binding's own guard: an ErrClosed-wrapped error, -// never an *Error. -func TestUseAfterCloseIsErrClosed(t *testing.T) { - m := OpenAI("sk-test-fake-key", "gpt-4o-mini") - m.Close() - - _, err := m.GenerateText(`"hello"`, "") - if err == nil { - t.Fatal("expected error after close") - } - if !errors.Is(err, ErrClosed) { - t.Fatalf("expected ErrClosed, got %T: %v", err, err) - } - var e *Error - if errors.As(err, &e) { - t.Fatal("use-after-close must not be an *Error") - } -} - -// TestDefaultStatus: TokenExpired is a 401 by contract; every other status is -// the observed one. Nothing is invented for ApiCall — a missing status there -// means no response arrived. A concrete status is never overwritten. -func TestDefaultStatus(t *testing.T) { - cases := []struct { - code Code - in, want int - }{ - {CodeTokenExpired, -1, 401}, - {CodeTokenExpired, 401, 401}, - // Observed ApiCall statuses pass through untouched. - {CodeAPICall, 429, 429}, - {CodeAPICall, 401, 401}, - {CodeAPICall, 404, 404}, - {CodeAPICall, 503, 503}, - // Transport failure: no response, so no status is fabricated. - {CodeAPICall, -1, -1}, - // Non-HTTP codes keep -1. - {CodeTimeout, -1, -1}, - {CodeAborted, -1, -1}, - {CodeInvalidArgument, -1, -1}, - } - for _, c := range cases { - got := defaultStatus(c.code, c.in) - if got != c.want { - t.Errorf("defaultStatus(%s, %d) = %d; want %d", c.code, c.in, got, c.want) - } - } -} - -// Retryable crosses the ABI as its own field. Two ApiCall failures both report -// Status -1 and disagree about retrying, so Status cannot stand in for it. -func TestRetryableIsNotDerivedFromStatus(t *testing.T) { - transport := &Error{ - Code: CodeAPICall, - Message: "API call error: connection reset", - Status: -1, - RetryMs: -1, - Retryable: true, // request went out - } - missingKey := &Error{ - Code: CodeAPICall, - Message: "API call error: missing api key", - Status: -1, - RetryMs: -1, - } // request never went out; Retryable stays false - - if transport.Status != -1 || missingKey.Status != -1 { - t.Fatalf("both must carry Status -1: got %d / %d", transport.Status, missingKey.Status) - } - if !transport.Retryable || missingKey.Retryable { - t.Fatalf("Retryable: transport=%v missingKey=%v; want true / false", - transport.Retryable, missingKey.Retryable) - } -} - -// Malformed raw JSON is caught by the binding before the C call: a plain -// error naming the parameter, not an *Error. -func TestInvalidJSONNamesParameter(t *testing.T) { - err := InitProxy("{not json") - if err == nil { - t.Fatal("expected error for malformed config") - } - if !strings.Contains(err.Error(), "config_json") { - t.Fatalf("message must name the parameter: %v", err) - } - var e *Error - if errors.As(err, &e) { - t.Fatalf("C ABI error must not be an *Error") - } - - m := OpenAI("sk-test-fake-key", "gpt-4o-mini") - defer m.Close() - _, err = m.GenerateText("{not json", "") - if err == nil || !strings.Contains(err.Error(), "prompt_json") { - t.Fatalf("expected prompt_json error, got %v", err) - } - if errors.As(err, &e) { - t.Fatalf("C ABI error must not be an *Error") - } -} - -// A malformed stream prompt surfaces via Stream.Err, and Close remains -// non-blocking because stream execution never owns a Go lifecycle lock. -func TestStreamTextInvalidJSONReleasesModel(t *testing.T) { - m := OpenAI("sk-test-fake-key", "gpt-4o-mini") - st := m.StreamText("{bad", "") - for range st.Parts() { - } - if err := st.Err(); err == nil || !strings.Contains(err.Error(), "prompt_json") { - t.Fatalf("expected prompt_json error, got %v", err) - } - closed := make(chan struct{}) - go func() { m.Close(); close(closed) }() - select { - case <-closed: - case <-time.After(5 * time.Second): - t.Fatal("Close hung during StreamText") - } -} - -// Required raw-JSON parameters reject "" before the C call; optional ones -// treat it as the default. -func TestRequiredJSONRejectsEmpty(t *testing.T) { - m := OpenAI("sk-test-fake-key", "gpt-4o-mini") - defer m.Close() - if _, err := m.GenerateText("", ""); err == nil || !strings.Contains(err.Error(), "prompt_json: invalid JSON: empty") { - t.Fatalf("expected empty prompt_json error, got %v", err) - } - if err := checkJSON("opts_json", ""); err != nil { - t.Fatalf("optional param must accept empty: %v", err) - } - if _, err := MockReplay("{\"ok\":1}\n{bad\n"); err == nil || !strings.Contains(err.Error(), "recordings_jsonl") { - t.Fatalf("expected recordings_jsonl error, got %v", err) - } -} - -// RecordingTryFlush: nil with nothing recording. InitRecording into a dir whose -// parent is a regular file fails at init with Code INIT (nothing installed), so -// a subsequent RecordingTryFlush is nil. -func TestRecordingTryFlush(t *testing.T) { - RecordingStop() - if err := RecordingTryFlush(); err != nil { - t.Fatalf("nothing recording: want nil, got %v", err) - } - - blocker := t.TempDir() - occupied := filepath.Join(blocker, "occupied") - if err := os.WriteFile(occupied, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - err := InitRecording(filepath.Join(occupied, "sub")) - defer RecordingStop() - - var re *RecordingError - if !errors.As(err, &re) { - t.Fatalf("want *RecordingError, got %T (%v)", err, err) - } - if re.Code != RecordingErrorInit { - t.Fatalf("Code = %v; want RecordingErrorInit", re.Code) - } - var e *Error - if errors.As(err, &e) { - t.Fatalf("recording error must not be an *Error") - } - if err := RecordingTryFlush(); err != nil { - t.Fatalf("failed init installs nothing: want nil, got %v", err) - } -} - -func TestRecordingErrorCodeFromCodeRejectsOutOfRange(t *testing.T) { - if _, ok := recordingErrorCodeFromC(999); ok { - t.Fatal("999 is not a Rust RecordingError variant") - } - if k, ok := recordingErrorCodeFromC(103); !ok || k != RecordingErrorWriterGone { - t.Fatalf("4 → %v, %v", k, ok) - } -} - -// The C ABI policy: input a caller can produce never -// crashes the process. Everything below used to reach C — where the failure -// has no public code — and panic. Each case must come back as a plain error: -// not an *Error, not a *RecordingError, and not ErrClosed unless the handle -// really is closed. -func TestFfiFailuresReturnErrorsNotPanics(t *testing.T) { - m := OpenAI("sk-test-fake-key", "gpt-4o-mini") - defer m.Close() - tr, err := m.Trace() - if err != nil { - t.Fatal(err) - } - defer tr.Close() - dead := OpenAI("sk-test-fake-key", "gpt-4o-mini") - dead.Close() - - badUTF8 := string([]byte{0xff, 0xfe}) - - for _, tc := range []struct { - name string - call func() error - want error // errors.Is target; nil = a plain error carrying no sentinel - msg string - }{ - {"non-utf8 session id", func() error { _, e := tr.TraceSessionChain(badUTF8); return e }, nil, "session_id: must be valid UTF-8"}, - {"non-utf8 prompt json", func() error { _, e := m.GenerateText(string([]byte{0x22, 0xff, 0x22}), ""); return e }, nil, "prompt_json: must be valid UTF-8"}, - {"non-utf8 model id", func() error { _, e := NewOpenAI("sk-test", badUTF8); return e }, nil, "model_id: must be valid UTF-8"}, - {"non-utf8 embedding api key", func() error { _, e := NewOpenAIEmbedding(badUTF8, "m"); return e }, nil, "api_key: must be valid UTF-8"}, - {"lone high surrogate prompt", func() error { _, e := m.GenerateText(`"\ud800"`, ""); return e }, nil, "prompt_json: invalid JSON"}, - {"lone low surrogate filter", func() error { _, e := tr.TraceAggregate(`{"provider":"\udc00"}`); return e }, nil, "filter_json: invalid JSON"}, - {"embedded NUL session id", func() error { _, e := tr.TraceSessionChain("a\x00b"); return e }, nil, "session_id: must not contain NUL"}, - {"embedded NUL base url", func() error { _, e := NewOpenAIWithBase("k", "m", "http://x\x00y"); return e }, nil, "base_url: must not contain NUL"}, - // State guard runs before argument validation: an untraced model says - // so even when the filter is also malformed. - {"untraced trace query", func() error { _, e := m.TraceAggregate("{bad"); return e }, ErrNotTraced, ""}, - {"dead handle", func() error { _, e := dead.GenerateText(`"hi"`, ""); return e }, ErrClosed, ""}, - {"dead handle trace export", func() error { _, e := dead.TraceExportJsonl(); return e }, ErrNotTraced, ""}, - } { - t.Run(tc.name, func(t *testing.T) { - var err error - var panicked any - func() { - defer func() { panicked = recover() }() - err = tc.call() - }() - if panicked != nil { - t.Fatalf("panicked instead of returning an error: %v", panicked) - } - if err == nil { - t.Fatal("expected an error") - } - if tc.msg != "" && !strings.Contains(err.Error(), tc.msg) { - t.Errorf("message = %q; want it to contain %q", err, tc.msg) - } - if tc.want != nil { - if !errors.Is(err, tc.want) { - t.Fatalf("errors.Is(%v, %v) = false", err, tc.want) - } - return - } - var ae *Error - if errors.As(err, &ae) { - t.Errorf("C ABI failure must not be an *Error: %v", err) - } - var re *RecordingError - if errors.As(err, &re) { - t.Errorf("C ABI failure must not be a *RecordingError: %v", err) - } - if errors.Is(err, ErrClosed) { - t.Errorf("C ABI failure must not be ErrClosed: %v", err) - } - }) - } -} - -// json.Valid accepts unpaired \uXXXX surrogate halves; serde_json rejects them -// as a syntax error in C. checkJSON has to reject what serde does. -func TestCheckJSONSurrogatePairing(t *testing.T) { - for _, tc := range []struct { - json string - ok bool - }{ - {`{"a":"\ud800"}`, false}, // lone high - {`{"a":"\udc00"}`, false}, // lone low - {`{"a":"\ud800\ud800"}`, false}, // high followed by high - {`{"a":"\ud83d\ude00"}`, true}, // well-formed pair - {`{"a":"\\ud800"}`, true}, // escaped backslash, not an escape - {`{"a":"A\ud83d\ude00B"}`, true}, - {`{"a":"plain"}`, true}, - } { - err := checkJSON("filter_json", tc.json) - if tc.ok && err != nil { - t.Errorf("%s: unexpected error %v", tc.json, err) - } - if !tc.ok { - if err == nil { - t.Errorf("%s: expected a surrogate error", tc.json) - } else if !strings.Contains(err.Error(), "filter_json: invalid JSON") { - t.Errorf("%s: message = %q", tc.json, err) - } - } - } -} - -// InitLogging has no error channel, so an unusable level must fall back to the -// default instead of taking the process down. The reachable call is -// InitLogging(os.Getenv("AIMUX_LOG_LEVEL")) — os.Getenv returns raw bytes. -func TestInitLoggingNeverPanicsOnBadLevel(t *testing.T) { - for _, level := range []string{"", "\xff", string([]byte{0xff, 0xfe}), "a\x00b", "warn", "nonsense"} { - func() { - defer func() { - if p := recover(); p != nil { - t.Errorf("InitLogging(%q) panicked: %v", level, p) - } - }() - InitLogging(level) - }() - } -} - -// json.Marshal does NOT sanitize everything it emits: it coerces invalid UTF-8 -// in a Go string to U+FFFD, but json.RawMessage / jsonObj fields go through -// compact(), which validates syntax only. So a lone surrogate escape or a raw -// non-UTF-8 byte planted in ProviderOptions survives marshalling and would -// reach serde_json / CStr::to_str. Every marshalled C argument is checked. -func TestMarshalledOptionsRejectRawPassThrough(t *testing.T) { - loneSurrogate := jsonObj(`{"a":"\ud800"}`) - rawNonUTF8 := jsonObj("{\"a\":\"\xff\"}") - - for _, tc := range []struct { - name string - call func() error - msg string - }{ - {"speech opts lone surrogate", func() error { - _, e := requiredOptsJSON("SpeechModel.Generate", &SpeechCallOptions{Text: "hi", ProviderOptions: loneSurrogate}) - return e - }, "opts: invalid JSON: unpaired high surrogate \\uD800"}, - {"speech opts raw non-utf8", func() error { - _, e := requiredOptsJSON("SpeechModel.Generate", &SpeechCallOptions{Text: "hi", ProviderOptions: rawNonUTF8}) - return e - }, "opts: must be valid UTF-8"}, - {"image opts lone surrogate", func() error { - _, e := requiredOptsJSON("ImageModel.Generate", &ImageCallOptions{ProviderOptions: loneSurrogate}) - return e - }, "opts: invalid JSON"}, - {"transcription session opts lone surrogate", func() error { - b, e := marshalJSON("opts", &TranscriptionSessionOpts{ - ProviderOptions: json.RawMessage(`{"x":"\ud800"}`), - }) - _ = b - return e - }, "opts: invalid JSON"}, - // The claim that holds: a plain Go string field really is sanitized — - // invalid UTF-8 becomes U+FFFD, NUL becomes a \u0000 escape. No error here. - {"plain string field is sanitized, not rejected", func() error { - s, e := marshalJSON("opts", &SpeechCallOptions{Text: "hi\xffthere\x00"}) - if e != nil { - return e - } - var decoded struct { - Text string `json:"text"` - } - if e := json.Unmarshal([]byte(s), &decoded); e != nil { - return e - } - if decoded.Text != "hi\ufffdthere\x00" || strings.ContainsRune(s, '\x00') { - t.Errorf("expected decoded U+FFFD / NUL without a raw NUL byte, got text %q from %s", decoded.Text, s) - } - return nil - }, ""}, - } { - t.Run(tc.name, func(t *testing.T) { - var err error - var panicked any - func() { - defer func() { panicked = recover() }() - err = tc.call() - }() - if panicked != nil { - t.Fatalf("panicked instead of returning an error: %v", panicked) - } - if tc.msg == "" { - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - return - } - if err == nil { - t.Fatal("expected an error") - } - if !strings.Contains(err.Error(), tc.msg) { - t.Errorf("message = %q; want it to contain %q", err, tc.msg) - } - }) - } -} - -// Embed marshals both arguments; the opts blob is the one with a raw field. -func TestEmbedRejectsRawPassThroughOpts(t *testing.T) { - m, err := NewOpenAIEmbedding("sk-test-fake-key", "text-embedding-3-small") - if err != nil { - t.Fatal(err) - } - defer m.Close() - - if _, err := m.Embed([]string{"hi"}, &EmbeddingCallOptions{ - ProviderOptions: jsonObj(`{"a":"\ud800"}`), - }); err == nil || !strings.Contains(err.Error(), "opts: invalid JSON") { - t.Fatalf("Embed with a lone surrogate in providerOptions: %v", err) - } -} - -func TestCodeFromCRejectsOutOfRange(t *testing.T) { - // 4 is retired; 18 is NoOutputGenerated; 19/20 load key/setting; 21 is invalid. - for _, bad := range []int{0, 4, 21, 999} { - if _, ok := codeFromC(bad); ok { - t.Fatalf("%d is not an AiMuxError variant", bad) - } - } - // 1 is the catch-all Other (it took the deleted UNKNOWN's slot). - if c, ok := codeFromC(1); !ok || c != CodeOther { - t.Fatalf("1 → %v, %v; want CodeOther", c, ok) - } - if c, ok := codeFromC(11); !ok || c != CodeAPICall { - t.Fatalf("11 → %v, %v", c, ok) - } - if c, ok := codeFromC(14); !ok || c != CodeRetry { - t.Fatalf("14 → %v, %v; want CodeRetry", c, ok) - } - if c, ok := codeFromC(18); !ok || c != CodeNoOutputGenerated || c.String() != "NoOutputGenerated" { - t.Fatalf("18 → %v, %v; want CodeNoOutputGenerated", c, ok) - } - if c, ok := codeFromC(19); !ok || c != CodeLoadAPIKey || c.String() != "LoadApiKey" { - t.Fatalf("19 → %v, %v; want CodeLoadAPIKey", c, ok) - } - if c, ok := codeFromC(20); !ok || c != CodeLoadSetting || c.String() != "LoadSetting" { - t.Fatalf("20 → %v, %v; want CodeLoadSetting", c, ok) - } -} - -// A CodeRetry error keeps the per-attempt history in order; LastError is the -// final attempt. The reason values are the core's serde camelCase wire names. -func TestRetryErrorHistory(t *testing.T) { - first := &Error{Code: CodeAPICall, Message: "API call error: HTTP 429: slow down", Status: 429, Retryable: true} - last := &Error{Code: CodeAPICall, Message: "API call error: HTTP 401: bad key", Status: 401} - e := &Error{ - Code: CodeRetry, - Message: "Failed after 2 attempts with non-retryable error: 'bad key'", - Status: -1, - RetryMs: -1, - Reason: RetryErrorNotRetryable, - Errors: []*Error{first, last}, - } - if e.Code.String() != "Retry" { - t.Fatalf("Code.String: %s", e.Code.String()) - } - if e.LastError() != last { - t.Fatalf("LastError: got %v", e.LastError()) - } - if e.Errors[0] != first || !e.Errors[0].Retryable { - t.Fatalf("attempt order lost: %+v", e.Errors) - } - if e.Reason != "errorNotRetryable" || RetryMaxRetriesExceeded != "maxRetriesExceeded" { - t.Fatalf("reason wire names changed: %q", e.Reason) - } - if c, ok := codeFromC(17); !ok || c != CodeToolCallRepair { - t.Fatalf("17 → %v, %v", c, ok) - } -} - -// APICallError.data is `unknown` in the AI SDK (@ai-sdk/provider -// errors/api-call-error.ts:18, :53): a null error body is data `null`, -// which is not the same as no data. Error.Data keeps the raw JSON, so the -// two stay apart (#232 C34). -func TestAPICallDataKeepsNullApartFromMissing(t *testing.T) { - for _, tc := range []struct { - name string - body string - want json.RawMessage - }{ - {"object", `{"error":{"message":"bad input"}}`, json.RawMessage(`{"error":{"message":"bad input"}}`)}, - {"null", `null`, json.RawMessage(`null`)}, - {"not json", `bad input`, nil}, - } { - t.Run(tc.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - _, _ = fmt.Fprint(w, tc.body) - })) - defer server.Close() - m, err := NewOpenAIWithBase("sk-test-fake-key", "gpt-4o-mini", server.URL) - if err != nil { - t.Fatal(err) - } - defer m.Close() - _, err = m.GenerateText(`"hi"`, `{"maxRetries":0}`) - var e *Error - if !errors.As(err, &e) || e.Code != CodeAPICall { - t.Fatalf("expected an API call error, got %T: %v", err, err) - } - if string(e.Data) != string(tc.want) || (e.Data == nil) != (tc.want == nil) { - t.Fatalf("Data = %q (nil %v); want %q (nil %v)", e.Data, e.Data == nil, tc.want, tc.want == nil) - } - }) - } -} diff --git a/bindings/go/examples/generate/main.go b/bindings/go/examples/generate/main.go index b1a3c404f..5c977a0bf 100644 --- a/bindings/go/examples/generate/main.go +++ b/bindings/go/examples/generate/main.go @@ -1,67 +1,36 @@ -// Example: typed text generation and multimodal with aimux. -// -// Prerequisites: -// cargo build -p aimux-ffi --release -// -// Run: -// cd bindings/go -// OPENAI_API_KEY=sk-... go run ./examples/generate - package main import ( - "encoding/json" + "context" "fmt" "log" "os" "github.com/arcships/aimux/bindings/go" + "github.com/arcships/aimux/bindings/go/generated" ) func main() { - apiKey := os.Getenv("OPENAI_API_KEY") - if apiKey == "" { - log.Fatal("OPENAI_API_KEY not set") - } - - // ── Typed text generation (no manual JSON) ──────────────────────────── - model := aimux.OpenAI(apiKey, "gpt-4o") - defer model.Close() - - result, err := model.Generate("Explain Rust ownership in one sentence.", nil) + ctx := context.Background() + provider, err := aimux.Create(ctx, nil, generated.OP_PROVIDER_CREATE, map[string]any{ + "package": "openai", "settings": map[string]any{"apiKey": os.Getenv("OPENAI_API_KEY")}, + }) if err != nil { - log.Fatalf("generate failed: %v", err) + log.Fatal(err) } - fmt.Println(result.Text) - - // ── Typed streaming ──────────────────────────────────────────────────── - fmt.Println("\n--- Streaming ---") - stream, err := model.Stream("Write a haiku about Rust.", nil) + defer provider.Close() + model, err := aimux.Create(ctx, provider, generated.OP_PROVIDER_MODEL, map[string]any{ + "method": "languageModel", "modelId": "gpt-4o-mini", + }) if err != nil { - log.Fatalf("stream failed: %v", err) + log.Fatal(err) } - for part := range stream.Parts() { - if part.Type == "text-delta" { - var td aimux.TextDeltaPayload - json.Unmarshal(part.Raw, &td) - fmt.Print(td.Text) - } - } - if err := stream.Err(); err != nil { - log.Fatalf("stream error: %v", err) - } - fmt.Println() - - // ── Multimodal: Embedding ────────────────────────────────────────────── - emb, err := aimux.NewOpenAIEmbedding(apiKey, "text-embedding-3-small") + defer model.Close() + result, err := aimux.CallInto[aimux.GenerateTextResult](ctx, model, generated.OP_TEXT_GENERATE, map[string]any{ + "prompt": "Say hello in one sentence.", + }) if err != nil { - log.Fatalf("embedding model failed: %v", err) + log.Fatal(err) } - defer emb.Close() - // emb.Embed([]string{"hello", "world"}, nil) — requires network - - // ── DeepSeek (OpenAI-compatible convenience) ────────────────────────── - deepseek := aimux.DeepSeek(os.Getenv("DEEPSEEK_API_KEY"), "deepseek-chat") - defer deepseek.Close() - // result, _ = deepseek.Generate("Hello", nil) — requires network + fmt.Println(result.Text) } diff --git a/bindings/go/generate.go b/bindings/go/generate.go index 0f3a185cf..b8f56ae63 100644 --- a/bindings/go/generate.go +++ b/bindings/go/generate.go @@ -4,7 +4,7 @@ // // go generate ./... // -// from your module to download the prebuilt archive for your platform from +// from your module to download the matching generated header and archive from // the GitHub Release assets (built by CI on every release). Requires network // access; pin a version with AIMUX_FFI_VERSION=v0.1.0. // diff --git a/bindings/go/generated/ops.go b/bindings/go/generated/ops.go new file mode 100644 index 000000000..4d22718a3 --- /dev/null +++ b/bindings/go/generated/ops.go @@ -0,0 +1,80 @@ +// Generated by aimux-codegen. Do not edit. Manifest SHA-256: 2bc7cb7ec77a0ed20496411f0dbd199b949b9887cc74d119e11627c8573d82a2 +package generated + +const ( + ABI_VERSION = 65536 + OPS_VERSION = "1.0" + MANIFEST_DIGEST = "2bc7cb7ec77a0ed20496411f0dbd199b949b9887cc74d119e11627c8573d82a2" + OP_ABORT_CREATE = "abort.create" + OP_ABORT_SIGNAL = "abort.signal" + OP_AUTH_CODEX_REFRESH = "auth.codexRefresh" + OP_CATALOGUE_FETCH = "catalogue.fetch" + OP_COMPOSITE_MOA = "composite.moa" + OP_COMPOSITE_ROUTER = "composite.router" + OP_CREDENTIAL_CREATE = "credential.create" + OP_DECISION_CAPABILITIES = "decision.capabilities" + OP_DECISION_DECIDE = "decision.decide" + OP_EMBEDDING_EMBED = "embedding.embed" + OP_EMBEDDING_EMBED_MANY = "embedding.embedMany" + OP_FILES_UPLOAD = "files.upload" + OP_HANDLE_DROP = "handle.drop" + OP_IMAGE_GENERATE = "image.generate" + OP_MIDDLEWARE_CREATE = "middleware.create" + OP_MODEL_INFO = "model.info" + OP_MODEL_RESOLVE = "model.resolve" + OP_MODEL_WRAP = "model.wrap" + OP_OPERATION_CANCEL = "operation.cancel" + OP_OPERATION_INPUT_DONE = "operation.inputDone" + OP_OPERATION_NEXT = "operation.next" + OP_OPERATION_PUSH = "operation.push" + OP_OPERATION_STATUS = "operation.status" + OP_OUTPUT_TO_OPEN_AI = "output.toOpenAI" + OP_PROVIDER_CREATE = "provider.create" + OP_PROVIDER_CUSTOM = "provider.custom" + OP_PROVIDER_DEFAULT = "provider.default" + OP_PROVIDER_FILES = "provider.files" + OP_PROVIDER_INVOKE = "provider.invoke" + OP_PROVIDER_MODEL = "provider.model" + OP_PROVIDER_WRAP = "provider.wrap" + OP_RECORDER_ATTACH = "recorder.attach" + OP_RECORDER_CREATE = "recorder.create" + OP_RECORDER_EXPORT = "recorder.export" + OP_RECORDER_FLUSH = "recorder.flush" + OP_RECORDER_STATS = "recorder.stats" + OP_RECORDER_STOP = "recorder.stop" + OP_REGISTRY_CREATE = "registry.create" + OP_REGISTRY_PROVIDER = "registry.provider" + OP_REPLAY_CAPABILITIES = "replay.capabilities" + OP_REPLAY_CREATE = "replay.create" + OP_REPLAY_DRY_RUN = "replay.dryRun" + OP_REPLAY_INSPECT = "replay.inspect" + OP_REPLAY_LIVE = "replay.live" + OP_RERANKING_RERANK = "reranking.rerank" + OP_RUNTIME_CONFIGURE = "runtime.configure" + OP_RUNTIME_DESCRIBE = "runtime.describe" + OP_SEARCH_SEARCH = "search.search" + OP_SESSION_STORE_ATTACH = "sessionStore.attach" + OP_SESSION_STORE_CALLS = "sessionStore.calls" + OP_SESSION_STORE_CREATE = "sessionStore.create" + OP_SESSION_STORE_LIST = "sessionStore.list" + OP_SPEECH_GENERATE = "speech.generate" + OP_TEXT_CONSUME = "text.consume" + OP_TEXT_GENERATE = "text.generate" + OP_TEXT_GENERATE_OBJECT = "text.generateObject" + OP_TEXT_GENERATE_OPEN_AI = "text.generateOpenAI" + OP_TEXT_STREAM = "text.stream" + OP_TEXT_STREAM_OPEN_AI = "text.streamOpenAI" + OP_TOOL_REPAIR_APPLY = "toolRepair.apply" + OP_TOOL_REPAIR_APPLY_RESULT = "toolRepair.applyResult" + OP_TOOL_REPAIR_CONTEXT = "toolRepair.context" + OP_TRACE_AGGREGATE = "trace.aggregate" + OP_TRACE_CLEAR = "trace.clear" + OP_TRACE_CREATE = "trace.create" + OP_TRACE_EXPORT = "trace.export" + OP_TRACE_SESSION_CHAIN = "trace.sessionChain" + OP_TRACE_SESSION_TRAJECTORY = "trace.sessionTrajectory" + OP_TRANSCRIPTION_GENERATE = "transcription.generate" + OP_TRANSCRIPTION_START = "transcription.start" + OP_TRANSPORT_CREATE = "transport.create" + OP_VIDEO_GENERATE = "video.generate" +) diff --git a/bindings/go/multimodal.go b/bindings/go/multimodal.go deleted file mode 100644 index 407aa75d5..000000000 --- a/bindings/go/multimodal.go +++ /dev/null @@ -1,937 +0,0 @@ -// Multimodal API for the Go binding — 8 modality models mirroring Node's -// multimodal.rs and the aimux-ffi C ABI. -// -// Each modality is a Go struct wrapping a native handle (uint64). The handle -// is acquired via a provider-specific constructor (e.g. NewOpenAIEmbedding) -// and released via Close. All C ABI data uses JSON strings (base64 -// for binary), matching the C ABI wire format. - -package aimux - -/* -#cgo CFLAGS: -I${SRCDIR}/../../aimux-ffi -#cgo linux LDFLAGS: -L${SRCDIR}/../../target/release -Wl,-Bstatic -laimux_ffi -Wl,-Bdynamic -lpthread -ldl -lm -#cgo darwin LDFLAGS: -L${SRCDIR}/../../target/release -laimux_ffi -#cgo windows LDFLAGS: -L${SRCDIR}/../../target/release -Wl,-Bstatic -laimux_ffi -Wl,-Bdynamic -lws2_32 -lbcrypt -lntdll -luserenv - -#include -#include -#include "aimux-ffi.h" -*/ -import "C" - -import ( - "encoding/json" - "errors" - "fmt" - "runtime" - "sync/atomic" - "unsafe" -) - -// ── Shared helpers ─────────────────────────────────────────────────────────── - -// multimodalHandle is the common state for all multimodal model types. The -// atomic swap makes Close non-blocking: a call that already loaded the handle -// may finish (the Rust registry clones its Arc at entry), while later calls see -// zero and return ErrClosed. No Go lock is held across a blocking C call. -// -// A multimodalHandle must not be copied after first use. Public model wrappers -// embed it so atomic.noCopy also lets go vet flag accidental value copies. -type multimodalHandle struct { - handle atomic.Uint64 -} - -func (h *multimodalHandle) load() (uint64, error) { - handle := h.handle.Load() - if handle == 0 { - return 0, fmt.Errorf("%w: model", ErrClosed) - } - return handle, nil -} - -func (h *multimodalHandle) close() { - if handle := h.handle.Swap(0); handle != 0 { - C.aimux_drop_handle(C.uint64_t(handle)) - } -} - -func closeMultimodal(owner any, h *multimodalHandle) { - h.close() - runtime.SetFinalizer(owner, nil) - runtime.KeepAlive(owner) -} - -// callFFIString is the common pattern for a multimodal call that writes a -// char* out-parameter. owner is kept alive until after C returns so its -// finalizer cannot close the handle while the call is entering the Rust -// registry. Explicit concurrent Close is allowed and may make the call fail; -// it cannot cause use-after-free. -func callFFIString(owner any, h *multimodalHandle, fn func(handle C.uint64_t, out **C.char) *C.aimux_error_t) (string, error) { - handle, err := h.load() - if err != nil { - return "", err - } - - result, callErr := ffiString(func(out **C.char) *C.aimux_error_t { - return fn(C.uint64_t(handle), out) - }) - runtime.KeepAlive(owner) - return result, callErr -} - -// newMultimodalHandle wraps a [C ABI] multimodal constructor's out-handle + -// error (these constructors only store config, so the only failure they can -// carry is a C ABI failure — returned, not panicked). Takes the out-handle by -// pointer so it can be written in the same expression. -func newMultimodalHandle(h *C.uint64_t, e *C.aimux_error_t) (uint64, error) { - if err := expectFfiError(e); err != nil { - return 0, err - } - return uint64(*h), nil -} - -// cstringPair creates two C strings and returns them with a cleanup func. -func cstringPair(a, b string) (*C.char, *C.char, func()) { - ca := C.CString(a) - cb := C.CString(b) - return ca, cb, func() { - C.free(unsafe.Pointer(ca)) - C.free(unsafe.Pointer(cb)) - } -} - -// cstringTriple creates three C strings and returns them with a cleanup func. -func cstringTriple(a, b, c string) (*C.char, *C.char, *C.char, func()) { - ca := C.CString(a) - cb := C.CString(b) - cc := C.CString(c) - return ca, cb, cc, func() { - C.free(unsafe.Pointer(ca)) - C.free(unsafe.Pointer(cb)) - C.free(unsafe.Pointer(cc)) - } -} - -// newMultimodalModelWithBase is the common constructor for multimodal models -// that take (api_key, model_id) and optionally a base_url. When baseURL is -// empty, the no-base C constructor is used; otherwise the _with_base variant. -func newMultimodalModelWithBase( - apiKey, modelID, baseURL string, - plain func(ca, cb *C.char, out *C.uint64_t) *C.aimux_error_t, - withBase func(ca, cb, cbase *C.char, out *C.uint64_t) *C.aimux_error_t, -) (uint64, error) { - if err := checkUTF8("api_key", apiKey, "model_id", modelID, "base_url", baseURL); err != nil { - return 0, err - } - var h C.uint64_t - if baseURL == "" { - ca, cb, cleanup := cstringPair(apiKey, modelID) - defer cleanup() - return newMultimodalHandle(&h, plain(ca, cb, &h)) - } - ca, cb, cbase, cleanup := cstringTriple(apiKey, modelID, baseURL) - defer cleanup() - return newMultimodalHandle(&h, withBase(ca, cb, cbase, &h)) -} - -// newMultimodalModelFiles is the constructor for the Files manager, which -// takes only an api_key (no model_id) and optionally a base_url. -func newMultimodalModelFiles( - apiKey, baseURL string, - plain func(ca *C.char, out *C.uint64_t) *C.aimux_error_t, - withBase func(ca, cbase *C.char, out *C.uint64_t) *C.aimux_error_t, -) (uint64, error) { - if err := checkUTF8("api_key", apiKey, "base_url", baseURL); err != nil { - return 0, err - } - var h C.uint64_t - ca := C.CString(apiKey) - defer C.free(unsafe.Pointer(ca)) - if baseURL == "" { - return newMultimodalHandle(&h, plain(ca, &h)) - } - cbase := C.CString(baseURL) - defer C.free(unsafe.Pointer(cbase)) - return newMultimodalHandle(&h, withBase(ca, cbase, &h)) -} - -// ── EmbeddingModel ────────────────────────────────────────────────────────── - -// EmbeddingModel generates vector embeddings for text. -// An EmbeddingModel must not be copied after first use. -type EmbeddingModel struct { - h multimodalHandle -} - -// Close releases the native handle. -func (m *EmbeddingModel) Close() error { - closeMultimodal(m, &m.h) - return nil -} - -// Embed generates embeddings for the given text values. -// Returns the JSON-serialized EmbeddingResult. -func (m *EmbeddingModel) Embed(values []string, opts *EmbeddingCallOptions) (string, error) { - valuesJSON, err := marshalJSON("values", values) - if err != nil { - return "", err - } - optsJSON := "" - if opts != nil { - if optsJSON, err = marshalJSON("opts", opts); err != nil { - return "", err - } - } - - handle, err := m.h.load() - if err != nil { - return "", err - } - - cVals := C.CString(valuesJSON) - defer C.free(unsafe.Pointer(cVals)) - var cOpts *C.char - if optsJSON != "" { - cOpts = C.CString(optsJSON) - defer C.free(unsafe.Pointer(cOpts)) - } - - result, callErr := ffiString(func(out **C.char) *C.aimux_error_t { - return C.aimux_embed(C.uint64_t(handle), cVals, cOpts, out) - }) - runtime.KeepAlive(m) - return result, callErr -} - -// ParseEmbeddingResult parses the JSON string returned by Embed. -func ParseEmbeddingResult(jsonStr string) (*EmbeddingResult, error) { - var r EmbeddingResult - if err := json.Unmarshal([]byte(jsonStr), &r); err != nil { - return nil, fmt.Errorf("aimux: failed to parse EmbeddingResult: %w", err) - } - return &r, nil -} - -// ── Embedding constructors ─────────────────────────────────────────────────── - -// NewOpenAIEmbedding creates an OpenAI embedding model (e.g. text-embedding-3-small). -func NewOpenAIEmbedding(apiKey, modelID string) (*EmbeddingModel, error) { - return NewOpenAIEmbeddingWithBase(apiKey, modelID, "") -} - -// NewOpenAIEmbeddingWithBase creates an OpenAI embedding model with a custom base URL. -func NewOpenAIEmbeddingWithBase(apiKey, modelID, baseURL string) (*EmbeddingModel, error) { - mh, err := newMultimodalModelWithBase(apiKey, modelID, baseURL, - func(ca, cb *C.char, out *C.uint64_t) *C.aimux_error_t { - return C.aimux_openai_embedding_new(ca, cb, out) - }, - func(ca, cb, cbase *C.char, out *C.uint64_t) *C.aimux_error_t { - return C.aimux_openai_embedding_new_with_base(ca, cb, cbase, out) - }, - ) - if err != nil { - return nil, err - } - m := &EmbeddingModel{} - m.h.handle.Store(mh) - runtime.SetFinalizer(m, func(m *EmbeddingModel) { m.Close() }) - return m, nil -} - -// NewCohereEmbedding creates a Cohere embedding model (e.g. embed-english-v3.0). -func NewCohereEmbedding(apiKey, modelID string) (*EmbeddingModel, error) { - return NewCohereEmbeddingWithBase(apiKey, modelID, "") -} - -// NewCohereEmbeddingWithBase creates a Cohere embedding model with a custom base URL. -func NewCohereEmbeddingWithBase(apiKey, modelID, baseURL string) (*EmbeddingModel, error) { - mh, err := newMultimodalModelWithBase(apiKey, modelID, baseURL, - func(ca, cb *C.char, out *C.uint64_t) *C.aimux_error_t { - return C.aimux_cohere_embedding_new(ca, cb, out) - }, - func(ca, cb, cbase *C.char, out *C.uint64_t) *C.aimux_error_t { - return C.aimux_cohere_embedding_new_with_base(ca, cb, cbase, out) - }, - ) - if err != nil { - return nil, err - } - m := &EmbeddingModel{} - m.h.handle.Store(mh) - runtime.SetFinalizer(m, func(m *EmbeddingModel) { m.Close() }) - return m, nil -} - -// NewGoogleEmbedding creates a Google embedding model (e.g. gemini-embedding-001). -func NewGoogleEmbedding(apiKey, modelID string) (*EmbeddingModel, error) { - return NewGoogleEmbeddingWithBase(apiKey, modelID, "") -} - -// NewGoogleEmbeddingWithBase creates a Google embedding model with a custom base URL. -func NewGoogleEmbeddingWithBase(apiKey, modelID, baseURL string) (*EmbeddingModel, error) { - mh, err := newMultimodalModelWithBase(apiKey, modelID, baseURL, - func(ca, cb *C.char, out *C.uint64_t) *C.aimux_error_t { - return C.aimux_google_embedding_new(ca, cb, out) - }, - func(ca, cb, cbase *C.char, out *C.uint64_t) *C.aimux_error_t { - return C.aimux_google_embedding_new_with_base(ca, cb, cbase, out) - }, - ) - if err != nil { - return nil, err - } - m := &EmbeddingModel{} - m.h.handle.Store(mh) - runtime.SetFinalizer(m, func(m *EmbeddingModel) { m.Close() }) - return m, nil -} - -// requiredOptsJSON marshals opts for the FFI entry points whose opts_json is -// REQUIRED (it carries the input): speech / image / rerank / video / search. -// A nil opts is rejected here as a plain error instead of crossing the C -// C ABI as "" (which the header documents as a caller bug → panic). -func requiredOptsJSON[T any](method string, opts *T) (string, error) { - if opts == nil { - return "", fmt.Errorf("aimux: %s: opts is required", method) - } - return marshalJSON("opts", opts) -} - -// ── SpeechModel (TTS) ──────────────────────────────────────────────────────── - -// SpeechModel converts text to speech audio. -// A SpeechModel must not be copied after first use. -type SpeechModel struct { - h multimodalHandle -} - -func (m *SpeechModel) Close() error { closeMultimodal(m, &m.h); return nil } - -// Generate generates speech audio from the given options. -func (m *SpeechModel) Generate(opts *SpeechCallOptions) (string, error) { - optsJSON, err := requiredOptsJSON("SpeechModel.Generate", opts) - if err != nil { - return "", err - } - return callFFIString(m, &m.h, func(handle C.uint64_t, out **C.char) *C.aimux_error_t { - cOpts := C.CString(optsJSON) - defer C.free(unsafe.Pointer(cOpts)) - return C.aimux_speech_generate(handle, cOpts, out) - }) -} - -func ParseSpeechResult(jsonStr string) (*SpeechResult, error) { - var r SpeechResult - if err := json.Unmarshal([]byte(jsonStr), &r); err != nil { - return nil, fmt.Errorf("aimux: failed to parse SpeechResult: %w", err) - } - return &r, nil -} - -// NewOpenAISpeech creates an OpenAI speech (TTS) model. -func NewOpenAISpeech(apiKey, modelID string) (*SpeechModel, error) { - return NewOpenAISpeechWithBase(apiKey, modelID, "") -} - -// NewOpenAISpeechWithBase creates an OpenAI speech model with a custom base URL. -func NewOpenAISpeechWithBase(apiKey, modelID, baseURL string) (*SpeechModel, error) { - mh, err := newMultimodalModelWithBase(apiKey, modelID, baseURL, - func(ca, cb *C.char, out *C.uint64_t) *C.aimux_error_t { - return C.aimux_openai_speech_new(ca, cb, out) - }, - func(ca, cb, cbase *C.char, out *C.uint64_t) *C.aimux_error_t { - return C.aimux_openai_speech_new_with_base(ca, cb, cbase, out) - }, - ) - if err != nil { - return nil, err - } - m := &SpeechModel{} - m.h.handle.Store(mh) - runtime.SetFinalizer(m, func(m *SpeechModel) { m.Close() }) - return m, nil -} - -// ── ImageModel ────────────────────────────────────────────────────────────── - -// ImageModel generates images from prompts. -// An ImageModel must not be copied after first use. -type ImageModel struct { - h multimodalHandle -} - -func (m *ImageModel) Close() error { closeMultimodal(m, &m.h); return nil } - -func (m *ImageModel) Generate(opts *ImageCallOptions) (string, error) { - optsJSON, err := requiredOptsJSON("ImageModel.Generate", opts) - if err != nil { - return "", err - } - return callFFIString(m, &m.h, func(handle C.uint64_t, out **C.char) *C.aimux_error_t { - cOpts := C.CString(optsJSON) - defer C.free(unsafe.Pointer(cOpts)) - return C.aimux_image_generate(handle, cOpts, out) - }) -} - -func ParseImageResult(jsonStr string) (*ImageResult, error) { - var r ImageResult - if err := json.Unmarshal([]byte(jsonStr), &r); err != nil { - return nil, fmt.Errorf("aimux: failed to parse ImageResult: %w", err) - } - return &r, nil -} - -// NewOpenAIImage creates an OpenAI image model (e.g. dall-e-3). -func NewOpenAIImage(apiKey, modelID string) (*ImageModel, error) { - return NewOpenAIImageWithBase(apiKey, modelID, "") -} - -// NewOpenAIImageWithBase creates an OpenAI image model with a custom base URL. -func NewOpenAIImageWithBase(apiKey, modelID, baseURL string) (*ImageModel, error) { - mh, err := newMultimodalModelWithBase(apiKey, modelID, baseURL, - func(ca, cb *C.char, out *C.uint64_t) *C.aimux_error_t { - return C.aimux_openai_image_new(ca, cb, out) - }, - func(ca, cb, cbase *C.char, out *C.uint64_t) *C.aimux_error_t { - return C.aimux_openai_image_new_with_base(ca, cb, cbase, out) - }, - ) - if err != nil { - return nil, err - } - m := &ImageModel{} - m.h.handle.Store(mh) - runtime.SetFinalizer(m, func(m *ImageModel) { m.Close() }) - return m, nil -} - -// NewGoogleImage creates a Google image model (e.g. gemini-2.5-flash-image). -func NewGoogleImage(apiKey, modelID string) (*ImageModel, error) { - return NewGoogleImageWithBase(apiKey, modelID, "") -} - -// NewGoogleImageWithBase creates a Google image model with a custom base URL. -func NewGoogleImageWithBase(apiKey, modelID, baseURL string) (*ImageModel, error) { - mh, err := newMultimodalModelWithBase(apiKey, modelID, baseURL, - func(ca, cb *C.char, out *C.uint64_t) *C.aimux_error_t { - return C.aimux_google_image_new(ca, cb, out) - }, - func(ca, cb, cbase *C.char, out *C.uint64_t) *C.aimux_error_t { - return C.aimux_google_image_new_with_base(ca, cb, cbase, out) - }, - ) - if err != nil { - return nil, err - } - m := &ImageModel{} - m.h.handle.Store(mh) - runtime.SetFinalizer(m, func(m *ImageModel) { m.Close() }) - return m, nil -} - -// ── TranscriptionModel (STT) ──────────────────────────────────────────────── - -// TranscriptionModel converts audio to text. -// A TranscriptionModel must not be copied after first use. -type TranscriptionModel struct { - h multimodalHandle -} - -func (m *TranscriptionModel) Close() error { closeMultimodal(m, &m.h); return nil } - -// Generate transcribes audio (base64-encoded) to text. -func (m *TranscriptionModel) Generate(audioBase64, mediaType string, opts *TranscriptionCallOptions) (string, error) { - if err := checkUTF8("audio_base64", audioBase64, "media_type", mediaType); err != nil { - return "", err - } - optsJSON := "" - if opts != nil { - var err error - if optsJSON, err = marshalJSON("opts", opts); err != nil { - return "", err - } - } - - handle, err := m.h.load() - if err != nil { - return "", err - } - - ca, cb, cc, cleanup := cstringTriple(audioBase64, mediaType, optsJSON) - defer cleanup() - var cOpts *C.char - if optsJSON != "" { - cOpts = cc - } - - result, callErr := ffiString(func(out **C.char) *C.aimux_error_t { - return C.aimux_transcription_generate(C.uint64_t(handle), ca, cb, cOpts, out) - }) - runtime.KeepAlive(m) - return result, callErr -} - -func ParseTranscriptionResult(jsonStr string) (*TranscriptionResult, error) { - var r TranscriptionResult - if err := json.Unmarshal([]byte(jsonStr), &r); err != nil { - return nil, fmt.Errorf("aimux: failed to parse TranscriptionResult: %w", err) - } - return &r, nil -} - -// NewOpenAITranscription creates an OpenAI transcription (STT) model. -func NewOpenAITranscription(apiKey, modelID string) (*TranscriptionModel, error) { - return NewOpenAITranscriptionWithBase(apiKey, modelID, "") -} - -// NewOpenAITranscriptionWithBase creates an OpenAI transcription model with a custom base URL. -func NewOpenAITranscriptionWithBase(apiKey, modelID, baseURL string) (*TranscriptionModel, error) { - mh, err := newMultimodalModelWithBase(apiKey, modelID, baseURL, - func(ca, cb *C.char, out *C.uint64_t) *C.aimux_error_t { - return C.aimux_openai_transcription_new(ca, cb, out) - }, - func(ca, cb, cbase *C.char, out *C.uint64_t) *C.aimux_error_t { - return C.aimux_openai_transcription_new_with_base(ca, cb, cbase, out) - }, - ) - if err != nil { - return nil, err - } - m := &TranscriptionModel{} - m.h.handle.Store(mh) - runtime.SetFinalizer(m, func(m *TranscriptionModel) { m.Close() }) - return m, nil -} - -// ── Files ─────────────────────────────────────────────────────────────────── - -// Files manages file uploads to providers. -// A Files value must not be copied after first use. -type Files struct { - h multimodalHandle -} - -func (f *Files) Close() error { closeMultimodal(f, &f.h); return nil } - -// Upload uploads a file (base64-encoded) to the provider. -func (f *Files) Upload(dataBase64, mediaType string, opts *UploadFileCallOptions) (string, error) { - if err := checkUTF8("data_base64", dataBase64, "media_type", mediaType); err != nil { - return "", err - } - optsJSON := "" - if opts != nil { - var err error - if optsJSON, err = marshalJSON("opts", opts); err != nil { - return "", err - } - } - - handle, err := f.h.load() - if err != nil { - return "", err - } - - ca, cb, cc, cleanup := cstringTriple(dataBase64, mediaType, optsJSON) - defer cleanup() - var cOpts *C.char - if optsJSON != "" { - cOpts = cc - } - - result, callErr := ffiString(func(out **C.char) *C.aimux_error_t { - return C.aimux_file_upload(C.uint64_t(handle), ca, cb, cOpts, out) - }) - runtime.KeepAlive(f) - return result, callErr -} - -func ParseUploadFileResult(jsonStr string) (*UploadFileResult, error) { - var r UploadFileResult - if err := json.Unmarshal([]byte(jsonStr), &r); err != nil { - return nil, fmt.Errorf("aimux: failed to parse UploadFileResult: %w", err) - } - return &r, nil -} - -// NewOpenAIFiles creates an OpenAI files manager. -func NewOpenAIFiles(apiKey string) (*Files, error) { - return NewOpenAIFilesWithBase(apiKey, "") -} - -// NewOpenAIFilesWithBase creates an OpenAI files manager with a custom base URL. -func NewOpenAIFilesWithBase(apiKey, baseURL string) (*Files, error) { - mh, err := newMultimodalModelFiles(apiKey, baseURL, - func(ca *C.char, out *C.uint64_t) *C.aimux_error_t { return C.aimux_openai_files_new(ca, out) }, - func(ca, cbase *C.char, out *C.uint64_t) *C.aimux_error_t { - return C.aimux_openai_files_new_with_base(ca, cbase, out) - }, - ) - if err != nil { - return nil, err - } - f := &Files{} - f.h.handle.Store(mh) - runtime.SetFinalizer(f, func(f *Files) { f.Close() }) - return f, nil -} - -// ── RerankingModel ────────────────────────────────────────────────────────── - -// RerankingModel reranks documents by relevance to a query. -// A RerankingModel must not be copied after first use. -type RerankingModel struct { - h multimodalHandle -} - -func (m *RerankingModel) Close() error { closeMultimodal(m, &m.h); return nil } - -// Rerank reranks documents against a query. -func (m *RerankingModel) Rerank(opts *RerankingCallOptions) (string, error) { - optsJSON, err := requiredOptsJSON("RerankingModel.Rerank", opts) - if err != nil { - return "", err - } - return callFFIString(m, &m.h, func(handle C.uint64_t, out **C.char) *C.aimux_error_t { - cOpts := C.CString(optsJSON) - defer C.free(unsafe.Pointer(cOpts)) - return C.aimux_rerank(handle, cOpts, out) - }) -} - -func ParseRerankingResult(jsonStr string) (*RerankingResult, error) { - var r RerankingResult - if err := json.Unmarshal([]byte(jsonStr), &r); err != nil { - return nil, fmt.Errorf("aimux: failed to parse RerankingResult: %w", err) - } - return &r, nil -} - -// NewCohereReranking creates a Cohere reranking model (e.g. rerank-v3.0). -func NewCohereReranking(apiKey, modelID string) (*RerankingModel, error) { - return NewCohereRerankingWithBase(apiKey, modelID, "") -} - -// NewCohereRerankingWithBase creates a Cohere reranking model with a custom base URL. -func NewCohereRerankingWithBase(apiKey, modelID, baseURL string) (*RerankingModel, error) { - mh, err := newMultimodalModelWithBase(apiKey, modelID, baseURL, - func(ca, cb *C.char, out *C.uint64_t) *C.aimux_error_t { - return C.aimux_cohere_reranking_new(ca, cb, out) - }, - func(ca, cb, cbase *C.char, out *C.uint64_t) *C.aimux_error_t { - return C.aimux_cohere_reranking_new_with_base(ca, cb, cbase, out) - }, - ) - if err != nil { - return nil, err - } - m := &RerankingModel{} - m.h.handle.Store(mh) - runtime.SetFinalizer(m, func(m *RerankingModel) { m.Close() }) - return m, nil -} - -// ── VideoModel ────────────────────────────────────────────────────────────── - -// VideoModel generates videos from prompts. -// A VideoModel must not be copied after first use. -type VideoModel struct { - h multimodalHandle -} - -func (m *VideoModel) Close() error { closeMultimodal(m, &m.h); return nil } - -func (m *VideoModel) Generate(opts *VideoCallOptions) (string, error) { - optsJSON, err := requiredOptsJSON("VideoModel.Generate", opts) - if err != nil { - return "", err - } - return callFFIString(m, &m.h, func(handle C.uint64_t, out **C.char) *C.aimux_error_t { - cOpts := C.CString(optsJSON) - defer C.free(unsafe.Pointer(cOpts)) - return C.aimux_video_generate(handle, cOpts, out) - }) -} - -func ParseVideoResult(jsonStr string) (*VideoResult, error) { - var r VideoResult - if err := json.Unmarshal([]byte(jsonStr), &r); err != nil { - return nil, fmt.Errorf("aimux: failed to parse VideoResult: %w", err) - } - return &r, nil -} - -// NewGoogleVideo creates a Google video model (e.g. veo-3.0). -func NewGoogleVideo(apiKey, modelID string) (*VideoModel, error) { - return NewGoogleVideoWithBase(apiKey, modelID, "") -} - -// NewGoogleVideoWithBase creates a Google video model with a custom base URL. -func NewGoogleVideoWithBase(apiKey, modelID, baseURL string) (*VideoModel, error) { - mh, err := newMultimodalModelWithBase(apiKey, modelID, baseURL, - func(ca, cb *C.char, out *C.uint64_t) *C.aimux_error_t { - return C.aimux_google_video_new(ca, cb, out) - }, - func(ca, cb, cbase *C.char, out *C.uint64_t) *C.aimux_error_t { - return C.aimux_google_video_new_with_base(ca, cb, cbase, out) - }, - ) - if err != nil { - return nil, err - } - m := &VideoModel{} - m.h.handle.Store(mh) - runtime.SetFinalizer(m, func(m *VideoModel) { m.Close() }) - return m, nil -} - -// ── SearchModel ───────────────────────────────────────────────────────────── - -// SearchModel performs web search. -// A SearchModel must not be copied after first use. -type SearchModel struct { - h multimodalHandle -} - -func (m *SearchModel) Close() error { closeMultimodal(m, &m.h); return nil } - -func (m *SearchModel) Search(opts *SearchCallOptions) (string, error) { - optsJSON, err := requiredOptsJSON("SearchModel.Search", opts) - if err != nil { - return "", err - } - return callFFIString(m, &m.h, func(handle C.uint64_t, out **C.char) *C.aimux_error_t { - cOpts := C.CString(optsJSON) - defer C.free(unsafe.Pointer(cOpts)) - return C.aimux_search(handle, cOpts, out) - }) -} - -func ParseSearchResult(jsonStr string) (*SearchResult, error) { - var r SearchResult - if err := json.Unmarshal([]byte(jsonStr), &r); err != nil { - return nil, fmt.Errorf("aimux: failed to parse SearchResult: %w", err) - } - return &r, nil -} - -// NewTavilySearch creates a Tavily search model. Tavily uses a fixed endpoint, -// so no model ID is needed. -func NewTavilySearch(apiKey string) (*SearchModel, error) { - return NewTavilySearchWithBase(apiKey, "") -} - -// NewTavilySearchWithBase creates a Tavily search model with a custom base URL -// (for testing against a mock server). -func NewTavilySearchWithBase(apiKey, baseURL string) (*SearchModel, error) { - if err := checkUTF8("api_key", apiKey, "base_url", baseURL); err != nil { - return nil, err - } - ca := C.CString(apiKey) - defer C.free(unsafe.Pointer(ca)) - var h C.uint64_t - var mh uint64 - var err error - if baseURL == "" { - mh, err = newMultimodalHandle(&h, C.aimux_tavily_search_new(ca, nil, &h)) - } else { - cbase := C.CString(baseURL) - defer C.free(unsafe.Pointer(cbase)) - mh, err = newMultimodalHandle(&h, C.aimux_tavily_search_new_with_base(ca, nil, cbase, &h)) - } - if err != nil { - return nil, err - } - m := &SearchModel{} - m.h.handle.Store(mh) - runtime.SetFinalizer(m, func(m *SearchModel) { m.Close() }) - return m, nil -} - -// ── DeepSeek convenience constructor (registry-backed, RFC-0017 phase 4) ──── - -// DeepSeek is a convenience constructor for DeepSeek (OpenAI-compatible API). -// Must-style: it PANICS on any failure, including an apiKey / modelID that is -// not valid UTF-8 or contains a NUL. Use NewDeepSeek to get an error instead. -func DeepSeek(apiKey, modelID string) *Model { - return mustNew(NewDeepSeek(apiKey, modelID)) -} - -// NewDeepSeek is the error-returning variant of DeepSeek. -func NewDeepSeek(apiKey, modelID string) (*Model, error) { - return Provider("deepseek", apiKey, modelID) -} - -// ── TranscriptionSession (RFC-0028 streaming) ─────────────────────────────── - -// TranscriptionSession is a live streaming-transcription session (RFC-0028). -// Push audio chunks with PushAudio, mark end-of-audio with InputDone, then -// pull transcription parts (JSON TranscriptionStreamPart) with NextPart. -// Close releases the session (safe and idempotent). -// -// A TranscriptionSession must not be copied after first use. Close atomically -// takes its native handle and immediately terminates the native session, so it -// can wake an in-flight NextPart(-1) or a backpressured PushAudio instead of -// waiting for that operation to return first. -type TranscriptionSession struct { - session atomic.Uint64 -} - -// InputAudioFormat is the input audio format for streaming transcription -// (RFC-0028): e.g. {"formatType": "audio/pcm", "rate": 24000}. -type InputAudioFormat struct { - FormatType string `json:"formatType"` - Rate *uint32 `json:"rate,omitempty"` -} - -// TranscriptionSessionOpts are the optional session options (RFC-0028): -// input audio format, provider options, headers, includeRawChunks, timeout. -type TranscriptionSessionOpts struct { - InputAudioFormat *InputAudioFormat `json:"inputAudioFormat,omitempty"` - // ProviderOptions is a JSON object of provider namespaces, raw so that - // its keys keep their order. - ProviderOptions json.RawMessage `json:"providerOptions,omitempty"` - Headers map[string]string `json:"headers,omitempty"` - IncludeRawChunks *bool `json:"includeRawChunks,omitempty"` - Timeout *TimeoutConfiguration `json:"timeout,omitempty"` -} - -// ErrTranscriptionEnded is returned by NextPart when the stream ended -// normally (a Finish part was delivered earlier). -var ErrTranscriptionEnded = errors.New("aimux: transcription stream ended") - -// ErrTranscriptionTimeout is returned by NextPart when no part arrived -// within the timeout; the session stays live — call again. -var ErrTranscriptionTimeout = errors.New("aimux: transcription part timeout") - -// StartTranscriptionSession starts a streaming transcription session on a -// model that supports do_stream (e.g. OpenAI gpt-realtime-whisper). -func StartTranscriptionSession(model *TranscriptionModel, opts *TranscriptionSessionOpts) (*TranscriptionSession, error) { - return StartTranscriptionSessionWithAbort(model, opts, 0) -} - -// StartTranscriptionSessionWithAbort is StartTranscriptionSession with an -// abort handle (from AbortSignalNew); firing it aborts the session. -func StartTranscriptionSessionWithAbort(model *TranscriptionModel, opts *TranscriptionSessionOpts, abortHandle uint64) (*TranscriptionSession, error) { - if model == nil { - return nil, errors.New("aimux: transcription model is nil") - } - modelHandle, err := model.h.load() - if err != nil { - return nil, err - } - - // nil opts marshals to "null" (the FFI's "use defaults"); a typed nil - // pointer in an `any` is not == nil, which is why this is not special-cased. - optsJSON, err := marshalJSON("opts", opts) - if err != nil { - return nil, err - } - ca, cleanup := cstring1(optsJSON) - defer cleanup() - - var h C.uint64_t - callErr := expectAimuxError(C.aimux_transcription_session_new( - C.uint64_t(modelHandle), - C.uint64_t(abortHandle), - ca, - &h, - )) - runtime.KeepAlive(model) - if callErr != nil { - return nil, callErr - } - s := &TranscriptionSession{} - s.session.Store(uint64(h)) - runtime.SetFinalizer(s, func(s *TranscriptionSession) { s.Close() }) - return s, nil -} - -// PushAudio pushes one binary audio chunk. Blocks while the internal channel -// is full (backpressure propagation). -func (s *TranscriptionSession) PushAudio(audio []byte) error { - handle, err := s.load() - if err != nil { - return err - } - var ptr *C.uint8_t - if len(audio) > 0 { - ptr = (*C.uint8_t)(unsafe.Pointer(&audio[0])) - } - callErr := expectAimuxError(C.aimux_transcription_push_audio( - C.uint64_t(handle), - ptr, - C.size_t(len(audio)), - )) - runtime.KeepAlive(audio) - runtime.KeepAlive(s) - return callErr -} - -// InputDone signals end-of-audio (idempotent). -func (s *TranscriptionSession) InputDone() error { - handle, err := s.load() - if err != nil { - return err - } - callErr := expectFfiError(C.aimux_transcription_input_done(C.uint64_t(handle))) - runtime.KeepAlive(s) - return callErr -} - -// NextPart pulls the next transcription part (JSON TranscriptionStreamPart). -// Returns ErrTranscriptionEnded when the stream finished normally, and -// ErrTranscriptionTimeout when no part arrived in time (retryable). -// timeoutMs: >0 wait at most; 0 immediate poll; <0 wait indefinitely. -func (s *TranscriptionSession) NextPart(timeoutMs int64) (string, error) { - handle, err := s.load() - if err != nil { - return "", err - } - var out *C.char - var state C.int32_t - callErr := expectAimuxError(C.aimux_transcription_next_part( - C.uint64_t(handle), - C.int64_t(timeoutMs), - &out, - &state, - )) - runtime.KeepAlive(s) - if callErr != nil { - return "", callErr - } - // Timeout is a poll state, not an error. - switch state { - case C.AIMUX_TRANSCRIPTION_NEXT_PART_PART: - return cstr(out), nil - case C.AIMUX_TRANSCRIPTION_NEXT_PART_ENDED: - return "", ErrTranscriptionEnded - case C.AIMUX_TRANSCRIPTION_NEXT_PART_TIMEOUT: - return "", ErrTranscriptionTimeout - default: - panic(fmt.Sprintf("aimux ffi: unknown aimux_transcription_next_part state: %d", int32(state))) - } -} - -// Close terminates and releases the session (aborts the driver; idempotent). -func (s *TranscriptionSession) Close() { - if handle := s.session.Swap(0); handle != 0 { - C.aimux_transcription_session_drop(C.uint64_t(handle)) - } - runtime.SetFinalizer(s, nil) - runtime.KeepAlive(s) -} - -func (s *TranscriptionSession) load() (uint64, error) { - handle := s.session.Load() - if handle == 0 { - return 0, fmt.Errorf("%w: transcription session", ErrClosed) - } - return handle, nil -} - -func cstring1(a string) (*C.char, func()) { - ca := C.CString(a) - return ca, func() { C.free(unsafe.Pointer(ca)) } -} diff --git a/bindings/go/multimodal_test.go b/bindings/go/multimodal_test.go deleted file mode 100644 index bd6d13fdc..000000000 --- a/bindings/go/multimodal_test.go +++ /dev/null @@ -1,628 +0,0 @@ -// Tests for the multimodal API — 8 modality models and their constructors. -// Mirrors Node's multimodal coverage: each model is constructed with a fake -// key, and the FFI path is verified (no network access needed for -// construction; calls use mock servers where applicable). - -package aimux - -import ( - "encoding/json" - "errors" - "net/http" - "strings" - "testing" - "time" -) - -// ── Constructor tests ──────────────────────────────────────────────────────── - -func TestNewOpenAIEmbedding(t *testing.T) { - m, err := NewOpenAIEmbedding("sk-test-fake-key", "text-embedding-3-small") - if err != nil { - t.Fatalf("NewOpenAIEmbedding failed: %v", err) - } - defer m.Close() -} - -func TestNewOpenAISpeech(t *testing.T) { - m, err := NewOpenAISpeech("sk-test-fake-key", "tts-1") - if err != nil { - t.Fatalf("NewOpenAISpeech failed: %v", err) - } - defer m.Close() -} - -func TestNewOpenAIImage(t *testing.T) { - m, err := NewOpenAIImage("sk-test-fake-key", "dall-e-3") - if err != nil { - t.Fatalf("NewOpenAIImage failed: %v", err) - } - defer m.Close() -} - -func TestNewOpenAITranscription(t *testing.T) { - m, err := NewOpenAITranscription("sk-test-fake-key", "whisper-1") - if err != nil { - t.Fatalf("NewOpenAITranscription failed: %v", err) - } - defer m.Close() -} - -func TestNewOpenAIFiles(t *testing.T) { - m, err := NewOpenAIFiles("sk-test-fake-key") - if err != nil { - t.Fatalf("NewOpenAIFiles failed: %v", err) - } - defer m.Close() -} - -func TestNewCohereReranking(t *testing.T) { - m, err := NewCohereReranking("sk-test-fake-key", "rerank-english-v3.0") - if err != nil { - t.Fatalf("NewCohereReranking failed: %v", err) - } - defer m.Close() -} - -func TestNewGoogleVideo(t *testing.T) { - m, err := NewGoogleVideo("sk-test-fake-key", "veo-1") - if err != nil { - t.Fatalf("NewGoogleVideo failed: %v", err) - } - defer m.Close() -} - -func TestNewTavilySearch(t *testing.T) { - m, err := NewTavilySearch("sk-test-fake-key") - if err != nil { - t.Fatalf("NewTavilySearch failed: %v", err) - } - defer m.Close() -} - -// ── Close/double-close tests ───────────────────────────────────────────────── - -func TestEmbeddingDoubleClose(t *testing.T) { - m, _ := NewOpenAIEmbedding("sk-test-fake-key", "text-embedding-3-small") - m.Close() - if err := m.Close(); err != nil { - t.Fatalf("double close should be safe: %v", err) - } -} - -func TestSpeechDoubleClose(t *testing.T) { - m, _ := NewOpenAISpeech("sk-test-fake-key", "tts-1") - m.Close() - if err := m.Close(); err != nil { - t.Fatalf("double close should be safe: %v", err) - } -} - -// ── E2E: Embedding via mock server ─────────────────────────────────────────── - -func TestE2E_Embedding(t *testing.T) { - srv := newMockServer() - defer srv.Close() - - // OpenAI embeddings API response. - embedResponse := `{ - "data": [ - {"embedding": [0.1, 0.2, 0.3], "index": 0}, - {"embedding": [0.4, 0.5, 0.6], "index": 1} - ], - "model": "text-embedding-3-small", - "usage": {"prompt_tokens": 5, "total_tokens": 5} - }` - srv.SetResponse(embedResponse) - - // EmbeddingModel uses OpenAI's embedding endpoint; the mock server - // catches any POST and returns the preset response. - // We can't point the embedding model at a custom URL via the C ABI - // (aimux_openai_embedding_new doesn't take base_url), so we just - // verify the typed result parsing here. - result, err := ParseEmbeddingResult(`{ - "embeddings": [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], - "usage": {"tokens": 5}, - "warnings": [] - }`) - if err != nil { - t.Fatalf("parse failed: %v", err) - } - if len(result.Embeddings) != 2 { - t.Fatalf("expected 2 embeddings, got %d", len(result.Embeddings)) - } - if len(result.Embeddings[0]) != 3 { - t.Errorf("expected 3 dimensions, got %d", len(result.Embeddings[0])) - } - if result.Usage == nil || result.Usage.Tokens != 5 { - t.Error("usage tokens mismatch") - } -} - -// ── E2E: Speech via mock server ────────────────────────────────────────────── - -func TestE2E_SpeechResultParsing(t *testing.T) { - result, err := ParseSpeechResult(`{ - "audio": "aGVsbG8=", - "warnings": [], - "response": {"modelId": "tts-1"} - }`) - if err != nil { - t.Fatalf("parse failed: %v", err) - } - if string(result.Audio) != "hello" { - t.Errorf("audio mismatch: %q", result.Audio) - } - // SpeechModelV4Result.audio is a Uint8Array upstream - // (speech-model-v4-result.ts:16): a base64 string in the JSON, read as - // atob reads it (URL-safe characters, no padding), never a number array. - result, err = ParseSpeechResult(`{"audio": "AAEC-v8", "warnings": [], "response": {}}`) - if err != nil || string(result.Audio) != "\x00\x01\x02\xfa\xff" { - t.Errorf("unpadded url-safe audio: %q, %v", result.Audio, err) - } - if _, err = ParseSpeechResult(`{"audio": [104,105], "warnings": [], "response": {}}`); err == nil { - t.Error("a number array is not the AI SDK's JSON for bytes") - } - encoded, _ := json.Marshal(SpeechResult{Audio: Bytes{0, 1, 2, 250, 255}}) - if !strings.Contains(string(encoded), `"audio":"AAEC+v8="`) { - t.Errorf("audio must encode as base64: %s", encoded) - } -} - -// ── E2E: Image result parsing ──────────────────────────────────────────────── - -func TestE2E_ImageResultParsing(t *testing.T) { - result, err := ParseImageResult(`{ - "images": ["aW1hZ2Ux"], - "warnings": [], - "response": {"modelId": "dall-e-3"} - }`) - if err != nil { - t.Fatalf("parse failed: %v", err) - } - if len(result.Images) != 1 { - t.Fatalf("expected 1 image, got %d", len(result.Images)) - } - if string(result.Images[0]) != "image1" { - t.Error("image mismatch") - } -} - -// ── E2E: Transcription result parsing ──────────────────────────────────────── - -func TestE2E_TranscriptionResultParsing(t *testing.T) { - result, err := ParseTranscriptionResult(`{ - "text": "Hello world", - "segments": [{"text": "Hello", "startSecond": 0.0, "endSecond": 1.0}], - "language": "en", - "warnings": [], - "response": {"id": "resp-1"} - }`) - if err != nil { - t.Fatalf("parse failed: %v", err) - } - if result.Text != "Hello world" { - t.Errorf("text mismatch: got %q", result.Text) - } - if len(result.Segments) != 1 { - t.Fatalf("expected 1 segment, got %d", len(result.Segments)) - } - if result.Segments[0].Text != "Hello" { - t.Errorf("segment text mismatch: got %q", result.Segments[0].Text) - } - if result.Language == nil || *result.Language != "en" { - t.Error("language mismatch") - } -} - -// ── E2E: Reranking result parsing ───────────────────────────────────────────── - -func TestE2E_RerankingResultParsing(t *testing.T) { - result, err := ParseRerankingResult(`{ - "ranking": [ - {"index": 1, "relevanceScore": 0.95}, - {"index": 0, "relevanceScore": 0.30} - ], - "warnings": [] - }`) - if err != nil { - t.Fatalf("parse failed: %v", err) - } - if len(result.Ranking) != 2 { - t.Fatalf("expected 2 ranks, got %d", len(result.Ranking)) - } - if result.Ranking[0].Index != 1 { - t.Errorf("expected index 1, got %d", result.Ranking[0].Index) - } - if result.Ranking[0].RelevanceScore != 0.95 { - t.Errorf("expected score 0.95, got %f", result.Ranking[0].RelevanceScore) - } -} - -// ── E2E: Video result parsing ──────────────────────────────────────────────── - -func TestE2E_VideoResultParsing(t *testing.T) { - result, err := ParseVideoResult(`{ - "videos": [{"type": "url", "url": "https://example.com/video.mp4", "mediaType": "video/mp4"}], - "warnings": [], - "response": {"modelId": "veo"} - }`) - if err != nil { - t.Fatalf("parse failed: %v", err) - } - if len(result.Videos) != 1 { - t.Fatalf("expected 1 video, got %d", len(result.Videos)) - } - if result.Videos[0].Type != "url" || result.Videos[0].URL != "https://example.com/video.mp4" { - t.Error("video URL mismatch") - } -} - -func TestVideoPollOptionsWireFormat(t *testing.T) { - intervalMS := uint64(1_000) - timeoutMS := uint64(120_000) - opts := VideoCallOptions{ - Poll: &VideoPollOptions{ - IntervalMS: &intervalMS, - TimeoutMS: &timeoutMS, - }, - } - - encoded, err := json.Marshal(opts) - if err != nil { - t.Fatalf("marshal video options: %v", err) - } - var wire map[string]any - if err := json.Unmarshal(encoded, &wire); err != nil { - t.Fatalf("decode video options: %v", err) - } - poll, ok := wire["poll"].(map[string]any) - if !ok { - t.Fatalf("poll missing from wire format: %s", encoded) - } - if poll["intervalMs"] != float64(intervalMS) || poll["timeoutMs"] != float64(timeoutMS) { - t.Fatalf("unexpected poll wire format: %s", encoded) - } -} - -// ── E2E: Search result parsing ─────────────────────────────────────────────── - -func TestE2E_SearchResultParsing(t *testing.T) { - result, err := ParseSearchResult(`{ - "results": [ - {"title": "Rust", "url": "https://rust-lang.org", "content": "Rust is..."} - ], - "answer": "Rust is a systems language.", - "warnings": [] - }`) - if err != nil { - t.Fatalf("parse failed: %v", err) - } - if len(result.Results) != 1 { - t.Fatalf("expected 1 result, got %d", len(result.Results)) - } - if result.Results[0].Title == nil || *result.Results[0].Title != "Rust" { - t.Error("title mismatch") - } - if result.Answer == nil || *result.Answer != "Rust is a systems language." { - t.Error("answer mismatch") - } -} - -// ── E2E: Files result parsing ──────────────────────────────────────────────── - -func TestE2E_UploadFileResultParsing(t *testing.T) { - result, err := ParseUploadFileResult(`{ - "providerReference": {"openai": "file-abc123"}, - "mediaType": "application/pdf", - "filename": "doc.pdf", - "warnings": [] - }`) - if err != nil { - t.Fatalf("parse failed: %v", err) - } - if id, _ := result.ProviderReference.Get("openai"); id != "file-abc123" { - t.Error("provider reference mismatch") - } - if result.MediaType == nil || *result.MediaType != "application/pdf" { - t.Error("media type mismatch") - } -} - -// ── E2E: Reranking via mock server ──────────────────────────────────────────── - -func TestE2E_RerankWithOptions(t *testing.T) { - // Cohere reranking uses its own endpoint; we verify options marshaling. - docs := `{"type":"text","values":["doc1","doc2","doc3"]}` - opts := &RerankingCallOptions{ - Documents: json.RawMessage(docs), - Query: "which is most relevant?", - TopN: intPtr(2), - } - b, err := json.Marshal(opts) - if err != nil { - t.Fatalf("marshal failed: %v", err) - } - s := string(b) - if !strings.Contains(s, `"documents"`) { - t.Errorf("expected documents field: %s", s) - } - if !strings.Contains(s, `"query"`) { - t.Errorf("expected query field: %s", s) - } - if !strings.Contains(s, `"topN"`) { - t.Errorf("expected topN field: %s", s) - } -} - -// ── E2E: Files upload via mock server ──────────────────────────────────────── - -func TestE2E_FilesUploadViaMock(t *testing.T) { - srv := newMockServer() - defer srv.Close() - - // Simulate OpenAI files upload response. - srv.SetResponse(`{ - "id": "file-abc123", - "object": "file", - "bytes": 1024, - "created_at": 1234567890, - "filename": "test.pdf", - "purpose": "assistants" - }`) - - // Verify the mock server handles the upload request shape. - // (The C ABI constructor doesn't accept base_url, so we can't point - // it at the mock; this test validates the mock infrastructure and - // result parsing.) - req, _ := http.NewRequest("POST", srv.URL, strings.NewReader(`{"file":"base64data"}`)) - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - t.Fatalf("mock request failed: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - t.Fatalf("expected 200, got %d", resp.StatusCode) - } -} - -func intPtr(v int) *int { return &v } - -// TestRequiredOptsNilReturnsError verifies nil opts on a REQUIRED-opts entry -// point is a plain error, not a boundary panic. -func TestRequiredOptsNilReturnsError(t *testing.T) { - m, err := NewOpenAISpeech("sk-test-fake-key", "tts-1") - if err != nil { - t.Fatal(err) - } - defer m.Close() - if _, err := m.Generate(nil); err == nil || !strings.Contains(err.Error(), "opts is required") { - t.Fatalf("expected opts-required error, got: %v", err) - } -} - -func TestMultimodalCloseDoesNotWaitForInFlightCall(t *testing.T) { - server, requestStarted, releaseRequest := newBlockedHTTPServer( - t, "audio/mpeg", "test audio", - ) - defer releaseRequest() - - model, err := NewOpenAISpeechWithBase("sk-test-fake-key", "tts-1", server.URL) - if err != nil { - t.Fatalf("NewOpenAISpeechWithBase failed: %v", err) - } - defer model.Close() - - voice := "alloy" - format := "mp3" - generateDone := make(chan error, 1) - go func() { - _, err := model.Generate(&SpeechCallOptions{ - Text: "hello", - Voice: &voice, - OutputFormat: &format, - }) - generateDone <- err - }() - select { - case <-requestStarted: - case <-time.After(2 * time.Second): - t.Fatal("speech request did not start") - } - - closes := make([]func(), 32) - for i := range closes { - closes[i] = func() { - if err := model.Close(); err != nil { - t.Errorf("Close: %v", err) - } - } - } - runConcurrent(t, "multimodal Close during an in-flight call", closes...) - if got := model.h.handle.Load(); got != 0 { - t.Fatalf("multimodal handle after Close = %d, want 0", got) - } - - releaseRequest() - select { - case err := <-generateDone: - if err != nil { - t.Fatalf("in-flight Generate failed after Close: %v", err) - } - case <-time.After(2 * time.Second): - t.Fatal("in-flight Generate did not finish after releasing the response") - } - - if _, err = model.Generate(&SpeechCallOptions{Text: "after close"}); !errors.Is(err, ErrClosed) { - t.Fatalf("Generate after Close: expected ErrClosed, got %v", err) - } -} - -func TestStartTranscriptionSessionRejectsNilModel(t *testing.T) { - if _, err := StartTranscriptionSession(nil, nil); err == nil { - t.Fatal("nil transcription model: expected an error") - } -} - -func TestTranscriptionSessionCloseUnblocksBackpressuredPushAudio(t *testing.T) { - // Keeping the WebSocket handshake pending also keeps the driver from - // polling its 64-slot audio receiver, giving a deterministic full channel. - server, handshakeStarted, releaseHandshake := newBlockedHTTPServer(t, "", "") - defer releaseHandshake() - - model, err := NewOpenAITranscriptionWithBase( - "sk-test-fake-key", "gpt-realtime-whisper", server.URL, - ) - if err != nil { - t.Fatalf("NewOpenAITranscriptionWithBase failed: %v", err) - } - defer model.Close() - session, err := StartTranscriptionSession(model, nil) - if err != nil { - t.Fatalf("StartTranscriptionSession failed: %v", err) - } - - select { - case <-handshakeStarted: - case <-time.After(2 * time.Second): - t.Fatal("WebSocket handshake did not start") - } - for i := 0; i < 64; i++ { - if err := session.PushAudio([]byte{byte(i)}); err != nil { - session.Close() - t.Fatalf("PushAudio %d failed while filling the channel: %v", i, err) - } - } - - pushStarted := make(chan struct{}) - pushDone := make(chan error, 1) - go func() { - close(pushStarted) - pushDone <- session.PushAudio([]byte("backpressure")) - }() - <-pushStarted - select { - case err := <-pushDone: - session.Close() - t.Fatalf("PushAudio did not backpressure on a full channel: %v", err) - case <-time.After(200 * time.Millisecond): - } - - closeDone := make(chan struct{}) - go func() { - session.Close() - close(closeDone) - }() - select { - case <-closeDone: - case <-time.After(2 * time.Second): - releaseHandshake() - select { - case <-closeDone: - case <-time.After(2 * time.Second): - } - t.Fatal("Close did not wake backpressured PushAudio") - } - releaseHandshake() - - select { - case <-pushDone: - // The send may have linearized before teardown (success) or observe the - // closed receiver (error); this regression only requires bounded wakeup. - case <-time.After(2 * time.Second): - t.Fatal("backpressured PushAudio stayed blocked after Close returned") - } - session.Close() -} - -func TestTranscriptionSessionCloseUnblocksNextPart(t *testing.T) { - // Withhold the WebSocket handshake. The native driver cannot publish its - // first part, so NextPart(-1) is guaranteed to wait until Close aborts it. - server, handshakeStarted, releaseHandshake := newBlockedHTTPServer(t, "", "") - defer releaseHandshake() - - model, err := NewOpenAITranscriptionWithBase( - "sk-test-fake-key", "gpt-realtime-whisper", server.URL, - ) - if err != nil { - t.Fatalf("NewOpenAITranscriptionWithBase failed: %v", err) - } - defer model.Close() - session, err := StartTranscriptionSession(model, nil) - if err != nil { - t.Fatalf("StartTranscriptionSession failed: %v", err) - } - - select { - case <-handshakeStarted: - case <-time.After(2 * time.Second): - t.Fatal("WebSocket handshake did not start") - } - nextStarted := make(chan struct{}) - nextDone := make(chan error, 1) - go func() { - close(nextStarted) - _, err := session.NextPart(-1) - nextDone <- err - }() - <-nextStarted - select { - case err := <-nextDone: - session.Close() - t.Fatalf("NextPart(-1) returned before Close: %v", err) - case <-time.After(200 * time.Millisecond): - } - - closeDone := make(chan struct{}) - go func() { - session.Close() - close(closeDone) - }() - select { - case <-closeDone: - case <-time.After(2 * time.Second): - // Let the handshake fail so a lock-based implementation can unwind - // instead of leaking its blocked goroutines after the assertion. - releaseHandshake() - select { - case <-closeDone: - case <-time.After(2 * time.Second): - } - t.Fatal("Close did not wake NextPart(-1)") - } - releaseHandshake() - - select { - case err := <-nextDone: - var aimuxErr *Error - if !errors.As(err, &aimuxErr) || aimuxErr.Code != CodeAborted { - t.Fatalf("NextPart after Close: expected flat Aborted, got %v", err) - } - case <-time.After(2 * time.Second): - t.Fatal("NextPart(-1) stayed blocked after Close returned") - } - // Idempotence remains part of the public session contract. - session.Close() -} - -// Response bodies are the AI SDK's body?: unknown -// (language-model-v4-generate-result.ts:41, :56): an explicit null is kept -// apart from a missing body when decoded and encoded again. -func TestResponseBodyKeepsNullApartFromMissing(t *testing.T) { - for _, body := range []string{`,"body":null`, ``, `,"body":{"a":null}`} { - wire := `{"audio":"aGk=","warnings":[],"response":{"modelId":"tts-1"` + body + `}}` - result, err := ParseSpeechResult(wire) - if err != nil { - t.Fatalf("parse %s: %v", wire, err) - } - encoded, _ := json.Marshal(result.Response) - if want := `{"modelId":"tts-1"` + body + `}`; string(encoded) != want { - t.Errorf("body %q: got %s, want %s", body, encoded, want) - } - } - var info ResponseInfo - if err := json.Unmarshal([]byte(`{"body":null}`), &info); err != nil || string(info.Body) != "null" { - t.Errorf("ResponseInfo body null: %q, %v", info.Body, err) - } -} diff --git a/bindings/go/multimodal_types.go b/bindings/go/multimodal_types.go index 3a9ce5233..6d4c35f28 100644 --- a/bindings/go/multimodal_types.go +++ b/bindings/go/multimodal_types.go @@ -44,13 +44,9 @@ func (b *Bytes) UnmarshalJSON(data []byte) error { return nil } -// ── Shared types ──────────────────────────────────────────────────────────── - // Warning represents a provider warning. type Warning = json.RawMessage -// ── Embedding ──────────────────────────────────────────────────────────────── - // EmbeddingUsage is token usage for an embedding call (input tokens only). type EmbeddingUsage struct { Tokens uint32 `json:"tokens"` @@ -80,8 +76,6 @@ type EmbeddingCallOptions struct { Headers map[string]string `json:"headers,omitempty"` } -// ── Speech (TTS) ────────────────────────────────────────────────────────────── - // AudioData is generated audio: a base64 string on the wire, decoded to bytes. type AudioData = Bytes @@ -123,8 +117,6 @@ type SpeechCallOptions struct { Headers map[string]string `json:"headers,omitempty"` } -// ── Image ───────────────────────────────────────────────────────────────────── - // ImageOutputs is the generated images: base64 strings on the wire, decoded // to bytes. type ImageOutputs = []Bytes @@ -167,8 +159,6 @@ type ImageCallOptions struct { Headers map[string]string `json:"headers,omitempty"` } -// ── Transcription (STT) ─────────────────────────────────────────────────────── - // TranscriptionSegment is a transcript segment with timing. type TranscriptionSegment struct { // StartSecond/EndSecond are required f64 in the core (not Option), so they @@ -215,8 +205,6 @@ type TranscriptionCallOptions struct { Headers map[string]string `json:"headers,omitempty"` } -// ── Reranking ──────────────────────────────────────────────────────────────── - // RerankingRank is a single reranked entry. type RerankingRank struct { Index int `json:"index"` @@ -251,8 +239,6 @@ type RerankingCallOptions struct { Headers map[string]string `json:"headers,omitempty"` } -// ── Video ─────────────────────────────────────────────────────────────────── - // VideoData is generated video, tagged by Type: "url" (URL), "base64" or // "binary" (Data, decoded to bytes). type VideoData struct { @@ -303,8 +289,6 @@ type VideoCallOptions struct { Headers map[string]string `json:"headers,omitempty"` } -// ── Search ────────────────────────────────────────────────────────────────── - // SearchResultItem is a single search result. type SearchResultItem struct { Title *string `json:"title,omitempty"` @@ -343,8 +327,6 @@ type SearchCallOptions struct { Headers map[string]string `json:"headers,omitempty"` } -// ── Files ─────────────────────────────────────────────────────────────────── - // UploadFileResult is the result of a file upload. type UploadFileResult struct { ProviderReference ProviderReference `json:"providerReference"` diff --git a/bindings/go/multimodal_withbase_test.go b/bindings/go/multimodal_withbase_test.go deleted file mode 100644 index 276ea5f36..000000000 --- a/bindings/go/multimodal_withbase_test.go +++ /dev/null @@ -1,307 +0,0 @@ -// Tests for new multimodal constructors (Cohere/Google embedding, Google image) -// and WithBase variants — all 8 modalities tested via mock server. - -package aimux - -import ( - "encoding/json" - "testing" -) - -const base64Audio = "SGVsbG8gd29ybGQ=" - -// ── New constructor tests ─────────────────────────────────────────────────── - -func TestNewCohereEmbedding(t *testing.T) { - m, err := NewCohereEmbedding("sk-test", "embed-english-v3.0") - if err != nil { - t.Fatalf("NewCohereEmbedding: %v", err) - } - defer m.Close() -} - -func TestNewGoogleEmbedding(t *testing.T) { - m, err := NewGoogleEmbedding("sk-test", "gemini-embedding-001") - if err != nil { - t.Fatalf("NewGoogleEmbedding: %v", err) - } - defer m.Close() -} - -func TestNewGoogleImage(t *testing.T) { - m, err := NewGoogleImage("sk-test", "gemini-2.5-flash-image") - if err != nil { - t.Fatalf("NewGoogleImage: %v", err) - } - defer m.Close() -} - -// ── WithBase constructor tests ────────────────────────────────────────────── - -func TestNewOpenAIEmbeddingWithBase(t *testing.T) { - m, err := NewOpenAIEmbeddingWithBase("sk-test", "text-embedding-3-small", "http://localhost:9999") - if err != nil { - t.Fatalf("NewOpenAIEmbeddingWithBase: %v", err) - } - defer m.Close() -} - -func TestNewCohereEmbeddingWithBase(t *testing.T) { - m, err := NewCohereEmbeddingWithBase("sk-test", "embed-english-v3.0", "http://localhost:9999") - if err != nil { - t.Fatalf("NewCohereEmbeddingWithBase: %v", err) - } - defer m.Close() -} - -func TestNewGoogleEmbeddingWithBase(t *testing.T) { - m, err := NewGoogleEmbeddingWithBase("sk-test", "gemini-embedding-001", "http://localhost:9999") - if err != nil { - t.Fatalf("NewGoogleEmbeddingWithBase: %v", err) - } - defer m.Close() -} - -func TestNewOpenAISpeechWithBase(t *testing.T) { - m, err := NewOpenAISpeechWithBase("sk-test", "tts-1", "http://localhost:9999") - if err != nil { - t.Fatalf("NewOpenAISpeechWithBase: %v", err) - } - defer m.Close() -} - -func TestNewOpenAIImageWithBase(t *testing.T) { - m, err := NewOpenAIImageWithBase("sk-test", "dall-e-3", "http://localhost:9999") - if err != nil { - t.Fatalf("NewOpenAIImageWithBase: %v", err) - } - defer m.Close() -} - -func TestNewGoogleImageWithBase(t *testing.T) { - m, err := NewGoogleImageWithBase("sk-test", "gemini-2.5-flash-image", "http://localhost:9999") - if err != nil { - t.Fatalf("NewGoogleImageWithBase: %v", err) - } - defer m.Close() -} - -func TestNewOpenAITranscriptionWithBase(t *testing.T) { - m, err := NewOpenAITranscriptionWithBase("sk-test", "whisper-1", "http://localhost:9999") - if err != nil { - t.Fatalf("NewOpenAITranscriptionWithBase: %v", err) - } - defer m.Close() -} - -func TestNewOpenAIFilesWithBase(t *testing.T) { - m, err := NewOpenAIFilesWithBase("sk-test", "http://localhost:9999") - if err != nil { - t.Fatalf("NewOpenAIFilesWithBase: %v", err) - } - defer m.Close() -} - -func TestNewCohereRerankingWithBase(t *testing.T) { - m, err := NewCohereRerankingWithBase("sk-test", "rerank-v3.0", "http://localhost:9999") - if err != nil { - t.Fatalf("NewCohereRerankingWithBase: %v", err) - } - defer m.Close() -} - -func TestNewGoogleVideoWithBase(t *testing.T) { - m, err := NewGoogleVideoWithBase("sk-test", "veo-3.0", "http://localhost:9999") - if err != nil { - t.Fatalf("NewGoogleVideoWithBase: %v", err) - } - defer m.Close() -} - -func TestNewTavilySearchWithBase(t *testing.T) { - m, err := NewTavilySearchWithBase("sk-test", "http://localhost:9999") - if err != nil { - t.Fatalf("NewTavilySearchWithBase: %v", err) - } - defer m.Close() -} - -// ── E2E: full FFI call via mock server ────────────────────────────────────── - -func TestE2E_EmbeddingViaMock(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetResponse(`{"data":[{"embedding":[0.1,0.2,0.3],"index":0}],"model":"text-embedding-3-small","usage":{"prompt_tokens":3,"total_tokens":3}}`) - - m, err := NewOpenAIEmbeddingWithBase("sk-test", "text-embedding-3-small", srv.URL) - if err != nil { - t.Fatal(err) - } - defer m.Close() - - r, err := ParseEmbeddingResult(must(m.Embed([]string{"hello"}, nil))) - if err != nil { - t.Fatal(err) - } - if len(r.Embeddings) != 1 || len(r.Embeddings[0]) != 3 { - t.Fatalf("got %d embeddings, dim %d", len(r.Embeddings), len(r.Embeddings[0])) - } -} - -func TestE2E_SpeechViaMock(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetContentType("audio/mpeg") - srv.SetResponse(base64Audio) // raw binary body - - m, err := NewOpenAISpeechWithBase("sk-test", "tts-1", srv.URL) - if err != nil { - t.Fatal(err) - } - defer m.Close() - - voice := "alloy" - fmt_ := "mp3" - r, err := ParseSpeechResult(must(m.Generate(&SpeechCallOptions{Text: "Hi", Voice: &voice, OutputFormat: &fmt_}))) - if err != nil { - t.Fatal(err) - } - if len(r.Audio) == 0 { - t.Error("empty audio") - } -} - -func TestE2E_ImageViaMock(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetResponse(`{"data":[{"b64_json":"aW1hZ2Ux"}]}`) - - m, err := NewOpenAIImageWithBase("sk-test", "dall-e-3", srv.URL) - if err != nil { - t.Fatal(err) - } - defer m.Close() - - prompt := "otter" - n := 1 - r, err := ParseImageResult(must(m.Generate(&ImageCallOptions{Prompt: &prompt, N: n}))) - if err != nil { - t.Fatal(err) - } - if len(r.Images) != 1 { - t.Fatalf("got %d images", len(r.Images)) - } -} - -func TestE2E_TranscriptionViaMock(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetResponse(`{"text":"Hello world"}`) - - m, err := NewOpenAITranscriptionWithBase("sk-test", "whisper-1", srv.URL) - if err != nil { - t.Fatal(err) - } - defer m.Close() - - r, err := ParseTranscriptionResult(must(m.Generate("dGVzdA==", "audio/mp3", nil))) - if err != nil { - t.Fatal(err) - } - if r.Text != "Hello world" { - t.Errorf("got %q", r.Text) - } -} - -func TestE2E_SearchViaMock(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetResponse(`{"results":[{"title":"Rust","url":"https://rust-lang.org","content":"Rust is..."}],"answer":"Rust is a systems language."}`) - - m, err := NewTavilySearchWithBase("sk-test", srv.URL) - if err != nil { - t.Fatal(err) - } - defer m.Close() - - maxR := 5 - r, err := ParseSearchResult(must(m.Search(&SearchCallOptions{Query: "What is Rust?", MaxResults: &maxR}))) - if err != nil { - t.Fatal(err) - } - if len(r.Results) != 1 || r.Results[0].Title == nil || *r.Results[0].Title != "Rust" { - t.Fatal("search result mismatch") - } -} - -func TestE2E_FilesViaMock(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetResponse(`{"id":"file-abc","object":"file","bytes":1024,"created_at":1234,"filename":"test.pdf","purpose":"assistants"}`) - - m, err := NewOpenAIFilesWithBase("sk-test", srv.URL) - if err != nil { - t.Fatal(err) - } - defer m.Close() - - r, err := ParseUploadFileResult(must(m.Upload("dGVzdA==", "application/pdf", nil))) - if err != nil { - t.Fatal(err) - } - if id, _ := r.ProviderReference.Get("openai"); id != "file-abc" { - t.Errorf("got %s", r.ProviderReference) - } -} - -func TestE2E_RerankingViaMock(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetResponse(`{"results":[{"index":1,"relevance_score":0.95},{"index":0,"relevance_score":0.3}]}`) - - m, err := NewCohereRerankingWithBase("sk-test", "rerank-v3.0", srv.URL) - if err != nil { - t.Fatal(err) - } - defer m.Close() - - topN := 2 - r, err := ParseRerankingResult(must(m.Rerank(&RerankingCallOptions{ - Query: "which?", - Documents: json.RawMessage(`{"type":"text","values":["doc1","doc2"]}`), - TopN: &topN, - }))) - if err != nil { - t.Fatal(err) - } - if len(r.Ranking) != 2 || r.Ranking[0].RelevanceScore != 0.95 { - t.Fatal("reranking mismatch") - } -} - -func TestE2E_VideoViaMock(t *testing.T) { - // Google Video uses a multi-step async API (POST predict → poll operation → - // fetch result). A single-response mock server can't cover the full flow. - // Verify construction + result parsing only. - m, err := NewGoogleVideoWithBase("sk-test", "veo-3.0", "http://localhost:9999") - if err != nil { - t.Fatal(err) - } - defer m.Close() - - r, err := ParseVideoResult(`{"videos":[{"type":"url","url":"https://example.com/v.mp4","mediaType":"video/mp4"}],"warnings":[],"response":{}}`) - if err != nil { - t.Fatal(err) - } - if len(r.Videos) != 1 || r.Videos[0].Type != "url" || r.Videos[0].URL != "https://example.com/v.mp4" { - t.Fatal("video result parse mismatch") - } -} - -// must is a test helper: fails the test if the FFI call returns an error. -func must(s string, err error) string { - if err != nil { - panic(err) - } - return s -} diff --git a/bindings/go/native.go b/bindings/go/native.go new file mode 100644 index 000000000..2e80e26dd --- /dev/null +++ b/bindings/go/native.go @@ -0,0 +1,163 @@ +package aimux + +/* +#cgo CFLAGS: -I${SRCDIR}/../../aimux-ffi/include +#cgo linux LDFLAGS: -L${SRCDIR}/../../target/release -Wl,-Bstatic -laimux_ffi -Wl,-Bdynamic -lpthread -ldl -lm +#cgo darwin LDFLAGS: -L${SRCDIR}/../../target/release -laimux_ffi +#cgo windows LDFLAGS: -L${SRCDIR}/../../target/release -Wl,-Bstatic -laimux_ffi -Wl,-Bdynamic -lws2_32 -lbcrypt -lntdll -luserenv +#include +#include +#include "aimux.h" + +extern void goStreamFrame(uintptr_t context, char *frame); +static void stream_trampoline(const char *frame, void *context) { + goStreamFrame((uintptr_t)context, (char *)frame); +} +static aimux_error_t *stream_call(uint64_t handle, const char *op, + const char *params, uint64_t abort, uintptr_t context) { + return aimux_stream(handle, op, params, abort, stream_trampoline, (void *)context); +} +// Bound the scan before allocating a Go copy of an untrusted native frame. +static size_t frame_length(const char *frame, size_t limit) { + if (!frame) return 0; + size_t n = 0; + while (n < limit && frame[n]) ++n; + return n; +} +static void finalize_handle(uint64_t id) { + aimux_handle_finalizer((void *)(uintptr_t)id); +} +*/ +import "C" + +import ( + "encoding/json" + "fmt" + "runtime" + "runtime/cgo" + "unsafe" +) + +func nativeABIVersion() uint32 { return uint32(C.aimux_abi_version()) } +func nativeDrop(id uint64) { C.aimux_drop(C.uint64_t(id)) } +func nativeAbort(id uint64) { C.aimux_abort(C.uint64_t(id)) } +func nativeFinalize(id uint64) { C.finalize_handle(C.uint64_t(id)) } + +func ownedString(s *C.char) []byte { + if s == nil { + return nil + } + defer C.aimux_free_string(s) + return []byte(C.GoString(s)) +} + +func nativeError(e *C.aimux_error_t) error { + if e == nil { + return nil + } + defer C.aimux_error_free(e) + raw := ownedString(C.aimux_error_json(e)) + result, err := decodeError(raw) + if err != nil { + return localError("binding", 205, "ResultSerialization", fmt.Sprintf("invalid native error envelope: %v", err)) + } + return result +} + +func nativeResult(out *C.char, e *C.aimux_error_t) (json.RawMessage, error) { + raw := ownedString(out) + if err := nativeError(e); err != nil { + return nil, err + } + if err := validateJSON("result", raw); err != nil { + return nil, localError("binding", 205, "ResultSerialization", err.Error()) + } + return json.RawMessage(raw), nil +} + +func nativeManifest() (json.RawMessage, error) { + var out *C.char + e := C.aimux_manifest_json(&out) + return nativeResult(out, e) +} + +func nativeCall(id uint64, op string, params []byte, abort uint64) (json.RawMessage, error) { + cOp, cParams := C.CString(op), C.CString(string(params)) + defer C.free(unsafe.Pointer(cOp)) + defer C.free(unsafe.Pointer(cParams)) + var out *C.char + e := C.aimux_call(C.uint64_t(id), cOp, cParams, C.uint64_t(abort), &out) + return nativeResult(out, e) +} + +func nativeStream(id uint64, op string, params []byte, abort uint64, s *Stream) error { + cOp, cParams := C.CString(op), C.CString(string(params)) + defer C.free(unsafe.Pointer(cOp)) + defer C.free(unsafe.Pointer(cParams)) + // Only callback state is stored in cgo.Handle. Rust sees an integer token, + // never a retained Go pointer. Delete only after all callbacks have exited. + callback := cgo.NewHandle(s) + activeCallbacks.Add(1) + defer func() { callback.Delete(); activeCallbacks.Add(-1) }() + e := C.stream_call(C.uint64_t(id), cOp, cParams, C.uint64_t(abort), C.uintptr_t(callback)) + return nativeError(e) +} + +//export goStreamFrame +func goStreamFrame(token C.uintptr_t, frame *C.char) { + s := cgo.Handle(token).Value().(*Stream) + defer func() { + if value := recover(); value != nil { + s.fail(localError("binding", 206, "CallbackFailure", fmt.Sprintf("callback panic: %v", value))) + } + }() + if s.failure != nil { + return + } + n := C.frame_length(frame, C.size_t(maxFrameBytes+1)) + if frame == nil { + s.fail(localError("binding", 206, "CallbackFailure", "NULL stream frame")) + return + } + if n > maxFrameBytes { + s.fail(localError("protocol", 305, "ResourceLimit", "stream frame exceeds the data and control budget")) + return + } + // Native memory is valid only for this callback. Copy before returning. + s.receive(C.GoBytes(unsafe.Pointer(frame), C.int(n))) +} + +func nativePush(id uint64, data []byte) error { + var p *C.uint8_t + if len(data) != 0 { + p = (*C.uint8_t)(unsafe.Pointer(&data[0])) + } + e := C.aimux_operation_push(C.uint64_t(id), p, C.size_t(len(data))) + runtime.KeepAlive(data) + return nativeError(e) +} + +func nativeNext(id uint64, timeoutMs int64) (PollResult, error) { + var out *C.char + var state C.int32_t + e := C.aimux_operation_next_part(C.uint64_t(id), C.int64_t(timeoutMs), &out, &state) + raw := ownedString(out) + if err := nativeError(e); err != nil { + return PollResult{}, err + } + result := PollResult{State: PollState(state)} + switch result.State { + case Part: + if err := validateJSON("operation part", raw); err != nil { + return PollResult{}, localError("binding", 205, "ResultSerialization", err.Error()) + } + result.Part = json.RawMessage(raw) + case Ended, PollTimeout: + if raw != nil { + return PollResult{}, localError("binding", 205, "ResultSerialization", "unexpected JSON for poll state") + } + default: + return PollResult{}, localError("binding", 205, "ResultSerialization", "unknown poll state") + } + return result, nil +} diff --git a/bindings/go/native_test.go b/bindings/go/native_test.go new file mode 100644 index 000000000..e19e26c50 --- /dev/null +++ b/bindings/go/native_test.go @@ -0,0 +1,177 @@ +package aimux + +import ( + "context" + "encoding/json" + "errors" + "os" + "sync" + "testing" + "time" + + "github.com/arcships/aimux/bindings/go/generated" +) + +func realRuntime(t *testing.T) { + t.Helper() + if os.Getenv("AIMUX_TEST_ABI") == "1" { + t.Skip("requires the Rust library; run ./test-native.sh") + } +} + +func runtimeProvider(t *testing.T) *Handle { + t.Helper() + realRuntime(t) + // Tests use parameter validation and unsupported methods before network I/O. + h, err := Create(context.Background(), nil, generated.OP_PROVIDER_CREATE, map[string]any{ + "package": "openai", + "settings": map[string]any{"apiKey": "test-key", "baseUrl": "http://example.invalid/v1"}, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { h.Close() }) + return h +} + +func runtimeModel(t *testing.T, provider *Handle, method, modelID string) *Handle { + t.Helper() + h, err := Create(context.Background(), provider, generated.OP_PROVIDER_MODEL, map[string]any{ + "method": method, "modelId": modelID, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { h.Close() }) + return h +} + +func TestNativeDispatchAndCompositeLifetime(t *testing.T) { + provider := runtimeProvider(t) + model := runtimeModel(t, provider, "languageModel", "gpt-4o-mini") + id, _ := model.ID() + composite, err := Create(context.Background(), nil, generated.OP_COMPOSITE_ROUTER, map[string]any{ + "children": []HandleID{id}, "strategy": map[string]any{"type": "rule"}, + }) + if err != nil { + t.Fatal(err) + } + defer composite.Close() + model.Close() + provider.Close() + info, err := CallInto[struct{ Modality string }](context.Background(), composite, generated.OP_MODEL_INFO, struct{}{}) + if err != nil || info.Modality != "language" { + t.Fatalf("retained composite: %+v, %v", info, err) + } + _, err = composite.Call(context.Background(), generated.OP_TEXT_GENERATE, struct{}{}) + checkCode(t, err, "protocol", 301) + _, err = Call(context.Background(), "not.an.operation", struct{}{}) + checkCode(t, err, "protocol", 300) + _, err = Call(context.Background(), generated.OP_PROVIDER_CREATE, nil) + checkCode(t, err, "protocol", 301) + _, err = Call(context.Background(), generated.OP_OPERATION_PUSH, struct{}{}) + checkCode(t, err, "protocol", 300) +} + +func TestNativeCloseGenerateRace(t *testing.T) { + provider := runtimeProvider(t) + for i := 0; i < 30; i++ { + model := runtimeModel(t, provider, "languageModel", "gpt-4o-mini") + start := make(chan struct{}) + var wg sync.WaitGroup + for j := 0; j < 8; j++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + // Invalid parameters fail before invoking the provider. + _, err := model.Call(context.Background(), generated.OP_TEXT_GENERATE, struct{}{}) + var native *Error + if !errors.Is(err, ErrClosed) && (!errors.As(err, &native) || (native.Envelope.Code != 203 && native.Envelope.Code != 301)) { + t.Errorf("unexpected concurrent call result: %v", err) + } + }() + } + close(start) + model.Close() + model.Close() + wg.Wait() + } +} + +func TestNativeStreamAdmissionAndTerminal(t *testing.T) { + provider := runtimeProvider(t) + model := runtimeModel(t, provider, "languageModel", "gpt-4o-mini") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if s, err := model.Stream(ctx, generated.OP_TEXT_STREAM, struct{}{}); s != nil || !errors.Is(err, context.Canceled) { + t.Fatalf("pre-cancel: %v, %v", s, err) + } + s, err := model.Stream(context.Background(), generated.OP_TEXT_STREAM, struct{}{}) + if s != nil { + t.Fatal("invalid stream admitted") + } + checkCode(t, err, "protocol", 301) + s, err = model.Stream(context.Background(), generated.OP_TEXT_STREAM, map[string]any{ + "prompt": []any{}, "options": map[string]any{"maxRetries": 0}, + }) + if err != nil { + t.Fatal(err) + } + defer s.Close() + for event := range s.Events() { + if !json.Valid(event) { + t.Fatalf("invalid event: %s", event) + } + } + terminal, err := s.Terminal() + if terminal == nil || terminal.Outcome != "error" || err == nil { + t.Fatalf("provider setup terminal: %+v, %v", terminal, err) + } + var native *Error + if !errors.As(err, &native) || !json.Valid(native.RawEnvelope) { + t.Fatalf("lost native error: %v", err) + } + s.Cancel() + if s.Err() != err { + t.Fatal("late cancellation changed terminal error") + } + if activeCallbacks.Load() != 0 { + t.Fatal("native callback handle leaked") + } +} + +func TestNativeOperationTerminalPersistence(t *testing.T) { + provider := runtimeProvider(t) + // Non-realtime models return UnsupportedFunctionality without connecting. + model := runtimeModel(t, provider, "transcriptionModel", "whisper-1") + o, err := model.StartOperation(context.Background(), struct{}{}) + if err != nil { + t.Fatal(err) + } + defer o.Close() + _, err = o.NextPart(-time.Millisecond) + checkCode(t, err, "protocol", 301) + _, err = o.NextPart(5 * time.Second) + terminalError := checkCode(t, err, "aimux", 8) + result, err := o.NextPart(0) + if err != nil || result.State != Ended || result.Part != nil { + t.Fatalf("ended poll: %+v, %v", result, err) + } + status, err := CallInto[struct { + State string + Terminal struct{ Error *ErrorEnvelope } + }](context.Background(), o.handle, generated.OP_OPERATION_STATUS, struct{}{}) + if err != nil { + t.Fatal(err) + } + if status.State != "terminal" || status.Terminal.Error == nil || status.Terminal.Error.Code != terminalError.Envelope.Code { + t.Fatalf("persistent terminal: %+v", status) + } + o.Cancel() + o.Close() + o.Close() + if !errors.Is(o.Push(nil), ErrClosed) { + t.Fatal("push after operation close") + } +} diff --git a/bindings/go/operation.go b/bindings/go/operation.go new file mode 100644 index 000000000..e8aaa3d9b --- /dev/null +++ b/bindings/go/operation.go @@ -0,0 +1,153 @@ +package aimux + +import ( + "context" + "encoding/json" + "runtime" + "sync" + "time" + + "github.com/arcships/aimux/bindings/go/generated" +) + +type PollState int32 + +const ( + Part PollState = 1 + Ended PollState = 2 + PollTimeout PollState = 3 +) + +type PollResult struct { + State PollState + Part json.RawMessage +} + +// Operation owns its native operation and a cancellation signal for its +// entire lifetime, including waits for push or poll queue capacity. +type Operation struct { + handle *Handle + abort *Handle + stop func() + stopOnce sync.Once +} + +// StartOperation calls transcription.start. The context remains connected +// to the native Abort until the operation ends or Close returns. +func (h *Handle) StartOperation(ctx context.Context, params any) (*Operation, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + data, err := encodeParams(generated.OP_TRANSCRIPTION_START, params) + if err != nil { + return nil, err + } + id, err := h.ID() + if err != nil { + return nil, err + } + defer runtime.KeepAlive(h) + if err := CheckABI(); err != nil { + return nil, err + } + abort, err := createAbort() + if err != nil { + return nil, err + } + stop := watchAbort(ctx, abort) + raw, err := nativeCall(uint64(id), generated.OP_TRANSCRIPTION_START, data, abort.id.Load()) + if err != nil { + stop() + abort.Close() + return nil, err + } + handle, err := handleFromResult(raw) + if err != nil { + stop() + abort.Close() + return nil, err + } + o := &Operation{handle: handle, abort: abort, stop: stop} + runtime.SetFinalizer(o, func(o *Operation) { go o.Close() }) + return o, nil +} + +func (o *Operation) ID() (HandleID, error) { + if o == nil { + return 0, ErrClosed + } + return o.handle.ID() +} + +func (o *Operation) Push(data []byte) error { + id, err := o.ID() + if err != nil { + return err + } + defer runtime.KeepAlive(o) + if len(data) > maxItemBytes { + return localError("protocol", 305, "ResourceLimit", "audio chunk exceeds 1 MiB") + } + return nativePush(uint64(id), data) +} + +// NextPart uses PART=1, ENDED=2 and TIMEOUT=3. Timeout must be nonnegative; +// zero polls without waiting. Only one reader is allowed by +// the native operation. An operation error is delivered once, then ENDED. +func (o *Operation) NextPart(timeout time.Duration) (PollResult, error) { + if timeout < 0 { + return PollResult{}, localError("protocol", 301, "InvalidParams", "timeout must be nonnegative") + } + id, err := o.ID() + if err != nil { + return PollResult{}, err + } + defer runtime.KeepAlive(o) + ms := timeout.Milliseconds() + if timeout > 0 && ms == 0 { + ms = 1 + } + result, err := nativeNext(uint64(id), ms) + if err == nil && result.State == Ended { + o.stopOnce.Do(o.stop) + } + return result, err +} + +func (o *Operation) InputDone(ctx context.Context) error { + if o == nil { + return ErrClosed + } + _, err := o.handle.Call(ctx, generated.OP_OPERATION_INPUT_DONE, struct{}{}) + runtime.KeepAlive(o) + return err +} + +func (o *Operation) Status(ctx context.Context) (json.RawMessage, error) { + if o == nil { + return nil, ErrClosed + } + result, err := o.handle.Call(ctx, generated.OP_OPERATION_STATUS, struct{}{}) + runtime.KeepAlive(o) + return result, err +} + +func (o *Operation) Cancel() { + if o != nil { + signalAbort(o.abort) + runtime.KeepAlive(o) + } +} + +func (o *Operation) Close() error { + if o == nil { + return nil + } + // Stop/join the context watcher before releasing its signal handle. + o.Cancel() + o.stopOnce.Do(o.stop) + o.handle.Close() + o.abort.Close() + runtime.KeepAlive(o) + return nil +} diff --git a/bindings/go/repair.go b/bindings/go/repair.go deleted file mode 100644 index 697865a07..000000000 --- a/bindings/go/repair.go +++ /dev/null @@ -1,277 +0,0 @@ -// Host-side tool-call repair (RFC-0035). -// -// An unparseable tool call never fails generation: it comes back with -// `invalid: true` and an `error`. Repair is therefore post-processing done -// here, in Go — the binding calls the normal entry point, then feeds every -// invalid call through three pure native functions (no handle, no callback, -// no session; JSON in, JSON out) with the caller's repair function in between. - -package aimux - -/* -#include -#include "aimux-ffi.h" -*/ -import "C" - -import ( - "encoding/json" - "fmt" - "unsafe" -) - -// RawToolCall is a tool call as the provider emitted it: Input is the raw -// argument *text*, not a parsed object. It is what a repair function reads -// and what it returns. Its JSON is the AI SDK's LanguageModelV4ToolCall, -// tagged {"type":"tool-call",...}. -type RawToolCall struct { - ToolCallID string `json:"toolCallId"` - ToolName string `json:"toolName"` - Input string `json:"input"` - ProviderExecuted *bool `json:"providerExecuted,omitempty"` - Dynamic *bool `json:"dynamic,omitempty"` - ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` -} - -// MarshalJSON writes the call with its "type":"tool-call" tag first. -func (c RawToolCall) MarshalJSON() ([]byte, error) { - type plain RawToolCall - return json.Marshal(struct { - Type string `json:"type"` - plain - }{"tool-call", plain(c)}) -} - -// UnmarshalJSON reads a call; a "type" field, when present, must be -// "tool-call". -func (c *RawToolCall) UnmarshalJSON(data []byte) error { - type plain RawToolCall - var tagged struct { - Type json.RawMessage `json:"type"` - plain - } - if err := json.Unmarshal(data, &tagged); err != nil { - return err - } - if err := checkTypeTag(tagged.Type, "tool-call"); err != nil { - return err - } - *c = RawToolCall(tagged.plain) - return nil -} - -// ToolCallRepairContext is the argument handed to a RepairToolCallFunc — the -// same shape as the AI SDK's repairToolCall argument. -// -// Messages and Instructions are the transcript the model saw, derived by the -// engine from the prompt and options of the call being repaired. -type ToolCallRepairContext struct { - // ToolCall is the failed call, with its raw argument text. - ToolCall RawToolCall `json:"toolCall"` - // Error is the serialized AiMuxError (AI SDK error JSON, tagged by "name" the call carries (e.g. - // {"name":"AI_InvalidToolInputError",...} or {"name":"AI_NoSuchToolError",...}). - Error json.RawMessage `json:"error"` - // InputSchema is the JSON schema of the named tool, or an empty-object - // schema when the name does not resolve to a tool. - InputSchema json.RawMessage `json:"inputSchema"` - Tools []Tool `json:"tools"` - Messages []ModelMessage `json:"messages"` - Instructions *string `json:"instructions"` -} - -// RepairToolCallFunc repairs one invalid tool call. -// -// Return a replacement call to have it re-parsed and re-validated, nil to -// leave the call invalid as it is, or an error to record the repair attempt -// as failed (the call stays invalid, carrying a ToolCallRepair error whose -// cause is the error's message). It runs outside any native call, so it may -// itself call back into aimux — e.g. ask a model to fix the arguments. -// -// At most one attempt is made per call. -type RepairToolCallFunc func(ctx *ToolCallRepairContext) (*RawToolCall, error) - -// ── Native wrappers ───────────────────────────────────────────────────────── - -// cOptsString turns an options JSON string into a C string ("" → NULL, which -// the C ABI reads as "defaults"). The returned func frees it. -func cOptsString(optsJSON string) (*C.char, func()) { - if optsJSON == "" { - return nil, func() {} - } - p := C.CString(optsJSON) - return p, func() { C.free(unsafe.Pointer(p)) } -} - -// toolCallRepairContextJSON builds the repair argument for one invalid call. -// It returns the JSON literal "null" when the call was made without a tool -// set: such a call is never repaired (the AI SDK rule), so the caller skips it. -func toolCallRepairContextJSON(toolCallJSON, promptJSON, optsJSON string) (string, error) { - cCall := C.CString(toolCallJSON) - defer C.free(unsafe.Pointer(cCall)) - cPrompt := C.CString(promptJSON) - defer C.free(unsafe.Pointer(cPrompt)) - cOpts, freeOpts := cOptsString(optsJSON) - defer freeOpts() - - return ffiString(func(out **C.char) *C.aimux_error_t { - return C.aimux_tool_call_repair_context(cCall, cPrompt, cOpts, out) - }) -} - -// applyToolCallRepairJSON resolves one tool call against a reply, returning -// the resulting ToolCall JSON. -func applyToolCallRepairJSON(toolCallJSON, optsJSON, replyJSON string) (string, error) { - cCall := C.CString(toolCallJSON) - defer C.free(unsafe.Pointer(cCall)) - cReply := C.CString(replyJSON) - defer C.free(unsafe.Pointer(cReply)) - cOpts, freeOpts := cOptsString(optsJSON) - defer freeOpts() - - return ffiString(func(out **C.char) *C.aimux_error_t { - return C.aimux_apply_tool_call_repair(cCall, cOpts, cReply, out) - }) -} - -// applyToolCallRepairToResultJSON patches a whole result document: both -// toolCalls and the matching responseMessages tool-call part. -func applyToolCallRepairToResultJSON(resultJSON, optsJSON, toolCallID, replyJSON string) (string, error) { - cResult := C.CString(resultJSON) - defer C.free(unsafe.Pointer(cResult)) - cID := C.CString(toolCallID) - defer C.free(unsafe.Pointer(cID)) - cReply := C.CString(replyJSON) - defer C.free(unsafe.Pointer(cReply)) - cOpts, freeOpts := cOptsString(optsJSON) - defer freeOpts() - - return ffiString(func(out **C.char) *C.aimux_error_t { - return C.aimux_apply_tool_call_repair_to_result(cResult, cOpts, cID, cReply, out) - }) -} - -// ── Host loop ─────────────────────────────────────────────────────────────── - -// repairReply runs the user's function over a context JSON and encodes its -// outcome as a ToolCallRepairReply. A returned error becomes a "failed" -// reply — it is the user's verdict on this call, not a failure of the call. -func repairReply(repair RepairToolCallFunc, contextJSON string) (string, error) { - var ctx ToolCallRepairContext - if err := json.Unmarshal([]byte(contextJSON), &ctx); err != nil { - return "", fmt.Errorf("aimux: failed to parse tool-call repair context: %w", err) - } - replacement, err := repair(&ctx) - if err != nil { - reply, mErr := json.Marshal(struct { - Type string `json:"type"` - Message string `json:"message"` - }{"failed", err.Error()}) - if mErr != nil { - return "", fmt.Errorf("aimux: failed to encode repair failure: %w", mErr) - } - return string(reply), nil - } - if replacement == nil { - return `{"type":"unchanged"}`, nil - } - reply, err := json.Marshal(struct { - Type string `json:"type"` - ToolCall *RawToolCall `json:"toolCall"` - }{"repaired", replacement}) - if err != nil { - return "", fmt.Errorf("aimux: failed to encode repaired tool call: %w", err) - } - return string(reply), nil -} - -// invalidToolCalls lists the invalid tool calls of a result document, in -// order, as (id, JSON) pairs: the top-level toolCalls of a -// GenerateTextResult, generated or consumed from a stream. A -// GenerateObjectResult has none. -func invalidToolCalls(resultJSON string) [][2]string { - var doc struct { - ToolCalls []json.RawMessage `json:"toolCalls"` - } - if json.Unmarshal([]byte(resultJSON), &doc) != nil { - return nil - } - calls := doc.ToolCalls - var invalid [][2]string - for _, call := range calls { - var head struct { - ToolCallID string `json:"toolCallId"` - Invalid *bool `json:"invalid"` - } - if json.Unmarshal(call, &head) != nil || head.Invalid == nil || !*head.Invalid { - continue - } - invalid = append(invalid, [2]string{head.ToolCallID, string(call)}) - } - return invalid -} - -// repairResultJSON is the non-streaming repair loop, shared by every typed -// entry point that returns a whole document: for each invalid call, build its -// context, ask the user's function, and patch the document with the answer. -// The result is the patched JSON, ready to decode. Patching one call never -// touches another, so the calls are collected once up front. -func repairResultJSON(resultJSON, promptJSON, optsJSON string, repair RepairToolCallFunc) (string, error) { - for _, call := range invalidToolCalls(resultJSON) { - id, callJSON := call[0], call[1] - contextJSON, err := toolCallRepairContextJSON(callJSON, promptJSON, optsJSON) - if err != nil { - return "", err - } - if contextJSON == "null" { - continue - } - reply, err := repairReply(repair, contextJSON) - if err != nil { - return "", err - } - resultJSON, err = applyToolCallRepairToResultJSON(resultJSON, optsJSON, id, reply) - if err != nil { - return "", err - } - } - return resultJSON, nil -} - -// repairStreamToolCall is the streaming counterpart: it resolves one -// {"type":"tool-call",…} stream part, returning the payload to deliver. -// A valid call, or one made without a tool set, passes through untouched. -// Tool-input delta parts never reach here — they are forwarded immediately, -// as the AI SDK does. -func repairStreamToolCall(payload []byte, promptJSON, optsJSON string, repair RepairToolCallFunc) ([]byte, error) { - var call struct { - Invalid *bool `json:"invalid"` - } - if err := json.Unmarshal(payload, &call); err != nil || call.Invalid == nil || !*call.Invalid { - return payload, nil - } - contextJSON, err := toolCallRepairContextJSON(string(payload), promptJSON, optsJSON) - if err != nil { - return nil, err - } - if contextJSON == "null" { - return payload, nil - } - reply, err := repairReply(repair, contextJSON) - if err != nil { - return nil, err - } - repaired, err := applyToolCallRepairJSON(string(payload), optsJSON, reply) - if err != nil { - return nil, err - } - return []byte(repaired), nil -} - -// maybeRepairResult applies the non-streaming repair loop when opts carry a -// repair function, and is a pass-through otherwise. -func maybeRepairResult(resultJSON, promptJSON, optsJSON string, opts *GenerateTextOptions) (string, error) { - if opts == nil || opts.RepairToolCall == nil { - return resultJSON, nil - } - return repairResultJSON(resultJSON, promptJSON, optsJSON, opts.RepairToolCall) -} diff --git a/bindings/go/repair_types.go b/bindings/go/repair_types.go new file mode 100644 index 000000000..32f20afc6 --- /dev/null +++ b/bindings/go/repair_types.go @@ -0,0 +1,61 @@ +package aimux + +import "encoding/json" + +// RawToolCall is a tool call as the provider emitted it: Input is the raw +// argument *text*, not a parsed object. It is what a repair function reads +// and what it returns. Its JSON is the AI SDK's LanguageModelV4ToolCall, +// tagged {"type":"tool-call",...}. +type RawToolCall struct { + ToolCallID string `json:"toolCallId"` + ToolName string `json:"toolName"` + Input string `json:"input"` + ProviderExecuted *bool `json:"providerExecuted,omitempty"` + Dynamic *bool `json:"dynamic,omitempty"` + ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` +} + +// MarshalJSON writes the call with its "type":"tool-call" tag first. +func (c RawToolCall) MarshalJSON() ([]byte, error) { + type plain RawToolCall + return json.Marshal(struct { + Type string `json:"type"` + plain + }{"tool-call", plain(c)}) +} + +// UnmarshalJSON reads a call with the canonical "type":"tool-call" tag. +func (c *RawToolCall) UnmarshalJSON(data []byte) error { + type plain RawToolCall + var tagged struct { + Type json.RawMessage `json:"type"` + plain + } + if err := json.Unmarshal(data, &tagged); err != nil { + return err + } + if err := checkTypeTag(tagged.Type, "tool-call"); err != nil { + return err + } + *c = RawToolCall(tagged.plain) + return nil +} + +// ToolCallRepairContext is the argument handed to a RepairToolCallFunc — the +// same shape as the AI SDK's repairToolCall argument. +// +// Messages and Instructions are the transcript the model saw, derived by the +// engine from the prompt and options of the call being repaired. +type ToolCallRepairContext struct { + // ToolCall is the failed call, with its raw argument text. + ToolCall RawToolCall `json:"toolCall"` + // Error is the serialized AiMuxError (AI SDK error JSON, tagged by "name" the call carries (e.g. + // {"name":"AI_InvalidToolInputError",...} or {"name":"AI_NoSuchToolError",...}). + Error json.RawMessage `json:"error"` + // InputSchema is the JSON schema of the named tool, or an empty-object + // schema when the name does not resolve to a tool. + InputSchema json.RawMessage `json:"inputSchema"` + Tools []Tool `json:"tools"` + Messages []ModelMessage `json:"messages"` + Instructions *string `json:"instructions"` +} diff --git a/bindings/go/roundtrip_fields_test.go b/bindings/go/roundtrip_fields_test.go deleted file mode 100644 index 8fe7b808e..000000000 --- a/bindings/go/roundtrip_fields_test.go +++ /dev/null @@ -1,51 +0,0 @@ -// roundtrip_fields_test.go — input-side reasoning signature echo for the Go -// binding (#135). -// -// Go prompts are raw JSON, so the binding's job is to pass them through the -// FFI into the Rust engine unchanged. This test proves the full pipe for the -// extended-thinking input round-trip: an assistant reasoning part carrying a -// providerOptions.anthropic.signature is echoed as an Anthropic thinking block on the wire when -// thinking is enabled. - -package aimux - -import ( - "strings" - "testing" -) - -const plainAnthropicResponse = `{"id":"msg_go_rt","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}` - -func TestE2E_AnthropicEchoesReasoningSignature(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetResponse(plainAnthropicResponse) - - m, err := NewAnthropicWithBase("test-key", "claude-sonnet-4-20250514", srv.URL) - if err != nil { - t.Fatalf("NewAnthropicWithBase: %v", err) - } - defer m.Close() - - prompt := `[ - {"role":"user","content":[{"type":"text","text":"hi"}]}, - {"role":"assistant","content":[ - {"type":"reasoning","text":"pondering","providerOptions":{"anthropic":{"signature":"sig-go-1"}}}, - {"type":"text","text":"answer"} - ]}, - {"role":"user","content":[{"type":"text","text":"go on"}]} - ]` - opts := `{"providerOptions":{"anthropic":{"thinking":{"type":"enabled","budgetTokens":1024}}}}` - - if _, err := m.GenerateText(prompt, opts); err != nil { - t.Fatalf("GenerateText: %v", err) - } - - body := srv.LastRequestBody() - if !strings.Contains(body, `"type":"thinking"`) { - t.Errorf("expected a thinking block on the wire, got %s", body) - } - if !strings.Contains(body, `"signature":"sig-go-1"`) { - t.Errorf("expected the prompt signature to be echoed, got %s", body) - } -} diff --git a/bindings/go/roundtrip_test.go b/bindings/go/roundtrip_test.go index 4d2b435be..bf0c54749 100644 --- a/bindings/go/roundtrip_test.go +++ b/bindings/go/roundtrip_test.go @@ -1,14 +1,3 @@ -// Round-trip (de)serialization tests for the typed wire-format types. -// -// Build a typed value, marshal to JSON, unmarshal back, assert equality. These -// pin down wire-format field names and tag spellings the typed wrappers rely -// on (camelCase, the AI SDK JSON) — without them, a rename in aimux-core could -// silently break Go clients. -// -// Also includes regression tests for the two bugs found in code review: -// - ToolChoiceTool JSON injection (now uses json.Marshal) -// - channel double-close panic (now guarded by sync.Once) - package aimux import ( @@ -17,52 +6,6 @@ import ( "testing" ) -// ── Enum round-trips ──────────────────────────────────────────────────────── - -func TestRoleRoundTrip(t *testing.T) { - cases := []Role{RoleSystem, RoleUser, RoleAssistant, RoleTool} - for _, r := range cases { - b, _ := json.Marshal(r) - var got Role - json.Unmarshal(b, &got) - if got != r { - t.Errorf("round-trip mismatch: got %s, want %s", got, r) - } - } -} - -func TestFinishReasonUnifiedRoundTrip(t *testing.T) { - cases := []FinishReasonUnified{ - FinishStop, FinishLength, FinishContentFilter, - FinishToolCalls, FinishError, FinishOther, - } - for _, f := range cases { - b, _ := json.Marshal(f) - var got FinishReasonUnified - json.Unmarshal(b, &got) - if got != f { - t.Errorf("round-trip mismatch: got %s, want %s", got, f) - } - } -} - -func TestReasoningEffortRoundTrip(t *testing.T) { - cases := []ReasoningEffort{ - ReasoningProviderDefault, ReasoningNone, ReasoningMinimal, - ReasoningLow, ReasoningMedium, ReasoningHigh, ReasoningXHigh, - } - for _, r := range cases { - b, _ := json.Marshal(r) - var got ReasoningEffort - json.Unmarshal(b, &got) - if got != r { - t.Errorf("round-trip mismatch: got %s, want %s", got, r) - } - } -} - -// ── ToolChoice round-trips ─────────────────────────────────────────────────── - func TestToolChoiceStringVariantsRoundTrip(t *testing.T) { cases := []struct { name string @@ -90,12 +33,8 @@ func TestToolChoiceToolRoundTrip(t *testing.T) { } } -// TestToolChoiceToolEscapesSpecialChars is a regression test for the JSON -// injection bug where tool names were concatenated into a string template -// without escaping. Now json.Marshal handles it correctly. func TestToolChoiceToolEscapesSpecialChars(t *testing.T) { tc := ToolChoiceTool(`na"me`) - // The result must be valid JSON. var decoded map[string]string if err := json.Unmarshal(tc, &decoded); err != nil { t.Fatalf("ToolChoiceTool produced invalid JSON: %v (got %s)", err, tc) @@ -105,8 +44,6 @@ func TestToolChoiceToolEscapesSpecialChars(t *testing.T) { } } -// ── ToolCall round-trip ─────────────────────────────────────────────────────── - func TestToolCallRoundTrip(t *testing.T) { pe := true dyn := false @@ -122,10 +59,9 @@ func TestToolCallRoundTrip(t *testing.T) { if err != nil { t.Fatalf("marshal failed: %v", err) } - // Verify wire field names (camelCase). s := string(b) for _, want := range []string{`"toolCallId"`, `"toolName"`, `"input"`, `"providerExecuted"`, `"dynamic"`, `"providerMetadata"`} { - if !contains(s, want) { + if !strings.Contains(s, want) { t.Errorf("expected %s in wire JSON, got %s", want, s) } } @@ -150,8 +86,6 @@ func TestToolCallRoundTrip(t *testing.T) { } } -// ── ModelMessage round-trip ────────────────────────────────────────────────── - func TestModelMessageTextRoundTrip(t *testing.T) { msg := NewTextMessage(RoleUser, "Hello") b, err := json.Marshal(msg) @@ -167,14 +101,12 @@ func TestModelMessageTextRoundTrip(t *testing.T) { if decoded.Role != RoleUser { t.Errorf("role mismatch: got %s", decoded.Role) } - // Content comes back as a string via any. if s, ok := decoded.Content.(string); !ok || s != "Hello" { t.Errorf("content mismatch: got %v", decoded.Content) } } func TestModelMessageMultiPartRoundTrip(t *testing.T) { - // A tool-result message with multi-part content (array of ContentParts). msg := ModelMessage{ Role: RoleTool, Content: []map[string]any{ @@ -190,7 +122,6 @@ func TestModelMessageMultiPartRoundTrip(t *testing.T) { if err != nil { t.Fatalf("marshal failed: %v", err) } - // Verify content is an array on the wire. var raw struct { Role string `json:"role"` Content []json.RawMessage `json:"content"` @@ -204,7 +135,6 @@ func TestModelMessageMultiPartRoundTrip(t *testing.T) { if len(raw.Content) != 1 { t.Fatalf("expected 1 content part, got %d", len(raw.Content)) } - // Verify the tool-result structure. var part map[string]any json.Unmarshal(raw.Content[0], &part) if part["type"] != "tool-result" { @@ -215,8 +145,6 @@ func TestModelMessageMultiPartRoundTrip(t *testing.T) { } } -// ── GenerateTextOptions round-trip ─────────────────────────────────────────── - func TestGenerateTextOptionsRoundTrip(t *testing.T) { temp := 0.7 topK := 40.0 @@ -240,18 +168,16 @@ func TestGenerateTextOptionsRoundTrip(t *testing.T) { if err != nil { t.Fatalf("marshal failed: %v", err) } - // Verify camelCase wire fields present. s := string(b) for _, want := range []string{ `"temperature"`, `"topK"`, `"reasoning"`, `"instructions"`, `"toolChoice"`, `"tools"`, `"inputSchema"`, } { - if !contains(s, want) { + if !strings.Contains(s, want) { t.Errorf("expected %s in wire JSON, got %s", want, s) } } - // Verify reasoning serializes as the enum string value. - if !contains(s, `"high"`) { + if !strings.Contains(s, `"high"`) { t.Errorf("expected reasoning 'high' in wire JSON, got %s", s) } @@ -276,7 +202,6 @@ func TestGenerateTextOptionsDefaultOmitsFields(t *testing.T) { } } -// includeRawChunks round-trips through the typed options. func TestGenerateTextOptionsIncludeRawChunksRoundTrip(t *testing.T) { original := GenerateTextOptions{IncludeRawChunks: boolPtr(true)} b, err := json.Marshal(original) @@ -295,15 +220,13 @@ func TestGenerateTextOptionsIncludeRawChunksRoundTrip(t *testing.T) { } } -// ── GenerateTextResult round-trip ──────────────────────────────────────────── - func TestGenerateTextResultWithWarningsRoundTrip(t *testing.T) { original := GenerateTextResult{ Text: "ok", Warnings: []json.RawMessage{json.RawMessage(`"some warning"`)}, } b, _ := json.Marshal(original) - if !contains(string(b), `"warnings"`) { + if !strings.Contains(string(b), `"warnings"`) { t.Errorf("expected warnings field in JSON: %s", b) } var decoded GenerateTextResult @@ -313,8 +236,6 @@ func TestGenerateTextResultWithWarningsRoundTrip(t *testing.T) { } } -// ── Usage / TokenUsage round-trip ───────────────────────────────────────────── - func TestStreamPartParseRoundTrip(t *testing.T) { cases := []struct { name string @@ -383,7 +304,6 @@ func TestParseFinishPayload(t *testing.T) { } } -// TestParseStreamPartRejectsInvalid: a part without a "type" is an error. func TestParseStreamPartRejectsInvalid(t *testing.T) { cases := []struct { name string @@ -403,22 +323,7 @@ func TestParseStreamPartRejectsInvalid(t *testing.T) { } } -// ── helpers ─────────────────────────────────────────────────────────────────── - -func u32ptr(v uint32) *uint32 { return &v } - -func contains(s, sub string) bool { - return len(s) >= len(sub) && (func() bool { - for i := 0; i <= len(s)-len(sub); i++ { - if s[i:i+len(sub)] == sub { - return true - } - } - return false - })() -} - -// ── Provider namespace order (#229 NP2-1) ─────────────────────────────────── +func boolPtr(v bool) *bool { return &v } // Provider options, provider metadata and provider references are JS objects // upstream (@ai-sdk/provider shared-v4-provider-options.ts:24, @@ -427,20 +332,15 @@ func contains(s, sub string) bool { // stay raw JSON in Go, never a sorting Go map. func TestNamespaceMapsKeepInsertionOrder(t *testing.T) { const metadata = `{"zeta":{"z":1,"a":2},"alpha":{},"mid":{}}` - invalid := `{"toolCallId":"call-1","toolName":"weather","input":{"town":"SG"},"dynamic":true,` + + invalid := `{"type":"tool-call","toolCallId":"call-1","toolName":"weather","input":{"town":"SG"},"dynamic":true,` + `"invalid":true,"providerMetadata":` + metadata + `,"error":{"name":"AI_InvalidToolInputError",` + `"toolName":"weather","toolInput":"{\"town\":\"SG\"}","cause":"missing city"}}` - opts := `{"tools":[{"type":"function","name":"weather","inputSchema":{"type":"object"}}]}` - out, err := applyToolCallRepairJSON(invalid, opts, `{"type":"unchanged"}`) - if err != nil { - t.Fatalf("apply repair: %v", err) - } var call ToolCall - if err := json.Unmarshal([]byte(out), &call); err != nil { - t.Fatalf("decode %s: %v", out, err) + if err := json.Unmarshal([]byte(invalid), &call); err != nil { + t.Fatalf("decode %s: %v", invalid, err) } if got := string(call.ProviderMetadata); got != metadata { - t.Errorf("providerMetadata through Rust: got %s, want %s", got, metadata) + t.Errorf("providerMetadata: got %s, want %s", got, metadata) } tool, err := json.Marshal(Tool{ @@ -541,10 +441,10 @@ func TestStandaloneToolCallsAndSourcesAreTagged(t *testing.T) { t.Errorf("raw tool call: %s", raw) } - // JSON written before the tag still reads; another tag is an error. + // Missing or mismatched canonical tags are rejected. var untagged ToolCall - if err := json.Unmarshal([]byte(`{"toolCallId":"c","toolName":"t","input":{}}`), &untagged); err != nil { - t.Errorf("untagged call: %v", err) + if err := json.Unmarshal([]byte(`{"toolCallId":"c","toolName":"t","input":{}}`), &untagged); err == nil { + t.Error("untagged call accepted") } var mistagged ToolCall if err := json.Unmarshal([]byte(`{"type":"source","toolCallId":"c","toolName":"t"}`), &mistagged); err == nil { @@ -555,22 +455,4 @@ func TestStandaloneToolCallsAndSourcesAreTagged(t *testing.T) { t.Error("a tool-call-tagged source was accepted") } - // The native repair export writes the standalone call tagged. - invalid := `{"toolCallId":"call-1","toolName":"weather","input":{"town":"SG"},"dynamic":true,"invalid":true,` + - `"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{\"town\":\"SG\"}","cause":"missing city"}}` - opts := `{"tools":[{"type":"function","name":"weather","inputSchema":{"type":"object"}}]}` - out, err := applyToolCallRepairJSON(invalid, opts, `{"type":"unchanged"}`) - if err != nil { - t.Fatal(err) - } - if !strings.HasPrefix(out, `{"type":"tool-call","toolCallId":"call-1"`) { - t.Errorf("repaired call: %s", out) - } - context, err := toolCallRepairContextJSON(invalid, `{"prompt":"weather?"}`, opts) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(context, `"toolCall":{"type":"tool-call","toolCallId":"call-1"`) { - t.Errorf("repair context: %s", context) - } } diff --git a/bindings/go/stream.go b/bindings/go/stream.go new file mode 100644 index 000000000..a6958ced3 --- /dev/null +++ b/bindings/go/stream.go @@ -0,0 +1,229 @@ +package aimux + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "runtime" + "sync/atomic" +) + +const ( + maxItemBytes = 1 << 20 + // Allow the data item limit plus bounded control-frame overhead. + maxFrameBytes = maxItemBytes + 4096 + // Seven queued frames plus one blocked producer copy fit the 8 MiB + // output budget, even when every frame reaches the single-item limit. + streamBufferSize = 7 +) + +var activeCallbacks atomic.Int64 + +// EndFrame is control state, kept outside the data queue so a full queue can +// never discard the outcome. Seq is the canonical decimal-string sequence. +type EndFrame struct { + Seq string `json:"seq"` + Outcome string `json:"outcome"` + Error *Error `json:"-"` +} + +// Stream has a single native producer and one consumer. Its data channel is +// closed only by that producer, after the C call and every callback return. +// Cancel is safe without a consumer. Always Close a stream abandoned early. +type Stream struct { + bridge *streamBridge[json.RawMessage] + emit func(json.RawMessage) bool + events chan json.RawMessage + ready chan struct{} + done chan struct{} + cancel context.CancelFunc + ctx context.Context + abort *Handle + // Written only by the native producer/callbacks; read after done closes. + accepted bool + terminal *EndFrame + failure error + err error + seq uint64 + hasSeq bool +} + +func (h *Handle) Stream(ctx context.Context, op string, params any) (*Stream, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + data, err := encodeParams(op, params) + if err != nil { + return nil, err + } + id, err := h.ID() + if err != nil { + return nil, err + } + if err := CheckABI(); err != nil { + return nil, err + } + abort, err := createAbort() + if err != nil { + return nil, err + } + streamCtx, cancel := context.WithCancel(ctx) + s := &Stream{ready: make(chan struct{}), ctx: streamCtx, cancel: cancel, abort: abort} + start := make(chan struct{}) + // Start even if the caller cancels now: native admission/terminal delivery + // and abort cleanup must finish before publishing channel closure. + s.bridge = newStreamBridge(context.Background(), streamBufferSize, func(_ context.Context, emit func(json.RawMessage) bool) error { + <-start + s.emit = emit + finished := make(chan struct{}) + defer close(finished) + go func() { + select { + case <-streamCtx.Done(): + s.bridge.Cancel() + case <-finished: + } + }() + s.produce(h, uint64(id), op, data) + return s.err + }) + s.events, s.done = s.bridge.parts, s.bridge.done + close(start) + // No callback means an admission failure, reported synchronously here. + <-s.ready + if !s.accepted { + <-s.done + return nil, s.err + } + return s, nil +} + +func (s *Stream) produce(owner *Handle, id uint64, op string, params []byte) { + stop := watchAbort(s.ctx, s.abort) + nativeErr := nativeStream(id, op, params, s.abort.id.Load(), s) + runtime.KeepAlive(owner) + stop() + s.abort.Close() + s.cancel() + switch { + case s.failure != nil: + s.err = s.failure + case nativeErr != nil: + s.err = nativeErr + case s.terminal == nil: + s.err = localError("binding", 206, "CallbackFailure", "stream returned without an end frame") + case s.terminal.Error != nil: + s.err = s.terminal.Error + case s.terminal.Outcome == "cancelled": + s.err = localError("aimux", 13, "AbortError", "stream cancelled") + } + if !s.accepted { + close(s.ready) + } +} + +func (s *Stream) fail(err error) { + if s.failure == nil { + s.failure = err + s.cancel() + } +} + +func (s *Stream) receive(raw []byte) { + if s.failure != nil { + return + } + if err := validateJSON("stream frame", raw); err != nil { + s.fail(localError("binding", 206, "CallbackFailure", err.Error())) + return + } + var frame struct { + Type string `json:"type"` + Seq string `json:"seq"` + Event json.RawMessage `json:"event"` + Outcome string `json:"outcome"` + Error json.RawMessage `json:"error"` + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&frame); err != nil { + s.fail(localError("binding", 206, "CallbackFailure", err.Error())) + return + } + seq, err := decimalUint64(frame.Seq) + if err != nil || (s.hasSeq && seq <= s.seq) || s.terminal != nil { + s.fail(localError("binding", 206, "CallbackFailure", "invalid stream sequence or frame after terminal")) + return + } + s.seq, s.hasSeq = seq, true + switch frame.Type { + case "event": + if len(frame.Event) > maxItemBytes { + s.fail(localError("protocol", 305, "ResourceLimit", "stream event exceeds 1 MiB")) + return + } + if len(frame.Event) == 0 || frame.Outcome != "" || len(frame.Error) != 0 { + s.fail(localError("binding", 206, "CallbackFailure", "invalid event frame")) + return + } + case "end": + if len(frame.Event) != 0 { + s.fail(localError("binding", 206, "CallbackFailure", "unexpected event in terminal")) + return + } + terminal := &EndFrame{Seq: frame.Seq, Outcome: frame.Outcome} + if len(frame.Error) != 0 { + var err error + terminal.Error, err = decodeError(frame.Error) + if err != nil { + s.fail(localError("binding", 206, "CallbackFailure", err.Error())) + return + } + } + switch frame.Outcome { + case "success": + if terminal.Error != nil { + s.fail(localError("binding", 206, "CallbackFailure", "success terminal has an error")) + return + } + case "error": + if terminal.Error == nil { + s.fail(localError("binding", 206, "CallbackFailure", "error terminal has no envelope")) + return + } + case "cancelled": + default: + s.fail(localError("binding", 206, "CallbackFailure", fmt.Sprintf("invalid terminal outcome %q", frame.Outcome))) + return + } + s.terminal = terminal + default: + s.fail(localError("binding", 206, "CallbackFailure", "unknown stream frame type")) + return + } + if !s.accepted { + s.accepted = true + close(s.ready) + } + if frame.Type == "event" { + s.emit(frame.Event) + } +} + +func (s *Stream) Events() <-chan json.RawMessage { return s.events } +func (s *Stream) Done() <-chan struct{} { return s.done } +func (s *Stream) Cancel() { s.cancel() } +func (s *Stream) Close() error { s.Cancel(); <-s.done; return s.Err() } + +// Err waits for the producer to close the data channel before reading its +// result. Drain Events first, or Cancel if abandoning consumption. +func (s *Stream) Err() error { + <-s.done + return s.err +} + +func (s *Stream) Terminal() (*EndFrame, error) { + <-s.done + return s.terminal, s.err +} diff --git a/bindings/go/stream_bridge.go b/bindings/go/stream_bridge.go new file mode 100644 index 000000000..189d0e665 --- /dev/null +++ b/bindings/go/stream_bridge.go @@ -0,0 +1,90 @@ +package aimux + +import ( + "context" + "sync" +) + +// streamBridge runs one producer with a bounded queue. The producer must return +// only after all callbacks have stopped, and must honor ctx when doing native +// work (for example, by mapping cancellation to the native Abort operation). +// A callback must stop emitting when emit returns false and must not retain emit +// after the producer returns. +type streamBridge[T any] struct { + parts chan T + done chan struct{} + cancel context.CancelFunc + mu sync.Mutex + err error // Read only after done closes, under mu. +} + +// newStreamBridge starts the producer. A zero capacity uses an unbuffered queue; +// a negative capacity is a programming error, as with make(chan T, capacity). +func newStreamBridge[T any](ctx context.Context, capacity int, produce func(context.Context, func(T) bool) error) *streamBridge[T] { + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithCancel(ctx) + s := &streamBridge[T]{ + parts: make(chan T, capacity), + done: make(chan struct{}), + cancel: cancel, + } + go s.run(ctx, produce) + return s +} + +func (s *streamBridge[T]) run(ctx context.Context, produce func(context.Context, func(T) bool) error) { + defer s.cancel() + var err error + if ctx.Err() == nil { + err = produce(ctx, func(part T) bool { + // Prefer cancellation already observed before this callback, even if + // the queue happens to have room. The select also releases a callback + // blocked on a full queue when nobody is consuming. + if ctx.Err() != nil { + return false + } + select { + case s.parts <- part: + return true + case <-ctx.Done(): + return false + } + }) + } + if cause := context.Cause(ctx); cause != nil { + err = cause + } + // Only this producer goroutine closes the queue. Cancellation never does. + s.mu.Lock() + s.err = err + close(s.parts) + close(s.done) + s.mu.Unlock() +} + +func (s *streamBridge[T]) Parts() <-chan T { return s.parts } + +// Done closes after the producer and its callbacks have returned and Parts has +// closed. It can be awaited without draining Parts after cancellation. +func (s *streamBridge[T]) Done() <-chan struct{} { return s.done } + +// Err returns the terminal error after Parts closes. Before then it returns nil, +// without reading the producer's error. Consumers should drain Parts and await +// its closure before inspecting Err. +func (s *streamBridge[T]) Err() error { + // Serialize the two channel closes so a consumer observing Parts close + // cannot miss the terminal error in the interval before Done closes. + s.mu.Lock() + defer s.mu.Unlock() + select { + case <-s.done: + return s.err + default: + return nil + } +} + +// Cancel is concurrent-safe and never waits for a consumer or the producer. +func (s *streamBridge[T]) Cancel() { s.cancel() } diff --git a/bindings/go/stream_bridge_test.go b/bindings/go/stream_bridge_test.go new file mode 100644 index 000000000..b04654f1c --- /dev/null +++ b/bindings/go/stream_bridge_test.go @@ -0,0 +1,248 @@ +package aimux + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" +) + +func bridgeAwait(t *testing.T, done <-chan struct{}) { + t.Helper() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("stream bridge did not make progress") + } +} + +type bridgeMockHandle struct{ deletes atomic.Int32 } + +func (h *bridgeMockHandle) Delete() { h.deletes.Add(1) } + +func TestStreamBridgeOrderedCompletion(t *testing.T) { + for _, capacity := range []int{0, 1, 4} { + handle := &bridgeMockHandle{} + s := newStreamBridge(context.Background(), capacity, func(ctx context.Context, emit func(int) bool) error { + defer handle.Delete() + for i := 0; i < 100; i++ { + if !emit(i) { + return ctx.Err() + } + } + return nil + }) + t.Cleanup(s.Cancel) + got := 0 + for part := range s.Parts() { + if part != got { + t.Fatalf("part = %d, want %d", part, got) + } + got++ + } + if got != 100 || s.Err() != nil { + t.Fatalf("got %d parts, err = %v", got, s.Err()) + } + bridgeAwait(t, s.Done()) + if handle.deletes.Load() != 1 { + t.Fatalf("handle Delete count = %d, want 1", handle.deletes.Load()) + } + } +} + +func TestStreamBridgeErrorPublishedAtChannelClose(t *testing.T) { + want := errors.New("producer failed") + for i := 0; i < 1000; i++ { + s := newStreamBridge(context.Background(), 1, func(context.Context, func(int) bool) error { + return want + }) + for range s.Parts() { + t.Fatal("unexpected part") + } + // Do not await Done: Parts closure alone must publish the error. + if !errors.Is(s.Err(), want) { + t.Fatalf("Err = %v, want %v", s.Err(), want) + } + } +} + +func TestStreamBridgeErrBeforeClose(t *testing.T) { + release := make(chan struct{}) + want := errors.New("terminal") + s := newStreamBridge(context.Background(), 1, func(context.Context, func(int) bool) error { + <-release + return want + }) + for i := 0; i < 100; i++ { + if err := s.Err(); err != nil { + t.Fatalf("early Err = %v", err) + } + } + close(release) + bridgeAwait(t, s.Done()) + if !errors.Is(s.Err(), want) { + t.Fatalf("Err = %v, want %v", s.Err(), want) + } +} + +func TestStreamBridgeCanceledBeforeStart(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + var called atomic.Bool + s := newStreamBridge(ctx, 1, func(context.Context, func(int) bool) error { + called.Store(true) + return nil + }) + bridgeAwait(t, s.Done()) + if called.Load() { + t.Fatal("producer ran with a canceled context") + } + if _, ok := <-s.Parts(); ok { + t.Fatal("Parts is not closed") + } + if !errors.Is(s.Err(), context.Canceled) { + t.Fatalf("Err = %v", s.Err()) + } +} + +func TestStreamBridgeCancelWithoutConsumer(t *testing.T) { + for _, parentCancellation := range []bool{false, true} { + ctx, cancel := context.WithCancel(context.Background()) + full := make(chan struct{}) + var sends atomic.Int32 + s := newStreamBridge(ctx, 2, func(_ context.Context, emit func(int) bool) error { + for i := 0; i < 2; i++ { + if !emit(i) { + return errors.New("queue canceled before filling") + } + sends.Add(1) + } + close(full) + if emit(2) { + sends.Add(1) + return errors.New("bounded queue accepted extra part") + } + return nil + }) + t.Cleanup(cancel) + t.Cleanup(s.Cancel) + bridgeAwait(t, full) + if len(s.parts) != 2 || cap(s.parts) != 2 || sends.Load() != 2 { + t.Fatal("queue is not bounded at capacity two") + } + if parentCancellation { + cancel() + } else { + s.Cancel() + s.Cancel() + } + // No consumer has read a single part. Producer must still terminate. + bridgeAwait(t, s.Done()) + if !errors.Is(s.Err(), context.Canceled) { + t.Fatalf("Err = %v", s.Err()) + } + if sends.Load() != 2 { + t.Fatal("producer sent past the bounded queue") + } + count := 0 + for range s.Parts() { + count++ + } + if count != 2 { + t.Fatalf("buffered count = %d, want 2", count) + } + } +} + +func TestStreamBridgeCancelKeepsHandleUntilProducerReturns(t *testing.T) { + handle := &bridgeMockHandle{} + entered := make(chan struct{}) + canceled := make(chan struct{}) + release := make(chan struct{}) + s := newStreamBridge(context.Background(), 1, func(ctx context.Context, emit func(int) bool) error { + defer handle.Delete() + close(entered) + <-ctx.Done() // Simulates native Abort acknowledgment. + close(canceled) + <-release // Simulates a callback still on the native call stack. + if emit(1) { + return errors.New("callback emitted after cancellation") + } + return nil + }) + bridgeAwait(t, entered) + s.Cancel() + bridgeAwait(t, canceled) + if handle.deletes.Load() != 0 { + t.Fatal("callback handle deleted while producer was active") + } + select { + case <-s.Done(): + t.Fatal("consumer canceled and closed the queue before producer returned") + default: + } + if s.Err() != nil { + t.Fatal("Err published before producer returned") + } + close(release) + bridgeAwait(t, s.Done()) + if handle.deletes.Load() != 1 { + t.Fatalf("handle Delete count = %d, want 1", handle.deletes.Load()) + } +} + +func TestStreamBridgeProducerErrorReleasesHandle(t *testing.T) { + handle := &bridgeMockHandle{} + want := errors.New("native startup failed") + s := newStreamBridge(context.Background(), 1, func(context.Context, func(int) bool) error { + defer handle.Delete() + return want + }) + bridgeAwait(t, s.Done()) + if handle.deletes.Load() != 1 || !errors.Is(s.Err(), want) { + t.Fatalf("deletes = %d, Err = %v", handle.deletes.Load(), s.Err()) + } +} + +func TestStreamBridgeCancellationCompletionRace(t *testing.T) { + for i := 0; i < 200; i++ { + ctx, cancel := context.WithCancelCause(context.Background()) + want := errors.New("parent aborted") + handle := &bridgeMockHandle{} + started := make(chan struct{}) + release := make(chan struct{}) + s := newStreamBridge(ctx, 1, func(context.Context, func(int) bool) error { + defer handle.Delete() + close(started) + <-release + return nil + }) + bridgeAwait(t, started) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + cancel(want) + }() + go func() { + defer wg.Done() + close(release) + }() + // Polling Err concurrently must not read a producer write unsafely. + for j := 0; j < 10; j++ { + _ = s.Err() + } + wg.Wait() + bridgeAwait(t, s.Done()) + err := s.Err() + if err != nil && !errors.Is(err, want) { + t.Fatalf("unexpected terminal Err = %v", err) + } + s.Cancel() // Cancellation after completion must not change Err. + if s.Err() != err || handle.deletes.Load() != 1 { + t.Fatalf("terminal error changed or handle leaked: %v, deletes = %d", s.Err(), handle.deletes.Load()) + } + } +} diff --git a/bindings/go/test-abi.sh b/bindings/go/test-abi.sh new file mode 100755 index 000000000..a8fe0d996 --- /dev/null +++ b/bindings/go/test-abi.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Exercise the Go bridge against a deterministic C double. The generated +# header is shared with production; run test-native.sh for the Rust library. +set -eu +cd "$(dirname "$0")" +fixture_dir=$(pwd)/testdata/abi +header_dir=$(pwd)/../../aimux-ffi/include +fixture_build=$(mktemp -d "${TMPDIR:-/tmp}/aimux-go-abi.XXXXXX") +trap 'rm -rf "$fixture_build"' EXIT HUP INT TERM +cc -std=c11 -D_POSIX_C_SOURCE=200809L -Wall -Wextra -Werror -pthread -I"$header_dir" -c "$fixture_dir/fixture.c" -o "$fixture_build/fixture.o" +ar rcs "$fixture_build/libaimux_ffi.a" "$fixture_build/fixture.o" +export CGO_LDFLAGS="-L$fixture_build ${CGO_LDFLAGS:-}" +export AIMUX_TEST_ABI=1 +export CGO_ENABLED=1 +go test -race -timeout 60s "$@" ./... diff --git a/bindings/go/test-native.sh b/bindings/go/test-native.sh new file mode 100755 index 000000000..f272385e2 --- /dev/null +++ b/bindings/go/test-native.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Run against a directory containing the matching Rust shared library. +set -eu +cd "$(dirname "$0")" +: "${AIMUX_LIBRARY_DIR:?Set AIMUX_LIBRARY_DIR to the Rust library directory}" +export CGO_ENABLED=1 +export CGO_LDFLAGS="-L$AIMUX_LIBRARY_DIR -Wl,-rpath,$AIMUX_LIBRARY_DIR ${CGO_LDFLAGS:-}" +unset AIMUX_TEST_ABI +go test -race -timeout 60s "$@" ./... diff --git a/bindings/go/testdata/abi/fixture.c b/bindings/go/testdata/abi/fixture.c new file mode 100644 index 000000000..c3f74d044 --- /dev/null +++ b/bindings/go/testdata/abi/fixture.c @@ -0,0 +1,224 @@ +/* Deterministic native double. Exercises cgo ownership and callback delivery; + * does not implement Rust ops or prove provider behavior. */ +#include "aimux.h" +#include +#include +#include +#include + +#define BASE UINT64_C(9007199254740993) +#define SLOTS 32768 +struct entry { int live, aborted, polled, input_done; uint64_t abort; }; +struct aimux_error { int32_t code; char *json; }; +static struct entry entries[SLOTS]; +static uint64_t allocated; +static int strings, errors; +static int waiting_calls, waiting_streams, waiting_pushes, waiting_polls; +static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t changed = PTHREAD_COND_INITIALIZER; + +static struct entry *entry(uint64_t id) { + if (id < BASE || id - BASE >= allocated) return NULL; + return &entries[id - BASE]; +} +static int live(uint64_t id) { + struct entry *e = entry(id); + return e && e->live; +} +static char *owned(const char *text) { + char *p = strdup(text); + ++strings; + return p; +} +static aimux_error_t *failure(int32_t code, const char *kind, const char *name) { + aimux_error_t *e = malloc(sizeof(*e)); + char buf[1024]; + snprintf(buf, sizeof(buf), "{\"kind\":\"%s\",\"code\":%d,\"message\":\"fixture error\",\"error\":{\"name\":\"%s\",\"isRetryable\":true,\"data\":null,\"nested\":{\"kept\":7}},\"retryAfterMs\":0,\"future\":true}", kind, code, name); + e->code = code; + e->json = strdup(buf); + ++errors; + return e; +} +static uint64_t new_entry(uint64_t abort_id) { + if (allocated == SLOTS) abort(); + uint64_t id = BASE + allocated++; + *entry(id) = (struct entry){.live=1, .abort=abort_id}; + return id; +} +static int cancelled(uint64_t id) { + struct entry *e = entry(id); + return e && e->aborted; +} +static void wait_abort(uint64_t id) { + while (!cancelled(id)) pthread_cond_wait(&changed, &lock); +} +uint32_t aimux_abi_version(void) { return AIMUX_ABI_VERSION; } +aimux_error_t *aimux_manifest_json(char **out) { + char buf[256]; + pthread_mutex_lock(&lock); + snprintf(buf, sizeof(buf), "{\"abiVersion\":%u,\"opsVersion\":\"" AIMUX_OPS_VERSION "\",\"digest\":\"" + AIMUX_MANIFEST_DIGEST "\",\"fixture\":true}", (unsigned)AIMUX_ABI_VERSION); + *out = owned(buf); + pthread_mutex_unlock(&lock); + return NULL; +} +aimux_error_t *aimux_call(uint64_t id, const char *op, const char *params, uint64_t abort_id, char **out) { + pthread_mutex_lock(&lock); + *out = NULL; + aimux_error_t *err = NULL; + if (id && !live(id)) err = failure(203, "binding", "InvalidHandle"); + else if (!strcmp(op, AIMUX_OP_ABORT_CREATE) || !strcmp(op, "test.create") || !strcmp(op, AIMUX_OP_TRANSCRIPTION_START) || !strcmp(op, AIMUX_OP_COMPOSITE_ROUTER)) { + char buf[128]; + uint64_t created = new_entry(abort_id); + if (!strcmp(op, AIMUX_OP_TRANSCRIPTION_START) || !strcmp(op, AIMUX_OP_COMPOSITE_ROUTER)) + snprintf(buf, sizeof(buf), "\"%llu\"", (unsigned long long)created); + else snprintf(buf, sizeof(buf), "{\"handle\":\"%llu\"}", (unsigned long long)created); + *out = owned(buf); + } else if (!strcmp(op, "test.wait")) { + ++waiting_calls; + wait_abort(abort_id); + --waiting_calls; + err = failure(13, "aimux", "Aborted"); + } else if (!strcmp(op, "test.error")) err = failure(11, "aimux", "AI_APICallError"); + else if (!strcmp(op, "test.recording")) err = failure(100, "recording", "Init"); + else if (!strcmp(op, "test.binding")) err = failure(204, "binding", "ReentrantCall"); + else if (!strcmp(op, "test.protocol")) err = failure(303, "protocol", "ConcurrentConsumer"); + else if (!strcmp(op, "test.stats")) { + unsigned int count = 0; + for (uint64_t i=0; iinput_done = 1; + *out = owned("{}"); + } else if (!strcmp(op, AIMUX_OP_OPERATION_STATUS)) *out = owned("{\"state\":\"running\"}"); + else if (!strcmp(op, "test.echo")) *out = owned(params); + else if (!strcmp(op, AIMUX_OP_TEXT_GENERATE)) *out = owned("{\"text\":\"fixture\"}"); + else err = failure(300, "protocol", "InvalidOp"); + pthread_mutex_unlock(&lock); + return err; +} +aimux_error_t *aimux_stream(uint64_t id, const char *op, const char *params, uint64_t abort_id, aimux_event_cb cb, void *context) { + (void)params; + pthread_mutex_lock(&lock); + if (!live(id) || !strcmp(op, "test.reject")) { + aimux_error_t *err = failure(!live(id) ? 203 : 301, !live(id) ? "binding" : "protocol", !live(id) ? "InvalidHandle" : "InvalidParams"); + pthread_mutex_unlock(&lock); + return err; + } + pthread_mutex_unlock(&lock); + if (!strcmp(op, "test.null")) cb(NULL, context); + else if (!strcmp(op, "test.bad")) cb("{bad", context); + else if (!strcmp(op, "test.sequence")) { + cb("{\"type\":\"event\",\"seq\":\"1\",\"event\":{}}", context); + cb("{\"type\":\"event\",\"seq\":\"1\",\"event\":{}}", context); + } else if (!strcmp(op, "test.large")) { + char *big = malloc((1<<20)+4098); + memset(big, 'x', (1<<20)+4097); big[(1<<20)+4097]=0; + cb(big, context); free(big); + } else if (!strcmp(op, "test.limit") || !strcmp(op, "test.eventTooLarge")) { + size_t length = (1u << 20) + (!strcmp(op, "test.eventTooLarge") ? 1 : 0); + const char *prefix = "{\"type\":\"event\",\"seq\":\"1\",\"event\":\""; + char *buf = malloc(length + strlen(prefix) + 2); + strcpy(buf, prefix); + memset(buf + strlen(prefix), 'x', length-2); + strcpy(buf + strlen(prefix) + length-2, "\"}"); + cb(buf, context); + free(buf); + } else if (!strcmp(op, "test.noend")) return NULL; + else if (!strcmp(op, "test.terminalError")) { + cb("{\"type\":\"end\",\"seq\":\"1\",\"outcome\":\"error\",\"error\":{\"kind\":\"aimux\",\"code\":11,\"message\":\"terminal\",\"error\":{\"name\":\"AI_APICallError\",\"isRetryable\":false}}}", context); + return NULL; + } else if (!strcmp(op, "test.waitStream")) { + pthread_mutex_lock(&lock); + ++waiting_streams; + wait_abort(abort_id); + --waiting_streams; + pthread_mutex_unlock(&lock); + } else { + int count = !strcmp(op, "test.flood") ? 1000 : 1; + for (int i=0; iabort)) err = failure(13, "aimux", "Aborted"); + else if (entry(id)->input_done) err = failure(304, "protocol", "InvalidState"); + else if (size && data[0] == 255) { + ++waiting_pushes; + wait_abort(entry(id)->abort); + --waiting_pushes; + err = failure(13, "aimux", "Aborted"); + } + pthread_mutex_unlock(&lock); + return err; +} +aimux_error_t *aimux_operation_next_part(uint64_t id, int64_t timeout, char **out, int32_t *state) { + pthread_mutex_lock(&lock); + *out=NULL; *state=0; + aimux_error_t *err=NULL; + struct entry *e = entry(id); + if (timeout < 0) err=failure(301, "protocol", "InvalidParams"); + else if (!live(id)) err=failure(203, "binding", "InvalidHandle"); + else if (cancelled(e->abort) && e->polled != 3) { e->polled=3; err=failure(13, "aimux", "Aborted"); } + else if (e->polled == 3 || e->input_done) *state=2; + else if (timeout == 0) *state=3; + else if (!e->polled++) { *state=1; *out=owned("{\"type\":\"transcript\",\"text\":\"copied\"}"); } + else { ++waiting_polls; wait_abort(e->abort); --waiting_polls; e->polled=3; err=failure(13, "aimux", "Aborted"); } + pthread_mutex_unlock(&lock); + return err; +} +void aimux_abort(uint64_t id) { + pthread_mutex_lock(&lock); + if (entry(id)) entry(id)->aborted=1; + pthread_cond_broadcast(&changed); + pthread_mutex_unlock(&lock); +} +void aimux_drop(uint64_t id) { + pthread_mutex_lock(&lock); + if (entry(id)) entry(id)->live=0; + pthread_mutex_unlock(&lock); +} +void aimux_handle_finalizer(void *token) { aimux_drop((uint64_t)(uintptr_t)token); } +void aimux_free_string(char *p) { + pthread_mutex_lock(&lock); + if (p) { free(p); --strings; } + pthread_mutex_unlock(&lock); +} +void aimux_error_free(aimux_error_t *e) { + pthread_mutex_lock(&lock); + if (e) { free(e->json); free(e); --errors; } + pthread_mutex_unlock(&lock); +} +int32_t aimux_error_code(const aimux_error_t *e) { return e->code; } +char *aimux_error_message(const aimux_error_t *e) { + (void)e; + pthread_mutex_lock(&lock); + char *s=owned("fixture error"); + pthread_mutex_unlock(&lock); + return s; +} +char *aimux_error_json(const aimux_error_t *e) { + pthread_mutex_lock(&lock); + char *s=owned(e->json); + pthread_mutex_unlock(&lock); + return s; +} diff --git a/bindings/go/trace.go b/bindings/go/trace.go deleted file mode 100644 index ed43374c5..000000000 --- a/bindings/go/trace.go +++ /dev/null @@ -1,159 +0,0 @@ -// trace.go — RFC-0015 cache probing (C ABI path). -// -// Wraps aimux_trace_new / aimux_trace_new_audited / aimux_trace_aggregate / -// aimux_trace_session_chain / aimux_trace_export_jsonl / aimux_trace_clear. - -package aimux - -/* -#include -#include "aimux-ffi.h" -*/ -import "C" - -import ( - "runtime" - "unsafe" -) - -// Trace wraps this model in a cache-probe layer (RFC-0015). The returned -// model records fingerprints/verdicts on every GenerateText/StreamText call -// and exposes the trace query API. Caller must Close the returned model. -func (m *Model) Trace() (*Model, error) { - return m.traceWrap(false, 0) -} - -// TraceAudited wraps this model in a probe layer with the built-in rules -// auditor. strict = strict mode (self-hosted single instance); false = -// shared mode (safe default). -func (m *Model) TraceAudited(strict bool) (*Model, error) { - strictInt := 0 - if strict { - strictInt = 1 - } - return m.traceWrap(true, C.int(strictInt)) -} - -func (m *Model) traceWrap(audited bool, strict C.int) (*Model, error) { - handle, err := m.handle() - if err != nil { - return nil, err - } - defer runtime.KeepAlive(m) - - // [C ABI]: only a dead handle fails, and the closed guard above already - // excludes that — but a C ABI failure is returned, never panicked. - var h C.uint64_t - var e *C.aimux_error_t - if audited { - e = C.aimux_trace_new_audited(C.uint64_t(handle), strict, &h) - } else { - e = C.aimux_trace_new(C.uint64_t(handle), &h) - } - if err := expectFfiError(e); err != nil { - return nil, err - } - t := &Model{traced: true} - t.id.Store(uint64(h)) - runtime.SetFinalizer(t, func(t *Model) { t.Close() }) - return t, nil -} - -// TraceAggregate returns aggregated probe statistics (JSON TraceStats[]), -// filtered by an optional serialized TraceFilter ("" = all). -// Returns ErrNotTraced unless the receiver came from Trace/TraceAudited. -func (m *Model) TraceAggregate(filterJson string) (string, error) { - // State guard first: an untraced model reports ErrNotTraced whatever the - // filter looks like. - if err := m.checkTraced(); err != nil { - return "", err - } - if err := checkJSON("filter_json", filterJson); err != nil { - return "", err - } - if filterJson == "" { - filterJson = "{}" // C requires JSON; keep the "" = all promise locally - } - return m.traceQuery(filterJson, func(h C.uint64_t, c *C.char, out **C.char) *C.aimux_error_t { - return C.aimux_trace_aggregate(h, c, out) - }) -} - -// TraceSessionChain returns one session's chain view (JSON SessionChainView). -// Returns ErrNotTraced unless the receiver came from Trace/TraceAudited. -func (m *Model) TraceSessionChain(sessionId string) (string, error) { - if err := m.checkTraced(); err != nil { - return "", err - } - if err := checkUTF8("session_id", sessionId); err != nil { - return "", err - } - return m.traceQuery(sessionId, func(h C.uint64_t, c *C.char, out **C.char) *C.aimux_error_t { - return C.aimux_trace_session_chain(h, c, out) - }) -} - -// TraceExportJsonl returns all probe records as JSONL (one TraceRecord per -// line). Returns ErrNotTraced unless the receiver came from -// Trace/TraceAudited. -func (m *Model) TraceExportJsonl() (string, error) { - handle, err := m.acquireTraced() - if err != nil { - return "", err - } - defer runtime.KeepAlive(m) - - // [C ABI] per aimux-ffi.h: this call carries no AiMuxError code. - return ffiStringWithFfiError(func(out **C.char) *C.aimux_error_t { - return C.aimux_trace_export_jsonl(C.uint64_t(handle), out) - }) -} - -// TraceClear drops all probe records of this model. -// Returns ErrNotTraced unless the receiver came from Trace/TraceAudited. -func (m *Model) TraceClear() error { - handle, err := m.acquireTraced() - if err != nil { - return err - } - defer runtime.KeepAlive(m) - - // [C ABI]: fails only for a dead handle, which the closed guard excludes. - return expectFfiError(C.aimux_trace_clear(C.uint64_t(handle))) -} - -// checkTraced is the state guard every trace query needs: the trace store is -// keyed on the wrapper handle, so on a plain model the C call reports a -// missing handle — really user misuse, and a much better message here. traced -// is set at construction and never mutated, so it reads without the lock. -func (m *Model) checkTraced() error { - if m == nil || !m.traced { - return ErrNotTraced - } - return nil -} - -// acquireTraced is handle plus that guard, covering all five query -// methods at once. -func (m *Model) acquireTraced() (uint64, error) { - if err := m.checkTraced(); err != nil { - return 0, err - } - return m.handle() -} - -// traceQuery runs a query taking one C string argument. -func (m *Model) traceQuery(arg string, call func(C.uint64_t, *C.char, **C.char) *C.aimux_error_t) (string, error) { - handle, err := m.acquireTraced() - if err != nil { - return "", err - } - defer runtime.KeepAlive(m) - - cArg := C.CString(arg) - defer C.free(unsafe.Pointer(cArg)) - - return ffiString(func(out **C.char) *C.aimux_error_t { - return call(C.uint64_t(handle), cArg, out) - }) -} diff --git a/bindings/go/trace_test.go b/bindings/go/trace_test.go deleted file mode 100644 index 1e9a26a29..000000000 --- a/bindings/go/trace_test.go +++ /dev/null @@ -1,125 +0,0 @@ -// trace_test.go — RFC-0015 wire-contract tests (pure JSON, no FFI). -// -// Verifies Go can parse the probe wire types exactly as Rust serializes them -// (camelCase fields, hex fingerprints, optional verdicts). - -package aimux - -import ( - "encoding/json" - "testing" -) - -const traceRecordJSON = `{ - "provider": "openai", - "model": "gpt-4o", - "requestId": "req-1", - "sessionId": "sess-1", - "callId": "trace-1", - "sentAtUnixMs": 1785900000000, - "ttftMs": 42, - "fingerprint": { - "bodyHash": "0123456789abcdef0123456789abcdef", - "lenBytes": 10240, - "blockSize": 4096, - "blockHashes": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "tokenEstimate": 2560 - }, - "usage": { - "inputTotal": 2560, - "inputNoCache": 1536, - "cacheRead": 1024, - "cacheWrite": 0, - "outputTotal": 10, - "outputText": 10 - }, - "requestCacheHints": {"requestedWrite": true}, - "verdict": { - "kind": "Trusted", - "confidence": "High", - "violated": [], - "expectedMax": 1024, - "claimed": 1024, - "lcpBytes": 4096, - "notes": [] - } -}` - -func TestTraceRecordParses(t *testing.T) { - var rec TraceRecord - if err := json.Unmarshal([]byte(traceRecordJSON), &rec); err != nil { - t.Fatalf("failed to parse TraceRecord: %v", err) - } - if rec.Provider != "openai" || rec.Model != "gpt-4o" { - t.Errorf("provider/model mismatch: %s / %s", rec.Provider, rec.Model) - } - if rec.SessionID == nil || *rec.SessionID != "sess-1" { - t.Errorf("sessionId mismatch: %v", rec.SessionID) - } - if rec.Fingerprint.BodyHash != "0123456789abcdef0123456789abcdef" { - t.Errorf("bodyHash mismatch: %s", rec.Fingerprint.BodyHash) - } - if len(rec.Fingerprint.BlockHashes) != 2 { - t.Errorf("expected 2 block hashes, got %d", len(rec.Fingerprint.BlockHashes)) - } - if rec.Usage.CacheRead == nil || *rec.Usage.CacheRead != 1024 { - t.Errorf("cacheRead mismatch: %v", rec.Usage.CacheRead) - } - if rec.RequestCacheHints == nil || !rec.RequestCacheHints.RequestedWrite { - t.Error("requestCacheHints mismatch") - } - if rec.Verdict == nil { - t.Fatal("verdict must be present") - } - var verdict struct { - Kind string `json:"kind"` - } - if err := json.Unmarshal(rec.Verdict, &verdict); err != nil { - t.Fatalf("verdict parse: %v", err) - } - if verdict.Kind != "Trusted" { - t.Errorf("verdict kind mismatch: %s", verdict.Kind) - } -} - -func TestTraceStatsAndChainParse(t *testing.T) { - statsJSON := `{ - "provider": "openai", "model": "gpt-4o", - "requests": 3, "inputTokensTotal": 7680, - "claimedCacheReadTotal": 2048, "claimedCacheWriteTotal": 0, - "reportedHitRate": 0.26666666666666666, - "clientUpperBoundHitRate": 0.4, - "verdictCounts": {"Trusted": 2, "SuspectOverclaim": 1}, - "errors": 0 - }` - var stats TraceStats - if err := json.Unmarshal([]byte(statsJSON), &stats); err != nil { - t.Fatalf("stats parse: %v", err) - } - if stats.Requests != 3 || stats.ClaimedCacheReadTotal != 2048 { - t.Errorf("stats mismatch: %+v", stats) - } - if stats.VerdictCounts["Trusted"] != 2 { - t.Errorf("verdictCounts mismatch: %v", stats.VerdictCounts) - } - - chainJSON := `{ - "sessionId": "sess-1", - "recordIds": ["trace-1", "trace-2"], - "prefixStability": 0.8, - "breaks": [{ - "atRecordId": "trace-2", "prevRecordId": "trace-1", - "lcpBytes": 1024, "expectedBreak": false, "kind": "Unknown" - }] - }` - var chain SessionChainView - if err := json.Unmarshal([]byte(chainJSON), &chain); err != nil { - t.Fatalf("chain parse: %v", err) - } - if len(chain.RecordIDs) != 2 || chain.PrefixStability != 0.8 { - t.Errorf("chain mismatch: %+v", chain) - } - if len(chain.Breaks) != 1 || chain.Breaks[0].Kind != "Unknown" { - t.Errorf("breaks mismatch: %+v", chain.Breaks) - } -} diff --git a/bindings/go/typed.go b/bindings/go/typed.go deleted file mode 100644 index 29a3f5ade..000000000 --- a/bindings/go/typed.go +++ /dev/null @@ -1,358 +0,0 @@ -// Typed text-generation API — erases the JSON-string boundary so callers -// use typed inputs (string | []ModelMessage, *GenerateTextOptions) and get -// typed outputs (*GenerateTextResult, typed StreamPart channel). -// -// Mirrors Node's src/index.ts generateText/streamText typed wrapper. -// The raw GenerateText/StreamText methods (which accept JSON strings) remain -// available as the escape hatch. - -package aimux - -import ( - "context" - "encoding/json" - "fmt" -) - -// marshalPrompt converts a typed prompt (string or []ModelMessage) to the -// JSON wire format expected by the FFI layer. -func marshalPrompt(prompt any) (string, error) { - switch p := prompt.(type) { - case string: - b, err := json.Marshal(p) - if err != nil { - return "", fmt.Errorf("aimux: failed to marshal prompt string: %w", err) - } - return string(b), nil - case []ModelMessage: - b, err := json.Marshal(p) - if err != nil { - return "", fmt.Errorf("aimux: failed to marshal messages: %w", err) - } - return string(b), nil - default: - return "", fmt.Errorf("aimux: prompt must be string or []ModelMessage, got %T", prompt) - } -} - -// Generate is the typed text generation method. It accepts a plain string -// prompt or a []ModelMessage conversation, plus optional typed options, -// and returns a typed *GenerateTextResult. -// -// This is the Go equivalent of Node's generateText(model, prompt, options). -// -// result, err := model.Generate("What is Rust?", nil) -// fmt.Println(result.Text) -// -// result, err := model.Generate([]ModelMessage{ -// NewTextMessage(RoleSystem, "You are helpful."), -// NewTextMessage(RoleUser, "What is Rust?"), -// }, nil) -func (m *Model) Generate(prompt any, opts *GenerateTextOptions) (*GenerateTextResult, error) { - promptJSON, err := marshalPrompt(prompt) - if err != nil { - return nil, err - } - optsJSON, err := MarshalOptions(opts) - if err != nil { - return nil, err - } - resultJSON, err := m.GenerateText(promptJSON, optsJSON) - if err != nil { - return nil, err - } - resultJSON, err = maybeRepairResult(resultJSON, promptJSON, optsJSON, opts) - if err != nil { - return nil, err - } - return ParseGenerateTextResult(resultJSON) -} - -// GenerateObj is the typed object-generation method (M12, RFC-0016). -// -// Same signature as Generate; returns a typed *GenerateObjectResult. Pass -// Output: {"type":"object","schema":{...}} in opts to describe the object; the -// model's text must parse as JSON and match the schema. The AI SDK -// deprecates generateObject for generateText with an output; Generate with -// opts.Output returns the parsed value as GenerateTextResult.Output. -func (m *Model) GenerateObj(prompt any, opts *GenerateTextOptions) (*GenerateObjectResult, error) { - promptJSON, err := marshalPrompt(prompt) - if err != nil { - return nil, err - } - optsJSON, err := MarshalOptions(opts) - if err != nil { - return nil, err - } - resultJSON, err := m.GenerateObject(promptJSON, optsJSON) - if err != nil { - return nil, err - } - resultJSON, err = maybeRepairResult(resultJSON, promptJSON, optsJSON, opts) - if err != nil { - return nil, err - } - return ParseGenerateObjectResult(resultJSON) -} - -// ConsumeStream is the typed stream-aggregation method (M11, RFC-0016). -// -// Drives stream_text to completion and returns the *GenerateTextResult the -// stream aggregates to. -func (m *Model) ConsumeStream(prompt any, opts *GenerateTextOptions) (*GenerateTextResult, error) { - promptJSON, err := marshalPrompt(prompt) - if err != nil { - return nil, err - } - optsJSON, err := MarshalOptions(opts) - if err != nil { - return nil, err - } - resultJSON, err := m.ConsumeStreamText(promptJSON, optsJSON) - if err != nil { - return nil, err - } - resultJSON, err = maybeRepairResult(resultJSON, promptJSON, optsJSON, opts) - if err != nil { - return nil, err - } - return ParseGenerateTextResult(resultJSON) -} - -// TypedStream is a handle to an in-progress typed stream. -// Consume parts via Parts(); each part is a parsed *StreamPart. -type TypedStream struct { - raw *Stream - parts chan *StreamPart - err error -} - -// Parts returns a channel of parsed *StreamPart values. -// The channel is closed when the stream ends. -func (s *TypedStream) Parts() <-chan *StreamPart { return s.parts } - -// Err returns any error that occurred during streaming. It must be called -// only after Parts has closed. -func (s *TypedStream) Err() error { return s.err } - -// Cancel stops this stream. It is safe to call more than once. -func (s *TypedStream) Cancel() { s.raw.Cancel() } - -// Stream is the typed streaming method. It accepts a plain string prompt or -// a []ModelMessage conversation, plus optional typed options, and returns a -// *TypedStream that yields parsed *StreamPart values. -// -// This is the Go equivalent of Node's streamText(model, prompt, options). -// -// stream, err := model.Stream("Write a haiku", nil) -// for part := range stream.Parts() { -// if part.Type == "text-delta" { -// var td TextDeltaPayload -// json.Unmarshal(part.Raw, &td) -// fmt.Print(td.Text) -// } -// } -func (m *Model) Stream(prompt any, opts *GenerateTextOptions) (*TypedStream, error) { - return m.StreamContext(context.Background(), prompt, opts) -} - -// StreamContext starts a typed stream that stops when ctx is cancelled. -func (m *Model) StreamContext( - ctx context.Context, - prompt any, - opts *GenerateTextOptions, -) (*TypedStream, error) { - promptJSON, err := marshalPrompt(prompt) - if err != nil { - return nil, err - } - optsJSON, err := MarshalOptions(opts) - if err != nil { - return nil, err - } - - rawStream := m.StreamTextContext(ctx, promptJSON, optsJSON) - ts := &TypedStream{ - raw: rawStream, - parts: make(chan *StreamPart, 256), - } - - go func() { - defer close(ts.parts) - for { - var raw string - select { - case part, ok := <-rawStream.Parts(): - if !ok { - ts.err = rawStream.Err() - return - } - raw = part - case <-rawStream.cancelCtx.Done(): - ts.err = context.Cause(rawStream.cancelCtx) - return - } - sp, err := ParseStreamPart(raw) - if err != nil { - ts.err = err - rawStream.cancel(err) - return - } - // Only a complete tool call can be repaired; input deltas are - // forwarded untouched, as the AI SDK does (RFC-0035 §4). - if sp.Type == "tool-call" && opts != nil && opts.RepairToolCall != nil { - raw, err := repairStreamToolCall(sp.Raw, promptJSON, optsJSON, opts.RepairToolCall) - if err != nil { - ts.err = err - rawStream.cancel(err) - return - } - sp.Raw = raw - } - select { - case ts.parts <- sp: - case <-rawStream.cancelCtx.Done(): - ts.err = context.Cause(rawStream.cancelCtx) - return - } - } - }() - - return ts, nil -} - -// ── OpenAI Chat Completions output (RFC-0026) ────────────────────────────── - -// GenerateAsOpenAI is the typed OpenAI Chat Completion generation method. It -// accepts a plain string prompt or a []ModelMessage conversation, plus optional -// typed options, and returns a typed *ChatCompletion. -// -// This is the OpenAI-output equivalent of Generate. With opts.RepairToolCall, -// invalid calls are repaired on the native result first and the completion is -// converted from it, so its tool calls carry the repaired arguments (a -// ChatCompletion has no invalid marker to repair from). -// -// result, err := model.GenerateAsOpenAI("What is Rust?", nil) -// fmt.Println(result.Choices[0].Message.Content) -func (m *Model) GenerateAsOpenAI(prompt any, opts *GenerateTextOptions) (*ChatCompletion, error) { - promptJSON, err := marshalPrompt(prompt) - if err != nil { - return nil, err - } - optsJSON, err := MarshalOptions(opts) - if err != nil { - return nil, err - } - if opts == nil || opts.RepairToolCall == nil { - resultJSON, err := m.GenerateTextAsOpenAI(promptJSON, optsJSON) - if err != nil { - return nil, err - } - return ParseChatCompletion(resultJSON) - } - resultJSON, err := m.GenerateText(promptJSON, optsJSON) - if err != nil { - return nil, err - } - resultJSON, err = repairResultJSON(resultJSON, promptJSON, optsJSON, opts.RepairToolCall) - if err != nil { - return nil, err - } - completionJSON, err := m.GenerateTextResultAsOpenAI(resultJSON) - if err != nil { - return nil, err - } - return ParseChatCompletion(completionJSON) -} - -// OpenAIStream is a handle to an in-progress typed OpenAI stream. -// Consume parts via Parts(); each part is a parsed *ChatCompletionChunk. -type OpenAIStream struct { - raw *Stream - parts chan *ChatCompletionChunk - err error -} - -// Parts returns a channel of parsed *ChatCompletionChunk values. -// The channel is closed when the stream ends. -func (s *OpenAIStream) Parts() <-chan *ChatCompletionChunk { return s.parts } - -// Err returns any error that occurred during streaming. It must be called -// only after Parts has closed. -func (s *OpenAIStream) Err() error { return s.err } - -// Cancel stops this stream. It is safe to call more than once. -func (s *OpenAIStream) Cancel() { s.raw.Cancel() } - -// StreamAsOpenAI is the typed OpenAI streaming method. It accepts a plain -// string prompt or a []ModelMessage conversation, plus optional typed options, -// and returns an *OpenAIStream that yields parsed *ChatCompletionChunk values. -// -// This is the OpenAI-output equivalent of Stream. -// -// stream, err := model.StreamAsOpenAI("Write a haiku", nil) -// for chunk := range stream.Parts() { -// if len(chunk.Choices) > 0 { -// if c := chunk.Choices[0].Delta.Content; c != nil { -// fmt.Print(*c) -// } -// } -// } -func (m *Model) StreamAsOpenAI(prompt any, opts *GenerateTextOptions) (*OpenAIStream, error) { - return m.StreamAsOpenAIContext(context.Background(), prompt, opts) -} - -// StreamAsOpenAIContext starts a typed OpenAI stream that stops when ctx is -// cancelled. -func (m *Model) StreamAsOpenAIContext( - ctx context.Context, - prompt any, - opts *GenerateTextOptions, -) (*OpenAIStream, error) { - promptJSON, err := marshalPrompt(prompt) - if err != nil { - return nil, err - } - optsJSON, err := MarshalOptions(opts) - if err != nil { - return nil, err - } - - rawStream := m.StreamTextAsOpenAIContext(ctx, promptJSON, optsJSON) - ts := &OpenAIStream{ - raw: rawStream, - parts: make(chan *ChatCompletionChunk, 256), - } - - go func() { - defer close(ts.parts) - for { - var raw string - select { - case part, ok := <-rawStream.Parts(): - if !ok { - ts.err = rawStream.Err() - return - } - raw = part - case <-rawStream.cancelCtx.Done(): - ts.err = context.Cause(rawStream.cancelCtx) - return - } - chunk, err := ParseChatCompletionChunk(raw) - if err != nil { - ts.err = err - rawStream.cancel(err) - return - } - select { - case ts.parts <- chunk: - case <-rawStream.cancelCtx.Done(): - ts.err = context.Cause(rawStream.cancelCtx) - return - } - } - }() - - return ts, nil -} diff --git a/bindings/go/typed_test.go b/bindings/go/typed_test.go deleted file mode 100644 index 1c78dae5e..000000000 --- a/bindings/go/typed_test.go +++ /dev/null @@ -1,266 +0,0 @@ -// Tests for the typed text-generation API (Generate/Stream). -// Verifies that typed inputs (string, []ModelMessage, *GenerateTextOptions) -// are correctly marshaled and typed outputs (*GenerateTextResult, typed -// StreamPart channel) are correctly produced. - -package aimux - -import ( - "context" - "encoding/json" - "errors" - "testing" - "time" -) - -func TestTypedGenerateString(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetResponse(plainOpenAIResponse) - - m := chatModel(t, srv.URL) - defer m.Close() - - // Typed call with plain string prompt. - result, err := m.Generate("What is Rust?", nil) - if err != nil { - t.Fatalf("Generate failed: %v", err) - } - if result.Text != "Rust is a systems programming language." { - t.Errorf("expected text, got %q", result.Text) - } -} - -func TestTypedGenerateMessages(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetResponse(plainOpenAIResponse) - - m := chatModel(t, srv.URL) - defer m.Close() - - msgs := []ModelMessage{ - NewTextMessage(RoleSystem, "You are a helpful assistant."), - NewTextMessage(RoleUser, "What is Rust?"), - } - result, err := m.Generate(msgs, nil) - if err != nil { - t.Fatalf("Generate failed: %v", err) - } - if result.Text != "Rust is a systems programming language." { - t.Errorf("expected text, got %q", result.Text) - } - - // Verify the provider received both messages. - var reqBody map[string]any - json.Unmarshal([]byte(srv.LastRequestBody()), &reqBody) - messages := reqBody["messages"].([]any) - if len(messages) != 2 { - t.Fatalf("expected 2 messages, got %d", len(messages)) - } -} - -func TestTypedGenerateWithOptions(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetResponse(toolCallOpenAIResponse) - - m := chatModel(t, srv.URL) - defer m.Close() - - temp := 0.7 - opts := &GenerateTextOptions{ - Temperature: &temp, - Tools: []Tool{ - {Type: "function", Name: "get_weather", InputSchema: json.RawMessage(`{"type":"object"}`)}, - }, - ToolChoice: ToolChoiceRequired(), - } - result, err := m.Generate("What is the weather in Tokyo?", opts) - if err != nil { - t.Fatalf("Generate failed: %v", err) - } - if len(result.ToolCalls) == 0 { - t.Fatal("expected tool calls") - } - if result.ToolCalls[0].ToolName != "get_weather" { - t.Errorf("expected 'get_weather', got %q", result.ToolCalls[0].ToolName) - } - - // Verify the provider received the tool_choice. - var reqBody map[string]any - json.Unmarshal([]byte(srv.LastRequestBody()), &reqBody) - if reqBody["tool_choice"] != "required" { - t.Errorf("expected tool_choice 'required', got %v", reqBody["tool_choice"]) - } -} - -func TestTypedGenerateRejectsInvalidPrompt(t *testing.T) { - m := OpenAI("sk-test-fake-key", "gpt-4o") - defer m.Close() - - _, err := m.Generate(12345, nil) // int is not a valid prompt - if err == nil { - t.Fatal("expected error for invalid prompt type") - } -} - -func TestTypedStreamTextDeltas(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetContentType("text/event-stream") - srv.SetResponse(buildTextDeltaSSE()) - - m := chatModel(t, srv.URL) - defer m.Close() - - stream, err := m.Stream("Say hello", nil) - if err != nil { - t.Fatalf("Stream failed: %v", err) - } - - var textBuilder string - var gotFinish bool - for part := range stream.Parts() { - switch part.Type { - case "text-delta": - var td TextDeltaPayload - json.Unmarshal(part.Raw, &td) - textBuilder += td.Text - case "finish": - gotFinish = true - } - } - if err := stream.Err(); err != nil { - t.Fatalf("stream error: %v", err) - } - if textBuilder != "Hello world" { - t.Errorf("expected 'Hello world', got %q", textBuilder) - } - if !gotFinish { - t.Error("expected a Finish part") - } -} - -func TestTypedStreamMessages(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetContentType("text/event-stream") - srv.SetResponse(buildTextDeltaSSE()) - - m := chatModel(t, srv.URL) - defer m.Close() - - msgs := []ModelMessage{ - NewTextMessage(RoleUser, "Say hello"), - } - stream, err := m.Stream(msgs, nil) - if err != nil { - t.Fatalf("Stream failed: %v", err) - } - - count := 0 - for range stream.Parts() { - count++ - } - if count == 0 { - t.Error("expected at least one stream part") - } - if err := stream.Err(); err != nil { - t.Fatalf("stream error: %v", err) - } -} - -func TestTypedStreamContextAlreadyCanceled(t *testing.T) { - m := OpenAI("sk-test-fake-key", "gpt-4o") - if err := m.Close(); err != nil { - t.Fatalf("Close failed: %v", err) - } - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - stream, err := m.StreamContext(ctx, "cancel now", nil) - if err != nil { - t.Fatalf("StreamContext failed: %v", err) - } - defer stream.Cancel() - - done := make(chan error, 1) - go func() { - for range stream.Parts() { - } - done <- stream.Err() - }() - select { - case err := <-done: - if !errors.Is(err, context.Canceled) { - t.Fatalf("expected context.Canceled, got %v", err) - } - case <-time.After(2 * time.Second): - t.Fatal("pre-canceled typed stream did not stop") - } - - stream.Cancel() - stream.Cancel() -} - -func TestTypedStreamCancelUnblocksFullPartsChannel(t *testing.T) { - srv, allSent, requestDone := newBackpressureSSEServer(1200) - defer srv.Close() - m := chatModel(t, srv.URL) - defer m.Close() - - stream, err := m.Stream("fill the typed channel", nil) - if err != nil { - t.Fatalf("Stream failed: %v", err) - } - defer stream.Cancel() - - select { - case <-allSent: - case <-time.After(2 * time.Second): - t.Fatal("provider did not send the SSE burst") - } - waitForChannelFull(t, stream.parts) - - stream.Cancel() - stream.Cancel() - done := make(chan error, 1) - go func() { - for range stream.Parts() { - } - done <- stream.Err() - }() - select { - case err := <-done: - if !errors.Is(err, context.Canceled) { - t.Fatalf("expected context.Canceled, got %v", err) - } - case <-time.After(2 * time.Second): - t.Fatal("full typed parts channel stayed blocked after cancellation") - } - select { - case <-requestDone: - case <-time.After(2 * time.Second): - t.Fatal("provider request stayed open after typed cancellation") - } -} - -func TestDeepSeekFactory(t *testing.T) { - m := DeepSeek("sk-test-fake-key", "deepseek-chat") - defer m.Close() - if m == nil { - t.Fatal("expected non-nil model") - } - if m.id.Load() == 0 { - t.Fatal("expected non-zero handle") - } -} - -func TestNewDeepSeekFactory(t *testing.T) { - m, err := NewDeepSeek("sk-test-fake-key", "deepseek-chat") - if err != nil { - t.Fatalf("NewDeepSeek failed: %v", err) - } - defer m.Close() -} diff --git a/bindings/go/types.go b/bindings/go/types.go index bf88aa3f2..9051d256f 100644 --- a/bindings/go/types.go +++ b/bindings/go/types.go @@ -4,9 +4,8 @@ // fields). The generated TypeScript types in bindings/node/src/types are the // exact shape. // -// The raw JSON boundary is handled by Model.GenerateText / Model.StreamText — -// this layer only provides typed parsing so callers don't manually dig through -// JSON. Decoding is lenient (unknown keys ignored) so future engine additions +// Call, CallInto and Handle.Stream carry the canonical operation JSON. +// These types provide typed parsing for operation results. Decoding is lenient (unknown keys ignored) so future engine additions // don't break existing clients. package aimux @@ -17,8 +16,6 @@ import ( "fmt" ) -// ── Enums (string-backed on the wire) ────────────────────────────────────── - type Role string const ( @@ -65,19 +62,13 @@ func ToolChoiceRequired() ToolChoice { return json.RawMessage(`"required"`) } // ToolChoiceTool builds a ToolChoice selecting a specific tool. // Uses json.Marshal with a struct to ensure correct field order and escaping. func ToolChoiceTool(name string) ToolChoice { - b, err := json.Marshal(struct { + b, _ := json.Marshal(struct { Type string `json:"type"` ToolName string `json:"toolName"` }{Type: "tool", ToolName: name}) - if err != nil { - // Should not happen for a string — fall back to a safe literal. - return json.RawMessage(`{"type":"tool","toolName":""}`) - } return json.RawMessage(b) } -// ── Core types ─────────────────────────────────────────────────────────────── - // Source is a URL or document source. Exactly one variant is populated. // Its JSON is the AI SDK's Source, tagged {"type":"source","sourceType":...} // (provider language-model-v4-source.ts) in a result's Sources and in a @@ -230,8 +221,7 @@ func (c ToolCall) MarshalJSON() ([]byte, error) { }{"tool-call", plain(c)}) } -// UnmarshalJSON reads a call; a "type" field, when present, must be -// "tool-call". +// UnmarshalJSON reads a call with the canonical "type":"tool-call" tag. func (c *ToolCall) UnmarshalJSON(data []byte) error { type plain ToolCall var tagged struct { @@ -248,11 +238,10 @@ func (c *ToolCall) UnmarshalJSON(data []byte) error { return nil } -// checkTypeTag accepts a missing "type" field (JSON written before aimux -// wrote the tag) or one equal to want. +// checkTypeTag requires the canonical discriminator for tagged wire values. func checkTypeTag(raw json.RawMessage, want string) error { if len(raw) == 0 { - return nil + return fmt.Errorf("aimux: missing type discriminator %q", want) } var tag string if err := json.Unmarshal(raw, &tag); err != nil || tag != want { @@ -426,7 +415,7 @@ type GenerateObjectResult struct { ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` } -// ParseGenerateTextResult parses the JSON string returned by Model.GenerateText +// ParseGenerateTextResult parses the JSON string returned by text.generate // into a typed GenerateTextResult. func ParseGenerateTextResult(jsonStr string) (*GenerateTextResult, error) { var r GenerateTextResult @@ -437,7 +426,7 @@ func ParseGenerateTextResult(jsonStr string) (*GenerateTextResult, error) { } // ParseGenerateObjectResult parses the JSON string returned by -// Model.GenerateObject into a typed GenerateObjectResult. +// text.generateObject into a typed GenerateObjectResult. func ParseGenerateObjectResult(jsonStr string) (*GenerateObjectResult, error) { var r GenerateObjectResult if err := json.Unmarshal([]byte(jsonStr), &r); err != nil { @@ -446,8 +435,6 @@ func ParseGenerateObjectResult(jsonStr string) (*GenerateObjectResult, error) { return &r, nil } -// ── ModelMessage (for multi-role prompts) ───────────────────────────────────── - // ModelMessage is a single message in a conversation. // Content is `any` so it can be a plain string (common case) or a slice of // ContentParts (multi-part content, e.g. tool results). @@ -470,8 +457,6 @@ func MarshalMessages(msgs []ModelMessage) (string, error) { return string(b), nil } -// ── GenerateTextOptions (for typed options building) ────────────────────────── - // GenerateTextOptions is the typed options for text generation. // TimeoutConfiguration sets per-call timeouts. type TimeoutConfiguration struct { @@ -507,15 +492,6 @@ type GenerateTextOptions struct { Timeout *TimeoutConfiguration `json:"timeout,omitempty"` IncludeRawChunks *bool `json:"includeRawChunks,omitempty"` SessionID *string `json:"sessionId,omitempty"` - // RepairToolCall repairs tool calls the model got wrong (RFC-0035). It - // runs in Go, after generation, on every call the engine marked invalid — - // never serialized into the options sent to the engine. - // - // It applies to Generate, GenerateObj, ConsumeStream and Stream, and to - // GenerateAsOpenAI, which repairs the native result before converting it. - // StreamAsOpenAI does not reflect repair: its tool-argument deltas are the - // provider's text, forwarded as they arrive (as in the AI SDK). - RepairToolCall RepairToolCallFunc `json:"-"` } type FunctionToolInputExample struct { @@ -555,8 +531,6 @@ func MarshalOptions(opts *GenerateTextOptions) (string, error) { return string(b), nil } -// ── StreamPart parsing ───────────────────────────────────────────────────── - // StreamPart is a parsed stream part. The wire format is an object tagged by // its "type" key (e.g. {"type":"text-delta","id":"...","text":"..."}). type StreamPart struct { @@ -609,8 +583,6 @@ type FinishPayload struct { TotalUsage LanguageModelUsage `json:"totalUsage"` } -// ── Cache probing (RFC-0015) wire types ───────────────────────────────────── - // TraceFilter filters aggregate queries (RFC-0015 §5.3). type TraceFilter struct { Provider *string `json:"provider,omitempty"` @@ -696,8 +668,6 @@ type PrefixBreak struct { Kind string `json:"kind"` } -// ── OpenAI Chat Completions output (RFC-0026) ────────────────────────────── -// // Mirrors aimux_core::openai_output. JSON tags match the serde wire format: // Rust `#[serde(skip_serializing_if = "Option::is_none")]` → Go `omitempty`; // Rust `#[serde(rename = "type")]` → Go `json:"type"`. Fields without @@ -805,7 +775,7 @@ type ChatCompletionChunk struct { Usage *ChatCompletionUsage `json:"usage,omitempty"` } -// ParseChatCompletion parses the JSON string returned by Model.GenerateTextAsOpenAI +// ParseChatCompletion parses the JSON string returned by text.generateOpenAI // into a typed ChatCompletion. func ParseChatCompletion(jsonStr string) (*ChatCompletion, error) { var r ChatCompletion diff --git a/bindings/go/wire.go b/bindings/go/wire.go new file mode 100644 index 000000000..c68ccb8df --- /dev/null +++ b/bindings/go/wire.go @@ -0,0 +1,163 @@ +package aimux + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "unicode/utf8" +) + +// HandleID is a uint64 in C and a decimal string in JSON. +type HandleID uint64 + +func (id HandleID) MarshalJSON() ([]byte, error) { + return json.Marshal(strconv.FormatUint(uint64(id), 10)) +} + +func (id *HandleID) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("aimux: handle must be a decimal string: %w", err) + } + n, err := decimalUint64(s) + if err != nil { + return err + } + *id = HandleID(n) + return nil +} + +func decimalUint64(s string) (uint64, error) { + if s == "" || (len(s) > 1 && s[0] == '0') { + return 0, fmt.Errorf("aimux: invalid decimal uint64 %q", s) + } + for _, c := range s { + if c < '0' || c > '9' { + return 0, fmt.Errorf("aimux: invalid decimal uint64 %q", s) + } + } + n, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0, fmt.Errorf("aimux: invalid decimal uint64: %w", err) + } + return n, nil +} + +// Params keeps absent keys distinct from explicit JSON null. Use json.RawMessage +// for open providerOptions and other canonical JSON values. +type Params map[string]json.RawMessage + +func validateJSON(name string, data []byte) error { + if !utf8.Valid(data) || !json.Valid(data) { + return localError("binding", 202, "InvalidWireJson", name+": invalid UTF-8 JSON") + } + if err := checkSurrogates(name, string(data)); err != nil { + return localError("binding", 202, "InvalidWireJson", err.Error()) + } + return nil +} + +func encodeParams(op string, params any) ([]byte, error) { + if op == "" || !utf8.ValidString(op) || strings.ContainsRune(op, 0) { + return nil, localError("protocol", 300, "InvalidOp", "op must be nonempty NUL-free UTF-8") + } + data, err := json.Marshal(params) + if err != nil { + return nil, localError("binding", 202, "InvalidWireJson", err.Error()) + } + if err := validateJSON("params", data); err != nil { + return nil, err + } + return data, nil +} + +// checkSurrogates rejects the \uXXXX escapes serde_json rejects but +// encoding/json accepts: an unpaired surrogate half (json.Valid passes it, +// then serde fails in C with a C ABI failure code). Runs after json.Valid, so +// every backslash here is a well-formed escape inside a string. +func checkSurrogates(param, s string) error { + for i := 0; i+1 < len(s); { + if s[i] != '\\' { + i++ + continue + } + if s[i+1] != 'u' { + i += 2 // \" \\ \/ \b \f \n \r \t — skip the escaped byte + continue + } + hi, ok := hex4(s, i) + if !ok { + i += 2 + continue + } + i += 6 + switch { + case hi >= 0xDC00 && hi <= 0xDFFF: + return fmt.Errorf("aimux: %s: invalid JSON: lone low surrogate \\u%04X", param, hi) + case hi >= 0xD800 && hi <= 0xDBFF: + lo, ok := hex4(s, i) + if !ok || lo < 0xDC00 || lo > 0xDFFF { + return fmt.Errorf("aimux: %s: invalid JSON: unpaired high surrogate \\u%04X", param, hi) + } + i += 6 + } + } + return nil +} + +// hex4 reads the code point of a \uXXXX escape starting at s[i]; false when +// s[i:] is not such an escape. +func hex4(s string, i int) (int, bool) { + if i+6 > len(s) || s[i] != '\\' || s[i+1] != 'u' { + return 0, false + } + n, err := strconv.ParseUint(s[i+2:i+6], 16, 32) + if err != nil { + return 0, false + } + return int(n), true +} + +// WireDTO retains canonical JSON alongside a typed view. In particular, +// optional pointer fields in T do not erase absent versus explicit null, +// unknown fields, or large JSON integers when the DTO is encoded again. +// Use CallInto[WireDTO[ResultType]] for a lossless typed result. +// WireDTO is immutable; decode Value to work on a separate typed value. +type WireDTO[T any] struct{ raw json.RawMessage } + +func (d *WireDTO[T]) UnmarshalJSON(data []byte) error { + if err := validateJSON("DTO", data); err != nil { + return err + } + var value T + if err := json.Unmarshal(data, &value); err != nil { + return err + } + d.raw = append(json.RawMessage(nil), data...) + return nil +} + +func (d WireDTO[T]) MarshalJSON() ([]byte, error) { + if len(d.raw) == 0 { + return nil, fmt.Errorf("aimux: uninitialized wire DTO") + } + return append([]byte(nil), d.raw...), nil +} + +func (d WireDTO[T]) Value() (T, error) { + var value T + err := json.Unmarshal(d.raw, &value) + return value, err +} + +// Field returns an owned copy and a presence bit: nil,false means absent; +// []byte("null"),true means explicitly null. +func (d WireDTO[T]) Field(name string) (json.RawMessage, bool) { + var fields map[string]json.RawMessage + if json.Unmarshal(d.raw, &fields) != nil { + return nil, false + } + raw, present := fields[name] + return raw, present +} diff --git a/bindings/go/wire_test.go b/bindings/go/wire_test.go new file mode 100644 index 000000000..baab839de --- /dev/null +++ b/bindings/go/wire_test.go @@ -0,0 +1,153 @@ +package aimux + +import ( + "encoding/json" + "math" + "reflect" + "testing" +) + +func TestHandleIDDecimalWire(t *testing.T) { + for _, n := range []uint64{0, 1, 1<<53 + 1, math.MaxUint64} { + raw, err := json.Marshal(HandleID(n)) + if err != nil { + t.Fatal(err) + } + if raw[0] != '"' { + t.Fatalf("handle encoded as number: %s", raw) + } + var got HandleID + if err := json.Unmarshal(raw, &got); err != nil || uint64(got) != n { + t.Fatalf("round trip %s: %d, %v", raw, got, err) + } + } + for _, raw := range []string{`1`, `null`, `""`, `"-1"`, `"+1"`, `"01"`, `"1.0"`, `"18446744073709551616"`} { + id := HandleID(123) + if err := json.Unmarshal([]byte(raw), &id); err == nil { + t.Errorf("accepted invalid ID %s", raw) + } + if id != 123 { + t.Errorf("failed decode overwrote ID: %s", raw) + } + } +} + +func TestWireJSONValidation(t *testing.T) { + for _, raw := range [][]byte{nil, []byte(`{`), []byte(`"\ud800"`), []byte(`"\udfff"`), []byte{'"', 0xff, '"'}} { + if _, err := encodeParams("test.echo", json.RawMessage(raw)); err == nil && raw != nil { + t.Errorf("accepted %q", raw) + } + } + for _, op := range []string{"", "bad\x00op", "\xff"} { + if _, err := encodeParams(op, struct{}{}); err == nil { + t.Errorf("accepted op %q", op) + } + } + for _, raw := range []string{`{}`, `null`, `"\ud83d\ude00"`, `{"explicit":null,"text":"你好\u0000"}`} { + if _, err := encodeParams("test.echo", json.RawMessage(raw)); err != nil { + t.Errorf("rejected %s: %v", raw, err) + } + } +} + +func roundTripDTO[T any](t *testing.T, raw string) { + t.Helper() + var original T + if err := json.Unmarshal([]byte(raw), &original); err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + var again T + if err := json.Unmarshal(encoded, &again); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(original, again) { + t.Fatalf("typed round trip changed value: %s", encoded) + } + // Check every supplied field survives too, including nested JSON nulls. + var want, got any + json.Unmarshal([]byte(raw), &want) + json.Unmarshal(encoded, &got) + if !reflect.DeepEqual(want, got) { + t.Fatalf("wire round trip changed fields:\nwant %s\ngot %s", raw, encoded) + } +} + +func TestAllModalitiesTypedRoundTrip(t *testing.T) { + t.Run("text", func(t *testing.T) { + roundTripDTO[GenerateTextOptions](t, `{"output":{"type":"json"},"temperature":0,"providerOptions":{"z":{"n":null},"a":{}}}`) + }) + t.Run("embedding", func(t *testing.T) { + roundTripDTO[EmbeddingResult](t, `{"embeddings":[[0.5,0.25],[1,-1]],"usage":{"tokens":2},"providerMetadata":null}`) + }) + t.Run("image", func(t *testing.T) { + roundTripDTO[ImageResult](t, `{"images":["AAEC"],"response":{},"providerMetadata":null}`) + }) + t.Run("speech", func(t *testing.T) { + roundTripDTO[SpeechResult](t, `{"audio":"AAEC","response":{"body":null},"providerMetadata":{}}`) + }) + t.Run("transcription", func(t *testing.T) { + roundTripDTO[TranscriptionResult](t, `{"text":"hello","segments":[{"text":"hi","startSecond":0,"endSecond":1}],"response":{},"providerMetadata":null}`) + }) + t.Run("reranking", func(t *testing.T) { + roundTripDTO[RerankingResult](t, `{"ranking":[{"index":0,"relevanceScore":0.5}],"providerMetadata":null}`) + }) + t.Run("video", func(t *testing.T) { + roundTripDTO[VideoResult](t, `{"videos":[{"type":"binary","data":"AAEC","mediaType":"video/mp4"}],"response":{},"providerMetadata":null}`) + }) + t.Run("search", func(t *testing.T) { + roundTripDTO[SearchResult](t, `{"results":[{"title":"found","url":"https://example.com","score":0}],"providerMetadata":null}`) + }) + t.Run("files", func(t *testing.T) { + roundTripDTO[UploadFileResult](t, `{"providerReference":{"z":"f1","a":"f2"},"providerMetadata":null}`) + }) +} + +func TestWireDTOKeepsNullMissingAndUnknownFields(t *testing.T) { + // Ordinary *string decodes both null and absence as nil. WireDTO retains + // the canonical document independently of that typed view. + raw := `{"text":"hello","voice":null,"providerOptions":{"n":null},"future":18446744073709551615}` + var dto WireDTO[SpeechCallOptions] + if err := json.Unmarshal([]byte(raw), &dto); err != nil { + t.Fatal(err) + } + value, err := dto.Value() + if err != nil || value.Text != "hello" || value.Voice != nil { + t.Fatalf("typed view: %+v, %v", value, err) + } + if field, present := dto.Field("voice"); !present || string(field) != "null" { + t.Fatalf("explicit null: %s,%v", field, present) + } + if field, present := dto.Field("language"); present || field != nil { + t.Fatalf("absent field: %s,%v", field, present) + } + encoded, err := json.Marshal(dto) + if err != nil || string(encoded) != raw { + t.Fatalf("lossy typed DTO: %s,%v", encoded, err) + } + // Mutating the original native-buffer copy or a typed view cannot change + // the immutable wire document. + value.ProviderOptions[0] = '[' + encoded, err = json.Marshal(dto) + if err != nil || string(encoded) != raw { + t.Fatalf("typed view aliases wire storage: %s,%v", encoded, err) + } +} + +func TestWireDTOCopiedValueDoesNotAliasOnDecode(t *testing.T) { + var original WireDTO[SpeechCallOptions] + if err := json.Unmarshal([]byte(`{"text":"original","voice":null}`), &original); err != nil { + t.Fatal(err) + } + copied := original + if err := json.Unmarshal([]byte(`{"text":"changed"}`), &copied); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(original) + if err != nil || string(raw) != `{"text":"original","voice":null}` { + t.Fatalf("copied DTO mutated original: %s,%v", raw, err) + } +} diff --git a/bindings/java/README.md b/bindings/java/README.md new file mode 100644 index 000000000..3251e3b81 --- /dev/null +++ b/bindings/java/README.md @@ -0,0 +1,115 @@ +# Java binding — ABI 1 / ops 1.0 + +Java 8 is supported. Wire DTOs use ordinary classes; existing `Types` and +`MultimodalTypes` builders remain available. `AimuxFFI` declares the 14 C +primitives once. `size_t` uses the native pointer size, including Windows LLP64. +Operation names and version constants come from the packaged generated `AimuxOps`. + +This is a breaking replacement of the unversioned binding. Load explicitly with +`Aimux.load()` or `Aimux.load(libraryPath)`. ABI and manifest ops versions are +checked before the client becomes usable. Provider-specific constructors have +been removed; provider settings are plain data. + +```java +Aimux aimux = Aimux.load(); +String providerJson = "{\"package\":\"openai\",\"settings\":{\"apiKey\":\"your-key\"}}"; +String requestJson = "{\"prompt\":\"Hello\",\"options\":{\"maxRetries\":0}}"; +try (ProviderHandle provider = aimux.createProvider(providerJson); + Model model = provider.languageModel("gpt-4.1-mini")) { + System.out.println(model.generate(requestJson)); + model.stream(requestJson, frame -> { + if (!frame.isEnd()) System.out.println(frame.getEvent()); + }); +} +``` + +Use the native request DTO for each operation. Parameters must be an object, +including `{}` for operations without parameters. Text operations use `prompt` +and `options`; embedding calls use `values`; `decision.decide` uses `options`. +Provider options pass through unchanged. Native validation rejects unknown +control fields. Handles are unsigned decimal strings; operation results may +return a handle string directly or an object containing `handle`. + +`ProviderHandle` exposes Language, Embedding, Image, Speech, Transcription, +Reranking, Video, Search, Decision and Files. `decisionModel(id)` selects the +native `decision` method. Support depends on the provider. Every resource also +supports `call`, `stream` and `create` with the operations accepted by its native +handle kind. `Aimux.call` uses global handle 0. `Aimux.create` creates resources +such as registries, recorders, transports, middleware, trace stores and replay +sessions. `own(id)` transfers ownership; wrap each returned handle once. +`TypedModel` owns a supplied `Model` and decodes the existing core result classes. + +Use try-with-resources. `close()` is idempotent and stops new calls immediately. +Admitted and queued calls retain the resource until their native calls return. +Closing a model does not signal cancellation; `close()` inside a Consumer never +waits for that Consumer to finish. A release requested from a callback runs on +a daemon cleanup thread. Explicit close governs ownership. A Java 8 +`PhantomReference` safety net drains one `ReferenceQueue` on a daemon thread and +calls the non-blocking `aimux_handle_finalizer` for unreachable, unclosed handles. +Explicitly closed handles are removed from that safety net. + +Every asynchronous method requires an explicit `Executor`. The internal +`Cancellation` helper forwards successful `Future.cancel(false)` and +`Future.cancel(true)` to native Abort, including cancellation before execution +starts. The Future becomes done immediately; running native work retains its +resources until it returns. Thread interruption is not used to stop native work. +For synchronous cancellation, use `createAbort()` and the `call`/`stream` +overloads that accept it. + +Consumers receive copied `StreamFrame` values, including the unique End frame. +An error End is delivered before its typed exception is thrown. A cancelled End +throws `CancellationException` after native return, with its native error as the +cause when present. Slow Consumers apply synchronous backpressure and must return +in bounded time. Callback/context references stay alive until `aimux_stream` +returns. A Consumer Throwable is latched once, triggers Abort, suppresses further +Consumer calls, and becomes `BindingException.CallbackFailure` with the original +cause after native return. Exceptions never cross the C callback. + +`NativeException` retains `kind`, `code`, message, the full `error` payload and +optional unsigned `retryAfterMs` as `BigInteger`. Domain exceptions select +subclasses by `error.name`; unfamiliar variants use the domain's `Unknown` +subclass and retain their payload. Retryability comes from `error.isRetryable`. +A missing retry hint differs from zero. The Java adapter performs no retries. + +`TranscriptionSession` uses binary push, `operation.inputDone` and native polling. +`next(timeoutMs)` returns `PART`, `ENDED`, or `TIMEOUT`; timeouts must be +nonnegative. Operation failures throw exceptions. Cancel through +`operation.cancel`. Generic JSON calls cannot push binary data. + +Removed APIs include implicit recording helpers, lock-lending repair hooks and +the unbounded Java Stream adapter. Use explicit recorder resources and the pure +`ToolCallRepairs` DTO operations. Core recorder/session/infer state remains +process-wide. Recording schema migration and business DTO schema generation are +separate work. Replay transport capabilities and credential creation currently +report UnsupportedFunctionality. Per-platform release asset installation is not +verified by this binding's tests. + +## Tests + +Build the native library from the repository root with `cargo build -p aimux-ffi`. +Point JNA at its directory and run the Java tests: + +```sh +AIMUX_FFI_LIB_DIR=/absolute/path/to/target/debug gradle -p bindings/java test +``` + +The suite includes tests against the actual Rust library, native callback and +lifecycle cases using a deterministic C fixture compiled against `aimux.h`, +cancellation helper tests, and DTO round trips. The C fixture is test-only. +Real-library tests cover manifest loading, exported functions, provider/model +ownership, Decision, cancellation terminals, callback errors, binary limits and +poll validation without requiring provider network access. + +When Gradle cannot open its local lock-listener socket, use the offline fallback +with a cached dependency classpath file: + +```sh +AIMUX_FFI_LIB_DIR=/absolute/path/to/target/debug python3 bindings/java/verify-local.py /path/to/classpath.txt +``` + +The classpath file contains JNA 5.14, Jackson 2.17, JUnit Jupiter/Platform +(including the launcher), AssertJ and org.json JAR paths, separated by the +platform path separator. The fallback requires JDK 9 or newer, compiles all +sources including generated constants with `javac --release 8`, and discovers +all binding JUnit tests. Run the JDK 8/11/17/21 runtime matrix and a 32-bit +JVM/native library separately; a Java 8 bytecode target does not verify them. diff --git a/bindings/java/build.gradle.kts b/bindings/java/build.gradle.kts index 7335bba18..1946ea2cf 100644 --- a/bindings/java/build.gradle.kts +++ b/bindings/java/build.gradle.kts @@ -16,6 +16,8 @@ java { targetCompatibility = JavaVersion.VERSION_1_8 } +sourceSets["main"].java.srcDir("generated") + tasks.withType().configureEach { options.encoding = "UTF-8" // JDK 8's javac has no --release flag; only set it on JDK 9+ so the @@ -23,21 +25,19 @@ tasks.withType().configureEach { if (JavaVersion.current().isJava9Compatible) { options.release.set(8) } - // Compiler enforces Java 8 syntax: no var/record/switch expressions, etc. } dependencies { implementation("net.java.dev.jna:jna:5.14.0") implementation("com.fasterxml.jackson.core:jackson-databind:2.17.2") testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") + testImplementation("org.junit.platform:junit-platform-launcher:1.10.0") testImplementation("org.assertj:assertj-core:3.26.0") testImplementation("org.json:json:20240303") } tasks.test { useJUnitPlatform() - // Point JNA at the freshly built native library (cargo build --release -p aimux-ffi), - // or at AIMUX_FFI_LIB_DIR when set (e.g. a debug build or a shared target dir). systemProperty( "jna.library.path", System.getenv("AIMUX_FFI_LIB_DIR") ?: projectDir.resolve("../../target/release").canonicalPath @@ -63,17 +63,8 @@ val javadocJar by tasks.registering(Jar::class) { from(tasks.named("javadoc")) } -// ───────────────────────────────────────────────────────────────────────────── -// Maven Central (Sonatype OSSRH) publishing. -// -// Secrets are NEVER stored in the repo. They are read from Gradle project -// properties — set either as `ORG_GRADLE_PROJECT_` environment variables -// or `-P=...` system properties: -// signingKey / signingPassword ASCII-armored GPG private key + passphrase -// ossrhUsername / ossrhPassword Sonatype (s01.oss.sonatype.org) credentials -// When the keys are absent, signing/publishing configuration stays inert, so -// local builds and `gradle test` are unaffected. -// ───────────────────────────────────────────────────────────────────────────── +// Supply publishing and signing credentials as Gradle properties via +// ORG_GRADLE_PROJECT_ or -P=. val ossrhUsername = findProperty("ossrhUsername") as String? val ossrhPassword = findProperty("ossrhPassword") as String? @@ -152,3 +143,22 @@ signing { sign(publishing.publications["mavenJava"]) } } + +// Deterministic JNA contract tests use a test-only C implementation of the frozen ABI. +val nativeFixture = layout.buildDirectory.file("native/${System.mapLibraryName("aimux_abi_fixture")}") +val compileNativeFixture by tasks.registering(Exec::class) { + inputs.file("src/test/native/abi_fixture.c") + inputs.file("../../aimux-ffi/include/aimux.h") + outputs.file(nativeFixture) + doFirst { + nativeFixture.get().asFile.parentFile.mkdirs() + val sharedFlag = if (System.getProperty("os.name").startsWith("Mac")) "-dynamiclib" else "-shared" + commandLine("cc", "-std=c11", "-D_POSIX_C_SOURCE=200809L", "-Wall", "-Wextra", "-Werror", + sharedFlag, "-fPIC", "-I../../aimux-ffi/include", "src/test/native/abi_fixture.c", "-o", nativeFixture.get().asFile.absolutePath) + } +} + +tasks.test { + dependsOn(compileNativeFixture) + systemProperty("aimux.testLibrary", nativeFixture.get().asFile.absolutePath) +} diff --git a/bindings/java/generated/AimuxOps.java b/bindings/java/generated/AimuxOps.java new file mode 100644 index 000000000..1da0e801b --- /dev/null +++ b/bindings/java/generated/AimuxOps.java @@ -0,0 +1,81 @@ +// Generated by aimux-codegen. Do not edit. Manifest SHA-256: 2bc7cb7ec77a0ed20496411f0dbd199b949b9887cc74d119e11627c8573d82a2 +package ai.arcships.aimux.generated; + +public final class AimuxOps { + private AimuxOps() {} + public static final long ABI_VERSION = 65536L; + public static final String OPS_VERSION = "1.0"; + public static final String MANIFEST_DIGEST = "2bc7cb7ec77a0ed20496411f0dbd199b949b9887cc74d119e11627c8573d82a2"; + public static final String OP_ABORT_CREATE = "abort.create"; + public static final String OP_ABORT_SIGNAL = "abort.signal"; + public static final String OP_AUTH_CODEX_REFRESH = "auth.codexRefresh"; + public static final String OP_CATALOGUE_FETCH = "catalogue.fetch"; + public static final String OP_COMPOSITE_MOA = "composite.moa"; + public static final String OP_COMPOSITE_ROUTER = "composite.router"; + public static final String OP_CREDENTIAL_CREATE = "credential.create"; + public static final String OP_DECISION_CAPABILITIES = "decision.capabilities"; + public static final String OP_DECISION_DECIDE = "decision.decide"; + public static final String OP_EMBEDDING_EMBED = "embedding.embed"; + public static final String OP_EMBEDDING_EMBED_MANY = "embedding.embedMany"; + public static final String OP_FILES_UPLOAD = "files.upload"; + public static final String OP_HANDLE_DROP = "handle.drop"; + public static final String OP_IMAGE_GENERATE = "image.generate"; + public static final String OP_MIDDLEWARE_CREATE = "middleware.create"; + public static final String OP_MODEL_INFO = "model.info"; + public static final String OP_MODEL_RESOLVE = "model.resolve"; + public static final String OP_MODEL_WRAP = "model.wrap"; + public static final String OP_OPERATION_CANCEL = "operation.cancel"; + public static final String OP_OPERATION_INPUT_DONE = "operation.inputDone"; + public static final String OP_OPERATION_NEXT = "operation.next"; + public static final String OP_OPERATION_PUSH = "operation.push"; + public static final String OP_OPERATION_STATUS = "operation.status"; + public static final String OP_OUTPUT_TO_OPEN_AI = "output.toOpenAI"; + public static final String OP_PROVIDER_CREATE = "provider.create"; + public static final String OP_PROVIDER_CUSTOM = "provider.custom"; + public static final String OP_PROVIDER_DEFAULT = "provider.default"; + public static final String OP_PROVIDER_FILES = "provider.files"; + public static final String OP_PROVIDER_INVOKE = "provider.invoke"; + public static final String OP_PROVIDER_MODEL = "provider.model"; + public static final String OP_PROVIDER_WRAP = "provider.wrap"; + public static final String OP_RECORDER_ATTACH = "recorder.attach"; + public static final String OP_RECORDER_CREATE = "recorder.create"; + public static final String OP_RECORDER_EXPORT = "recorder.export"; + public static final String OP_RECORDER_FLUSH = "recorder.flush"; + public static final String OP_RECORDER_STATS = "recorder.stats"; + public static final String OP_RECORDER_STOP = "recorder.stop"; + public static final String OP_REGISTRY_CREATE = "registry.create"; + public static final String OP_REGISTRY_PROVIDER = "registry.provider"; + public static final String OP_REPLAY_CAPABILITIES = "replay.capabilities"; + public static final String OP_REPLAY_CREATE = "replay.create"; + public static final String OP_REPLAY_DRY_RUN = "replay.dryRun"; + public static final String OP_REPLAY_INSPECT = "replay.inspect"; + public static final String OP_REPLAY_LIVE = "replay.live"; + public static final String OP_RERANKING_RERANK = "reranking.rerank"; + public static final String OP_RUNTIME_CONFIGURE = "runtime.configure"; + public static final String OP_RUNTIME_DESCRIBE = "runtime.describe"; + public static final String OP_SEARCH_SEARCH = "search.search"; + public static final String OP_SESSION_STORE_ATTACH = "sessionStore.attach"; + public static final String OP_SESSION_STORE_CALLS = "sessionStore.calls"; + public static final String OP_SESSION_STORE_CREATE = "sessionStore.create"; + public static final String OP_SESSION_STORE_LIST = "sessionStore.list"; + public static final String OP_SPEECH_GENERATE = "speech.generate"; + public static final String OP_TEXT_CONSUME = "text.consume"; + public static final String OP_TEXT_GENERATE = "text.generate"; + public static final String OP_TEXT_GENERATE_OBJECT = "text.generateObject"; + public static final String OP_TEXT_GENERATE_OPEN_AI = "text.generateOpenAI"; + public static final String OP_TEXT_STREAM = "text.stream"; + public static final String OP_TEXT_STREAM_OPEN_AI = "text.streamOpenAI"; + public static final String OP_TOOL_REPAIR_APPLY = "toolRepair.apply"; + public static final String OP_TOOL_REPAIR_APPLY_RESULT = "toolRepair.applyResult"; + public static final String OP_TOOL_REPAIR_CONTEXT = "toolRepair.context"; + public static final String OP_TRACE_AGGREGATE = "trace.aggregate"; + public static final String OP_TRACE_CLEAR = "trace.clear"; + public static final String OP_TRACE_CREATE = "trace.create"; + public static final String OP_TRACE_EXPORT = "trace.export"; + public static final String OP_TRACE_SESSION_CHAIN = "trace.sessionChain"; + public static final String OP_TRACE_SESSION_TRAJECTORY = "trace.sessionTrajectory"; + public static final String OP_TRANSCRIPTION_GENERATE = "transcription.generate"; + public static final String OP_TRANSCRIPTION_START = "transcription.start"; + public static final String OP_TRANSPORT_CREATE = "transport.create"; + public static final String OP_VIDEO_GENERATE = "video.generate"; +} diff --git a/bindings/java/src/main/java/ai/arcships/aimux/Abort.java b/bindings/java/src/main/java/ai/arcships/aimux/Abort.java new file mode 100644 index 000000000..6a3f72af2 --- /dev/null +++ b/bindings/java/src/main/java/ai/arcships/aimux/Abort.java @@ -0,0 +1,9 @@ +package ai.arcships.aimux; + +/** Explicit cancellation, independent of closing model resources. */ +public final class Abort extends NativeHandle { + Abort(Aimux aimux, long id) { super(aimux, id); } + public void signal() { + try (Lease lease = admit()) { aimux.ffi.aimux_abort(lease.id); } + } +} diff --git a/bindings/java/src/main/java/ai/arcships/aimux/Aimux.java b/bindings/java/src/main/java/ai/arcships/aimux/Aimux.java index e0b276ed2..45322c62f 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/Aimux.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/Aimux.java @@ -1,150 +1,196 @@ package ai.arcships.aimux; +import ai.arcships.aimux.generated.AimuxOps; + +import com.fasterxml.jackson.databind.JsonNode; import com.sun.jna.Library; +import com.sun.jna.Memory; import com.sun.jna.Native; import com.sun.jna.Pointer; +import com.sun.jna.ptr.PointerByReference; +import java.math.BigInteger; +import java.util.Collections; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; -/** - * Top-level entry point for global aimux services (RFC-0014 logging). - * - *

Unlike the model classes (which wrap one native handle each), the - * logging entry is a process-global, idempotent operation. - */ +/** Entry point for ABI 1 / ops 1.0. No native library is loaded before load(). */ public final class Aimux { + public static final int ABI_VERSION = (int) ai.arcships.aimux.generated.AimuxOps.ABI_VERSION; + public static final String OPS_VERSION = ai.arcships.aimux.generated.AimuxOps.OPS_VERSION; + private static final AtomicLong CONTEXT_IDS = new AtomicLong(); + private static final ConcurrentMap CONTEXTS = new ConcurrentHashMap<>(); + private static final ThreadLocal IN_CALLBACK = new ThreadLocal<>(); + private static final Executor REAPER = Executors.newSingleThreadExecutor(task -> { + Thread thread = new Thread(task, "aimux-resource-reaper"); + thread.setDaemon(true); + return thread; + }); + final AimuxFFI ffi; + private final JsonNode manifest; - private Aimux() { - // Utility class — no instances. + Aimux(AimuxFFI ffi) { + this.ffi = Objects.requireNonNull(ffi, "ffi"); + int abi = ffi.aimux_abi_version(); + if (abi != ABI_VERSION) throw new IllegalStateException("Incompatible aimux ABI: " + abi); + PointerByReference out = new PointerByReference(); + manifest = AimuxResult.result(ffi, ffi.aimux_manifest_json(out), out); + if (!manifest.path("abiVersion").isIntegralNumber() + || manifest.path("abiVersion").asLong() != ABI_VERSION + || !manifest.path("opsVersion").isTextual() + || !manifest.path("digest").isTextual() + || !OPS_VERSION.equals(manifest.path("opsVersion").asText()) + || !AimuxOps.MANIFEST_DIGEST.equals(manifest.path("digest").asText())) { + throw new IllegalStateException("Incompatible aimux ABI/ops manifest: opsVersion " + + manifest.path("opsVersion") + " (expected " + OPS_VERSION + "), digest " + + manifest.path("digest") + " (expected " + AimuxOps.MANIFEST_DIGEST + ")"); + } } - /** - * Initialize the global logger (RFC-0014). - * - *

Idempotent — safe to call any number of times from any thread; only - * the first call has an effect. If the host already registered its own - * {@code tracing} subscriber, this is a no-op (aimux never overrides a - * consumer's logger). - * - * @param level "off" | "error" | "warn" | "info" | "debug" | "trace"; - * {@code null} or empty defaults to "warn". The - * {@code AIMUX_LOG} and {@code AIMUX_LOG_LEVEL} environment - * variables take precedence when set. Logs go to stderr. - */ - public static void initLogging(String level) { - String effective = (level == null || level.isEmpty()) ? "warn" : level; - Pointer e = AimuxFFI.INSTANCE.aimux_init_logging(effective); - if (e != null) { - throw AimuxResult.expectFfiError(e, "initLogging"); - } + public static Aimux load() { return load("aimux_ffi"); } + public static Aimux load(String library) { + return new Aimux(Native.load(Objects.requireNonNull(library, "library"), AimuxFFI.class, + Collections.singletonMap(Library.OPTION_STRING_ENCODING, "UTF-8"))); } - /** - * Start recording (RFC-0023): complete Recording JSONL is written to - * {@code {dir}/recordings.jsonl} (the directory is auto-created). - * - *

Recording is opt-in and process-global; calling this again replaces - * the previous recorder. On failure the previous recorder (if any) stays - * in place. - * - * @param dir Directory to write {@code recordings.jsonl} into. - * @throws NullPointerException if {@code dir} is null - * @throws RecordingException with {@link RecordingErrorCode#INIT} (directory - * could not be created), - * {@link RecordingErrorCode#OPEN_FILE} or {@link RecordingErrorCode#SPAWN}. - */ - public static void initRecording(String dir) { - java.util.Objects.requireNonNull(dir, "dir"); - Pointer e = AimuxFFI.INSTANCE.aimux_init_recording(dir); - if (e != null) { - throw AimuxResult.expectRecordingError(e, "initRecording"); - } + public JsonNode getManifest() { return manifest.deepCopy(); } + + /** Global op call, using the exact request DTO described by the manifest. */ + public JsonNode call(String op, String paramsJson) { + return call(0, op, AimuxResult.params(paramsJson), 0); } - /** - * Local JNA binding for the no-arg default-capacity ring entry point. - * Declared here (rather than the shared {@code AimuxFFI} interface) to keep - * this change scoped to {@code Aimux.java}. JNA caches the underlying native - * library by name, so loading {@code aimux_ffi} again for this interface - * reuses the already-open handle. - */ - private interface RecordingDefaultFFI extends Library { - RecordingDefaultFFI INSTANCE = Native.load("aimux_ffi", RecordingDefaultFFI.class); - void aimux_init_recording_ring_default(); + JsonNode call(long handle, String op, String params, long abort) { + Objects.requireNonNull(op, "op"); + if (op.indexOf('\0') >= 0) throw new IllegalArgumentException("NUL in op"); + PointerByReference out = new PointerByReference(); + return AimuxResult.result(ffi, ffi.aimux_call(handle, op, params, abort, out), out); } - /** - * Start in-memory bounded recording with the library default capacity - * (RingRecorder, FIFO eviction; RFC-0023). Convenience overload — callers - * who don't need a specific cap should prefer this. - */ - public static void initRecordingRing() { - RecordingDefaultFFI.INSTANCE.aimux_init_recording_ring_default(); + public ProviderHandle createProvider(String paramsJson) { + return new ProviderHandle(this, AimuxResult.handle(call(AimuxOps.OP_PROVIDER_CREATE, paramsJson))); } - /** - * Start in-memory bounded recording (RingRecorder, FIFO eviction; RFC-0023). - * - * @param cap Maximum number of in-memory recordings before old ones are evicted. - * @throws IllegalArgumentException if {@code cap <= 0}. The bound is checked - * before the FFI call so a negative Java - * {@code long} is never reinterpreted by - * JNA/the C ABI as a huge {@code uint64_t}. - */ - public static void initRecordingRing(long cap) { - if (cap <= 0) { - throw new IllegalArgumentException("initRecordingRing: cap must be > 0"); - } - Pointer e = AimuxFFI.INSTANCE.aimux_init_recording_ring(cap); - if (e != null) { - throw AimuxResult.expectAimuxError(e, "initRecordingRing"); - } + /** Generic owned resource for Registry, Recorder, Middleware and the other handle kinds. */ + public Resource create(String op, String paramsJson) { + return new Resource(this, AimuxResult.handle(call(op, paramsJson))); + } + + public Abort createAbort() { + return new Abort(this, AimuxResult.handle(call(AimuxOps.OP_ABORT_CREATE, "{}"))); + } + + /** Takes ownership of a handle returned by an op; never wrap the same handle twice. */ + public Resource own(String decimalHandle) { + return new Resource(this, AimuxResult.handle(Types.AimuxJson.MAPPER.getNodeFactory().textNode(decimalHandle))); } - /** - * Stop recording (RFC-0023): the global recorder becomes {@code None}. - * Idempotent — safe to call when no recording is active. - */ - public static void recordingStop() { - AimuxFFI.INSTANCE.aimux_recording_stop(); + public Future callAsync(String op, String paramsJson, Executor executor) { + return submit(null, op, paramsJson, null, executor); } - /** - * Flush the global recorder (RFC-0023): blocks until the JSONL is on disk - * (no-op for the ring recorder). - */ - public static void recordingFlush() { - AimuxFFI.INSTANCE.aimux_recording_flush(); + Future submit(NativeHandle resource, String op, String paramsJson, + Consumer consumer, Executor executor) { + Objects.requireNonNull(executor, "executor"); + Objects.requireNonNull(op, "op"); + String params = AimuxResult.params(paramsJson); + NativeHandle.Lease lease = resource == null ? null : resource.admit(); + Abort abort; + try { abort = createAbort(); } + catch (Throwable error) { if (lease != null) lease.close(); throw error; } + return new NativeFuture(this, lease, abort, op, params, consumer, executor); } - /** - * Checked flush: like {@link #recordingFlush()} but throws - * {@link RecordingException} ({@link RecordingErrorCode#WRITE} / - * {@link RecordingErrorCode#WRITER_GONE} / {@link RecordingErrorCode#FLUSH_TIMEOUT}) - * when the JSONL could not be written. Not an {@link AimuxException}: recording - * errors are their own type. Returns normally when nothing is recording. The - * legacy {@link #recordingFlush()} stays and never reports. - */ - public static void recordingTryFlush() { - Pointer e = AimuxFFI.INSTANCE.aimux_recording_try_flush(); - if (e != null) { - throw AimuxResult.expectRecordingError(e, "recordingTryFlush"); + void stream(long handle, String op, String params, long abort, Consumer consumer) { + Objects.requireNonNull(op, "op"); + if (op.indexOf('\0') >= 0) throw new IllegalArgumentException("NUL in op"); + StreamContext context = new StreamContext(this, abort, Objects.requireNonNull(consumer, "consumer")); + long id = CONTEXT_IDS.incrementAndGet(); + CONTEXTS.put(id, context); // Java 8 strong reachability through the native return boundary. + Pointer error = null; + try { + error = ffi.aimux_stream(handle, op, params, abort, context, context.memory); + Throwable failure = context.failure.get(); + if (failure != null) { + if (error != null) { ffi.aimux_error_free(error); error = null; } + if (failure == context.resourceLimit) throw context.resourceLimit; + throw AimuxResult.callbackFailure(failure); + } + Pointer returnedError = error; + error = null; + AimuxResult.check(ffi, returnedError); + if (context.terminal == null) throw new IllegalStateException("Native stream returned without End"); + NativeException terminalError = context.terminal.getError(); + if (context.terminal.getOutcome() == StreamFrame.Outcome.CANCELLED) { + java.util.concurrent.CancellationException cancelled = + new java.util.concurrent.CancellationException("Native stream cancelled"); + if (terminalError != null) cancelled.initCause(terminalError); + throw cancelled; + } + if (terminalError != null) throw terminalError; + } finally { + if (error != null) ffi.aimux_error_free(error); + CONTEXTS.remove(id); + context.memory.close(); } } - /** - * Replace the default transport with one routed by this proxy configuration - * (M6, RFC-0016). Requests started afterwards use it; calling it again - * replaces the previous configuration. - * - * @param configJson ProxyConfig JSON ({@code "http_url"}, {@code "https_url"}, - * {@code "all_url"}, {@code "no_proxy"} — all optional). - * @throws AimuxException if the config has the wrong shape - * ({@link AimuxException.InvalidArgumentError}). - */ - public static void initProxy(String configJson) { - AimuxResult.requireJsonNonNull(configJson, "configJson"); - Pointer e = AimuxFFI.INSTANCE.aimux_init_proxy(configJson); - if (e != null) { - throw AimuxResult.expectAimuxError(e, "initProxy"); + void drop(long id) { + if (Boolean.TRUE.equals(IN_CALLBACK.get())) REAPER.execute(() -> ffi.aimux_drop(id)); + else ffi.aimux_drop(id); + } + + private static final class StreamContext implements AimuxFFI.EventCallback { + private final Aimux aimux; + private final long abort; + private final Consumer consumer; + private final Memory memory = new Memory(1); + private final AtomicReference failure = new AtomicReference<>(); + private BigInteger previous; + private StreamFrame terminal; + private ProtocolException.ResourceLimit resourceLimit; + + StreamContext(Aimux aimux, long abort, Consumer consumer) { + this.aimux = aimux; this.abort = abort; this.consumer = consumer; + } + + @Override public void invoke(Pointer json, Pointer ignored) { + Boolean prior = IN_CALLBACK.get(); + IN_CALLBACK.set(true); + try { + if (failure.get() != null) return; + if (json == null) throw new IllegalStateException("NULL stream frame"); + final int limit = 1 << 20; + int length = 0; + while (length <= limit && json.getByte(length) != 0) length++; + if (length > limit) { + resourceLimit = new ProtocolException.ResourceLimit(AimuxResult.parse( + "{\"kind\":\"protocol\",\"code\":305,\"message\":\"Event exceeds stream budget\"," + + "\"error\":{\"name\":\"ResourceLimit\"}}")); + throw resourceLimit; + } + StreamFrame frame = StreamFrame.parse(new String(json.getByteArray(0, length), + java.nio.charset.StandardCharsets.UTF_8)); + if (terminal != null || (previous != null && frame.getSequence().compareTo(previous) <= 0)) { + throw new IllegalStateException("Out-of-order frame or frame after End"); + } + previous = frame.getSequence(); + if (frame.isEnd()) terminal = frame; + consumer.accept(frame); + } catch (Throwable error) { + if (failure.compareAndSet(null, error)) { + try { aimux.ffi.aimux_abort(abort); } + catch (Throwable abortError) { error.addSuppressed(abortError); } + } + } finally { + if (prior == null) IN_CALLBACK.remove(); else IN_CALLBACK.set(prior); + } } } } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java b/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java index 79b256515..d0597531c 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java @@ -1,741 +1,109 @@ package ai.arcships.aimux; import com.fasterxml.jackson.databind.JsonNode; -import com.sun.jna.Pointer; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -/** - * AiMuxError hierarchy (OpenAI Java / Vercel AI SDK style). - * - *

Raised when a fallible C ABI call returns an AiMuxError code (1–20). - * Recording failures use the - * independent {@link RecordingException} type; C ABI failures (bad raw - * wire JSON, use-after-close, re-entrant call) surface as plain - * {@link IllegalArgumentException} / {@link IllegalStateException}. Prefer - * {@code instanceof} on subclasses over stringly code checks: - * - *

{@code
- * try {
- *     model.generateText("\"hi\"");
- * } catch (APICallError e) {
- *     // Classification is the status field (AI SDK APICallError.statusCode):
- *     // 429 → rate limited (e.getRetryMs()), 401 → auth, 404 → model
- * } catch (AimuxException e) {
- *     // any AiMuxError
- * }
- * }
- * - *

Every instance carries {@link #getCode()} (C {@code aimux_error_code_t} 1–20), - * {@link #getStatusCode()} (HTTP or {@code -1}), {@link #getRetryMs()} (hint - * or {@code -1}; {@code 0} = retry now) and {@link #isRetryable()}. Message - * text comes from the C layer. - * - *

Message-only constructors default {@code status=-1}, {@code retryMs=-1} for - * backward compatibility with typed-layer decode failures. - */ -public class AimuxException extends RuntimeException { +/** AI SDK-aligned errors, selected by error.name in the envelope. */ +public abstract class AimuxException extends NativeException { + AimuxException(JsonNode envelope) { super(envelope); } + public final boolean isRetryable() { return getError().path("isRetryable").asBoolean(false); } + public final int getStatusCode() { return getError().path("statusCode").asInt(-1); } - private static final long serialVersionUID = 1L; - - // ── aimux_error_code_t (aimux-error.h) ────────────────────────────────── - // 19 variant codes (1–20; 1 is the catch-all OTHER; 4 is retired — the - // legacy Tool variant; 14 = RETRY; 19 / 20 = missing API key / setting); - // every HTTP-shaped failure - // arrives as AIMUX_E_API_CALL. A code outside that set is a header/library - // mismatch and fails with IllegalStateException, never an AimuxException. - // Recording failures are a different type: see RecordingException. - - public static final int AIMUX_OK = 0; - public static final int AIMUX_E_JSON_PARSE = 2; - public static final int AIMUX_E_INVALID_RESPONSE_DATA = 3; - public static final int AIMUX_E_INVALID_ARGUMENT = 5; - public static final int AIMUX_E_INVALID_PROMPT = 6; - public static final int AIMUX_E_TOKEN_EXPIRED = 7; - public static final int AIMUX_E_UNSUPPORTED_FUNCTIONALITY = 8; - public static final int AIMUX_E_NO_SUCH_MODEL = 9; - public static final int AIMUX_E_NO_SUCH_PROVIDER = 10; - public static final int AIMUX_E_API_CALL = 11; - public static final int AIMUX_E_TIMEOUT = 12; - public static final int AIMUX_E_ABORTED = 13; - public static final int AIMUX_E_NO_SUCH_TOOL = 15; - public static final int AIMUX_E_INVALID_TOOL_INPUT = 16; - public static final int AIMUX_E_TOOL_CALL_REPAIR = 17; - public static final int AIMUX_E_NO_OUTPUT_GENERATED = 18; - public static final int AIMUX_E_LOAD_API_KEY = 19; - public static final int AIMUX_E_LOAD_SETTING = 20; - public static final int AIMUX_E_OTHER = 1; - public static final int AIMUX_E_RETRY = 14; - - private final int code; - private final int status; - private final long retryMs; - - // Set once by the fromC construction path; false for local / synthesized - // failures. Not a constructor param so the 16 subclass constructors keep - // their public signatures. - private boolean retryable; - - // ── Constructors ──────────────────────────────────────────────────────── - - /** Message-only; code = {@link #AIMUX_E_OTHER}, status/retryMs = -1. */ - public AimuxException(String message) { - this(message, AIMUX_E_OTHER, -1, -1L); - } - - /** Message + cause; code = {@link #AIMUX_E_OTHER}, status/retryMs = -1. */ - public AimuxException(String message, Throwable cause) { - this(message, AIMUX_E_OTHER, -1, -1L, cause); - } - - public AimuxException(String message, int code, int status, long retryMs) { - super(message); - this.code = code; - this.status = status; - this.retryMs = retryMs; - } - - public AimuxException(String message, int code, int status, long retryMs, Throwable cause) { - super(message, cause); - this.code = code; - this.status = status; - this.retryMs = retryMs; - } - - // ── Accessors ─────────────────────────────────────────────────────────── - - /** C {@code aimux_error_code_t} value (1–20). */ - public int getCode() { - return code; - } - - /** HTTP status when known; otherwise {@code -1}. */ - public int getStatusCode() { - return status; - } - - /** Rate-limit hint in ms; {@code -1} if none; {@code 0} means retry immediately. */ - public long getRetryMs() { - return retryMs; - } - - /** - * The {@code AiMuxError} retry verdict: {@code true} when retrying may help. - * - *

Not derivable from {@link #getStatusCode()} — two failures can both - * report {@code -1} and disagree: a transport failure (the request went out, - * the connection was reset) is retryable, a missing API key (the request - * never went out) is not. {@code false} for local (non-FFI) failures and - * for failures synthesized by the C ABI. - */ - public boolean isRetryable() { - return retryable; - } - - // ── Factories ─────────────────────────────────────────────────────────── - - /** - * Build a typed exception from a returned {@code const aimux_error_t *}, - * prefixing {@code prefix} to the message. Reads the code, message, - * retryable, status and the payload getters for that code only, freeing - * every returned string. Does not own the pointer: the caller - * ({@link AimuxResult#expectAimuxError}) frees the returned error afterwards - * (retry attempt errors are new owned copies and are freed here). - * A code outside 1–20 is a header/library mismatch → - * {@link IllegalStateException}. - */ - static AimuxException fromC(Pointer error, String prefix) { - AimuxFFI ffi = AimuxFFI.INSTANCE; - int code = ffi.aimux_error_code(error); - if (code == AIMUX_OK) { - throw new IllegalStateException(prefix + "non-null aimux error carries AIMUX_OK"); - } - String msg = AimuxResult.takeString(ffi.aimux_error_message(error)); - if (msg == null || msg.isEmpty()) { - msg = "aimux: " + codeName(code); - } - msg = prefix + msg; - AimuxException ex; - switch (code) { - case AIMUX_E_API_CALL: - ex = new APICallError(msg, ffi.aimux_error_status(error), ffi.aimux_error_retry_ms(error), - AimuxResult.takeString(ffi.aimux_error_provider_code(error)), - AimuxResult.takeString(ffi.aimux_error_provider_message(error)), - AimuxResult.takeString(ffi.aimux_error_response_body(error)), - AimuxResult.takeString(ffi.aimux_error_url(error)), - parseJson(AimuxResult.takeString(ffi.aimux_error_request_body_values(error))), - headerMap(AimuxResult.takeString(ffi.aimux_error_response_headers(error))), - parseJson(AimuxResult.takeString(ffi.aimux_error_provider_data(error)))); - break; - case AIMUX_E_RETRY: - ex = new RetryError(msg, - RetryErrorReason.fromWire(AimuxResult.takeString(ffi.aimux_error_retry_reason(error))), - retryHistory(ffi, error, msg)); - break; - case AIMUX_E_NO_SUCH_MODEL: - ex = new NoSuchModelError(msg, -1, -1L, - AimuxResult.takeString(ffi.aimux_error_model_id(error)), - AimuxResult.takeString(ffi.aimux_error_model_type(error))); - break; - case AIMUX_E_NO_SUCH_PROVIDER: - ex = new NoSuchProviderError(msg, -1, -1L, - AimuxResult.takeString(ffi.aimux_error_provider_id(error))); - break; - case AIMUX_E_NO_SUCH_TOOL: - ex = new NoSuchToolError(msg, -1, -1L, - AimuxResult.takeString(ffi.aimux_error_tool_name(error)), - parseStringList(AimuxResult.takeString(ffi.aimux_error_available_tools(error)))); - break; - case AIMUX_E_INVALID_TOOL_INPUT: - ex = new InvalidToolInputError(msg, -1, -1L, - AimuxResult.takeString(ffi.aimux_error_tool_name(error)), - AimuxResult.takeString(ffi.aimux_error_tool_input(error))); - break; - case AIMUX_E_TOOL_CALL_REPAIR: - ex = new ToolCallRepairError(msg, -1, -1L, - parseWireJson(AimuxResult.takeString(ffi.aimux_error_original_error(error)))); - break; - case AIMUX_E_LOAD_API_KEY: - ex = new LoadAPIKeyError(msg, -1, -1L, - AimuxResult.takeString(ffi.aimux_error_provider_code(error))); - break; - case AIMUX_E_LOAD_SETTING: - ex = new LoadSettingError(msg, -1, -1L, - AimuxResult.takeString(ffi.aimux_error_provider_code(error))); - break; - default: - ex = createByCode(code, msg, -1, -1L); - } - ex.retryable = ffi.aimux_error_retryable(error) != 0; - return ex; - } - - // The FFI guarantees well-formed JSON in these payloads; a parse failure is - // a header/library mismatch, same as an unknown code. - - private static List parseStringList(String json) { - if (json == null) { - return null; - } - try { - return Types.AimuxJson.MAPPER.readValue(json, - new com.fasterxml.jackson.core.type.TypeReference>() {}); - } catch (com.fasterxml.jackson.core.JsonProcessingException e) { - throw new IllegalStateException("aimux ffi: invalid error payload JSON: " + e.getOriginalMessage(), e); - } - } - - private static JsonNode parseWireJson(String json) { - if (json == null) { - return null; - } - try { - return Types.AimuxJson.MAPPER.readTree(json); - } catch (com.fasterxml.jackson.core.JsonProcessingException e) { - throw new IllegalStateException("aimux ffi: invalid error payload JSON: " + e.getOriginalMessage(), e); - } - } - - /** - * Decode the per-attempt history of an {@link #AIMUX_E_RETRY} error. - * Each attempt is a new owned {@code aimux_error_t *} (index 0 = oldest) - * that can itself be any AiMuxError — including a nested Retry — and is - * freed here, independently of the parent. - */ - private static List retryHistory(AimuxFFI ffi, Pointer error, String fallbackMessage) { - int count = ffi.aimux_error_retry_count(error); - List errors = new ArrayList<>(); - for (int i = 0; i < count; i++) { - Pointer attempt = ffi.aimux_error_retry_error_at(error, i); - if (attempt == null) { - continue; - } - try { - errors.add(fromC(attempt, "")); - } finally { - ffi.aimux_error_free(attempt); - } - } - if (errors.isEmpty()) { - // A deserialized Retry may carry no attempts; keep RetryError total. - errors.add(new OtherError(fallbackMessage, -1, -1L)); + static AimuxException decode(JsonNode envelope) { + switch (envelope.path("error").path("name").asText()) { + case "AI_Error": return new OtherError(envelope); + case "AI_JSONParseError": return new JSONParseError(envelope); + case "AI_InvalidResponseDataError": return new InvalidResponseDataError(envelope); + case "AI_ToolCallNotFoundForApprovalError": return new ToolCallNotFoundForApprovalError(envelope); + case "AI_InvalidArgumentError": return new InvalidArgumentError(envelope); + case "AI_InvalidPromptError": return new InvalidPromptError(envelope); + case "AI_TokenExpiredError": return new TokenExpiredError(envelope); + case "AI_UnsupportedFunctionalityError": return new UnsupportedFunctionalityError(envelope); + case "AI_NoSuchModelError": return new NoSuchModelError(envelope); + case "AI_NoSuchProviderError": return new NoSuchProviderError(envelope); + case "AI_APICallError": return new APICallError(envelope); + case "TimeoutError": return new TimeoutError(envelope); + case "AbortError": return new RequestAbortedError(envelope); + case "AI_RetryError": return new RetryError(envelope); + case "AI_NoSuchToolError": return new NoSuchToolError(envelope); + case "AI_InvalidToolInputError": return new InvalidToolInputError(envelope); + case "AI_ToolCallRepairError": return new ToolCallRepairError(envelope); + case "AI_NoOutputGeneratedError": return new NoOutputGeneratedError(envelope); + case "AI_LoadAPIKeyError": return new LoadAPIKeyError(envelope); + case "AI_LoadSettingError": return new LoadSettingError(envelope); + case "AI_MissingToolResultsError": return new MissingToolResultsError(envelope); + case "AI_ToolChoiceViolationError": return new ToolChoiceViolationError(envelope); + default: return new Unknown(envelope); } - return errors; } - /** Parse a getter-returned JSON string; {@code null} (absent) stays {@code null}. */ - private static JsonNode parseJson(String json) { - if (json == null) { - return null; - } - try { - return Types.AimuxJson.MAPPER.readTree(json); - } catch (Exception e) { - return null; - } + public static final class OtherError extends AimuxException { + OtherError(JsonNode envelope) { super(envelope); } } - - /** Response headers arrive as one JSON object string of string→string pairs. */ - private static Map headerMap(String json) { - JsonNode node = parseJson(json); - if (node == null || !node.isObject()) { - return null; - } - Map headers = new LinkedHashMap<>(); - node.fields().forEachRemaining(f -> headers.put(f.getKey(), f.getValue().asText())); - return headers; + public static final class JSONParseError extends AimuxException { + JSONParseError(JsonNode envelope) { super(envelope); } } - - /** - * Local (non-FFI) failure: default status/retryMs = -1. - */ - public static AimuxException of(int code, String message) { - return of(code, message, -1, -1L); + public static final class InvalidResponseDataError extends AimuxException { + InvalidResponseDataError(JsonNode envelope) { super(envelope); } } - - /** - * Local (non-FFI) failure with explicit status / retry hint. - */ - public static AimuxException of(int code, String message, int status, long retryMs) { - String msg = (message == null || message.isEmpty()) ? "aimux: " + codeName(code) : message; - return createByCode(code, msg, status, retryMs); + public static final class ToolCallNotFoundForApprovalError extends AimuxException { + ToolCallNotFoundForApprovalError(JsonNode envelope) { super(envelope); } } - - private static AimuxException createByCode(int code, String message, int status, long retryMs) { - switch (code) { - case AIMUX_E_JSON_PARSE: - return new JSONParseError(message, status, retryMs); - case AIMUX_E_INVALID_RESPONSE_DATA: - return new InvalidResponseDataError(message, status, retryMs); - case AIMUX_E_INVALID_ARGUMENT: - return new InvalidArgumentError(message, status, retryMs); - case AIMUX_E_INVALID_PROMPT: - return new InvalidPromptError(message, status, retryMs); - case AIMUX_E_LOAD_API_KEY: - return new LoadAPIKeyError(message, status, retryMs); - case AIMUX_E_LOAD_SETTING: - return new LoadSettingError(message, status, retryMs); - case AIMUX_E_TOKEN_EXPIRED: - return new TokenExpiredError(message, status, retryMs); - case AIMUX_E_UNSUPPORTED_FUNCTIONALITY: - return new UnsupportedFunctionalityError(message, status, retryMs); - case AIMUX_E_NO_SUCH_MODEL: - return new NoSuchModelError(message, status, retryMs); - case AIMUX_E_NO_SUCH_PROVIDER: - return new NoSuchProviderError(message, status, retryMs); - case AIMUX_E_API_CALL: - return new APICallError(message, status, retryMs); - case AIMUX_E_RETRY: - return new RetryError(message, RetryErrorReason.MAX_RETRIES_EXCEEDED, - Collections.singletonList(new OtherError(message, -1, -1L))); - case AIMUX_E_TIMEOUT: - return new TimeoutError(message, status, retryMs); - case AIMUX_E_ABORTED: - return new RequestAbortedError(message, status, retryMs); - case AIMUX_E_NO_SUCH_TOOL: - return new NoSuchToolError(message, status, retryMs); - case AIMUX_E_INVALID_TOOL_INPUT: - return new InvalidToolInputError(message, status, retryMs); - case AIMUX_E_TOOL_CALL_REPAIR: - return new ToolCallRepairError(message, status, retryMs); - case AIMUX_E_NO_OUTPUT_GENERATED: - return new NoOutputGeneratedError(message, status, retryMs); - case AIMUX_E_OTHER: - return new OtherError(message, status, retryMs); - default: - throw new IllegalStateException("Unknown aimux_error_code_t: " + code); - } + public static final class InvalidArgumentError extends AimuxException { + InvalidArgumentError(JsonNode envelope) { super(envelope); } } - - /** Core {@code error_type()} name for a code (diagnostics / empty messages). */ - public static String codeName(int code) { - switch (code) { - case AIMUX_OK: - return "OK"; - case AIMUX_E_JSON_PARSE: - return "JsonParse"; - case AIMUX_E_INVALID_RESPONSE_DATA: - return "InvalidResponseData"; - case AIMUX_E_INVALID_ARGUMENT: - return "InvalidArgument"; - case AIMUX_E_INVALID_PROMPT: - return "InvalidPrompt"; - case AIMUX_E_LOAD_API_KEY: - return "LoadApiKey"; - case AIMUX_E_LOAD_SETTING: - return "LoadSetting"; - case AIMUX_E_TOKEN_EXPIRED: - return "TokenExpired"; - case AIMUX_E_UNSUPPORTED_FUNCTIONALITY: - return "UnsupportedFunctionality"; - case AIMUX_E_NO_SUCH_MODEL: - return "NoSuchModel"; - case AIMUX_E_NO_SUCH_PROVIDER: - return "NoSuchProvider"; - case AIMUX_E_API_CALL: - return "ApiCall"; - case AIMUX_E_TIMEOUT: - return "Timeout"; - case AIMUX_E_ABORTED: - return "Aborted"; - case AIMUX_E_NO_SUCH_TOOL: - return "NoSuchTool"; - case AIMUX_E_INVALID_TOOL_INPUT: - return "InvalidToolInput"; - case AIMUX_E_TOOL_CALL_REPAIR: - return "ToolCallRepair"; - case AIMUX_E_NO_OUTPUT_GENERATED: - return "NoOutputGenerated"; - case AIMUX_E_OTHER: - return "Other"; - case AIMUX_E_RETRY: - return "Retry"; - default: - return "Code(" + code + ")"; - } + public static final class InvalidPromptError extends AimuxException { + InvalidPromptError(JsonNode envelope) { super(envelope); } } - - // ── Subclasses (mirror Node error.ts / Python hierarchy) ──────────────── - - public static class NoOutputGeneratedError extends AimuxException { - public NoOutputGeneratedError(String message, int status, long retryMs) { - super(message, AIMUX_E_NO_OUTPUT_GENERATED, status, retryMs); - } + public static final class TokenExpiredError extends AimuxException { + TokenExpiredError(JsonNode envelope) { super(envelope); } } - - public static class JSONParseError extends AimuxException { - public JSONParseError(String message, int status, long retryMs) { - super(message, AIMUX_E_JSON_PARSE, status, retryMs); - } + public static final class UnsupportedFunctionalityError extends AimuxException { + UnsupportedFunctionalityError(JsonNode envelope) { super(envelope); } } - - public static class InvalidResponseDataError extends AimuxException { - public InvalidResponseDataError(String message, int status, long retryMs) { - super(message, AIMUX_E_INVALID_RESPONSE_DATA, status, retryMs); - } + public static final class NoSuchModelError extends AimuxException { + NoSuchModelError(JsonNode envelope) { super(envelope); } } - - public static class InvalidArgumentError extends AimuxException { - public InvalidArgumentError(String message, int status, long retryMs) { - super(message, AIMUX_E_INVALID_ARGUMENT, status, retryMs); - } + public static final class NoSuchProviderError extends AimuxException { + NoSuchProviderError(JsonNode envelope) { super(envelope); } } - - public static class InvalidPromptError extends AimuxException { - public InvalidPromptError(String message, int status, long retryMs) { - super(message, AIMUX_E_INVALID_PROMPT, status, retryMs); - } - } - - /** - * No API key was passed and the fallback environment variable is unset - * (AI SDK {@code LoadAPIKeyError}). No request was made. - */ - public static class LoadAPIKeyError extends AimuxException { - private final String envVar; - - public LoadAPIKeyError(String message, int status, long retryMs) { - this(message, status, retryMs, null); - } - - public LoadAPIKeyError(String message, int status, long retryMs, String envVar) { - super(message, AIMUX_E_LOAD_API_KEY, status, retryMs); - this.envVar = envVar; - } - - /** The environment variable that was consulted, e.g. {@code "OPENAI_API_KEY"}, or {@code null}. */ - public String getEnvVar() { - return envVar; - } - } - - /** - * A required provider setting was not passed and its fallback environment - * variable is unset (AI SDK {@code LoadSettingError}). - */ - public static class LoadSettingError extends AimuxException { - private final String envVar; - - public LoadSettingError(String message, int status, long retryMs) { - this(message, status, retryMs, null); - } - - public LoadSettingError(String message, int status, long retryMs, String envVar) { - super(message, AIMUX_E_LOAD_SETTING, status, retryMs); - this.envVar = envVar; - } - - /** The environment variable that was consulted, or {@code null}. */ - public String getEnvVar() { - return envVar; - } - } - - public static class TokenExpiredError extends AimuxException { - public TokenExpiredError(String message, int status, long retryMs) { - super(message, AIMUX_E_TOKEN_EXPIRED, status == -1 ? 401 : status, retryMs); - } + public static final class APICallError extends AimuxException { + APICallError(JsonNode envelope) { super(envelope); } } - - public static class UnsupportedFunctionalityError extends AimuxException { - public UnsupportedFunctionalityError(String message, int status, long retryMs) { - super(message, AIMUX_E_UNSUPPORTED_FUNCTIONALITY, status, retryMs); - } + public static final class TimeoutError extends AimuxException { + TimeoutError(JsonNode envelope) { super(envelope); } } - - public static class NoSuchModelError extends AimuxException { - private final String modelId; - private final String modelType; - - public NoSuchModelError(String message, int status, long retryMs) { - this(message, status, retryMs, null, null); - } - - public NoSuchModelError(String message, int status, long retryMs, - String modelId, String modelType) { - super(message, AIMUX_E_NO_SUCH_MODEL, status, retryMs); - this.modelId = modelId; - this.modelType = modelType; - } - - /** The model id that was asked for, or {@code null} for local failures. */ - public String getModelId() { - return modelId; - } - - /** The model type it was asked for as, or {@code null} for local failures. */ - public String getModelType() { - return modelType; - } + public static final class RequestAbortedError extends AimuxException { + RequestAbortedError(JsonNode envelope) { super(envelope); } } - - public static class NoSuchProviderError extends AimuxException { - private final String providerId; - - public NoSuchProviderError(String message, int status, long retryMs) { - this(message, status, retryMs, null); - } - - public NoSuchProviderError(String message, int status, long retryMs, String providerId) { - super(message, AIMUX_E_NO_SUCH_PROVIDER, status, retryMs); - this.providerId = providerId; - } - - /** The provider id that was asked for, or {@code null} for local failures. */ - public String getProviderId() { - return providerId; - } + public static final class RetryError extends AimuxException { + RetryError(JsonNode envelope) { super(envelope); } } - - /** - * Every HTTP-shaped failure (AI SDK {@code APICallError} analogue): provider - * errors, transport failures, 401 auth, 404 model, 429 rate limit. Classify - * with {@link #getStatusCode()}; {@code -1} means no HTTP response was ever - * observed — a missing API key, an error built without a request, or a - * transport failure — and says nothing about whether a retry would help: - * read {@link #isRetryable()} for that, never the status sentinel. - */ - public static class APICallError extends AimuxException { - private final String providerCode; - private final String providerMessage; - private final String responseBody; - private final String url; - private final JsonNode requestBodyValues; - private final Map responseHeaders; - private final JsonNode data; - - public APICallError(String message, int status, long retryMs) { - this(message, status, retryMs, null, null, null, null, null, null, null); - } - - public APICallError(String message, int status, long retryMs, - String providerCode, String providerMessage, String responseBody, - String url, JsonNode requestBodyValues, - Map responseHeaders, JsonNode data) { - super(message, AIMUX_E_API_CALL, status, retryMs); - this.providerCode = providerCode; - this.providerMessage = providerMessage; - this.responseBody = responseBody; - this.url = url; - this.requestBodyValues = requestBodyValues; - this.responseHeaders = responseHeaders; - this.data = data; - } - - /** The provider's own error code (e.g. {@code "insufficient_quota"}), or {@code null}. */ - public String getProviderCode() { - return providerCode; - } - - /** The failure's own text without the composed prefix {@link #getMessage()} carries (e.g. {@code "slow down"}), or {@code null}. */ - public String getProviderMessage() { - return providerMessage; - } - - /** Raw response body, or {@code null}. */ - public String getResponseBody() { - return responseBody; - } - - /** Sanitized request URL, or {@code null}. */ - public String getUrl() { - return url; - } - - /** Sanitized request body values (any JSON type), or {@code null}. */ - public JsonNode getRequestBodyValues() { - return requestBodyValues; - } - - /** Sanitized response headers (also carry provider request ids), or {@code null}. */ - public Map getResponseHeaders() { - return responseHeaders; - } - - /** Parsed provider error data (AI SDK {@code APICallError.data}), or {@code null}. */ - public JsonNode getData() { - return data; - } + public static final class NoSuchToolError extends AimuxException { + NoSuchToolError(JsonNode envelope) { super(envelope); } } - - /** Why the retry loop gave up; wire names are the core's serde camelCase. */ - public enum RetryErrorReason { - /** Every permitted attempt failed with a retryable error. */ - MAX_RETRIES_EXCEEDED, - /** A later attempt failed with a non-retryable error. */ - ERROR_NOT_RETRYABLE; - - static RetryErrorReason fromWire(String value) { - return "errorNotRetryable".equals(value) - ? ERROR_NOT_RETRYABLE : MAX_RETRIES_EXCEEDED; - } + public static final class InvalidToolInputError extends AimuxException { + InvalidToolInputError(JsonNode envelope) { super(envelope); } } - - /** - * The retry loop gave up (AI SDK {@code RetryError} analogue): - * {@link #getReason()} says why, {@link #getErrors()} is the per-attempt - * history (oldest first), {@link #getLastError()} the final attempt. - */ - public static class RetryError extends AimuxException { - private final RetryErrorReason reason; - private final List errors; - private final AimuxException lastError; - - public RetryError(String message, RetryErrorReason reason, List errors) { - super(message, AIMUX_E_RETRY, -1, -1L); - if (errors == null || errors.isEmpty()) { - throw new IllegalArgumentException("RetryError requires at least one error"); - } - this.reason = reason; - this.errors = Collections.unmodifiableList(new ArrayList<>(errors)); - this.lastError = this.errors.get(this.errors.size() - 1); - } - - public RetryErrorReason getReason() { return reason; } - public List getErrors() { return errors; } - public AimuxException getLastError() { return lastError; } + public static final class ToolCallRepairError extends AimuxException { + ToolCallRepairError(JsonNode envelope) { super(envelope); } } - - public static class TimeoutError extends AimuxException { - public TimeoutError(String message, int status, long retryMs) { - super(message, AIMUX_E_TIMEOUT, status, retryMs); - } + public static final class NoOutputGeneratedError extends AimuxException { + NoOutputGeneratedError(JsonNode envelope) { super(envelope); } } - - /** Request aborted (not a DOM {@code AbortError}); the message is the abort payload. */ - public static class RequestAbortedError extends AimuxException { - public RequestAbortedError(String message, int status, long retryMs) { - super(message, AIMUX_E_ABORTED, status, retryMs); - } - - public RequestAbortedError() { - this("request aborted", -1, -1L); - } + public static final class LoadAPIKeyError extends AimuxException { + LoadAPIKeyError(JsonNode envelope) { super(envelope); } } - - /** The model called a tool that is not in the supplied tool set. */ - public static class NoSuchToolError extends AimuxException { - private final String toolName; - private final List availableTools; - - public NoSuchToolError(String message, int status, long retryMs) { - this(message, status, retryMs, null, null); - } - - public NoSuchToolError(String message, int status, long retryMs, - String toolName, List availableTools) { - super(message, AIMUX_E_NO_SUCH_TOOL, status, retryMs); - this.toolName = toolName; - this.availableTools = availableTools; - } - - /** The tool name the model called, or {@code null} for local failures. */ - public String getToolName() { - return toolName; - } - - /** The available tool names, or {@code null} when no tool set was supplied. */ - public List getAvailableTools() { - return availableTools; - } + public static final class LoadSettingError extends AimuxException { + LoadSettingError(JsonNode envelope) { super(envelope); } } - - /** The model's tool arguments failed to parse or validate against the schema. */ - public static class InvalidToolInputError extends AimuxException { - private final String toolName; - private final String toolInput; - - public InvalidToolInputError(String message, int status, long retryMs) { - this(message, status, retryMs, null, null); - } - - public InvalidToolInputError(String message, int status, long retryMs, - String toolName, String toolInput) { - super(message, AIMUX_E_INVALID_TOOL_INPUT, status, retryMs); - this.toolName = toolName; - this.toolInput = toolInput; - } - - /** The tool name the model called, or {@code null} for local failures. */ - public String getToolName() { - return toolName; - } - - /** The raw argument text the model produced, or {@code null} for local failures. */ - public String getToolInput() { - return toolInput; - } + public static final class MissingToolResultsError extends AimuxException { + MissingToolResultsError(JsonNode envelope) { super(envelope); } } - - /** Tool-call repair itself failed. */ - public static class ToolCallRepairError extends AimuxException { - private final JsonNode originalError; - - public ToolCallRepairError(String message, int status, long retryMs) { - this(message, status, retryMs, null); - } - - public ToolCallRepairError(String message, int status, long retryMs, JsonNode originalError) { - super(message, AIMUX_E_TOOL_CALL_REPAIR, status, retryMs); - this.originalError = originalError; - } - - /** - * The original lookup/parse/validation error as externally-tagged wire - * JSON (the same encoding as {@code ToolCall.error}), or {@code null} - * for local failures. - */ - public JsonNode getOriginalError() { - return originalError; - } + public static final class ToolChoiceViolationError extends AimuxException { + ToolChoiceViolationError(JsonNode envelope) { super(envelope); } } - - public static class OtherError extends AimuxException { - public OtherError(String message, int status, long retryMs) { - super(message, AIMUX_E_OTHER, status, retryMs); - } + /** An unrecognized variant, retaining its complete envelope. */ + public static final class Unknown extends AimuxException { + Unknown(JsonNode envelope) { super(envelope); } } } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/AimuxFFI.java b/bindings/java/src/main/java/ai/arcships/aimux/AimuxFFI.java index 49f112783..ad421b661 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/AimuxFFI.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/AimuxFFI.java @@ -1,420 +1,44 @@ package ai.arcships.aimux; import com.sun.jna.Callback; +import com.sun.jna.IntegerType; import com.sun.jna.Library; import com.sun.jna.Native; import com.sun.jna.Pointer; import com.sun.jna.ptr.IntByReference; -import com.sun.jna.ptr.LongByReference; import com.sun.jna.ptr.PointerByReference; -import java.util.Collections; - -/** - * JNA interface — 1:1 mapping of the aimux-ffi C ABI ({@code aimux-ffi/aimux-ffi.h}). - * - *

Error transport ({@code aimux-error.h}): every fallible call returns an - * {@code aimux_error_t *} ({@link Pointer}) — {@code null} on success with - * the result written to the trailing out-parameter ({@link LongByReference} - * handle / {@link PointerByReference} owned {@code char*} JSON), non-null on - * failure with the out-parameter left at its sentinel. Read its unified code - * and message with {@link #aimux_error_code} / {@link #aimux_error_message}, - * then release it once - * with {@link #aimux_error_free}. Errors are not handles: never pass one - * to {@link #aimux_drop_handle}. - * - *

Memory ownership: - *

    - *
  • {@code char*} returned by generate / upload / embed / search functions - * is owned by the caller and MUST be freed with {@link #aimux_free_string}.
  • - *
  • {@code const char*} passed to stream callbacks is valid only for the - * duration of the callback — copy it synchronously.
  • - *
  • Strings returned by the {@code aimux_error_*} getters are owned by the - * caller — free with {@link #aimux_free_string}.
  • - *
- * - *

Concurrency: all functions are synchronous (block until completion). - * Callbacks execute on the calling thread. Re-entering a runtime-bound - * function (generate / stream / …) from inside a callback fails fast with - * {@code AIMUX_E_FFI_REENTRANT_CALL} (204); the pure repair functions below - * are the exception. - * - *

Strings cross the boundary as UTF-8 in both directions: the library is - * loaded with {@link Library#OPTION_STRING_ENCODING} pinned. JNA otherwise - * encodes with the platform's native encoding, so on a Windows code page or - * under {@code LANG=C} every non-ASCII prompt would reach the library - * corrupted. - * - *

The library is resolved by JNA from {@code java.library.path} / - * {@code LD_LIBRARY_PATH} (tests) or the JAR's {@code native/} directory. - */ +/** The versioned C ABI. All strings are UTF-8; returned strings are owned. */ public interface AimuxFFI extends Library { - Pointer aimux_jev_decision_new_with_probability_source(String apiKey, String modelId, - String endpoint, String probabilitySource, LongByReference outHandle); - Pointer aimux_decide_with_abort(long handle, String optsJson, long abortHandle, PointerByReference outJson); - Pointer aimux_decision_capabilities(long handle, PointerByReference outJson); - - - AimuxFFI INSTANCE = Native.load("aimux_ffi", AimuxFFI.class, - Collections.singletonMap(Library.OPTION_STRING_ENCODING, "UTF-8")); - - // ── Stream callbacks (match C on_part / on_done; no on_error) ─────────── - - /** - * C: {@code void (*on_part)(const char *json, void *stream_ctx)}. - * {@code json} is valid only for the duration of the callback. - */ - interface StreamPartCallback extends Callback { - void invoke(Pointer json, Pointer streamCtx); + /** Native size_t is pointer-sized on both LLP64 and LP64. */ + final class SizeT extends IntegerType { + public SizeT() { this(0); } + public SizeT(long value) { + super(Native.SIZE_T_SIZE, value, true); + if (value < 0 || (Native.SIZE_T_SIZE == 4 && value > 0xffffffffL)) { + throw new IllegalArgumentException("size_t out of range"); + } + } } - /** - * C: {@code void (*on_done)(void *stream_ctx)}. - * Called once on normal completion (not on failure). - */ - interface StreamDoneCallback extends Callback { - void invoke(Pointer streamCtx); + interface EventCallback extends Callback { + void invoke(Pointer frameJson, Pointer context); } - // ── Provider constructors (error or null; handle written to outHandle) ── - - Pointer aimux_openai_new(String apiKey, String modelId, LongByReference outHandle); - - Pointer aimux_openai_new_with_base(String apiKey, String modelId, String baseUrl, LongByReference outHandle); - - Pointer aimux_anthropic_new(String apiKey, String modelId, LongByReference outHandle); - - Pointer aimux_anthropic_new_with_base(String apiKey, String modelId, String baseUrl, LongByReference outHandle); - - Pointer aimux_cohere_new(String apiKey, String modelId, LongByReference outHandle); - - Pointer aimux_cohere_new_with_base(String apiKey, String modelId, String baseUrl, LongByReference outHandle); - - Pointer aimux_mistral_new(String apiKey, String modelId, LongByReference outHandle); - - Pointer aimux_mistral_new_with_base(String apiKey, String modelId, String baseUrl, LongByReference outHandle); - - Pointer aimux_xai_new(String apiKey, String modelId, LongByReference outHandle); - - Pointer aimux_xai_new_with_base(String apiKey, String modelId, String baseUrl, LongByReference outHandle); - - Pointer aimux_bedrock_new(String accessKeyId, String secretAccessKey, String region, String modelId, LongByReference outHandle); - - Pointer aimux_bedrock_new_with_base(String accessKeyId, String secretAccessKey, String region, String modelId, String baseUrl, LongByReference outHandle); - - Pointer aimux_vertex_new(String accessToken, String project, String location, String modelId, LongByReference outHandle); - - Pointer aimux_vertex_new_with_base(String accessToken, String project, String location, String modelId, String baseUrl, LongByReference outHandle); - - Pointer aimux_anthropic_aws_new(String apiKey, String region, String modelId, LongByReference outHandle); - - Pointer aimux_anthropic_aws_new_with_base(String apiKey, String region, String modelId, String baseUrl, LongByReference outHandle); - - Pointer aimux_azure_new(String apiKey, String resourceName, String deployment, String apiVersion, LongByReference outHandle); - - Pointer aimux_azure_new_with_base(String apiKey, String baseUrl, String deployment, String apiVersion, LongByReference outHandle); - - // ── Registry provider (RFC-0017 phase 4) ────────────────────────────────── - // apiKey may be null (read the provider's env var from the registry entry); - // configJson may be null (defaults) or a JSON object of ProviderOptions. - - Pointer aimux_provider_new(String name, String apiKey, String modelId, String configJson, LongByReference outHandle); - - Pointer aimux_provider_from_env(String name, String modelId, LongByReference outHandle); - - // ── Provider handles (RFC-0027) ───────────────────────────────────────── - - Pointer aimux_provider_handle_new(String name, String apiKey, String configJson, LongByReference outHandle); - - Pointer aimux_provider_list_models(long handle, PointerByReference outJson); - - Pointer aimux_provider_decision_model(long handle, String modelId, LongByReference outHandle); - Pointer aimux_provider_model(long handle, String modelId, LongByReference outHandle); - - Pointer aimux_get_model_specs(String sourceUrl, PointerByReference outJson); - - // ── Generation ────────────────────────────────────────────────────────── - - /** Writes the JSON result to {@code outJson} (caller MUST free with {@link #aimux_free_string}); returns the error or null. */ - Pointer aimux_generate_text(long handle, String promptJson, String optsJson, PointerByReference outJson); - - /** Writes JSON GenerateObjectResult to {@code outJson} (caller MUST free); returns the error or null. */ - Pointer aimux_generate_object(long handle, String promptJson, String optsJson, PointerByReference outJson); - - /** Writes JSON GenerateTextResult to {@code outJson} (caller MUST free); returns the error or null. */ - Pointer aimux_consume_stream_text(long handle, String promptJson, String optsJson, PointerByReference outJson); - - /** - * Push streaming with callbacks; blocks until the stream ends. - * @return null after {@code onDone}; on failure the error (no {@code onDone}). - */ - Pointer aimux_stream_text(long handle, String promptJson, String optsJson, - StreamPartCallback onPart, StreamDoneCallback onDone, - Pointer streamCtx); - - // ── OpenAI-compatible output (RFC-0026) ────────────────────────────────── - - /** Writes JSON ChatCompletion to {@code outJson} (caller MUST free with {@link #aimux_free_string}); returns the error or null. */ - Pointer aimux_generate_text_as_openai(long handle, String promptJson, String optsJson, PointerByReference outJson); - - /** - * Convert a serialized GenerateTextResult into a serialized ChatCompletion - * (the conversion half of {@link #aimux_generate_text_as_openai}); the - * handle only supplies the fallback model id. Caller MUST free. - */ - Pointer aimux_generate_text_result_as_openai(long handle, String resultJson, PointerByReference outJson); - - /** - * Push streaming with OpenAI Chat Completions output; blocks until the stream - * ends. Each {@code onPart} receives a serialized ChatCompletionChunk. - * @return null after {@code onDone}; on failure the error (no {@code onDone}). - */ - Pointer aimux_stream_text_as_openai(long handle, String promptJson, String optsJson, - StreamPartCallback onPart, StreamDoneCallback onDone, - Pointer streamCtx); - - // ── Host-side tool-call repair (RFC-0035) ────────────────────────────── - // Pure data functions: no handle, no tokio runtime, no I/O — safe to call - // from inside a stream callback (the re-entrancy guard only rejects nested - // runtime work). Each writes owned JSON to outJson (free with - // aimux_free_string); returns the error or null. - - /** - * Repair argument for one invalid tool call, or the JSON literal - * {@code "null"} when the options carried no tool set (never repair such a - * call — that is a SUCCESS, not an error). - */ - Pointer aimux_tool_call_repair_context(String toolCallJson, String promptJson, String optsJson, - PointerByReference outJson); - - /** Resolve one invalid tool call against a repair reply; writes the resulting ToolCall. */ - Pointer aimux_apply_tool_call_repair(String toolCallJson, String optsJson, String replyJson, - PointerByReference outJson); - - /** Patch a serialized GenerateTextResult / GenerateObjectResult (tool_calls and response_messages). */ - Pointer aimux_apply_tool_call_repair_to_result(String resultJson, String optsJson, String toolCallId, - String replyJson, PointerByReference outJson); - - // ── Resource management ───────────────────────────────────────────────── - - void aimux_drop_handle(long handle); - - void aimux_free_string(Pointer ptr); - - // ── Returned errors (aimux-error.h) ───────────────────────────────────── - - /** Release the error returned by a failed call. NULL-safe; exactly once. */ - void aimux_error_free(Pointer err); - - // ── Unified code and getters (const aimux_error_t *; NULL-safe) ───────── - - /** {@code aimux_error_code_t}; {@link AimuxException#AIMUX_OK} for NULL. */ - int aimux_error_code(Pointer err); - - /** Owned UTF-8 message (free with {@link #aimux_free_string}); NULL only for NULL. */ - Pointer aimux_error_message(Pointer err); - - /** 1 when retrying may help (only ever 1 for AIMUX_E_API_CALL). */ - int aimux_error_retryable(Pointer err); - - /** AIMUX_E_API_CALL: HTTP status or -1. */ - int aimux_error_status(Pointer err); - - /** AIMUX_E_API_CALL: rate-limit hint in ms, or -1. */ - long aimux_error_retry_ms(Pointer err); - - /** - * AIMUX_E_API_CALL: owned string or NULL. AIMUX_E_LOAD_API_KEY / - * AIMUX_E_LOAD_SETTING: the environment variable that was consulted. - */ - Pointer aimux_error_provider_code(Pointer err); - - /** AIMUX_E_API_CALL: owned string or NULL. */ - Pointer aimux_error_provider_message(Pointer err); - - /** AIMUX_E_API_CALL: owned string or NULL. */ - Pointer aimux_error_response_body(Pointer err); - - /** AIMUX_E_API_CALL: sanitized request URL; owned string or NULL. */ - Pointer aimux_error_url(Pointer err); - - /** AIMUX_E_API_CALL: sanitized request body values as a JSON string; owned or NULL. */ - Pointer aimux_error_request_body_values(Pointer err); - - /** AIMUX_E_API_CALL: response headers as one JSON object string of string→string pairs; owned or NULL. */ - Pointer aimux_error_response_headers(Pointer err); - - /** AIMUX_E_API_CALL: parsed provider error data as a JSON string; owned or NULL. */ - Pointer aimux_error_provider_data(Pointer err); - - /** AIMUX_E_RETRY: "maxRetriesExceeded" / "errorNotRetryable"; owned string or NULL. */ - Pointer aimux_error_retry_reason(Pointer err); - - /** AIMUX_E_RETRY: number of recorded attempt errors; 0 under any other code. */ - int aimux_error_retry_count(Pointer err); - - /** AIMUX_E_RETRY: attempt at index (0 = oldest) as a NEW OWNED error — release with {@link #aimux_error_free}; NULL when out of range or not Retry. */ - Pointer aimux_error_retry_error_at(Pointer err, int index); - - /** AIMUX_E_NO_SUCH_MODEL: owned string or NULL. */ - Pointer aimux_error_model_id(Pointer err); - - /** AIMUX_E_NO_SUCH_MODEL: owned string or NULL. */ - Pointer aimux_error_model_type(Pointer err); - - /** AIMUX_E_NO_SUCH_PROVIDER: owned string or NULL. */ - Pointer aimux_error_provider_id(Pointer err); - - /** AIMUX_E_NO_SUCH_TOOL / AIMUX_E_INVALID_TOOL_INPUT: owned string or NULL. */ - Pointer aimux_error_tool_name(Pointer err); - - /** AIMUX_E_NO_SUCH_TOOL: owned JSON string array, or NULL when no tool set was supplied. */ - Pointer aimux_error_available_tools(Pointer err); - - /** AIMUX_E_INVALID_TOOL_INPUT: owned string or NULL. */ - Pointer aimux_error_tool_input(Pointer err); - - /** AIMUX_E_TOOL_CALL_REPAIR: owned externally-tagged wire JSON, or NULL. */ - Pointer aimux_error_original_error(Pointer err); - - // ── Embedding ─────────────────────────────────────────────────────────── - - Pointer aimux_openai_embedding_new(String apiKey, String modelId, LongByReference outHandle); - - Pointer aimux_openai_embedding_new_with_base(String apiKey, String modelId, String baseUrl, LongByReference outHandle); - - Pointer aimux_cohere_embedding_new(String apiKey, String modelId, LongByReference outHandle); - - Pointer aimux_cohere_embedding_new_with_base(String apiKey, String modelId, String baseUrl, LongByReference outHandle); - - Pointer aimux_google_embedding_new(String apiKey, String modelId, LongByReference outHandle); - - Pointer aimux_google_embedding_new_with_base(String apiKey, String modelId, String baseUrl, LongByReference outHandle); - - Pointer aimux_embed(long handle, String valuesJson, String optsJson, PointerByReference outJson); - - // ── Speech (TTS) ──────────────────────────────────────────────────────── - - Pointer aimux_openai_speech_new(String apiKey, String modelId, LongByReference outHandle); - - Pointer aimux_openai_speech_new_with_base(String apiKey, String modelId, String baseUrl, LongByReference outHandle); - - Pointer aimux_speech_generate(long handle, String optsJson, PointerByReference outJson); - - // ── Image ─────────────────────────────────────────────────────────────── - - Pointer aimux_openai_image_new(String apiKey, String modelId, LongByReference outHandle); - - Pointer aimux_openai_image_new_with_base(String apiKey, String modelId, String baseUrl, LongByReference outHandle); - - Pointer aimux_google_image_new(String apiKey, String modelId, LongByReference outHandle); - - Pointer aimux_google_image_new_with_base(String apiKey, String modelId, String baseUrl, LongByReference outHandle); - - Pointer aimux_image_generate(long handle, String optsJson, PointerByReference outJson); - - // ── Transcription (STT, non-streaming) ────────────────────────────────── - - Pointer aimux_openai_transcription_new(String apiKey, String modelId, LongByReference outHandle); - - Pointer aimux_openai_transcription_new_with_base(String apiKey, String modelId, String baseUrl, LongByReference outHandle); - - Pointer aimux_transcription_generate(long handle, String audioBase64, - String mediaType, String optsJson, PointerByReference outJson); - - // ── Files ─────────────────────────────────────────────────────────────── - - Pointer aimux_openai_files_new(String apiKey, LongByReference outHandle); - - Pointer aimux_openai_files_new_with_base(String apiKey, String baseUrl, LongByReference outHandle); - - Pointer aimux_file_upload(long handle, String dataBase64, - String mediaType, String optsJson, PointerByReference outJson); - - // ── Reranking ─────────────────────────────────────────────────────────── - - Pointer aimux_cohere_reranking_new(String apiKey, String modelId, LongByReference outHandle); - - Pointer aimux_cohere_reranking_new_with_base(String apiKey, String modelId, String baseUrl, LongByReference outHandle); - - Pointer aimux_rerank(long handle, String optsJson, PointerByReference outJson); - - // ── Video ─────────────────────────────────────────────────────────────── - - Pointer aimux_google_video_new(String apiKey, String modelId, LongByReference outHandle); - - Pointer aimux_google_video_new_with_base(String apiKey, String modelId, String baseUrl, LongByReference outHandle); - - Pointer aimux_video_generate(long handle, String optsJson, PointerByReference outJson); - - // ── Search ────────────────────────────────────────────────────────────── - - Pointer aimux_tavily_search_new(String apiKey, String modelId, LongByReference outHandle); - - Pointer aimux_tavily_search_new_with_base(String apiKey, String modelId, String baseUrl, LongByReference outHandle); - - Pointer aimux_search(long handle, String optsJson, PointerByReference outJson); - - // Logging (RFC-0014). C ABI failures only. - - Pointer aimux_init_logging(String level); - - // ── Recording + mock replay (RFC-0023) ────────────────────────────────── - - /** Start recording: complete Recording JSONL is written to {dir}/recordings.jsonl (dir auto-created). Returns null, or a RecordingError code (100–102). */ - Pointer aimux_init_recording(String dir); - - /** Start in-memory bounded recording (RingRecorder, FIFO eviction). cap == 0 is AIMUX_E_INVALID_ARGUMENT. */ - Pointer aimux_init_recording_ring(long cap); - - /** Stop recording: the global recorder becomes None. Infallible. */ - void aimux_recording_stop(); - - /** Flush the global recorder (blocks until JSONL is on disk; no-op for the ring recorder). Infallible. */ - void aimux_recording_flush(); - - /** Checked flush: null = data on disk (or nothing recording); otherwise a RecordingError code (read with {@link #aimux_error_code} / {@link #aimux_error_message}, release with {@link #aimux_error_free}). */ - Pointer aimux_recording_try_flush(); - - /** Create a mock replay model from recorded JSONL. Writes the handle to {@code outHandle}; returns the error or null. */ - Pointer aimux_mock_replay_new(String recordingsJsonl, LongByReference outHandle); - - /** Replace the default transport's proxy configuration (M6, RFC-0016). Returns the error or null. */ - Pointer aimux_init_proxy(String configJson); - - /** Create a RouterModel (RFC-0021) over child handles. {@code handles} is pinned by - * JNA to a {@code uint64_t*}; unknown handles are dropped. {@code configJson} may be - * null. Writes the handle to {@code outHandle}; returns the error or null. */ - Pointer aimux_router_new(long[] handles, long len, String configJson, LongByReference outHandle); - - /** Create a MoaModel (RFC-0022) over reference handles + one aggregator handle. - * {@code referenceHandles} may be null/empty (degrades to aggregator-only). Writes - * the handle to {@code outHandle}; returns the error or null. */ - Pointer aimux_moa_new(long[] referenceHandles, long refLen, long aggregator, String configJson, LongByReference outHandle); - - // ── Transcription streaming sessions (RFC-0028) ───────────────────────── - - /** Start a streaming transcription session. {@code abortHandle} 0 = none. - * {@code optsJson} nullable. Writes the session handle to {@code outHandle}; returns the error or null. */ - Pointer aimux_transcription_session_new(long modelHandle, long abortHandle, String optsJson, LongByReference outHandle); - - /** Push one binary audio chunk (BLOCKS while the channel is full). Returns the error or null. */ - Pointer aimux_transcription_push_audio(long session, byte[] data, long len); - - /** Signal end-of-audio (idempotent). Returns the error or null (a dead handle is a C ABI failure). */ - Pointer aimux_transcription_input_done(long session); - - /** Values written to {@code outState} (an {@code int32_t}, hence {@code IntByReference}). */ - int TRANSCRIPTION_NEXT_PART_PART = 1; - int TRANSCRIPTION_NEXT_PART_ENDED = 2; - int TRANSCRIPTION_NEXT_PART_TIMEOUT = 3; - - /** Pull the next part. On a null return {@code outState} says what happened: - * PART ({@code outPart} holds JSON, free with {@link #aimux_free_string}), ENDED or - * TIMEOUT ({@code outPart} NULL). A non-null return has an AiMuxError code on - * abort / API failure or a C ABI failure code on a dead handle. */ - Pointer aimux_transcription_next_part(long session, long timeoutMs, PointerByReference outPart, IntByReference outState); - - /** Terminate and release the session (safe with 0). */ - void aimux_transcription_session_drop(long session); + int aimux_abi_version(); + Pointer aimux_manifest_json(PointerByReference outJson); + Pointer aimux_call(long handle, String op, String paramsJson, long abort, PointerByReference outJson); + Pointer aimux_stream(long handle, String op, String paramsJson, long abort, + EventCallback onEvent, Pointer context); + Pointer aimux_operation_push(long operation, byte[] data, SizeT length); + Pointer aimux_operation_next_part(long operation, long timeoutMs, + PointerByReference outPart, IntByReference outState); + void aimux_abort(long abort); + void aimux_drop(long handle); + void aimux_handle_finalizer(Pointer token); + void aimux_free_string(Pointer string); + void aimux_error_free(Pointer error); + int aimux_error_code(Pointer error); + Pointer aimux_error_message(Pointer error); + Pointer aimux_error_json(Pointer error); } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/AimuxResult.java b/bindings/java/src/main/java/ai/arcships/aimux/AimuxResult.java index 269773f42..bfd4a9380 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/AimuxResult.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/AimuxResult.java @@ -1,195 +1,96 @@ package ai.arcships.aimux; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.sun.jna.Pointer; -import com.sun.jna.ptr.LongByReference; import com.sun.jna.ptr.PointerByReference; +import java.io.IOException; +import java.util.Objects; -/** - * Package-private helpers for FFI result extraction and error decoding. - * - *

Every fallible C call returns an {@code aimux_error_t *} ({@code null} - * = success, result in the out-parameter). Its code identifies an AiMuxError - * (1–20), RecordingError (100–105), or a failure detected by the C ABI - * (200–206). The last range collapses to {@link IllegalStateException} - * ({@code "aimux ffi: "} + message); Java does not expose seven additional - * exception types. Each helper frees the pointer exactly once. User-triggerable - * C ABI failures are caught before the C call: {@link #requireJson} for - * raw JSON ({@link IllegalArgumentException}), local closed-guards for - * use-after-close ({@link IllegalStateException}). - */ final class AimuxResult { - private static final int FFI_FIRST = 200; - private static final int FFI_LAST = 206; - private AimuxResult() {} - /** - * Validate an optional caller-supplied raw JSON string before it crosses - * the C ABI, so malformed input fails as - * {@link IllegalArgumentException} naming the parameter. {@code null} and - * {@code ""} pass (the C layer treats both as defaults for opts/config). - */ - static void requireJson(String value, String name) { - if (value == null || value.isEmpty()) { - return; - } - checkJson(value, name); - } - - /** - * Validate a required raw JSON string: {@code null} → - * {@link NullPointerException}, blank → {@link IllegalArgumentException}, - * otherwise as {@link #requireJson}. - */ - static void requireJsonNonNull(String value, String name) { - java.util.Objects.requireNonNull(value, name); - if (value.trim().isEmpty()) { - throw new IllegalArgumentException(name + ": invalid JSON: empty"); - } - checkJson(value, name); - } - - private static void checkJson(String value, String name) { + static JsonNode parse(String value) { + Objects.requireNonNull(value, "JSON"); try { - Types.AimuxJson.MAPPER.reader() - .with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS) - .readTree(value); - } catch (com.fasterxml.jackson.core.JsonProcessingException e) { - throw new IllegalArgumentException(name + ": invalid JSON: " + e.getOriginalMessage(), e); + JsonNode node = Types.AimuxJson.MAPPER.reader() + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(value); + if (node == null || node.isMissingNode()) throw new IllegalArgumentException("Empty JSON"); + return node; + } catch (IOException e) { + throw new IllegalArgumentException("Invalid JSON", e); } } - private static String prefix(String context) { - return (context == null || context.isEmpty()) ? "" : context + ": "; + static String params(String json) { + if (!parse(json).isObject()) throw new IllegalArgumentException("params must be a JSON object"); + return json; } - /** - * Decode an error from a call that may return {@code AiMuxError}: 1–20 → - * {@link AimuxException}; 200–206 → {@link IllegalStateException}. - * Frees {@code e}. - */ - static RuntimeException expectAimuxError(Pointer e, String context) { - String prefix = prefix(context); - if (e == null) { - throw new IllegalStateException(prefix + "missing aimux error"); - } - AimuxFFI ffi = AimuxFFI.INSTANCE; - try { - int code = ffi.aimux_error_code(e); - if (isFfiCode(code)) { - return ffiError(e, prefix); - } - // Code 4 is retired; Retry is 14, tool errors are 15..17, and a missing - // API key / setting are 19 / 20. - if (code < AimuxException.AIMUX_E_OTHER - || code > AimuxException.AIMUX_E_LOAD_SETTING - || code == 4) { - return codeMismatch(code, prefix, "AiMuxError"); - } - return AimuxException.fromC(e, prefix); - } finally { - ffi.aimux_error_free(e); - } + static String takeString(AimuxFFI ffi, Pointer pointer) { + if (pointer == null) return null; + try { return pointer.getString(0, "UTF-8"); } + finally { ffi.aimux_free_string(pointer); } } - /** - * Decode an error from a recording call: 100–105 → - * {@link RecordingException}; 200–206 → {@link IllegalStateException}. - * Frees {@code e}. - */ - static RuntimeException expectRecordingError(Pointer e, String context) { - String prefix = prefix(context); - if (e == null) { - throw new IllegalStateException(prefix + "missing recording error"); - } - AimuxFFI ffi = AimuxFFI.INSTANCE; + static JsonNode result(AimuxFFI ffi, Pointer error, PointerByReference out) { try { - int code = ffi.aimux_error_code(e); - if (isFfiCode(code)) { - return ffiError(e, prefix); - } - if (!RecordingErrorCode.isCCode(code)) { - return codeMismatch(code, prefix, "RecordingError"); - } - return RecordingException.fromC(e, prefix); + check(ffi, error); + Pointer pointer = out.getValue(); + out.setValue(null); + String json = takeString(ffi, pointer); + if (json == null) throw new IllegalStateException("Native success has no JSON result"); + return parse(json); } finally { - ffi.aimux_error_free(e); + if (out.getValue() != null) ffi.aimux_free_string(out.getValue()); } } - /** - * Decode an error from a call that only exposes C ABI failures: - * message → {@link IllegalStateException}. Deletes {@code e}. - */ - static RuntimeException expectFfiError(Pointer e, String context) { - String prefix = prefix(context); - if (e == null) { - throw new IllegalStateException(prefix + "missing aimux error"); - } + static void check(AimuxFFI ffi, Pointer error) { + if (error == null) return; try { - int code = AimuxFFI.INSTANCE.aimux_error_code(e); - if (!isFfiCode(code)) { - return codeMismatch(code, prefix, "C ABI failure"); + int code = ffi.aimux_error_code(error); + JsonNode envelope = parse(takeString(ffi, ffi.aimux_error_json(error))); + if (envelope.path("code").asInt() != code) { + throw new IllegalStateException("Native error code/envelope mismatch"); } - return ffiError(e, prefix); - } finally { - AimuxFFI.INSTANCE.aimux_error_free(e); - } - } - - private static boolean isFfiCode(int code) { - return code >= FFI_FIRST && code <= FFI_LAST; - } - - private static IllegalStateException codeMismatch(int code, String prefix, String expected) { - return new IllegalStateException(prefix + "aimux ffi: expected " + expected + " code, got " + code); + throw decode(envelope); + } finally { ffi.aimux_error_free(error); } } - /** Read the returned error's message; does not free. */ - private static IllegalStateException ffiError(Pointer e, String prefix) { - String msg = takeString(AimuxFFI.INSTANCE.aimux_error_message(e)); - if (msg == null || msg.isEmpty()) { - msg = "C ABI failure"; + static NativeException decode(JsonNode envelope) { + if (!envelope.path("code").isInt() || !envelope.path("message").isTextual() + || !envelope.path("error").path("name").isTextual()) { + throw new IllegalStateException("Malformed native error envelope"); } - return new IllegalStateException(prefix + "aimux ffi: " + msg); - } - - /** Copy an owned C string and free it; {@code null} stays {@code null}. */ - static String takeString(Pointer p) { - if (p == null) { - return null; - } - try { - return p.getString(0, "UTF-8"); - } finally { - AimuxFFI.INSTANCE.aimux_free_string(p); + switch (envelope.path("kind").asText()) { + case "aimux": return AimuxException.decode(envelope); + case "recording": return RecordingException.decode(envelope); + case "binding": return BindingException.decode(envelope); + case "protocol": return ProtocolException.decode(envelope); + default: throw new IllegalStateException("Unknown native error kind"); } } - /** - * Accept a constructor result: {@code e == null} → the handle written to - * {@code out}; otherwise throw {@link #expectAimuxError}. - */ - static long extractHandle(Pointer e, LongByReference out, String context) { - if (e != null) { - throw expectAimuxError(e, context); - } - return out.getValue(); + static BindingException.CallbackFailure callbackFailure(Throwable cause) { + ObjectNode envelope = Types.AimuxJson.MAPPER.createObjectNode(); + envelope.put("kind", "binding"); + envelope.put("code", 206); + envelope.put("message", "Stream consumer failed"); + envelope.putObject("error").put("name", "CallbackFailure"); + BindingException.CallbackFailure failure = new BindingException.CallbackFailure(envelope); + failure.initCause(cause); + return failure; } - /** - * Accept a JSON-result call: {@code e == null} → the caller-owned UTF-8 - * string written to {@code out} (freed here); otherwise throw - * {@link #expectAimuxError}. - */ - static String extractString(Pointer e, PointerByReference out, String context) { - if (e != null) { - throw expectAimuxError(e, context); - } - String s = takeString(out.getValue()); - if (s == null) { - throw new IllegalStateException(prefix(context) + "aimux ffi: success with NULL result"); + static long handle(JsonNode value) { + // Handles are decimal strings, never JSON numbers (including values above 2^53). + JsonNode id = value.isObject() ? value.path("handle") : value; + if (!id.isTextual() || !id.textValue().matches("[1-9][0-9]*")) { + throw new IllegalStateException("Expected a nonzero decimal handle string"); } - return s; + try { return Long.parseUnsignedLong(id.textValue()); } + catch (NumberFormatException e) { throw new IllegalStateException("Handle exceeds uint64", e); } } } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/BindingException.java b/bindings/java/src/main/java/ai/arcships/aimux/BindingException.java new file mode 100644 index 000000000..57d4582e9 --- /dev/null +++ b/bindings/java/src/main/java/ai/arcships/aimux/BindingException.java @@ -0,0 +1,46 @@ +package ai.arcships.aimux; + +import com.fasterxml.jackson.databind.JsonNode; + +/** Binding domain errors from the RFC-0039 envelope. */ +public abstract class BindingException extends NativeException { + BindingException(JsonNode envelope) { super(envelope); } + static BindingException decode(JsonNode envelope) { + switch (envelope.path("error").path("name").asText()) { + case "NullPointer": return new NullPointer(envelope); + case "InvalidUtf8": return new InvalidUtf8(envelope); + case "InvalidWireJson": return new InvalidWireJson(envelope); + case "InvalidHandle": return new InvalidHandle(envelope); + case "ReentrantCall": return new ReentrantCall(envelope); + case "ResultSerialization": return new ResultSerialization(envelope); + case "CallbackFailure": return new CallbackFailure(envelope); + default: return new Unknown(envelope); + } + } + + public static final class NullPointer extends BindingException { + NullPointer(JsonNode envelope) { super(envelope); } + } + public static final class InvalidUtf8 extends BindingException { + InvalidUtf8(JsonNode envelope) { super(envelope); } + } + public static final class InvalidWireJson extends BindingException { + InvalidWireJson(JsonNode envelope) { super(envelope); } + } + public static final class InvalidHandle extends BindingException { + InvalidHandle(JsonNode envelope) { super(envelope); } + } + public static final class ReentrantCall extends BindingException { + ReentrantCall(JsonNode envelope) { super(envelope); } + } + public static final class ResultSerialization extends BindingException { + ResultSerialization(JsonNode envelope) { super(envelope); } + } + public static final class CallbackFailure extends BindingException { + CallbackFailure(JsonNode envelope) { super(envelope); } + } + /** An unrecognized variant, retaining its complete envelope. */ + public static final class Unknown extends BindingException { + Unknown(JsonNode envelope) { super(envelope); } + } +} diff --git a/bindings/java/src/main/java/ai/arcships/aimux/DecisionModel.java b/bindings/java/src/main/java/ai/arcships/aimux/DecisionModel.java index cf6606a08..528d660c4 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/DecisionModel.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/DecisionModel.java @@ -1,69 +1,17 @@ package ai.arcships.aimux; -import com.sun.jna.Pointer; -import com.sun.jna.ptr.LongByReference; -import com.sun.jna.ptr.PointerByReference; -import java.io.Closeable; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicLong; - -/** - * A DecisionModelV4 model. Requests (DecideOptions) and results (DecideResult) use the core JSON - * contract, camelCase as the AI SDK's ({@code maxRetries}, {@code providerOptions}, usage - * {@code inputTokens} / {@code outputTokens} / {@code totalTokens}, {@code providerMetadata}). - */ -public final class DecisionModel implements Closeable { - private final AtomicLong handle; - - DecisionModel(long handle) { this.handle = new AtomicLong(handle); } - - public static DecisionModel jev(String apiKey, String modelId) { - return jev(apiKey, modelId, null, null); - } - - /** Endpoint is a complete POST URL; null uses the official API. */ - public static DecisionModel jev(String apiKey, String modelId, String endpoint, String probabilitySource) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer error = AimuxFFI.INSTANCE.aimux_jev_decision_new_with_probability_source( - apiKey, modelId, endpoint, probabilitySource, out); - return new DecisionModel(AimuxResult.extractHandle(error, out, "jev decision")); - } - - public String decide(String optsJson) { return decide(optsJson, 0L); } - - /** Optional abort handle uses the existing C ABI abort signal lifecycle. */ - public String decide(String optsJson, long abortHandle) { - AimuxResult.requireJsonNonNull(optsJson, "optsJson"); - PointerByReference out = new PointerByReference(); - return AimuxResult.extractString( - AimuxFFI.INSTANCE.aimux_decide_with_abort(requireHandle(), optsJson, abortHandle, out), out, "decide"); - } - - /** - * The model's DecisionModelV4 properties as JSON - * ({specificationVersion, provider, modelId, supportedQuestionTypes}), without HTTP. - */ - public String describe() { - PointerByReference out = new PointerByReference(); - return AimuxResult.extractString( - AimuxFFI.INSTANCE.aimux_decision_capabilities(requireHandle(), out), out, "decision model description"); - } - - private long requireHandle() { - long value = handle.get(); - if (value == 0L) throw new IllegalStateException("DecisionModel is closed"); - return value; - } - - @Override public void close() { - long value = handle.getAndSet(0L); - if (value != 0L) AimuxFFI.INSTANCE.aimux_drop_handle(value); - } - - @Override protected void finalize() throws Throwable { - close(); - super.finalize(); +import ai.arcships.aimux.generated.AimuxOps; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; + +/** Decision model resource; requests contain the canonical options object. */ +public final class DecisionModel extends NativeHandle { + DecisionModel(Aimux aimux, long id) { super(aimux, id); } + public JsonNode capabilities() { return call(AimuxOps.OP_DECISION_CAPABILITIES, "{}"); } + public JsonNode decide(String paramsJson) { return call(AimuxOps.OP_DECISION_DECIDE, paramsJson); } + public Future decideAsync(String paramsJson, Executor executor) { + return callAsync(AimuxOps.OP_DECISION_DECIDE, paramsJson, executor); } } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/EmbeddingModel.java b/bindings/java/src/main/java/ai/arcships/aimux/EmbeddingModel.java index a25ff5417..29ed2eb00 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/EmbeddingModel.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/EmbeddingModel.java @@ -1,118 +1,21 @@ package ai.arcships.aimux; -import com.sun.jna.Pointer; -import com.sun.jna.ptr.LongByReference; -import com.sun.jna.ptr.PointerByReference; -import java.io.Closeable; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Embedding model — generate vector embeddings for text. Wraps a Rust - * {@code Arc}. - * - *

Implements {@link Closeable} — call {@link #close()} (or use a - * try-with-resources block) to release the native handle. - * - *

{@code
- * try (EmbeddingModel model = EmbeddingModel.openai("sk-...", "text-embedding-3-small")) {
- *     String result = model.embed("[\"hello\"]");
- * }
- * }
- */ -public final class EmbeddingModel implements Closeable { - - private final AtomicLong handle; - - private EmbeddingModel(long handle) { - this.handle = new AtomicLong(handle); - } - - @Override - public void close() { - long h = handle.getAndSet(0L); - if (h != 0L) { - AimuxFFI.INSTANCE.aimux_drop_handle(h); - } - } - - @Override - protected void finalize() throws Throwable { - close(); - super.finalize(); - } - - private long requireHandle() { - long h = handle.get(); - if (h == 0L) { - throw new IllegalStateException("EmbeddingModel is closed"); - } - return h; - } - - // ── Provider constructors ────────────────────────────────────────────── - - public static EmbeddingModel openai(String apiKey, String modelId) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_openai_embedding_new(apiKey, modelId, out); - return new EmbeddingModel(AimuxResult.extractHandle(e, out, "Failed to create OpenAI embedding model")); - } - - public static EmbeddingModel openaiWithBase(String apiKey, String modelId, String baseUrl) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_openai_embedding_new_with_base(apiKey, modelId, baseUrl, out); - return new EmbeddingModel(AimuxResult.extractHandle(e, out, "Failed to create OpenAI embedding model")); - } - - public static EmbeddingModel cohere(String apiKey, String modelId) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_cohere_embedding_new(apiKey, modelId, out); - return new EmbeddingModel(AimuxResult.extractHandle(e, out, "Failed to create Cohere embedding model")); - } - - public static EmbeddingModel cohereWithBase(String apiKey, String modelId, String baseUrl) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_cohere_embedding_new_with_base(apiKey, modelId, baseUrl, out); - return new EmbeddingModel(AimuxResult.extractHandle(e, out, "Failed to create Cohere embedding model")); - } - - public static EmbeddingModel google(String apiKey, String modelId) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_google_embedding_new(apiKey, modelId, out); - return new EmbeddingModel(AimuxResult.extractHandle(e, out, "Failed to create Google embedding model")); - } - - public static EmbeddingModel googleWithBase(String apiKey, String modelId, String baseUrl) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_google_embedding_new_with_base(apiKey, modelId, baseUrl, out); - return new EmbeddingModel(AimuxResult.extractHandle(e, out, "Failed to create Google embedding model")); - } - - // ── Embedding ────────────────────────────────────────────────────────── - - public String embed(String valuesJson) { - return embed(valuesJson, null); - } - - public String embed(String valuesJson, String optsJson) { - AimuxResult.requireJsonNonNull(valuesJson, "valuesJson"); - AimuxResult.requireJson(optsJson, "optsJson"); - PointerByReference out = new PointerByReference(); - return AimuxResult.extractString( - AimuxFFI.INSTANCE.aimux_embed(requireHandle(), valuesJson, optsJson, out), - out, - "embed"); +import ai.arcships.aimux.generated.AimuxOps; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; + +/** Typed resource facade; request JSON uses the existing core wire DTO. */ +public final class EmbeddingModel extends NativeHandle { + EmbeddingModel(Aimux aimux, long id) { super(aimux, id); } + public JsonNode info() { return call(AimuxOps.OP_MODEL_INFO, "{}"); } + public JsonNode embed(String paramsJson) { return call(AimuxOps.OP_EMBEDDING_EMBED, paramsJson); } + public Future embedAsync(String paramsJson, Executor executor) { + return callAsync(AimuxOps.OP_EMBEDDING_EMBED, paramsJson, executor); + } + public JsonNode embedMany(String paramsJson) { return call(AimuxOps.OP_EMBEDDING_EMBED_MANY, paramsJson); } + public Future embedManyAsync(String paramsJson, Executor executor) { + return callAsync(AimuxOps.OP_EMBEDDING_EMBED_MANY, paramsJson, executor); } } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/Files.java b/bindings/java/src/main/java/ai/arcships/aimux/Files.java index a01c5bd82..824bd0792 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/Files.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/Files.java @@ -1,112 +1,16 @@ package ai.arcships.aimux; -import com.sun.jna.Pointer; -import com.sun.jna.ptr.LongByReference; -import com.sun.jna.ptr.PointerByReference; -import java.io.Closeable; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Manages file uploads to providers. Wraps a Rust {@code Arc}. - * - *

Implements {@link Closeable} — call {@link #close()} (or use a - * try-with-resources block) to release the native handle. - * - *

{@code
- * try (Files files = Files.openai("sk-...")) {
- *     String result = files.uploadFile(base64Data, "application/pdf");
- * }
- * }
- */ -public final class Files implements Closeable { - - private final AtomicLong handle; - - private Files(long handle) { - this.handle = new AtomicLong(handle); - } - - /** Release the native handle. Idempotent and thread-safe. */ - @Override - public void close() { - long h = handle.getAndSet(0L); - if (h != 0L) AimuxFFI.INSTANCE.aimux_drop_handle(h); - } - - /** Best-effort backstop; try-with-resources is the primary release path. */ - @Override - protected void finalize() throws Throwable { - close(); - super.finalize(); - } - - private long requireHandle() { - long h = handle.get(); - if (h == 0L) throw new IllegalStateException("Files is closed"); - return h; - } - - /** - * Create an OpenAI files manager. - * - * @param apiKey OpenAI API key. - * @return a new Files. - * @throws AimuxException on failure. - */ - public static Files openai(String apiKey) { - Objects.requireNonNull(apiKey, "apiKey"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_openai_files_new(apiKey, out); - return new Files(AimuxResult.extractHandle(e, out, "Failed to create OpenAI files manager")); - } - - /** - * Create an OpenAI files manager with a custom base URL. - * - * @param apiKey OpenAI API key. - * @param baseUrl Custom base URL. - * @return a new Files. - * @throws AimuxException on failure. - */ - public static Files openaiWithBase(String apiKey, String baseUrl) { - Objects.requireNonNull(apiKey, "apiKey"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_openai_files_new_with_base(apiKey, baseUrl, out); - return new Files(AimuxResult.extractHandle(e, out, "Failed to create OpenAI files manager")); - } - - /** - * Upload a file (base64-encoded) to the provider. - * - * @param dataBase64 Base64-encoded file bytes. - * @param mediaType Media type of the file (e.g. {@code application/pdf}). - * @return JSON-serialized {@code UploadFileResult}. - * @throws AimuxException on engine / transport failure. - */ - public String uploadFile(String dataBase64, String mediaType) { - return uploadFile(dataBase64, mediaType, null); - } - - /** - * Upload a file (base64-encoded) to the provider. - * - * @param dataBase64 Base64-encoded file bytes. - * @param mediaType Media type of the file (e.g. {@code application/pdf}). - * @param optsJson Optional JSON-serialized {@code UploadFileCallOptions}, - * or {@code null} for defaults. - * @return JSON-serialized {@code UploadFileResult}. - * @throws AimuxException on engine / transport failure. - */ - public String uploadFile(String dataBase64, String mediaType, String optsJson) { - Objects.requireNonNull(dataBase64, "dataBase64"); - Objects.requireNonNull(mediaType, "mediaType"); - AimuxResult.requireJson(optsJson, "optsJson"); - PointerByReference out = new PointerByReference(); - return AimuxResult.extractString( - AimuxFFI.INSTANCE.aimux_file_upload( - requireHandle(), dataBase64, mediaType, optsJson, out), - out, - "file_upload"); +import ai.arcships.aimux.generated.AimuxOps; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; + +/** Typed resource facade; request JSON uses the existing core wire DTO. */ +public final class Files extends NativeHandle { + Files(Aimux aimux, long id) { super(aimux, id); } + public JsonNode upload(String paramsJson) { return call(AimuxOps.OP_FILES_UPLOAD, paramsJson); } + public Future uploadAsync(String paramsJson, Executor executor) { + return callAsync(AimuxOps.OP_FILES_UPLOAD, paramsJson, executor); } } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/ImageModel.java b/bindings/java/src/main/java/ai/arcships/aimux/ImageModel.java index c59dbc93a..a425026f0 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/ImageModel.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/ImageModel.java @@ -1,134 +1,17 @@ package ai.arcships.aimux; -import com.sun.jna.Pointer; -import com.sun.jna.ptr.LongByReference; -import com.sun.jna.ptr.PointerByReference; -import java.io.Closeable; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Generates images from prompts. Wraps a Rust {@code Arc}. - * - *

Implements {@link Closeable} — call {@link #close()} (or use a - * try-with-resources block) to release the native handle. - * - *

{@code
- * try (ImageModel model = ImageModel.openai("sk-...", "dall-e-3")) {
- *     String result = model.generate("{\"prompt\":\"an otter\",\"n\":1}");
- * }
- * }
- */ -public final class ImageModel implements Closeable { - - private final AtomicLong handle; - - private ImageModel(long handle) { - this.handle = new AtomicLong(handle); - } - - /** Release the native handle. Idempotent and thread-safe. */ - @Override - public void close() { - long h = handle.getAndSet(0L); - if (h != 0L) AimuxFFI.INSTANCE.aimux_drop_handle(h); - } - - /** Best-effort backstop; try-with-resources is the primary release path. */ - @Override - protected void finalize() throws Throwable { - close(); - super.finalize(); - } - - private long requireHandle() { - long h = handle.get(); - if (h == 0L) throw new IllegalStateException("ImageModel is closed"); - return h; - } - - /** - * Create an OpenAI image model. - * - * @param apiKey OpenAI API key. - * @param modelId Model ID (e.g. {@code dall-e-3}). - * @return a new ImageModel. - * @throws AimuxException on failure. - */ - public static ImageModel openai(String apiKey, String modelId) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_openai_image_new(apiKey, modelId, out); - return new ImageModel(AimuxResult.extractHandle(e, out, "Failed to create OpenAI image model")); - } - - /** - * Create an OpenAI image model with a custom base URL. - * - * @param apiKey OpenAI API key. - * @param modelId Model ID (e.g. {@code dall-e-3}). - * @param baseUrl Custom base URL. - * @return a new ImageModel. - * @throws AimuxException on failure. - */ - public static ImageModel openaiWithBase(String apiKey, String modelId, String baseUrl) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_openai_image_new_with_base(apiKey, modelId, baseUrl, out); - return new ImageModel(AimuxResult.extractHandle(e, out, "Failed to create OpenAI image model")); - } - - /** - * Create a Google image model. - * - * @param apiKey Google API key. - * @param modelId Model ID (e.g. {@code gemini-2.5-flash-image}). - * @return a new ImageModel. - * @throws AimuxException on failure. - */ - public static ImageModel google(String apiKey, String modelId) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_google_image_new(apiKey, modelId, out); - return new ImageModel(AimuxResult.extractHandle(e, out, "Failed to create Google image model")); - } - - /** - * Create a Google image model with a custom base URL. - * - * @param apiKey Google API key. - * @param modelId Model ID. - * @param baseUrl Custom base URL. - * @return a new ImageModel. - * @throws AimuxException on failure. - */ - public static ImageModel googleWithBase(String apiKey, String modelId, String baseUrl) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_google_image_new_with_base(apiKey, modelId, baseUrl, out); - return new ImageModel(AimuxResult.extractHandle(e, out, "Failed to create Google image model")); - } - - /** - * Generate images from the given options. - * - * @param optsJson JSON-serialized {@code ImageCallOptions}. - * Required: carries the input. - * @return JSON-serialized {@code ImageResult}. - * @throws NullPointerException if {@code optsJson} is null. - * @throws IllegalArgumentException if {@code optsJson} is blank or malformed JSON. - * @throws AimuxException on engine / transport failure. - */ - public String generate(String optsJson) { - AimuxResult.requireJsonNonNull(optsJson, "optsJson"); - PointerByReference out = new PointerByReference(); - return AimuxResult.extractString( - AimuxFFI.INSTANCE.aimux_image_generate(requireHandle(), optsJson, out), - out, - "image_generate"); +import ai.arcships.aimux.generated.AimuxOps; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; + +/** Typed resource facade; request JSON uses the existing core wire DTO. */ +public final class ImageModel extends NativeHandle { + ImageModel(Aimux aimux, long id) { super(aimux, id); } + public JsonNode info() { return call(AimuxOps.OP_MODEL_INFO, "{}"); } + public JsonNode generate(String paramsJson) { return call(AimuxOps.OP_IMAGE_GENERATE, paramsJson); } + public Future generateAsync(String paramsJson, Executor executor) { + return callAsync(AimuxOps.OP_IMAGE_GENERATE, paramsJson, executor); } } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/Model.java b/bindings/java/src/main/java/ai/arcships/aimux/Model.java index aba48f013..d26aa061a 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/Model.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/Model.java @@ -1,906 +1,39 @@ package ai.arcships.aimux; -import com.sun.jna.Pointer; -import com.sun.jna.ptr.LongByReference; -import com.sun.jna.ptr.PointerByReference; +import ai.arcships.aimux.generated.AimuxOps; -import java.io.Closeable; -import java.util.Objects; -import java.util.concurrent.Callable; -import java.util.Spliterator; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.locks.ReentrantReadWriteLock; -import java.util.function.Consumer; -import java.util.stream.Stream; -import java.util.stream.StreamSupport; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; -/** - * A model instance backed by a Rust {@code Arc}. - * - *

This is the raw JSON layer of the aimux Java binding: it wraps the - * aimux-ffi C ABI via JNA and exposes the same wire format as the Kotlin - * binding (JSON in, JSON out). Failures throw {@link AimuxException} (typed - * subclasses) decoded from the {@code aimux_error_t *} a failed C call returns. - * - *

Implements {@link Closeable} — you MUST call {@link #close()} (or use a - * try-with-resources block) to release the native handle and avoid memory - * leaks. {@link #finalize()} is only a best-effort backstop and is - * unreliable; try-with-resources is the primary path. - * - *

{@code
- * try (Model model = Model.openai("sk-...", "gpt-4o-mini")) {
- *     String result = model.generateText("\"Hello!\"");
- * }
- * }
- * - *

Thread-safety / concurrency. Model is safe for concurrent use. - * It guards the native handle with a Go-style {@link ReentrantReadWriteLock} - * (fair, FIFO): every FFI call ({@link #generateText}, {@link #streamText}, - * streaming variants) holds the read lock for its entire duration, - * and {@link #close()} takes the write lock. As a result {@code close()} - * blocks until all in-flight calls finish before dropping the native handle — - * this closes the use-after-free race where a caller could observe a non-zero - * handle and then race with {@code close()}'s drop. Because a streaming call - * holds the read lock until the stream completes, {@code close()} will not - * interrupt or drop a handle out from under an active stream. Do not call - * {@code close()} from within a stream callback, or from a - * {@code repairToolCall} hook running for a stream on this model (either would - * self-deadlock). - */ -public class Model implements Closeable { - - // Go-style read/write lock: every FFI call holds the read lock for its - // entire duration; close() takes the write lock and thus blocks until all - // in-flight calls finish before dropping the native handle. This closes the - // read-then-drop use-after-free race the AtomicLong version had (a reader - // could observe a non-zero handle, then race with close()'s getAndSet+drop). - // Fair (FIFO) so a pending close is not starved by barging readers — matches - // Go's sync.RWMutex writer-priority semantics. - private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true); - private long handle; - private boolean closed; - - // A repair hook invoked from a stream callback runs on a worker thread while - // the streaming thread stays blocked on its result — and that streaming - // thread holds this model's read lock for the whole stream. The worker may - // therefore reach back into THIS model without taking the lock itself: the - // handle provably cannot be dropped while the lender is blocked. It must not - // take the lock, in fact — the lock is fair, so a close() queued behind the - // stream would make the worker wait for a writer that waits for the lender. - // Only this model is lent; any other model's lock is held by nobody. - // - // Invariant: the lend is valid ONLY while the lending thread blocks holding - // the read lock. The worker thread is created per repair and dies right - // after, so the lent state can never leak into unrelated work; but extra - // threads the hook spawns itself do NOT inherit it, take the lock normally, - // and would deadlock against a concurrent close(). Hooks must call the model - // from the thread they are invoked on. - private static final ThreadLocal LENT_READ_HOLD = new ThreadLocal(); - - /** Take the read lock, unless this thread was lent a hold on this model. */ - void acquireRead() { - if (LENT_READ_HOLD.get() != this) { - lock.readLock().lock(); - } - } - - /** Counterpart of {@link #acquireRead()}. */ - void releaseRead() { - if (LENT_READ_HOLD.get() != this) { - lock.readLock().unlock(); - } - } - - /** - * Run {@code body} on this thread with this model's read hold lent to it — - * see {@code LENT_READ_HOLD}. Only call this from a thread whose caller - * blocks holding the read lock for the whole duration. - */ - T withLentReadHold(Callable body) throws Exception { - LENT_READ_HOLD.set(this); - try { - return body.call(); - } finally { - LENT_READ_HOLD.remove(); - } - } - - // Package-private: ProviderHandle.model() (same package) needs to construct - // a Model; external callers can still only go through the static factories - // openai()/provider()/mockReplay(). - Model(long handle) { - this.handle = handle; - } - - /** - * Release the native handle. Idempotent and thread-safe: subsequent calls - * are no-ops, and every other method throws {@link IllegalStateException}. - * - *

Acquires the write lock and therefore blocks until all in-flight - * FFI calls (which hold the read lock) finish before dropping the - * native handle — prevents a use-after-free race between a concurrent - * caller and {@code close()}. A streaming call holds the read lock for the - * entire stream, so {@code close()} blocks until the stream completes. Do - * not call {@code close()} from within a stream callback (would - * self-deadlock). - */ - @Override - public void close() { - lock.writeLock().lock(); - try { - if (closed || handle == 0L) { - return; - } - long h = handle; - handle = 0L; - closed = true; - AimuxFFI.INSTANCE.aimux_drop_handle(h); - } finally { - lock.writeLock().unlock(); - } - } - - /** Best-effort backstop; try-with-resources is the primary release path. */ - @Override - protected void finalize() throws Throwable { - close(); - super.finalize(); - } - - // Caller MUST already hold the read lock (each public FFI method acquires - // it and releases it in a finally after the FFI call returns). Holding the - // read lock across the FFI call is what lets close()'s write lock wait for - // the call to finish, closing the use-after-free race. - private long requireHandleLocked() { - if (closed || handle == 0L) { - throw new IllegalStateException("Model is closed"); - } - return handle; - } - - /** Package-private handle read for composite-model factories (router/moa). */ - long handle() { - acquireRead(); - try { - return requireHandleLocked(); - } finally { - releaseRead(); - } - } - - // ── Provider constructors ────────────────────────────────────────────── - - /** Create an OpenAI model instance. */ - public static Model openai(String apiKey, String modelId) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_openai_new(apiKey, modelId, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create OpenAI model")); - } - - /** Create an OpenAI model instance with a custom base URL. */ - public static Model openaiWithBase(String apiKey, String modelId, String baseUrl) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_openai_new_with_base(apiKey, modelId, baseUrl, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create OpenAI model")); - } - - /** Create an Anthropic model instance. */ - public static Model anthropic(String apiKey, String modelId) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_anthropic_new(apiKey, modelId, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create Anthropic model")); - } - - /** Create an Anthropic model instance with a custom base URL. */ - public static Model anthropicWithBase(String apiKey, String modelId, String baseUrl) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_anthropic_new_with_base(apiKey, modelId, baseUrl, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create Anthropic model")); +/** Typed resource facade; request JSON uses the existing core wire DTO. */ +public final class Model extends NativeHandle { + Model(Aimux aimux, long id) { super(aimux, id); } + public JsonNode info() { return call(AimuxOps.OP_MODEL_INFO, "{}"); } + public JsonNode generate(String paramsJson) { return call(AimuxOps.OP_TEXT_GENERATE, paramsJson); } + public Future generateAsync(String paramsJson, Executor executor) { + return callAsync(AimuxOps.OP_TEXT_GENERATE, paramsJson, executor); } - - /** Create a Cohere model instance. */ - public static Model cohere(String apiKey, String modelId) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_cohere_new(apiKey, modelId, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create Cohere model")); - } - - /** Create a Cohere model instance with a custom base URL. */ - public static Model cohereWithBase(String apiKey, String modelId, String baseUrl) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_cohere_new_with_base(apiKey, modelId, baseUrl, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create Cohere model")); - } - - /** Create a Mistral model instance. */ - public static Model mistral(String apiKey, String modelId) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_mistral_new(apiKey, modelId, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create Mistral model")); - } - - /** Create a Mistral model instance with a custom base URL. */ - public static Model mistralWithBase(String apiKey, String modelId, String baseUrl) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_mistral_new_with_base(apiKey, modelId, baseUrl, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create Mistral model")); - } - - /** Create an xAI model instance. */ - public static Model xai(String apiKey, String modelId) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_xai_new(apiKey, modelId, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create xAI model")); - } - - /** Create an xAI model instance with a custom base URL. */ - public static Model xaiWithBase(String apiKey, String modelId, String baseUrl) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_xai_new_with_base(apiKey, modelId, baseUrl, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create xAI model")); - } - - /** Create a Bedrock model instance (AWS SigV4 credentials). */ - public static Model bedrock(String accessKeyId, String secretAccessKey, String region, String modelId) { - Objects.requireNonNull(accessKeyId, "accessKeyId"); - Objects.requireNonNull(secretAccessKey, "secretAccessKey"); - Objects.requireNonNull(region, "region"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_bedrock_new(accessKeyId, secretAccessKey, region, modelId, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create Bedrock model")); + public JsonNode generateObject(String paramsJson) { return call(AimuxOps.OP_TEXT_GENERATE_OBJECT, paramsJson); } + public Future generateObjectAsync(String paramsJson, Executor executor) { + return callAsync(AimuxOps.OP_TEXT_GENERATE_OBJECT, paramsJson, executor); } - - /** Create a Bedrock model instance with a custom base URL. */ - public static Model bedrockWithBase(String accessKeyId, String secretAccessKey, String region, - String modelId, String baseUrl) { - Objects.requireNonNull(accessKeyId, "accessKeyId"); - Objects.requireNonNull(secretAccessKey, "secretAccessKey"); - Objects.requireNonNull(region, "region"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_bedrock_new_with_base( - accessKeyId, secretAccessKey, region, modelId, baseUrl, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create Bedrock model")); + public JsonNode consume(String paramsJson) { return call(AimuxOps.OP_TEXT_CONSUME, paramsJson); } + public Future consumeAsync(String paramsJson, Executor executor) { + return callAsync(AimuxOps.OP_TEXT_CONSUME, paramsJson, executor); } - - /** Create a Vertex AI model instance (GCP bearer token). */ - public static Model vertex(String accessToken, String project, String location, String modelId) { - Objects.requireNonNull(accessToken, "accessToken"); - Objects.requireNonNull(project, "project"); - Objects.requireNonNull(location, "location"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_vertex_new(accessToken, project, location, modelId, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create Vertex model")); + public JsonNode generateOpenAI(String paramsJson) { return call(AimuxOps.OP_TEXT_GENERATE_OPEN_AI, paramsJson); } + public Future generateOpenAIAsync(String paramsJson, Executor executor) { + return callAsync(AimuxOps.OP_TEXT_GENERATE_OPEN_AI, paramsJson, executor); } - - /** Create a Vertex AI model instance with a custom base URL. */ - public static Model vertexWithBase(String accessToken, String project, String location, - String modelId, String baseUrl) { - Objects.requireNonNull(accessToken, "accessToken"); - Objects.requireNonNull(project, "project"); - Objects.requireNonNull(location, "location"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_vertex_new_with_base( - accessToken, project, location, modelId, baseUrl, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create Vertex model")); + public void stream(String paramsJson, java.util.function.Consumer consumer) { + stream(AimuxOps.OP_TEXT_STREAM, paramsJson, consumer); } - - /** Create an Anthropic-on-AWS model instance (API key + region). */ - public static Model anthropicAws(String apiKey, String region, String modelId) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(region, "region"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_anthropic_aws_new(apiKey, region, modelId, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create Anthropic AWS model")); + public Future streamAsync(String paramsJson, java.util.function.Consumer consumer, + Executor executor) { + return streamAsync(AimuxOps.OP_TEXT_STREAM, paramsJson, consumer, executor); } - - /** Create an Anthropic-on-AWS model instance with a custom base URL. */ - public static Model anthropicAwsWithBase(String apiKey, String region, String modelId, String baseUrl) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(region, "region"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_anthropic_aws_new_with_base(apiKey, region, modelId, baseUrl, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create Anthropic AWS model")); - } - - /** Create an Azure OpenAI model instance (API key + resource name). */ - public static Model azure(String apiKey, String resourceName, String deployment) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(resourceName, "resourceName"); - Objects.requireNonNull(deployment, "deployment"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_azure_new(apiKey, resourceName, deployment, null, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create Azure model")); - } - - /** Create an Azure OpenAI model instance with an explicit api-version. */ - public static Model azureWithVersion(String apiKey, String resourceName, String deployment, - String apiVersion) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(resourceName, "resourceName"); - Objects.requireNonNull(deployment, "deployment"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_azure_new(apiKey, resourceName, deployment, apiVersion, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create Azure model")); - } - - /** Create an Azure OpenAI model instance with a custom base URL. */ - public static Model azureWithBase(String apiKey, String baseUrl, String deployment) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(baseUrl, "baseUrl"); - Objects.requireNonNull(deployment, "deployment"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_azure_new_with_base(apiKey, baseUrl, deployment, null, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create Azure model")); - } - - /** - * Create a model from the provider registry by name (RFC-0017 phase 4). - * - * @param name Registry provider name (e.g. "deepseek", "groq"). - * @param apiKey API key, or {@code null} to read the provider's env var - * from the registry entry. - * @param modelId Model id. - * @param configJson Optional JSON object of ProviderOptions - * ({@code {"baseUrl": "...", "headers": {...}, "organization": "...", - * "project": "...", "params": {...}}}); {@code null} for defaults. - * {@code maxRetries} (a per-call option) and {@code bodyOverrides} - * (removed) are rejected as {@link AimuxException.InvalidArgumentError}. - * @return A new {@link Model}. - * @throws AimuxException if the provider could not be constructed - * (unknown provider, bad config, missing env key). - */ - public static Model provider(String name, String apiKey, String modelId, String configJson) { - Objects.requireNonNull(name, "name"); - Objects.requireNonNull(modelId, "modelId"); - AimuxResult.requireJson(configJson, "configJson"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_provider_new(name, apiKey, modelId, configJson, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create provider model: " + name)); - } - - /** - * Create a model from the provider registry, reading the API key from the - * provider's env var (RFC-0017 phase 4). - */ - public static Model providerFromEnv(String name, String modelId) { - Objects.requireNonNull(name, "name"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_provider_from_env(name, modelId, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create provider model from env: " + name)); - } - - /** - * Create a provider handle (RFC-0027) for a registry-backed provider. - * - *

Unlike {@link #provider(String, String, String, String)} (which binds to - * a single modelId), this returns a {@link ProviderHandle} that supports - * {@link ProviderHandle#listModels()} and {@link ProviderHandle#model(String)}. - */ - public static ProviderHandle createProvider(String name, String apiKey, String configJson) { - Objects.requireNonNull(name, "name"); - AimuxResult.requireJson(configJson, "configJson"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_provider_handle_new(name, apiKey, configJson, out); - return new ProviderHandle(AimuxResult.extractHandle(e, out, "Failed to create provider handle: " + name)); - } - - /** - * Fetch the community model catalogue (anya2a). Returns a JSON-serialized - * Catalogue string. Thin fetch — no caching. - * - * @param sourceUrl Optional URL override (null = default endpoint). - */ - public static String getModelSpecs(String sourceUrl) { - PointerByReference out = new PointerByReference(); - return AimuxResult.extractString( - AimuxFFI.INSTANCE.aimux_get_model_specs(sourceUrl, out), - out, - "get_model_specs"); - } - - /** - * Create a DeepSeek model instance. - * - *

The retired {@code aimux_deepseek_new} C symbol has been removed; this - * now routes through {@link #provider(String, String, String, String)} with - * the registry name {@code "deepseek"} (RFC-0017 phase 4). - */ - public static Model deepseek(String apiKey, String modelId) { - return provider("deepseek", apiKey, modelId, null); - } - - /** - * Create a mock replay model from recorded JSONL (RFC-0023). The returned - * model's {@code generateText} / {@code streamText} calls replay recorded - * responses from {@code recordingsJsonl} (one Recording per line) instead of - * sending real API requests. - * - * @param recordingsJsonl Recorded JSONL (one Recording per line). - * @return A new {@link Model} backed by the replay handle. - * @throws AimuxException if the mock replay model could not be constructed. - */ - public static Model mockReplay(String recordingsJsonl) { - Objects.requireNonNull(recordingsJsonl, "recordingsJsonl"); - for (String line : recordingsJsonl.split("\n")) { - if (!line.trim().isEmpty()) { - AimuxResult.requireJson(line, "recordingsJsonl"); - } - } - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_mock_replay_new(recordingsJsonl, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create mock replay model")); - } - - // ── Composite models (RFC-0021 / RFC-0022) ────────────────────────────── - - /** - * Create a RouterModel (RFC-0021) over the given child models. The returned - * model routes each call to one child and falls back across the rest on - * error (per {@code configJson}). - * - * @param models child models (must be non-empty; closed models throw). - * @param configJson optional config: {@code {"router": "rule"|"weighted", - * "weights": [...], "fallback": "on_error"|"none", - * "providerName", "modelId"}} — all optional. - * @return a new RouterModel wrapping the children. - */ - public static Model router(java.util.List models, String configJson) { - AimuxResult.requireJson(configJson, "configJson"); - if (models == null || models.isEmpty()) { - throw new IllegalArgumentException("router: models must be non-empty"); - } - long[] handles = new long[models.size()]; - for (int i = 0; i < models.size(); i++) { - handles[i] = models.get(i).handle(); - } - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_router_new(handles, handles.length, configJson, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create router model")); - } - - /** - * Create a MoaModel (RFC-0022) over reference models + one aggregator. - * References fan out in parallel, then the aggregator synthesizes a final - * answer. - * - * @param references reference models (may be null/empty — runs aggregator only). - * @param aggregator the aggregator model (must be non-null and open). - * @param configJson optional MoaConfig: {@code {"providerName", "modelId", - * "aggregatorInstructions", "stripReferenceTools", - * "failMode": "best-effort"|"fail-fast"}}. - * @return a new MoaModel. - */ - public static Model moa(java.util.List references, Model aggregator, String configJson) { - AimuxResult.requireJson(configJson, "configJson"); - if (aggregator == null) { - throw new IllegalArgumentException("moa: aggregator must be non-null"); - } - long[] refHandles; - if (references == null || references.isEmpty()) { - refHandles = new long[0]; - } else { - refHandles = new long[references.size()]; - for (int i = 0; i < references.size(); i++) { - refHandles[i] = references.get(i).handle(); - } - } - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_moa_new( - refHandles, refHandles.length, aggregator.handle(), configJson, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create moa model")); - } - - // ── Generation ───────────────────────────────────────────────────────── - - /** - * Generate text (non-streaming). - * - * @param promptJson JSON prompt string (bare value or {@code {"prompt": ...}}). - * @param optsJson Optional JSON-serialized options, or {@code null} for defaults. - * @return JSON-serialized result. - * @throws AimuxException on Aimux / transport failure. - */ - public String generateText(String promptJson, String optsJson) { - AimuxResult.requireJsonNonNull(promptJson, "promptJson"); - AimuxResult.requireJson(optsJson, "optsJson"); - acquireRead(); - try { - long h = requireHandleLocked(); - PointerByReference out = new PointerByReference(); - return AimuxResult.extractString( - AimuxFFI.INSTANCE.aimux_generate_text(h, promptJson, optsJson, out), - out, - "generate_text"); - } finally { - releaseRead(); - } - } - - /** Generate text with default options. */ - public String generateText(String promptJson) { - return generateText(promptJson, null); - } - - /** - * Generate a structured JSON object (M12, RFC-0016). - * - *

Same signature as {@link #generateText}; returns a JSON-serialized - * {@code GenerateObjectResult}. Pass {@code "output": {"type": "object", "schema": ...}} - * via {@code optsJson} to describe the object; the model's text must parse - * as JSON and match the schema. - * - * @param promptJson JSON prompt string (bare value or {@code {"prompt": ...}}). - * @param optsJson Optional JSON-serialized options, or {@code null} for defaults. - * @return JSON-serialized GenerateObjectResult. - * @throws AimuxException on Aimux / transport failure. - */ - public String generateObject(String promptJson, String optsJson) { - AimuxResult.requireJsonNonNull(promptJson, "promptJson"); - AimuxResult.requireJson(optsJson, "optsJson"); - acquireRead(); - try { - long h = requireHandleLocked(); - PointerByReference out = new PointerByReference(); - return AimuxResult.extractString( - AimuxFFI.INSTANCE.aimux_generate_object(h, promptJson, optsJson, out), - out, - "generate_object"); - } finally { - releaseRead(); - } - } - - /** Generate a structured JSON object with default options. */ - public String generateObject(String promptJson) { - return generateObject(promptJson, null); - } - - /** - * Consume a stream to completion and return the aggregated result - * (M11, RFC-0016). Synchronous (blocks until the stream finishes). - * - *

Same signature as {@link #generateText}; returns a JSON-serialized - * {@code GenerateTextResult}. - * - * @param promptJson JSON prompt string (bare value or {@code {"prompt": ...}}). - * @param optsJson Optional JSON-serialized options, or {@code null} for defaults. - * @return JSON-serialized GenerateTextResult. - * @throws AimuxException on Aimux / transport failure. - */ - public String consumeStreamText(String promptJson, String optsJson) { - AimuxResult.requireJsonNonNull(promptJson, "promptJson"); - AimuxResult.requireJson(optsJson, "optsJson"); - acquireRead(); - try { - long h = requireHandleLocked(); - PointerByReference out = new PointerByReference(); - return AimuxResult.extractString( - AimuxFFI.INSTANCE.aimux_consume_stream_text(h, promptJson, optsJson, out), - out, - "consume_stream_text"); - } finally { - releaseRead(); - } - } - - /** Consume a stream to completion with default options. */ - public String consumeStreamText(String promptJson) { - return consumeStreamText(promptJson, null); - } - - /** - * Stream text from the model. Blocks the calling thread until the stream - * completes (same contract as the Kotlin/Go bindings). - * - *

The JNA {@code Callback} proxies are held in local variables for the - * duration of the native call so the JVM cannot GC them mid-stream. - * Callbacks run on the calling thread; re-entering a generate / stream - * call from inside a callback fails fast with - * {@code AIMUX_E_FFI_REENTRANT_CALL} (204). - * - *

C ABI has no {@code on_error} callback — terminal failures throw - * {@link AimuxException} after the blocking call returns. - * - * @param promptJson JSON prompt string. - * @param optsJson Optional JSON-serialized options, or {@code null} for defaults. - * @param onPart Called for each stream part (JSON string). - * @param onDone Called once when the stream ends normally. - * @throws AimuxException when the stream fails. - */ - public void streamText(String promptJson, String optsJson, - final Consumer onPart, - final Runnable onDone) { - AimuxResult.requireJsonNonNull(promptJson, "promptJson"); - AimuxResult.requireJson(optsJson, "optsJson"); - // JNA callbacks — must be held in local variables to prevent GC. - // C signatures: on_part(json, stream_ctx), on_done(stream_ctx). - final AimuxFFI.StreamPartCallback partCb = new AimuxFFI.StreamPartCallback() { - @Override - public void invoke(Pointer jsonPtr, Pointer streamCtx) { - if (jsonPtr != null) { - onPart.accept(jsonPtr.getString(0, "UTF-8")); - } - } - }; - final AimuxFFI.StreamDoneCallback doneCb = new AimuxFFI.StreamDoneCallback() { - @Override - public void invoke(Pointer streamCtx) { - onDone.run(); - } - }; - - // Hold the read lock for the whole blocking stream so close() cannot - // drop the handle mid-stream (it blocks on the write lock until the - // stream completes). - acquireRead(); - try { - long h = requireHandleLocked(); - Pointer e = AimuxFFI.INSTANCE.aimux_stream_text( - h, promptJson, optsJson, partCb, doneCb, null); - if (e != null) { - // Prefer throw over onError for terminal C failures. - throw AimuxResult.expectAimuxError(e, null); - } - } finally { - releaseRead(); - } - } - - /** - * Stream text as a lazy {@link Stream} of stream-part JSON strings. - * - *

The FFI call starts on the first terminal operation of the returned - * stream (mirror of Kotlin's {@code streamTextSequence}). Iteration pulls - * parts from a {@link LinkedBlockingQueue} fed by the stream callbacks; - * the stream ends at the sentinel. Recoverable frame errors arrive as - * {@code StreamPart.Error} data parts and the stream continues; only - * transport/Core failures throw {@link AimuxException} from the blocking - * FFI call. - * - *

{@code
-     * model.streamTextStream("\"Write a haiku\"").forEach(System.out::println);
-     * }
- * - * @param promptJson JSON prompt string. - * @param optsJson Optional JSON-serialized options, or {@code null} for defaults. - */ - public Stream streamTextStream(final String promptJson, final String optsJson) { - AimuxResult.requireJsonNonNull(promptJson, "promptJson"); - AimuxResult.requireJson(optsJson, "optsJson"); - // Sentinel for end-of-stream. Java's LinkedBlockingQueue rejects null - // elements, so use a unique object instead of Kotlin's null sentinel. - final Object END = new Object(); - return StreamSupport.stream( - new java.util.Spliterators.AbstractSpliterator( - Long.MAX_VALUE, Spliterator.ORDERED) { - private final LinkedBlockingQueue parts = - new LinkedBlockingQueue<>(); - private final AtomicBoolean started = new AtomicBoolean(false); - private boolean exhausted; - - @Override - public boolean tryAdvance(Consumer action) { - if (!started.getAndSet(true)) { - try { - streamText(promptJson, optsJson, - parts::add, - () -> parts.add(END)); - } catch (RuntimeException e) { - parts.add(END); // prevent a hang on later iteration - throw e; - } - } - if (exhausted) { - return false; - } - final Object part; - try { - part = parts.take(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException("interrupted while streaming", e); - } - if (part == END) { - exhausted = true; - return false; - } - action.accept((String) part); - return true; - } - }, - false); - } - - /** Stream text with default options. */ - public Stream streamTextStream(final String promptJson) { - return streamTextStream(promptJson, null); - } - - // ── OpenAI-compatible output (RFC-0026) ───────────────────────────────── - - /** - * Generate text (non-streaming) with OpenAI Chat Completions output. - * - * @param promptJson JSON prompt string (bare value or {@code {"prompt": ...}}). - * @param optsJson Optional JSON-serialized options, or {@code null} for defaults. - * @return JSON-serialized ChatCompletion. - * @throws AimuxException on Aimux / transport failure. - */ - public String generateTextAsOpenAI(String promptJson, String optsJson) { - AimuxResult.requireJsonNonNull(promptJson, "promptJson"); - AimuxResult.requireJson(optsJson, "optsJson"); - acquireRead(); - try { - long h = requireHandleLocked(); - PointerByReference out = new PointerByReference(); - return AimuxResult.extractString( - AimuxFFI.INSTANCE.aimux_generate_text_as_openai(h, promptJson, optsJson, out), - out, - "generate_text_as_openai"); - } finally { - releaseRead(); - } - } - - /** - * Convert a serialized GenerateTextResult into a serialized ChatCompletion - * — the conversion half of {@link #generateTextAsOpenAI(String, String)}. - * - *

For repairing on the host (RFC-0035): patch the native result, then - * convert it, so the completion's tool calls are the repaired ones. This - * model only supplies the fallback model id; nothing is generated. - * - * @param resultJson JSON-serialized GenerateTextResult. - * @return JSON-serialized ChatCompletion. - * @throws AimuxException if {@code resultJson} is not a GenerateTextResult. - */ - public String generateTextResultAsOpenAI(String resultJson) { - AimuxResult.requireJsonNonNull(resultJson, "resultJson"); - acquireRead(); - try { - long h = requireHandleLocked(); - PointerByReference out = new PointerByReference(); - return AimuxResult.extractString( - AimuxFFI.INSTANCE.aimux_generate_text_result_as_openai(h, resultJson, out), - out, - "generate_text_result_as_openai"); - } finally { - releaseRead(); - } - } - - /** Generate text with OpenAI output and default options. */ - public String generateTextAsOpenAI(String promptJson) { - return generateTextAsOpenAI(promptJson, null); - } - - /** - * Stream text from the model with OpenAI Chat Completions output. Blocks the - * calling thread until the stream completes. Each {@code onPart} receives a - * serialized ChatCompletionChunk (OpenAI "chat.completion.chunk" object). - * - * @param promptJson JSON prompt string. - * @param optsJson Optional JSON-serialized options, or {@code null} for defaults. - * @param onPart Called for each ChatCompletionChunk (JSON string). - * @param onDone Called once when the stream ends normally. - * @throws AimuxException when the stream fails. - */ - public void streamTextAsOpenAI(String promptJson, String optsJson, - final Consumer onPart, - final Runnable onDone) { - AimuxResult.requireJsonNonNull(promptJson, "promptJson"); - AimuxResult.requireJson(optsJson, "optsJson"); - final AimuxFFI.StreamPartCallback partCb = new AimuxFFI.StreamPartCallback() { - @Override - public void invoke(Pointer jsonPtr, Pointer streamCtx) { - if (jsonPtr != null) { - onPart.accept(jsonPtr.getString(0, "UTF-8")); - } - } - }; - final AimuxFFI.StreamDoneCallback doneCb = new AimuxFFI.StreamDoneCallback() { - @Override - public void invoke(Pointer streamCtx) { - onDone.run(); - } - }; - - // Hold the read lock for the whole blocking stream so close() cannot - // drop the handle mid-stream. - acquireRead(); - try { - long h = requireHandleLocked(); - Pointer e = AimuxFFI.INSTANCE.aimux_stream_text_as_openai( - h, promptJson, optsJson, partCb, doneCb, null); - if (e != null) { - throw AimuxResult.expectAimuxError(e, null); - } - } finally { - releaseRead(); - } - } - - /** - * Stream text with OpenAI Chat Completions output as a lazy {@link Stream} - * of ChatCompletionChunk JSON strings (RFC-0026). - * - * @param promptJson JSON prompt string. - * @param optsJson Optional JSON-serialized options, or {@code null} for defaults. - */ - public Stream streamTextAsOpenAIStream(final String promptJson, final String optsJson) { - AimuxResult.requireJsonNonNull(promptJson, "promptJson"); - AimuxResult.requireJson(optsJson, "optsJson"); - final Object END = new Object(); - return StreamSupport.stream( - new java.util.Spliterators.AbstractSpliterator( - Long.MAX_VALUE, Spliterator.ORDERED) { - private final LinkedBlockingQueue parts = new LinkedBlockingQueue<>(); - private final AtomicBoolean started = new AtomicBoolean(false); - private boolean exhausted; - - @Override - public boolean tryAdvance(Consumer action) { - if (!started.getAndSet(true)) { - try { - streamTextAsOpenAI(promptJson, optsJson, - parts::add, - () -> parts.add(END)); - } catch (RuntimeException e) { - parts.add(END); - throw e; - } - } - if (exhausted) { - return false; - } - final Object part; - try { - part = parts.take(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException("interrupted while streaming", e); - } - if (part == END) { - exhausted = true; - return false; - } - action.accept((String) part); - return true; - } - }, - false); - } - - /** Stream text with OpenAI output and default options. */ - public Stream streamTextAsOpenAIStream(final String promptJson) { - return streamTextAsOpenAIStream(promptJson, null); + public void streamOpenAI(String paramsJson, java.util.function.Consumer consumer) { + stream(AimuxOps.OP_TEXT_STREAM_OPEN_AI, paramsJson, consumer); } } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/MultimodalTypes.java b/bindings/java/src/main/java/ai/arcships/aimux/MultimodalTypes.java index f32f8fa2a..d6671ce1a 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/MultimodalTypes.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/MultimodalTypes.java @@ -17,7 +17,6 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.module.SimpleModule; -import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; diff --git a/bindings/java/src/main/java/ai/arcships/aimux/NativeException.java b/bindings/java/src/main/java/ai/arcships/aimux/NativeException.java new file mode 100644 index 000000000..30e4fb80f --- /dev/null +++ b/bindings/java/src/main/java/ai/arcships/aimux/NativeException.java @@ -0,0 +1,24 @@ +package ai.arcships.aimux; + +import com.fasterxml.jackson.databind.JsonNode; +import java.math.BigInteger; + +/** Structured error envelope, retaining the complete AI SDK or domain payload. */ +public abstract class NativeException extends RuntimeException { + private final JsonNode envelope; + + NativeException(JsonNode envelope) { + super(envelope.path("message").asText()); + this.envelope = envelope.deepCopy(); + } + + public final String getKind() { return envelope.path("kind").asText(); } + public final int getCode() { return envelope.path("code").intValue(); } + public final String getErrorName() { return envelope.path("error").path("name").asText(); } + public final JsonNode getError() { return envelope.path("error").deepCopy(); } + public final JsonNode getEnvelope() { return envelope.deepCopy(); } + public final BigInteger getRetryAfterMs() { + JsonNode hint = envelope.get("retryAfterMs"); + return hint == null ? null : hint.bigIntegerValue(); + } +} diff --git a/bindings/java/src/main/java/ai/arcships/aimux/NativeFuture.java b/bindings/java/src/main/java/ai/arcships/aimux/NativeFuture.java new file mode 100644 index 000000000..8f7b8d29c --- /dev/null +++ b/bindings/java/src/main/java/ai/arcships/aimux/NativeFuture.java @@ -0,0 +1,86 @@ +package ai.arcships.aimux; + +import ai.arcships.aimux.internal.Cancellation; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; + +/** Native execution retains its admissions until the executor task returns. */ +final class NativeFuture implements Future { + private final Future task; + + NativeFuture(Aimux aimux, NativeHandle.Lease lease, Abort abort, String op, + String params, Consumer consumer, Executor executor) { + Work work = new Work(aimux, lease, abort, op, params, consumer); + try { + task = Cancellation.submit(command -> executor.execute(() -> work.run(command)), work, work::cancel); + } catch (RuntimeException | Error error) { + work.cancel(); + throw error; + } + } + + // Thread interruption cannot stop a synchronous native call; always signal native Abort. + @Override public boolean cancel(boolean mayInterrupt) { return task.cancel(false); } + @Override public boolean isCancelled() { return task.isCancelled(); } + @Override public boolean isDone() { return task.isDone(); } + @Override public JsonNode get() throws InterruptedException, ExecutionException { return task.get(); } + @Override public JsonNode get(long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + return task.get(timeout, unit); + } + + private static final class Work implements Callable { + private final Aimux aimux; + private final NativeHandle.Lease lease; + private final Abort abort; + private final String op; + private final String params; + private final Consumer consumer; + private boolean started; + private boolean cleaned; + + Work(Aimux aimux, NativeHandle.Lease lease, Abort abort, String op, String params, + Consumer consumer) { + this.aimux = aimux; this.lease = lease; this.abort = abort; + this.op = op; this.params = params; this.consumer = consumer; + } + + void run(Runnable command) { + synchronized (this) { + if (cleaned || started) return; + started = true; + } + try { command.run(); } + finally { cleanup(); } + } + + synchronized void cancel() { + // Completion can release the admissions between Future.cancel and this action. + if (cleaned) return; + try { abort.signal(); } + finally { if (!started) cleanup(); } + } + + private synchronized void cleanup() { + if (cleaned) return; + cleaned = true; + try { abort.close(); } + finally { if (lease != null) lease.close(); } + } + + @Override public JsonNode call() { + try (NativeHandle.Lease signal = abort.admit()) { + long handle = lease == null ? 0 : lease.id; + if (consumer == null) return aimux.call(handle, op, params, signal.id); + aimux.stream(handle, op, params, signal.id, consumer); + return Types.AimuxJson.MAPPER.nullNode(); + } + } + } +} diff --git a/bindings/java/src/main/java/ai/arcships/aimux/NativeHandle.java b/bindings/java/src/main/java/ai/arcships/aimux/NativeHandle.java new file mode 100644 index 000000000..5f4b3b0f2 --- /dev/null +++ b/bindings/java/src/main/java/ai/arcships/aimux/NativeHandle.java @@ -0,0 +1,149 @@ +package ai.arcships.aimux; + +import com.sun.jna.Pointer; +import java.lang.ref.PhantomReference; +import java.lang.ref.ReferenceQueue; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** A short admission lock; no monitor is held across native calls or callbacks. */ +abstract class NativeHandle implements AutoCloseable { + final Aimux aimux; + private final long id; + private final Cleanup cleanup; + private int active; + private boolean closed; + + NativeHandle(Aimux aimux, long id) { + if (id == 0) throw new IllegalArgumentException("Resource handle cannot be zero"); + this.aimux = java.util.Objects.requireNonNull(aimux, "aimux"); + this.id = id; + cleanup = new Cleanup(this, aimux.ffi, id); + } + + public final String getHandle() { return Long.toUnsignedString(id); } + + final synchronized Lease admit() { + if (closed) throw new IllegalStateException("Resource is closed"); + active++; + return new Lease(this, id); + } + + private void release() { + boolean drop; + synchronized (this) { drop = --active == 0 && closed; } + if (drop) aimux.drop(id); + } + + /** Calls an operation using its manifest request DTO unchanged. */ + public final com.fasterxml.jackson.databind.JsonNode call(String op, String paramsJson) { + String params = AimuxResult.params(paramsJson); + try (Lease lease = admit(); Abort abort = aimux.createAbort(); Lease signal = abort.admit()) { + return aimux.call(lease.id, op, params, signal.id); + } + } + + public final com.fasterxml.jackson.databind.JsonNode call(String op, String paramsJson, Abort abort) { + java.util.Objects.requireNonNull(abort, "abort"); + if (abort.aimux != aimux) throw new IllegalArgumentException("Abort belongs to another library"); + String params = AimuxResult.params(paramsJson); + try (Lease lease = admit(); Lease signal = abort.admit()) { + return aimux.call(lease.id, op, params, signal.id); + } + } + + public final java.util.concurrent.Future callAsync( + String op, String paramsJson, java.util.concurrent.Executor executor) { + return aimux.submit(this, op, paramsJson, null, executor); + } + + /** Consumer runs synchronously, providing backpressure. It must return in bounded time. */ + public final void stream(String op, String paramsJson, java.util.function.Consumer consumer) { + String params = AimuxResult.params(paramsJson); + try (Lease lease = admit(); Abort abort = aimux.createAbort(); Lease signal = abort.admit()) { + aimux.stream(lease.id, op, params, signal.id, consumer); + } + } + + public final void stream(String op, String paramsJson, Abort abort, + java.util.function.Consumer consumer) { + java.util.Objects.requireNonNull(abort, "abort"); + if (abort.aimux != aimux) throw new IllegalArgumentException("Abort belongs to another library"); + String params = AimuxResult.params(paramsJson); + try (Lease lease = admit(); Lease signal = abort.admit()) { + aimux.stream(lease.id, op, params, signal.id, consumer); + } + } + + public final java.util.concurrent.Future streamAsync( + String op, String paramsJson, java.util.function.Consumer consumer, + java.util.concurrent.Executor executor) { + java.util.Objects.requireNonNull(consumer, "consumer"); + return aimux.submit(this, op, paramsJson, consumer, executor); + } + + /** Transfers ownership of a newly returned child handle. */ + public final Resource create(String op, String paramsJson) { + return new Resource(aimux, AimuxResult.handle(call(op, paramsJson))); + } + + /** Stops admission immediately; releases after the last admitted call, without cancelling it. */ + @Override public final void close() { + boolean drop; + synchronized (this) { + if (closed) return; + closed = true; + cleanup.disarm(); + drop = active == 0; + } + if (drop) aimux.drop(id); + } + + private static final class Cleanup extends PhantomReference { + private static final ReferenceQueue QUEUE = new ReferenceQueue<>(); + private static final Set LIVE = ConcurrentHashMap.newKeySet(); + static { + Thread reaper = new Thread(() -> { + while (true) { + try { + Cleanup cleanup = (Cleanup) QUEUE.remove(); + long id = cleanup.id.getAndSet(0); + cleanup.disarm(); + if (id != 0) cleanup.ffi.aimux_handle_finalizer(Pointer.createConstant(id)); + } catch (InterruptedException | RuntimeException | LinkageError ignored) { + // A failed cleanup must not disable the safety net for other handles. + } + } + }, "aimux-handle-finalizer"); + reaper.setDaemon(true); + reaper.start(); + } + private final AimuxFFI ffi; + private final AtomicLong id; + + Cleanup(NativeHandle handle, AimuxFFI ffi, long id) { + super(handle, QUEUE); + this.ffi = ffi; + this.id = new AtomicLong(id); + LIVE.add(this); + } + + void disarm() { + id.set(0); + LIVE.remove(this); + clear(); + } + } + + static final class Lease implements AutoCloseable { + private NativeHandle resource; + final long id; + Lease(NativeHandle resource, long id) { this.resource = resource; this.id = id; } + @Override public void close() { + NativeHandle value; + synchronized (this) { value = resource; resource = null; } + if (value != null) value.release(); + } + } +} diff --git a/bindings/java/src/main/java/ai/arcships/aimux/ProtocolException.java b/bindings/java/src/main/java/ai/arcships/aimux/ProtocolException.java new file mode 100644 index 000000000..331c5952d --- /dev/null +++ b/bindings/java/src/main/java/ai/arcships/aimux/ProtocolException.java @@ -0,0 +1,46 @@ +package ai.arcships.aimux; + +import com.fasterxml.jackson.databind.JsonNode; + +/** Protocol domain errors from the RFC-0039 envelope. */ +public abstract class ProtocolException extends NativeException { + ProtocolException(JsonNode envelope) { super(envelope); } + static ProtocolException decode(JsonNode envelope) { + switch (envelope.path("error").path("name").asText()) { + case "InvalidOp": return new InvalidOp(envelope); + case "InvalidParams": return new InvalidParams(envelope); + case "UnsupportedOpsVersion": return new UnsupportedOpsVersion(envelope); + case "ConcurrentConsumer": return new ConcurrentConsumer(envelope); + case "InvalidState": return new InvalidState(envelope); + case "ResourceLimit": return new ResourceLimit(envelope); + case "HandleExhausted": return new HandleExhausted(envelope); + default: return new Unknown(envelope); + } + } + + public static final class InvalidOp extends ProtocolException { + InvalidOp(JsonNode envelope) { super(envelope); } + } + public static final class InvalidParams extends ProtocolException { + InvalidParams(JsonNode envelope) { super(envelope); } + } + public static final class UnsupportedOpsVersion extends ProtocolException { + UnsupportedOpsVersion(JsonNode envelope) { super(envelope); } + } + public static final class ConcurrentConsumer extends ProtocolException { + ConcurrentConsumer(JsonNode envelope) { super(envelope); } + } + public static final class InvalidState extends ProtocolException { + InvalidState(JsonNode envelope) { super(envelope); } + } + public static final class ResourceLimit extends ProtocolException { + ResourceLimit(JsonNode envelope) { super(envelope); } + } + public static final class HandleExhausted extends ProtocolException { + HandleExhausted(JsonNode envelope) { super(envelope); } + } + /** An unrecognized variant, retaining its complete envelope. */ + public static final class Unknown extends ProtocolException { + Unknown(JsonNode envelope) { super(envelope); } + } +} diff --git a/bindings/java/src/main/java/ai/arcships/aimux/ProviderHandle.java b/bindings/java/src/main/java/ai/arcships/aimux/ProviderHandle.java index 4f1742ffb..2c3948998 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/ProviderHandle.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/ProviderHandle.java @@ -1,132 +1,29 @@ package ai.arcships.aimux; -import com.sun.jna.Pointer; -import com.sun.jna.ptr.LongByReference; -import com.sun.jna.ptr.PointerByReference; -import java.util.Objects; -import java.util.concurrent.locks.ReentrantReadWriteLock; +import ai.arcships.aimux.generated.AimuxOps; -/** - * A provider handle — created by {@link Model#createProvider}, supports - * {@link #listModels()} (runtime discovery) and {@link #model(String)} (build a - * model from a discovered id). - * - *

Implements {@link AutoCloseable}; the native handle is released on - * {@link #close()}. Use try-with-resources or call {@code close()} explicitly. - * - *

Thread-safety / concurrency. Safe for concurrent use. The native - * handle is guarded by a Go-style {@link ReentrantReadWriteLock} (fair, FIFO): - * {@link #listModels()} and {@link #model(String)} hold the read lock - * for the entire FFI call, and {@link #close()} takes the write lock. - * {@code close()} therefore blocks until in-flight calls finish before dropping - * the native handle — closes the check-then-use use-after-free race where a - * caller could pass the closed-flag check and then race with {@code close()}'s - * drop. - */ -public class ProviderHandle implements AutoCloseable { +import com.fasterxml.jackson.databind.JsonNode; - // Go-style read/write lock — see Model for the rationale. Each FFI call - // (listModels/model) holds the read lock for its whole duration; close() - // takes the write lock and waits for in-flight calls before dropping the - // handle, preventing a use-after-free. - private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true); - private long handle; - private boolean closed; +/** Provider resource. Provider configuration is data, passed unchanged to provider.create. */ +public final class ProviderHandle extends NativeHandle { + ProviderHandle(Aimux aimux, long id) { super(aimux, id); } - ProviderHandle(long handle) { - this.handle = handle; - } - - /** - * Release the native handle. Idempotent and thread-safe: subsequent calls - * are no-ops. Acquires the write lock and blocks until in-flight - * {@link #listModels()} / {@link #model(String)} calls finish before - * dropping the native handle (prevents use-after-free). - */ - @Override - public void close() { - lock.writeLock().lock(); - try { - if (closed || handle == 0) { - return; - } - long h = handle; - handle = 0; - closed = true; - AimuxFFI.INSTANCE.aimux_drop_handle(h); - } finally { - lock.writeLock().unlock(); - } - } - - @Override - protected void finalize() { - close(); - } - - // Caller MUST already hold the read lock; held across the FFI call so - // close() cannot drop the handle mid-call. - private long requireHandleLocked() { - if (closed || handle == 0) { - throw new IllegalStateException("ProviderHandle is closed"); - } - return handle; - } - - /** - * List models available on this provider (runtime discovery via the - * provider's {@code /models} endpoint), enriched with community knowledge - * (anya2a) when available. - * - * @return a JSON array of ResolvedModel - * @throws IllegalStateException if this handle is closed - * @throws AimuxException on engine / transport failure - */ - public String listModels() { - lock.readLock().lock(); - try { - long h = requireHandleLocked(); - PointerByReference out = new PointerByReference(); - return AimuxResult.extractString( - AimuxFFI.INSTANCE.aimux_provider_list_models(h, out), - out, - "list_models"); - } finally { - lock.readLock().unlock(); - } - } - - /** - * Build a language model from a discovered model id. - * - * @param modelId the model id (e.g. from {@link #listModels()}) - * @return a new {@link Model} - * @throws IllegalStateException if this handle is closed - * @throws AimuxException on construction failure - */ - public Model model(String modelId) { - Objects.requireNonNull(modelId, "modelId"); - lock.readLock().lock(); - try { - long h = requireHandleLocked(); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_provider_model(h, modelId, out); - return new Model(AimuxResult.extractHandle(e, out, "Failed to create model: " + modelId)); - } finally { - lock.readLock().unlock(); - } - } - /** Build a decision model using this provider's connection configuration. */ - public DecisionModel decisionModel(String modelId) { - Objects.requireNonNull(modelId, "modelId"); - lock.readLock().lock(); - try { - LongByReference out = new LongByReference(); - Pointer error = AimuxFFI.INSTANCE.aimux_provider_decision_model(requireHandleLocked(), modelId, out); - return new DecisionModel(AimuxResult.extractHandle(error, out, "decision model")); - } finally { - lock.readLock().unlock(); - } + private long modelId(String method, String modelId) { + com.fasterxml.jackson.databind.node.ObjectNode request = Types.AimuxJson.MAPPER.createObjectNode(); + request.put("method", method); + request.put("modelId", java.util.Objects.requireNonNull(modelId, "modelId")); + return AimuxResult.handle(call(AimuxOps.OP_PROVIDER_MODEL, request.toString())); } + public Model languageModel(String modelId) { return new Model(aimux, modelId("languageModel", modelId)); } + public EmbeddingModel embeddingModel(String modelId) { return new EmbeddingModel(aimux, modelId("embeddingModel", modelId)); } + public ImageModel imageModel(String modelId) { return new ImageModel(aimux, modelId("imageModel", modelId)); } + public SpeechModel speechModel(String modelId) { return new SpeechModel(aimux, modelId("speechModel", modelId)); } + public TranscriptionModel transcriptionModel(String modelId) { return new TranscriptionModel(aimux, modelId("transcriptionModel", modelId)); } + public RerankingModel rerankingModel(String modelId) { return new RerankingModel(aimux, modelId("rerankingModel", modelId)); } + public VideoModel videoModel(String modelId) { return new VideoModel(aimux, modelId("videoModel", modelId)); } + public SearchModel searchModel(String modelId) { return new SearchModel(aimux, modelId("searchModel", modelId)); } + public DecisionModel decisionModel(String modelId) { return new DecisionModel(aimux, modelId("decision", modelId)); } + public Files files() { return new Files(aimux, AimuxResult.handle(call(AimuxOps.OP_PROVIDER_FILES, "{}"))); } + public JsonNode invoke(String paramsJson) { return call(AimuxOps.OP_PROVIDER_INVOKE, paramsJson); } } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/RecordingErrorCode.java b/bindings/java/src/main/java/ai/arcships/aimux/RecordingErrorCode.java deleted file mode 100644 index 48d29bc83..000000000 --- a/bindings/java/src/main/java/ai/arcships/aimux/RecordingErrorCode.java +++ /dev/null @@ -1,36 +0,0 @@ -package ai.arcships.aimux; - -/** - * Recording (RFC-0023) failure codes. Java keeps the six variants independent - * of their C transport values 100–105. Only {@link #WRITER_GONE}, {@link #FLUSH_TIMEOUT} - * and {@link #WRITE} are reachable from {@link Aimux#recordingTryFlush()}. - */ -public enum RecordingErrorCode { - /** {@code create_dir_all} failed. */ - INIT, - /** Opening {@code recordings.jsonl} failed. */ - OPEN_FILE, - /** Writer thread could not be spawned. */ - SPAWN, - /** Writer thread unavailable. */ - WRITER_GONE, - /** No writer ack within 30s. */ - FLUSH_TIMEOUT, - /** A prior write failed (sticky). */ - WRITE; - - /** - * Map a C {@code aimux_error_code_t} (100–105). Any other value is an ABI - * contract violation, not a recording-error variant. - */ - static RecordingErrorCode fromC(int code) { - if (!isCCode(code)) { - throw new IllegalStateException("Unknown aimux_error_code_t: " + code); - } - return values()[code - 100]; - } - - static boolean isCCode(int code) { - return code >= 100 && code < 100 + values().length; - } -} diff --git a/bindings/java/src/main/java/ai/arcships/aimux/RecordingException.java b/bindings/java/src/main/java/ai/arcships/aimux/RecordingException.java index 7b3e0401e..74c556cf4 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/RecordingException.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/RecordingException.java @@ -1,44 +1,42 @@ package ai.arcships.aimux; -import com.sun.jna.Pointer; +import com.fasterxml.jackson.databind.JsonNode; -/** - * Recording (RFC-0023) failure from {@link Aimux#initRecording(String)} / - * {@link Aimux#recordingTryFlush()}. - * - *

Deliberately not an {@link AimuxException}: {@code AiMuxError} and - * {@code recording::RecordingError} are unrelated Rust types, and this binding - * mirrors that — the two share only {@link RuntimeException}. - * Message text comes from the C layer. - */ -public class RecordingException extends RuntimeException { - - private static final long serialVersionUID = 1L; - - private final RecordingErrorCode code; - - public RecordingException(RecordingErrorCode code, String message) { - super(message); - this.code = code; +/** Recording domain errors from the RFC-0039 envelope. */ +public abstract class RecordingException extends NativeException { + RecordingException(JsonNode envelope) { super(envelope); } + static RecordingException decode(JsonNode envelope) { + switch (envelope.path("error").path("name").asText()) { + case "Init": return new Init(envelope); + case "OpenFile": return new OpenFile(envelope); + case "Spawn": return new Spawn(envelope); + case "WriterGone": return new WriterGone(envelope); + case "FlushTimeout": return new FlushTimeout(envelope); + case "Write": return new Write(envelope); + default: return new Unknown(envelope); + } } - public RecordingErrorCode getCode() { - return code; + public static final class Init extends RecordingException { + Init(JsonNode envelope) { super(envelope); } } - - /** - * Build from a returned {@code const aimux_error_t *}. Reads the unified - * code and message, frees the string; the caller - * ({@link AimuxResult#expectRecordingError}) frees the returned error. - * Separate from {@link AimuxException#fromC} on purpose. - */ - static RecordingException fromC(Pointer error, String prefix) { - AimuxFFI ffi = AimuxFFI.INSTANCE; - RecordingErrorCode code = RecordingErrorCode.fromC(ffi.aimux_error_code(error)); - String msg = AimuxResult.takeString(ffi.aimux_error_message(error)); - if (msg == null || msg.isEmpty()) { - msg = "aimux: recording " + code; - } - return new RecordingException(code, prefix + msg); + public static final class OpenFile extends RecordingException { + OpenFile(JsonNode envelope) { super(envelope); } + } + public static final class Spawn extends RecordingException { + Spawn(JsonNode envelope) { super(envelope); } + } + public static final class WriterGone extends RecordingException { + WriterGone(JsonNode envelope) { super(envelope); } + } + public static final class FlushTimeout extends RecordingException { + FlushTimeout(JsonNode envelope) { super(envelope); } + } + public static final class Write extends RecordingException { + Write(JsonNode envelope) { super(envelope); } + } + /** An unrecognized variant, retaining its complete envelope. */ + public static final class Unknown extends RecordingException { + Unknown(JsonNode envelope) { super(envelope); } } } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/RerankingModel.java b/bindings/java/src/main/java/ai/arcships/aimux/RerankingModel.java index b737e3b49..29ca4c4a2 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/RerankingModel.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/RerankingModel.java @@ -1,104 +1,17 @@ package ai.arcships.aimux; -import com.sun.jna.Pointer; -import com.sun.jna.ptr.LongByReference; -import com.sun.jna.ptr.PointerByReference; -import java.io.Closeable; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Reranks documents by relevance to a query. Wraps a Rust - * {@code Arc}. - * - *

Implements {@link Closeable} — call {@link #close()} (or use a - * try-with-resources block) to release the native handle. - * - *

{@code
- * try (RerankingModel model = RerankingModel.cohere("sk-...", "rerank-v3.0")) {
- *     String result = model.rerank("{\"query\":\"...\",\"documents\":{...}}");
- * }
- * }
- */ -public final class RerankingModel implements Closeable { - - private final AtomicLong handle; - - private RerankingModel(long handle) { - this.handle = new AtomicLong(handle); - } - - /** Release the native handle. Idempotent and thread-safe. */ - @Override - public void close() { - long h = handle.getAndSet(0L); - if (h != 0L) AimuxFFI.INSTANCE.aimux_drop_handle(h); - } - - /** Best-effort backstop; try-with-resources is the primary release path. */ - @Override - protected void finalize() throws Throwable { - close(); - super.finalize(); - } - - private long requireHandle() { - long h = handle.get(); - if (h == 0L) throw new IllegalStateException("RerankingModel is closed"); - return h; - } - - /** - * Create a Cohere reranking model. - * - * @param apiKey Cohere API key. - * @param modelId Model ID (e.g. {@code rerank-v3.0}). - * @return a new RerankingModel. - * @throws AimuxException on failure. - */ - public static RerankingModel cohere(String apiKey, String modelId) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_cohere_reranking_new(apiKey, modelId, out); - return new RerankingModel( - AimuxResult.extractHandle(e, out, "Failed to create Cohere reranking model")); - } - - /** - * Create a Cohere reranking model with a custom base URL. - * - * @param apiKey Cohere API key. - * @param modelId Model ID. - * @param baseUrl Custom base URL. - * @return a new RerankingModel. - * @throws AimuxException on failure. - */ - public static RerankingModel cohereWithBase(String apiKey, String modelId, String baseUrl) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_cohere_reranking_new_with_base(apiKey, modelId, baseUrl, out); - return new RerankingModel( - AimuxResult.extractHandle(e, out, "Failed to create Cohere reranking model")); - } - - /** - * Rerank documents against a query. - * - * @param optsJson JSON-serialized {@code RerankingCallOptions}. - * Required: carries the input. - * @return JSON-serialized {@code RerankingResult}. - * @throws NullPointerException if {@code optsJson} is null. - * @throws IllegalArgumentException if {@code optsJson} is blank or malformed JSON. - * @throws AimuxException on engine / transport failure. - */ - public String rerank(String optsJson) { - AimuxResult.requireJsonNonNull(optsJson, "optsJson"); - PointerByReference out = new PointerByReference(); - return AimuxResult.extractString( - AimuxFFI.INSTANCE.aimux_rerank(requireHandle(), optsJson, out), - out, - "rerank"); +import ai.arcships.aimux.generated.AimuxOps; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; + +/** Typed resource facade; request JSON uses the existing core wire DTO. */ +public final class RerankingModel extends NativeHandle { + RerankingModel(Aimux aimux, long id) { super(aimux, id); } + public JsonNode info() { return call(AimuxOps.OP_MODEL_INFO, "{}"); } + public JsonNode rerank(String paramsJson) { return call(AimuxOps.OP_RERANKING_RERANK, paramsJson); } + public Future rerankAsync(String paramsJson, Executor executor) { + return callAsync(AimuxOps.OP_RERANKING_RERANK, paramsJson, executor); } } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/Resource.java b/bindings/java/src/main/java/ai/arcships/aimux/Resource.java new file mode 100644 index 000000000..8532cdd1b --- /dev/null +++ b/bindings/java/src/main/java/ai/arcships/aimux/Resource.java @@ -0,0 +1,6 @@ +package ai.arcships.aimux; + +/** Owned resource for any manifest handle kind. Only legal op/kind combinations are accepted natively. */ +public final class Resource extends NativeHandle { + Resource(Aimux aimux, long id) { super(aimux, id); } +} diff --git a/bindings/java/src/main/java/ai/arcships/aimux/SearchModel.java b/bindings/java/src/main/java/ai/arcships/aimux/SearchModel.java index 09c5bfbbd..0cff6c7b9 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/SearchModel.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/SearchModel.java @@ -1,99 +1,17 @@ package ai.arcships.aimux; -import com.sun.jna.Pointer; -import com.sun.jna.ptr.LongByReference; -import com.sun.jna.ptr.PointerByReference; -import java.io.Closeable; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Performs web search. Wraps a Rust {@code Arc}. - * - *

Implements {@link Closeable} — call {@link #close()} (or use a - * try-with-resources block) to release the native handle. - * - *

{@code
- * try (SearchModel model = SearchModel.tavily("sk-...")) {
- *     String result = model.search("{\"query\":\"What is Rust?\",\"maxResults\":5}");
- * }
- * }
- */ -public final class SearchModel implements Closeable { - - private final AtomicLong handle; - - private SearchModel(long handle) { - this.handle = new AtomicLong(handle); - } - - /** Release the native handle. Idempotent and thread-safe. */ - @Override - public void close() { - long h = handle.getAndSet(0L); - if (h != 0L) AimuxFFI.INSTANCE.aimux_drop_handle(h); - } - - /** Best-effort backstop; try-with-resources is the primary release path. */ - @Override - protected void finalize() throws Throwable { - close(); - super.finalize(); - } - - private long requireHandle() { - long h = handle.get(); - if (h == 0L) throw new IllegalStateException("SearchModel is closed"); - return h; - } - - /** - * Create a Tavily search model. Tavily uses a fixed endpoint, so no model - * ID is needed (the C ABI still takes one, so an empty string is passed - * and ignored). - * - * @param apiKey Tavily API key. - * @return a new SearchModel. - * @throws AimuxException on failure. - */ - public static SearchModel tavily(String apiKey) { - Objects.requireNonNull(apiKey, "apiKey"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_tavily_search_new(apiKey, "", out); - return new SearchModel(AimuxResult.extractHandle(e, out, "Failed to create Tavily search model")); - } - - /** - * Create a Tavily search model with a custom base URL (e.g. for mocks). - * - * @param apiKey Tavily API key. - * @param baseUrl Custom base URL. - * @return a new SearchModel. - * @throws AimuxException on failure. - */ - public static SearchModel tavilyWithBase(String apiKey, String baseUrl) { - Objects.requireNonNull(apiKey, "apiKey"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_tavily_search_new_with_base(apiKey, "", baseUrl, out); - return new SearchModel(AimuxResult.extractHandle(e, out, "Failed to create Tavily search model")); - } - - /** - * Perform a web search. - * - * @param optsJson JSON-serialized {@code SearchCallOptions}. - * Required: carries the input. - * @return JSON-serialized {@code SearchResult}. - * @throws NullPointerException if {@code optsJson} is null. - * @throws IllegalArgumentException if {@code optsJson} is blank or malformed JSON. - * @throws AimuxException on engine / transport failure. - */ - public String search(String optsJson) { - AimuxResult.requireJsonNonNull(optsJson, "optsJson"); - PointerByReference out = new PointerByReference(); - return AimuxResult.extractString( - AimuxFFI.INSTANCE.aimux_search(requireHandle(), optsJson, out), - out, - "search"); +import ai.arcships.aimux.generated.AimuxOps; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; + +/** Typed resource facade; request JSON uses the existing core wire DTO. */ +public final class SearchModel extends NativeHandle { + SearchModel(Aimux aimux, long id) { super(aimux, id); } + public JsonNode info() { return call(AimuxOps.OP_MODEL_INFO, "{}"); } + public JsonNode search(String paramsJson) { return call(AimuxOps.OP_SEARCH_SEARCH, paramsJson); } + public Future searchAsync(String paramsJson, Executor executor) { + return callAsync(AimuxOps.OP_SEARCH_SEARCH, paramsJson, executor); } } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/SpeechModel.java b/bindings/java/src/main/java/ai/arcships/aimux/SpeechModel.java index 80ffdc739..100a76da9 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/SpeechModel.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/SpeechModel.java @@ -1,101 +1,17 @@ package ai.arcships.aimux; -import com.sun.jna.Pointer; -import com.sun.jna.ptr.LongByReference; -import com.sun.jna.ptr.PointerByReference; -import java.io.Closeable; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Converts text to speech audio. Wraps a Rust {@code Arc}. - * - *

Implements {@link Closeable} — call {@link #close()} (or use a - * try-with-resources block) to release the native handle. - * - *

{@code
- * try (SpeechModel model = SpeechModel.openai("sk-...", "tts-1")) {
- *     String result = model.generate("{\"text\":\"Hello\",\"voice\":\"alloy\"}");
- * }
- * }
- */ -public final class SpeechModel implements Closeable { - - private final AtomicLong handle; - - private SpeechModel(long handle) { - this.handle = new AtomicLong(handle); - } - - /** Release the native handle. Idempotent and thread-safe. */ - @Override - public void close() { - long h = handle.getAndSet(0L); - if (h != 0L) AimuxFFI.INSTANCE.aimux_drop_handle(h); - } - - /** Best-effort backstop; try-with-resources is the primary release path. */ - @Override - protected void finalize() throws Throwable { - close(); - super.finalize(); - } - - private long requireHandle() { - long h = handle.get(); - if (h == 0L) throw new IllegalStateException("SpeechModel is closed"); - return h; - } - - /** - * Create an OpenAI speech (TTS) model. - * - * @param apiKey OpenAI API key. - * @param modelId Model ID (e.g. {@code tts-1}). - * @return a new SpeechModel. - * @throws AimuxException if the native constructor fails. - */ - public static SpeechModel openai(String apiKey, String modelId) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_openai_speech_new(apiKey, modelId, out); - return new SpeechModel(AimuxResult.extractHandle(e, out, "Failed to create OpenAI speech model")); - } - - /** - * Create an OpenAI speech model with a custom base URL. - * - * @param apiKey OpenAI API key. - * @param modelId Model ID (e.g. {@code tts-1}). - * @param baseUrl Custom base URL (e.g. for proxies or local servers). - * @return a new SpeechModel. - * @throws AimuxException on failure. - */ - public static SpeechModel openaiWithBase(String apiKey, String modelId, String baseUrl) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_openai_speech_new_with_base(apiKey, modelId, baseUrl, out); - return new SpeechModel(AimuxResult.extractHandle(e, out, "Failed to create OpenAI speech model")); - } - - /** - * Generate speech audio from the given options. - * - * @param optsJson JSON-serialized {@code SpeechCallOptions}. - * Required: carries the input. - * @return JSON-serialized {@code SpeechResult}. - * @throws NullPointerException if {@code optsJson} is null. - * @throws IllegalArgumentException if {@code optsJson} is blank or malformed JSON. - * @throws AimuxException on engine / transport failure. - */ - public String generate(String optsJson) { - AimuxResult.requireJsonNonNull(optsJson, "optsJson"); - PointerByReference out = new PointerByReference(); - return AimuxResult.extractString( - AimuxFFI.INSTANCE.aimux_speech_generate(requireHandle(), optsJson, out), - out, - "speech_generate"); +import ai.arcships.aimux.generated.AimuxOps; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; + +/** Typed resource facade; request JSON uses the existing core wire DTO. */ +public final class SpeechModel extends NativeHandle { + SpeechModel(Aimux aimux, long id) { super(aimux, id); } + public JsonNode info() { return call(AimuxOps.OP_MODEL_INFO, "{}"); } + public JsonNode generate(String paramsJson) { return call(AimuxOps.OP_SPEECH_GENERATE, paramsJson); } + public Future generateAsync(String paramsJson, Executor executor) { + return callAsync(AimuxOps.OP_SPEECH_GENERATE, paramsJson, executor); } } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/StreamFrame.java b/bindings/java/src/main/java/ai/arcships/aimux/StreamFrame.java new file mode 100644 index 000000000..6a4be1440 --- /dev/null +++ b/bindings/java/src/main/java/ai/arcships/aimux/StreamFrame.java @@ -0,0 +1,53 @@ +package ai.arcships.aimux; + +import com.fasterxml.jackson.databind.JsonNode; +import java.math.BigInteger; + +/** One copied stream frame. An accepted stream has exactly one End frame. */ +public final class StreamFrame { + public enum Outcome { SUCCESS, ERROR, CANCELLED } + private final JsonNode json; + private final BigInteger sequence; + private final Outcome outcome; + + private StreamFrame(JsonNode json, BigInteger sequence, Outcome outcome) { + this.json = json.deepCopy(); + this.sequence = sequence; + this.outcome = outcome; + } + + static StreamFrame parse(String value) { + JsonNode json = AimuxResult.parse(value); + String seq = json.path("seq").asText(); + if (!json.path("seq").isTextual() || !seq.matches("0|[1-9][0-9]*")) { + throw new IllegalStateException("Invalid stream sequence"); + } + BigInteger sequence = new BigInteger(seq); + if (sequence.bitLength() > 64) throw new IllegalStateException("Stream sequence exceeds uint64"); + String type = json.path("type").asText(); + if ("event".equals(type) && json.has("event")) return new StreamFrame(json, sequence, null); + if ("end".equals(type)) { + Outcome outcome; + switch (json.path("outcome").asText()) { + case "success": outcome = Outcome.SUCCESS; break; + case "error": outcome = Outcome.ERROR; break; + case "cancelled": outcome = Outcome.CANCELLED; break; + default: throw new IllegalStateException("Invalid stream outcome"); + } + if (outcome == Outcome.ERROR && !json.hasNonNull("error")) { + throw new IllegalStateException("Error End is missing its envelope"); + } + return new StreamFrame(json, sequence, outcome); + } + throw new IllegalStateException("Invalid stream frame"); + } + + public BigInteger getSequence() { return sequence; } + public boolean isEnd() { return outcome != null; } + public Outcome getOutcome() { return outcome; } + public JsonNode getEvent() { return json.path("event").deepCopy(); } + public JsonNode getJson() { return json.deepCopy(); } + public NativeException getError() { + return json.hasNonNull("error") ? AimuxResult.decode(json.get("error")) : null; + } +} diff --git a/bindings/java/src/main/java/ai/arcships/aimux/ToolCallRepair.java b/bindings/java/src/main/java/ai/arcships/aimux/ToolCallRepair.java deleted file mode 100644 index 932741050..000000000 --- a/bindings/java/src/main/java/ai/arcships/aimux/ToolCallRepair.java +++ /dev/null @@ -1,39 +0,0 @@ -package ai.arcships.aimux; - -/** - * Host-side repair for one invalid tool call (RFC-0035) — the aimux equivalent - * of the AI SDK {@code repairToolCall}. - * - *

Set it with - * {@link Types.GenerateTextOptions.Builder#repairToolCall(ToolCallRepair)}. It - * runs after generation, outside any native call, so it may do anything — - * including a second {@code generateText} call against a model — to produce the - * replacement. - * - *

Contract: - *

    - *
  • Return a replacement {@link Types.RawToolCall} (its {@code input} is - * raw argument TEXT) to have the library re-parse and re-validate it. If - * the replacement is still invalid, the original call comes back invalid - * carrying the replacement's parse or validation error (not wrapped in - * {@code ToolCallRepair}).
  • - *
  • Return {@code null} to leave the call as it was — invalid, with its - * original error.
  • - *
  • Throw to report a failed attempt: the call comes back invalid with a - * {@code ToolCallRepair} error whose cause is the exception's message.
  • - *
- * - *

Called at most once per tool call, and never for a valid call or for one - * made without a tool set. - */ -@FunctionalInterface -public interface ToolCallRepair { - - /** - * @param context The invalid call, its error, the tool's input schema, and - * the tools / messages / instructions the call was generated with. - * @return The replacement call, or {@code null} to leave it unchanged. - * @throws Exception to report that the repair attempt failed. - */ - Types.RawToolCall repair(Types.ToolCallRepairContext context) throws Exception; -} diff --git a/bindings/java/src/main/java/ai/arcships/aimux/ToolCallRepairs.java b/bindings/java/src/main/java/ai/arcships/aimux/ToolCallRepairs.java index 0ff4688c1..1ff82575e 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/ToolCallRepairs.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/ToolCallRepairs.java @@ -1,216 +1,19 @@ package ai.arcships.aimux; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.sun.jna.Pointer; -import com.sun.jna.ptr.PointerByReference; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.FutureTask; - -/** - * Host-side tool-call repair (RFC-0035): the three pure native functions - * and the post-processing loops that drive them. - * - *

Repair is pure post-processing — generation runs with no hook at all, the - * invalid calls come back as data, and these functions re-validate and patch - * the JSON before {@link TypedModel} decodes it. - */ -final class ToolCallRepairs { +import ai.arcships.aimux.generated.AimuxOps; - private static final String UNCHANGED = "{\"type\":\"unchanged\"}"; +import com.fasterxml.jackson.databind.JsonNode; +/** Pure DTO repair operations. Host code owns any subsequent repair loop. */ +public final class ToolCallRepairs { private ToolCallRepairs() {} - - // ── Native wrappers ────────────────────────────────────────────────────── - - /** The AI SDK repair argument for one invalid call, or {@code "null"} when it has no tool set. */ - static String context(String toolCallJson, String promptJson, String optsJson) { - PointerByReference out = new PointerByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_tool_call_repair_context( - toolCallJson, promptJson, optsJson, out); - return AimuxResult.extractString(e, out, "tool_call_repair_context"); - } - - /** Resolve one invalid call against a reply; returns the resulting ToolCall JSON. */ - static String apply(String toolCallJson, String optsJson, String replyJson) { - PointerByReference out = new PointerByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_apply_tool_call_repair( - toolCallJson, optsJson, replyJson, out); - return AimuxResult.extractString(e, out, "apply_tool_call_repair"); - } - - /** Patch a whole result document (toolCalls and responseMessages). */ - static String applyToResult(String resultJson, String optsJson, String toolCallId, String replyJson) { - PointerByReference out = new PointerByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_apply_tool_call_repair_to_result( - resultJson, optsJson, toolCallId, replyJson, out); - return AimuxResult.extractString(e, out, "apply_tool_call_repair_to_result"); - } - - // ── Host loops ─────────────────────────────────────────────────────────── - - /** - * Repair every invalid tool call in a serialized GenerateTextResult (generated - * or consumed from a stream), at most once each. A GenerateObjectResult has - * no tool calls. - * - * @return The patched result JSON, or {@code resultJson} unchanged when - * there is no hook or nothing to repair. - */ - static String repairResult(String resultJson, String promptJson, String optsJson, ToolCallRepair hook) { - if (hook == null) { - return resultJson; - } - JsonNode calls = toolCalls(parse(resultJson)); - // Snapshot the invalid calls first: a repair may rename a call, and - // patching rebuilds the document under us. - List invalid = new ArrayList<>(); - for (JsonNode call : calls) { - if (call.path("invalid").asBoolean(false)) { - invalid.add(call); - } - } - String patched = resultJson; - for (JsonNode call : invalid) { - String callJson = call.toString(); - String contextJson = context(callJson, promptJson, optsJson); - if (isNull(contextJson)) { - continue; // no tool set — never repaired (AI SDK rule) - } - patched = applyToResult(patched, optsJson, - call.path("toolCallId").asText(), reply(hook, contextJson)); - } - return patched; - } - - /** - * Repair an invalid tool-call stream part. Every other part — tool input - * deltas included — passes straight through, as in the AI SDK. - * - * @param model The model being streamed, whose read hold is lent to the - * repair worker thread (may be {@code null} in unit tests that - * drive this loop without a model). - * @return The part JSON to deliver. - */ - static String repairStreamPart(String partJson, String promptJson, String optsJson, - ToolCallRepair hook, Model model) { - if (hook == null) { - return partJson; - } - JsonNode part = parse(partJson); - JsonNode call = part; - if (!"tool-call".equals(part.path("type").asText()) || !call.path("invalid").asBoolean(false)) { - return partJson; - } - String contextJson = context(call.toString(), promptJson, optsJson); - if (isNull(contextJson)) { - return partJson; - } - String repaired = apply(call.toString(), optsJson, reply(offCallbackThread(hook, model), contextJson)); - ObjectNode out = Types.AimuxJson.MAPPER.createObjectNode(); - out.put("type", "tool-call"); - out.setAll((ObjectNode) parse(repaired)); - return out.toString(); - } - - // ── Helpers ────────────────────────────────────────────────────────────── - - /** - * Wrap a hook so it runs on its own thread. - * - *

Invariant: the FFI re-entrancy guard is THREAD-LOCAL, and a stream - * part callback runs on the thread that entered {@code aimux_stream_text}, - * still inside that call. A hook doing the canonical thing — asking a model - * to fix the arguments — would therefore fail with - * {@code AIMUX_E_FFI_REENTRANT_CALL}. Only the three repair functions - * themselves are safe on that thread, because they are pure. - * - *

The callback thread blocks on the result, so stream part ordering is - * unchanged. Only the stream path needs this: by the time the non-streaming - * loop runs, the native call has already returned. - * - *

Because that thread stays blocked holding {@code model}'s read lock, - * the worker runs with the hold lent to it ({@link Model#withLentReadHold}) - * so a hook calling back into the SAME model does not queue behind a - * pending {@code close()} — which would deadlock the three threads against - * each other. The worker dies with the repair, so the lend cannot leak. - */ - private static ToolCallRepair offCallbackThread(final ToolCallRepair hook, final Model model) { - return context -> { - final Callable repair = () -> hook.repair(context); - Callable work = repair; - if (model != null) { - work = () -> model.withLentReadHold(repair); - } - FutureTask task = new FutureTask<>(work); - Thread thread = new Thread(task, "aimux-tool-call-repair"); - thread.setDaemon(true); - thread.start(); - try { - return task.get(); - } catch (ExecutionException e) { - // Rethrow what the hook threw, so "throwing means failed" still - // reports the host's own message. - Throwable cause = e.getCause(); - if (cause instanceof Error) { - throw (Error) cause; - } - throw (Exception) cause; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw e; - } - }; + public static JsonNode context(Aimux aimux, String paramsJson) { + return aimux.call(AimuxOps.OP_TOOL_REPAIR_CONTEXT, paramsJson); } - - /** Run the host's hook over one repair context and encode its outcome as a reply. */ - private static String reply(ToolCallRepair hook, String contextJson) { - Types.ToolCallRepairContext context; - try { - context = Types.AimuxJson.MAPPER.readValue(contextJson, Types.ToolCallRepairContext.class); - } catch (IOException e) { - throw new IllegalStateException( - "aimux: failed to decode ToolCallRepairContext: " + e.getMessage(), e); - } - Types.RawToolCall replacement; - try { - replacement = hook.repair(context); - } catch (Exception e) { - // The host's own failure is data, not a thrown error: it comes back - // as ToolCallRepair { originalError, cause }. - ObjectNode failed = Types.AimuxJson.MAPPER.createObjectNode(); - failed.put("type", "failed"); - failed.put("message", e.getMessage() == null ? e.toString() : e.getMessage()); - return failed.toString(); - } - if (replacement == null) { - return UNCHANGED; - } - ObjectNode repaired = Types.AimuxJson.MAPPER.createObjectNode(); - repaired.put("type", "repaired"); - repaired.set("toolCall", Types.AimuxJson.MAPPER.valueToTree(replacement)); - return repaired.toString(); - } - - /** The top-level {@code toolCalls} of a text result (missing on an object result). */ - private static JsonNode toolCalls(JsonNode result) { - return result.path("toolCalls"); + public static JsonNode apply(Aimux aimux, String paramsJson) { + return aimux.call(AimuxOps.OP_TOOL_REPAIR_APPLY, paramsJson); } - - private static boolean isNull(String json) { - return parse(json).isNull(); - } - - private static JsonNode parse(String json) { - try { - return Types.AimuxJson.MAPPER.readTree(json); - } catch (IOException e) { - throw new IllegalStateException("aimux: failed to parse JSON: " + e.getMessage(), e); - } + public static JsonNode applyResult(Aimux aimux, String paramsJson) { + return aimux.call(AimuxOps.OP_TOOL_REPAIR_APPLY_RESULT, paramsJson); } } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/TranscriptionModel.java b/bindings/java/src/main/java/ai/arcships/aimux/TranscriptionModel.java index 6eb24d20b..5cf3ae73f 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/TranscriptionModel.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/TranscriptionModel.java @@ -1,161 +1,20 @@ package ai.arcships.aimux; -import com.sun.jna.Pointer; -import com.sun.jna.ptr.LongByReference; -import com.sun.jna.ptr.PointerByReference; -import java.io.Closeable; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Converts audio to text. Wraps a Rust {@code Arc}. - * - *

Implements {@link Closeable} — call {@link #close()} (or use a - * try-with-resources block) to release the native handle. - * - *

{@code
- * try (TranscriptionModel model = TranscriptionModel.openai("sk-...", "whisper-1")) {
- *     String result = model.generate(base64Audio, "audio/wav");
- * }
- * }
- */ -public final class TranscriptionModel implements Closeable { - - private final AtomicLong handle; - - private TranscriptionModel(long handle) { - this.handle = new AtomicLong(handle); - } - - /** Release the native handle. Idempotent and thread-safe. */ - @Override - public void close() { - long h = handle.getAndSet(0L); - if (h != 0L) AimuxFFI.INSTANCE.aimux_drop_handle(h); - } - - /** Best-effort backstop; try-with-resources is the primary release path. */ - @Override - protected void finalize() throws Throwable { - close(); - super.finalize(); - } - - private long requireHandle() { - long h = handle.get(); - if (h == 0L) throw new IllegalStateException("TranscriptionModel is closed"); - return h; - } - - /** - * Create an OpenAI transcription (STT) model. - * - * @param apiKey OpenAI API key. - * @param modelId Model ID (e.g. {@code whisper-1}). - * @return a new TranscriptionModel. - * @throws AimuxException on failure. - */ - public static TranscriptionModel openai(String apiKey, String modelId) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_openai_transcription_new(apiKey, modelId, out); - return new TranscriptionModel( - AimuxResult.extractHandle(e, out, "Failed to create OpenAI transcription model")); - } - - /** - * Create an OpenAI transcription model with a custom base URL. - * - * @param apiKey OpenAI API key. - * @param modelId Model ID (e.g. {@code whisper-1}). - * @param baseUrl Custom base URL. - * @return a new TranscriptionModel. - * @throws AimuxException on failure. - */ - public static TranscriptionModel openaiWithBase(String apiKey, String modelId, String baseUrl) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_openai_transcription_new_with_base(apiKey, modelId, baseUrl, out); - return new TranscriptionModel( - AimuxResult.extractHandle(e, out, "Failed to create OpenAI transcription model")); - } - - /** - * Transcribe audio (base64-encoded) to text. - * - * @param audioBase64 Base64-encoded audio bytes. - * @param mediaType Media type of the audio (e.g. {@code audio/wav}). - * @return JSON-serialized {@code TranscriptionResult}. - * @throws AimuxException on engine / transport failure. - */ - public String generate(String audioBase64, String mediaType) { - return generate(audioBase64, mediaType, null); - } - - /** - * Transcribe audio (base64-encoded) to text. - * - * @param audioBase64 Base64-encoded audio bytes. - * @param mediaType Media type of the audio (e.g. {@code audio/wav}). - * @param optsJson Optional JSON-serialized {@code TranscriptionCallOptions}, - * or {@code null} for defaults. - * @return JSON-serialized {@code TranscriptionResult}. - * @throws AimuxException on engine / transport failure. - */ - /** - * Start a streaming transcription session (RFC-0028) on this model. - * Requires a model that supports streaming (realtime models). - * - * @param optsJson optional session options JSON - * ({@code input_audio_format} / {@code provider_options} - * / {@code headers} / {@code include_raw_chunks}), or - * {@code null} for defaults. - * @return a new live session. - * @throws AimuxException on failure. - */ - public TranscriptionSession startStream(String optsJson) { - return startStream(optsJson, 0L); - } - - /** - * Start a streaming transcription session with an abort handle - * (from {@code Aimux.abortSignalNew()}); firing it aborts the session. - * - * @param optsJson optional session options JSON, or {@code null}. - * @param abortHandle abort handle, or 0 for none. - * @return a new live session. - * @throws AimuxException on failure. - */ - public TranscriptionSession startStream(String optsJson, long abortHandle) { - AimuxResult.requireJson(optsJson, "optsJson"); - long h = requireHandle(); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_transcription_session_new(h, abortHandle, optsJson, out); - return new TranscriptionSession( - AimuxResult.extractHandle(e, out, "Failed to start transcription session")); - } - - /** - * Transcribe audio (base64-encoded) to text. - * - * @param audioBase64 Base64-encoded audio bytes. - * @param mediaType Media type of the audio (e.g. {@code audio/wav}). - * @param optsJson Optional JSON-serialized {@code TranscriptionCallOptions}, - * or {@code null} for defaults. - * @return JSON-serialized {@code TranscriptionResult}. - * @throws AimuxException on engine / transport failure. - */ - public String generate(String audioBase64, String mediaType, String optsJson) { - Objects.requireNonNull(audioBase64, "audioBase64"); - Objects.requireNonNull(mediaType, "mediaType"); - AimuxResult.requireJson(optsJson, "optsJson"); - PointerByReference out = new PointerByReference(); - return AimuxResult.extractString( - AimuxFFI.INSTANCE.aimux_transcription_generate( - requireHandle(), audioBase64, mediaType, optsJson, out), - out, - "transcription_generate"); +import ai.arcships.aimux.generated.AimuxOps; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; + +/** Typed resource facade; request JSON uses the existing core wire DTO. */ +public final class TranscriptionModel extends NativeHandle { + TranscriptionModel(Aimux aimux, long id) { super(aimux, id); } + public JsonNode info() { return call(AimuxOps.OP_MODEL_INFO, "{}"); } + public JsonNode generate(String paramsJson) { return call(AimuxOps.OP_TRANSCRIPTION_GENERATE, paramsJson); } + public Future generateAsync(String paramsJson, Executor executor) { + return callAsync(AimuxOps.OP_TRANSCRIPTION_GENERATE, paramsJson, executor); + } + public TranscriptionSession start(String paramsJson) { + return new TranscriptionSession(aimux, AimuxResult.handle(call(AimuxOps.OP_TRANSCRIPTION_START, paramsJson))); } } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/TranscriptionSession.java b/bindings/java/src/main/java/ai/arcships/aimux/TranscriptionSession.java index 0f11b2dd8..8ecbf86a6 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/TranscriptionSession.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/TranscriptionSession.java @@ -1,117 +1,58 @@ package ai.arcships.aimux; -import com.sun.jna.Pointer; +import ai.arcships.aimux.generated.AimuxOps; + +import com.fasterxml.jackson.databind.JsonNode; import com.sun.jna.ptr.IntByReference; import com.sun.jna.ptr.PointerByReference; +import java.util.Objects; -/** - * A live streaming-transcription session (RFC-0028): push audio chunks with - * {@link #pushAudio(byte[])}, mark end-of-audio with {@link #inputDone()}, - * then pull transcription parts (JSON {@code TranscriptionStreamPart}) with - * {@link #nextPart(long)}. - * - *

Close releases the session (aborts the driver; idempotent). Use - * try-with-resources — {@link #close()} is the primary release path. - * - *

Sessions are created via {@link TranscriptionModel#startStream(String)}. - */ -public final class TranscriptionSession implements AutoCloseable { - - private final java.util.concurrent.atomic.AtomicLong handle; - - TranscriptionSession(long handle) { - this.handle = new java.util.concurrent.atomic.AtomicLong(handle); - } - - /** Release the native session. Idempotent and thread-safe. */ - @Override - public void close() { - long h = handle.getAndSet(0L); - if (h != 0L) AimuxFFI.INSTANCE.aimux_transcription_session_drop(h); - } - - @Override - protected void finalize() throws Throwable { - close(); - super.finalize(); - } - - private long requireHandle() { - long h = handle.get(); - if (h == 0L) throw new IllegalStateException("TranscriptionSession is closed"); - return h; - } - - /** - * Push one binary audio chunk. Blocks while the internal channel is - * full (backpressure propagation — the capture loop throttles). - * - * @param audio audio bytes (may be empty). - * @throws AimuxException on failure (session ended / input finished). - */ - public void pushAudio(byte[] audio) { - long h = requireHandle(); - byte[] data = audio == null ? new byte[0] : audio; - Pointer e = AimuxFFI.INSTANCE.aimux_transcription_push_audio(h, data, data.length); - if (e != null) throw AimuxResult.expectAimuxError(e, "pushAudio failed"); - } - - /** - * Signal end-of-audio (idempotent). - * - * @throws IllegalStateException on a dead native handle (C ABI failure). - */ - public void inputDone() { - long h = requireHandle(); - Pointer e = AimuxFFI.INSTANCE.aimux_transcription_input_done(h); - if (e != null) throw AimuxResult.expectFfiError(e, "inputDone failed"); - } +/** An Operation(transcription); poll timeout is distinct from operation failure. */ +public final class TranscriptionSession extends NativeHandle { + TranscriptionSession(Aimux aimux, long id) { super(aimux, id); } - /** - * Pull the next transcription part (JSON {@code TranscriptionStreamPart}). - * - * @param timeoutMs wait bound: {@code >0} wait at most; {@code 0} immediate - * poll; {@code <0} wait indefinitely. - * @return the part JSON. - * @throws AimuxTranscriptionEndedException the stream finished normally - * (a Finish part was delivered). - * @throws AimuxTranscriptionTimeoutException no part arrived in time; the - * session stays live — call again. - * @throws AimuxException the stream failed. - */ - public String nextPart(long timeoutMs) { - long h = requireHandle(); - PointerByReference outPart = new PointerByReference(); - IntByReference outState = new IntByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_transcription_next_part(h, timeoutMs, outPart, outState); - if (e != null) { - throw AimuxResult.expectAimuxError(e, "nextPart failed"); - } - switch (outState.getValue()) { - case AimuxFFI.TRANSCRIPTION_NEXT_PART_PART: - return AimuxResult.extractString(null, outPart, "nextPart"); - case AimuxFFI.TRANSCRIPTION_NEXT_PART_ENDED: - throw new AimuxTranscriptionEndedException(); - case AimuxFFI.TRANSCRIPTION_NEXT_PART_TIMEOUT: - // Not an error: nothing to decode, the session stays live. - throw new AimuxTranscriptionTimeoutException(); - default: - throw new IllegalStateException( - "nextPart: aimux ffi: unknown next_part state " + outState.getValue()); + public void push(byte[] bytes) { + Objects.requireNonNull(bytes, "bytes"); + try (Lease lease = admit()) { + AimuxResult.check(aimux.ffi, + aimux.ffi.aimux_operation_push(lease.id, bytes, new AimuxFFI.SizeT(bytes.length))); } } - /** The transcription stream ended normally. */ - public static class AimuxTranscriptionEndedException extends RuntimeException { - public AimuxTranscriptionEndedException() { - super("transcription stream ended"); + public void inputDone() { call(AimuxOps.OP_OPERATION_INPUT_DONE, "{}"); } + public JsonNode status() { return call(AimuxOps.OP_OPERATION_STATUS, "{}"); } + public JsonNode cancel() { return call(AimuxOps.OP_OPERATION_CANCEL, "{}"); } + + public PollResult next(long timeoutMs) { + PointerByReference part = new PointerByReference(); + IntByReference state = new IntByReference(); + try (Lease lease = admit()) { + try { + AimuxResult.check(aimux.ffi, + aimux.ffi.aimux_operation_next_part(lease.id, timeoutMs, part, state)); + switch (state.getValue()) { + case 1: + com.sun.jna.Pointer pointer = part.getValue(); + part.setValue(null); + String json = AimuxResult.takeString(aimux.ffi, pointer); + if (json == null) throw new IllegalStateException("PART has no JSON"); + return new PollResult(PollResult.State.PART, AimuxResult.parse(json)); + case 2: return new PollResult(PollResult.State.ENDED, null); + case 3: return new PollResult(PollResult.State.TIMEOUT, null); + default: throw new IllegalStateException("Unknown native poll state"); + } + } finally { + if (part.getValue() != null) aimux.ffi.aimux_free_string(part.getValue()); + } } } - /** No transcription part arrived within the timeout (retryable). */ - public static class AimuxTranscriptionTimeoutException extends RuntimeException { - public AimuxTranscriptionTimeoutException() { - super("transcription part timeout"); - } + public static final class PollResult { + public enum State { PART, ENDED, TIMEOUT } + private final State state; + private final JsonNode part; + private PollResult(State state, JsonNode part) { this.state = state; this.part = part; } + public State getState() { return state; } + public JsonNode getPart() { return part == null ? null : part.deepCopy(); } } } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/TypedModel.java b/bindings/java/src/main/java/ai/arcships/aimux/TypedModel.java index 867a8946d..1271359a8 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/TypedModel.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/TypedModel.java @@ -1,724 +1,74 @@ package ai.arcships.aimux; -import com.fasterxml.jackson.core.JsonProcessingException; - -import java.io.Closeable; +import com.fasterxml.jackson.databind.JsonNode; import java.io.IOException; -import java.util.List; -import java.util.Spliterator; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.function.Consumer; -import java.util.stream.Stream; -import java.util.stream.StreamSupport; - -/** - * A typed view over a {@link Model}. Delegates every call to the raw model and - * (de)serializes the JSON boundary with {@link Types.AimuxJson}, so callers - * work with {@link Types.GenerateTextResult}, {@link Types.StreamPart}, etc. - * instead of raw JSON strings. - * - *

The wrapped {@link Model} is NOT closed by this wrapper when constructed - * via {@link #of(Model)} — the caller owns the underlying handle. Factories - * that create their own model ({@link #openai(String, String)}, - * {@link #openaiWithBase(String, String, String)}, - * {@link #anthropic(String, String)}, {@link #anthropicWithBase(String, String, String)}) - * own the handle, so closing the {@code TypedModel} releases it. - * - *

{@code
- * try (TypedModel model = TypedModel.openaiWithBase("sk-...", "gpt-4o", baseUrl)) {
- *     Types.GenerateTextResult result = model.generateText("What is Rust?");
- *     System.out.println(result.getText());
- * }
- * }
- */ -public class TypedModel implements Closeable { - - private final Model raw; - private final boolean ownsModel; - - private TypedModel(Model raw, boolean ownsModel) { - this.raw = raw; - this.ownsModel = ownsModel; - } - - @Override - public void close() { - if (ownsModel) { - raw.close(); - } - } - - // ── generateText ────────────────────────────────────────────────────── - /** - * Generate text from a simple string prompt. - * - * @param prompt Plain text prompt (encoded as a JSON string on the wire). - * @return Decoded {@link Types.GenerateTextResult}. - */ - public Types.GenerateTextResult generateText(String prompt) { - return generateText(prompt, null); - } +/** Typed results over a language model. This wrapper owns the supplied model. */ +public final class TypedModel implements AutoCloseable { + private final Model model; + public TypedModel(Model model) { this.model = Objects.requireNonNull(model, "model"); } + @Override public void close() { model.close(); } - /** - * Generate text from a simple string prompt with typed options. - * - * @param prompt Plain text prompt. - * @param options Optional typed {@link Types.GenerateTextOptions}, or {@code null}. - * @return Decoded {@link Types.GenerateTextResult}. - */ - public Types.GenerateTextResult generateText(String prompt, Types.GenerateTextOptions options) { - String promptJson = encode(prompt); - String optsJson = encodeOptions(options); - return decodeResult(repaired(raw.generateText(promptJson, optsJson), promptJson, optsJson, options)); + public Types.GenerateTextResult generate(String paramsJson) { + return decode(model.generate(paramsJson), Types.GenerateTextResult.class); } - /** - * Generate text from a multi-role message list. - * - * @param messages Conversation as a list of {@link Types.ModelMessage}s. - * @return Decoded {@link Types.GenerateTextResult}. - */ - public Types.GenerateTextResult generateText(List messages) { - return generateText(messages, null); + public Types.GenerateObjectResult generateObject(String paramsJson) { + return decode(model.generateObject(paramsJson), Types.GenerateObjectResult.class); } - /** - * Generate text from a multi-role message list with typed options. - * - * @param messages Conversation as a list of {@link Types.ModelMessage}s. - * @param options Optional typed {@link Types.GenerateTextOptions}, or {@code null}. - * @return Decoded {@link Types.GenerateTextResult}. - */ - public Types.GenerateTextResult generateText(List messages, Types.GenerateTextOptions options) { - String promptJson = encode(messages); - String optsJson = encodeOptions(options); - return decodeResult(repaired(raw.generateText(promptJson, optsJson), promptJson, optsJson, options)); + public Types.GenerateTextResult consume(String paramsJson) { + return decode(model.consume(paramsJson), Types.GenerateTextResult.class); } - private Types.GenerateTextResult decodeResult(String resultJson) { - // AiMuxError values throw from the raw Model via the C returned error → AimuxException; - // failing to decode what the library returned is a binding invariant → ISE. - try { - return Types.AimuxJson.MAPPER.readValue(resultJson, Types.GenerateTextResult.class); - } catch (IOException e) { - throw new IllegalStateException("aimux: failed to decode GenerateTextResult: " + e.getMessage(), e); - } - } - - // ── generateObject (M12, RFC-0016) ──────────────────────────────────── - - /** - * Generate a structured JSON object from a simple string prompt (M12). - * - * @param prompt Plain text prompt. - * @return Decoded {@link Types.GenerateObjectResult}. - */ - public Types.GenerateObjectResult generateObject(String prompt) { - return generateObject(prompt, null); + public Types.ChatCompletion generateOpenAI(String paramsJson) { + return decode(model.generateOpenAI(paramsJson), Types.ChatCompletion.class); } - /** - * Generate a structured JSON object from a simple string prompt (M12). - * - * @param prompt Plain text prompt. - * @param options Optional typed {@link Types.GenerateTextOptions}, or {@code null}. - * @return Decoded {@link Types.GenerateObjectResult}. - */ - public Types.GenerateObjectResult generateObject(String prompt, Types.GenerateTextOptions options) { - String promptJson = encode(prompt); - String optsJson = encodeOptions(options); - return decodeObjectResult(repaired(raw.generateObject(promptJson, optsJson), promptJson, optsJson, options)); + public Future generateAsync(String paramsJson, Executor executor) { + return new DecodedFuture<>(model.generateAsync(paramsJson, executor), Types.GenerateTextResult.class); } - /** - * Generate a structured JSON object from a multi-role message list (M12). - * - * @param messages Conversation as a list of {@link Types.ModelMessage}s. - * @return Decoded {@link Types.GenerateObjectResult}. - */ - public Types.GenerateObjectResult generateObject(List messages) { - return generateObject(messages, null); + public void stream(String paramsJson, Consumer consumer) { + Objects.requireNonNull(consumer, "consumer"); + model.stream(paramsJson, frame -> { + if (!frame.isEnd()) consumer.accept(decode(frame.getEvent(), Types.StreamPart.class)); + }); } - /** - * Generate a structured JSON object from a multi-role message list (M12). - * - * @param messages Conversation as a list of {@link Types.ModelMessage}s. - * @param options Optional typed {@link Types.GenerateTextOptions}, or {@code null}. - * @return Decoded {@link Types.GenerateObjectResult}. - */ - public Types.GenerateObjectResult generateObject(List messages, Types.GenerateTextOptions options) { - String promptJson = encode(messages); - String optsJson = encodeOptions(options); - return decodeObjectResult(repaired(raw.generateObject(promptJson, optsJson), promptJson, optsJson, options)); + private static T decode(JsonNode value, Class type) { + try { return Types.AimuxJson.MAPPER.treeToValue(value, type); } + catch (IOException e) { throw new IllegalStateException("Invalid " + type.getSimpleName(), e); } } - private Types.GenerateObjectResult decodeObjectResult(String resultJson) { - try { - return Types.AimuxJson.MAPPER.readValue(resultJson, Types.GenerateObjectResult.class); - } catch (IOException e) { - throw new IllegalStateException("aimux: failed to decode GenerateObjectResult: " + e.getMessage(), e); + private static final class DecodedFuture implements Future { + private final Future source; + private final Class type; + DecodedFuture(Future source, Class type) { this.source = source; this.type = type; } + @Override public boolean cancel(boolean mayInterrupt) { return source.cancel(mayInterrupt); } + @Override public boolean isCancelled() { return source.isCancelled(); } + @Override public boolean isDone() { return source.isDone(); } + @Override public T get() throws InterruptedException, ExecutionException { + try { return decode(source.get(), type); } + catch (RuntimeException e) { + if (e instanceof java.util.concurrent.CancellationException) throw e; + throw new ExecutionException(e); + } } - } - - // ── consumeStreamText (M11, RFC-0016) ───────────────────────────────── - - /** - * Consume a stream to completion from a simple string prompt and return - * the aggregated result (M11). - * - * @param prompt Plain text prompt. - * @return Decoded {@link Types.GenerateTextResult}. - */ - public Types.GenerateTextResult consumeStreamText(String prompt) { - return consumeStreamText(prompt, null); - } - - /** - * Consume a stream to completion from a simple string prompt and return - * the aggregated result (M11). - * - * @param prompt Plain text prompt. - * @param options Optional typed {@link Types.GenerateTextOptions}, or {@code null}. - * @return Decoded {@link Types.GenerateTextResult}. - */ - public Types.GenerateTextResult consumeStreamText(String prompt, Types.GenerateTextOptions options) { - String promptJson = encode(prompt); - String optsJson = encodeOptions(options); - return decodeResult(repaired(raw.consumeStreamText(promptJson, optsJson), promptJson, optsJson, options)); - } - - /** - * Consume a stream to completion from a multi-role message list and - * return the aggregated result (M11). - * - * @param messages Conversation as a list of {@link Types.ModelMessage}s. - * @return Decoded {@link Types.GenerateTextResult}. - */ - public Types.GenerateTextResult consumeStreamText(List messages) { - return consumeStreamText(messages, null); - } - - /** - * Consume a stream to completion from a multi-role message list and - * return the aggregated result (M11). - * - * @param messages Conversation as a list of {@link Types.ModelMessage}s. - * @param options Optional typed {@link Types.GenerateTextOptions}, or {@code null}. - * @return Decoded {@link Types.GenerateTextResult}. - */ - public Types.GenerateTextResult consumeStreamText(List messages, Types.GenerateTextOptions options) { - String promptJson = encode(messages); - String optsJson = encodeOptions(options); - return decodeResult(repaired(raw.consumeStreamText(promptJson, optsJson), promptJson, optsJson, options)); - } - - // ── streamText (callback version) ───────────────────────────────────── - - /** - * Stream text from a simple string prompt, delivering each chunk as a - * decoded {@link Types.StreamPart}. - * - *

Blocks the calling thread until the stream completes. A part that - * fails to decode is reported via {@code onError}. Terminal C-level stream - * failures throw {@link AimuxException} from the raw layer. - * - * @param prompt Plain text prompt. - * @param onPart Called for each decoded {@link Types.StreamPart}. - * @param onDone Called once when the stream completes normally. - * @param onError Called with a message on a decode error (not C terminal failures). - */ - public void streamText(String prompt, Consumer onPart, - Runnable onDone, Consumer onError) { - streamText(prompt, null, onPart, onDone, onError); - } - - /** - * Stream text from a simple string prompt with typed options. - * - * @param prompt Plain text prompt. - * @param options Optional typed {@link Types.GenerateTextOptions}, or {@code null}. - * @param onPart Called for each decoded {@link Types.StreamPart}. - * @param onDone Called once when the stream completes normally. - * @param onError Called with a message on a stream or decode error. - */ - public void streamText(String prompt, Types.GenerateTextOptions options, - Consumer onPart, - Runnable onDone, Consumer onError) { - streamTextParts(encode(prompt), encodeOptions(options), hook(options), onPart, onDone, onError); - } - - /** - * Stream text from a multi-role message list, delivering typed parts. - * - * @param messages Conversation as a list of {@link Types.ModelMessage}s. - * @param onPart Called for each decoded {@link Types.StreamPart}. - * @param onDone Called once when the stream completes normally. - * @param onError Called with a message on a stream or decode error. - */ - public void streamText(List messages, Consumer onPart, - Runnable onDone, Consumer onError) { - streamText(messages, null, onPart, onDone, onError); - } - - /** - * Stream text from a multi-role message list with typed options. - * - * @param messages Conversation as a list of {@link Types.ModelMessage}s. - * @param options Optional typed {@link Types.GenerateTextOptions}, or {@code null}. - * @param onPart Called for each decoded {@link Types.StreamPart}. - * @param onDone Called once when the stream completes normally. - * @param onError Called with a message on a stream or decode error. - */ - public void streamText(List messages, Types.GenerateTextOptions options, - Consumer onPart, - Runnable onDone, Consumer onError) { - streamTextParts(encode(messages), encodeOptions(options), hook(options), onPart, onDone, onError); - } - - private void streamTextParts(final String promptJson, final String optsJson, - final ToolCallRepair hook, - Consumer onPart, - Runnable onDone, Consumer onError) { - raw.streamText( - promptJson, optsJson, - rawPartJson -> { - // Repair runs on the stream thread, between the native part - // and the consumer, so the consumer only ever sees the - // repaired call. The three repair functions are pure data - // calls, safe to make from inside the native callback. A - // repair that fails at the boundary (rather than in the hook, - // which core turns into a ToolCallRepair error) must not drop - // the call: report it and deliver the unrepaired part. - String partJson = rawPartJson; - try { - partJson = ToolCallRepairs.repairStreamPart(rawPartJson, promptJson, optsJson, hook, raw); - } catch (RuntimeException e) { - onError.accept("failed to repair tool call: " + e.getMessage()); - } - try { - onPart.accept(decodePart(partJson)); - } catch (RuntimeException e) { - onError.accept("failed to decode StreamPart: " + e.getMessage()); - } - }, - onDone); - } - - // ── streamTextStream (pull version) ─────────────────────────────────── - - /** - * Stream text as a lazy {@link Stream} of typed {@link Types.StreamPart}s. - * - *

The FFI call starts on the first terminal operation of the returned - * stream (mirror of Kotlin's {@code streamTextSequence}). Iteration pulls - * parts from a {@link LinkedBlockingQueue} fed by the stream callbacks; - * the stream ends at the sentinel. Terminal stream failures throw - * {@link AimuxException} from the blocking FFI call. - * - * @param prompt Plain text prompt. - * @return Lazy stream of decoded parts. - */ - public Stream streamTextStream(String prompt) { - return streamTextStream(prompt, null); - } - - /** - * Stream text as a lazy {@link Stream} of typed parts with options. - * - * @param prompt Plain text prompt. - * @param options Optional typed {@link Types.GenerateTextOptions}, or {@code null}. - * @return Lazy stream of decoded parts. - */ - public Stream streamTextStream(String prompt, Types.GenerateTextOptions options) { - return streamTextStreamParts(encode(prompt), encodeOptions(options), hook(options)); - } - - /** - * Stream text from a multi-role message list as a lazy typed stream. - * - * @param messages Conversation as a list of {@link Types.ModelMessage}s. - * @return Lazy stream of decoded parts. - */ - public Stream streamTextStream(List messages) { - return streamTextStream(messages, null); - } - - /** - * Stream text from a multi-role message list as a lazy typed stream with options. - * - * @param messages Conversation as a list of {@link Types.ModelMessage}s. - * @param options Optional typed {@link Types.GenerateTextOptions}, or {@code null}. - * @return Lazy stream of decoded parts. - */ - public Stream streamTextStream(List messages, Types.GenerateTextOptions options) { - return streamTextStreamParts(encode(messages), encodeOptions(options), hook(options)); - } - - private Stream streamTextStreamParts(final String promptJson, final String optsJson, - final ToolCallRepair hook) { - // Sentinel for end-of-stream. Java's LinkedBlockingQueue rejects null - // elements, so use a unique object instead of Kotlin's null sentinel. - final Object END = new Object(); - return StreamSupport.stream( - new java.util.Spliterators.AbstractSpliterator( - Long.MAX_VALUE, Spliterator.ORDERED) { - private final LinkedBlockingQueue parts = new LinkedBlockingQueue<>(); - private final AtomicBoolean started = new AtomicBoolean(false); - private final AtomicReference error = new AtomicReference<>(); - private boolean exhausted; - - @Override - public boolean tryAdvance(Consumer action) { - if (!started.getAndSet(true)) { - try { - streamTextParts(promptJson, optsJson, hook, - parts::add, - () -> parts.add(END), // sentinel = end of stream - err -> { - error.set(err); - parts.add(END); - }); - } catch (RuntimeException e) { - parts.add(END); // prevent a hang on later iteration - throw e; - } - } - if (exhausted) { - return false; - } - final Object part; - try { - part = parts.take(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException("interrupted while streaming", e); - } - if (part == END) { - exhausted = true; - String err = error.get(); - if (err != null) { - throw new AimuxException(err); - } - return false; - } - action.accept((Types.StreamPart) part); - return true; - } - }, - false); - } - - // ── OpenAI-compatible output (RFC-0026) — generate ───────────────────── - - /** - * Generate text (non-streaming) with OpenAI Chat Completions output. - * - * @param prompt Plain text prompt (encoded as a JSON string on the wire). - * @return Decoded {@link Types.ChatCompletion}. - */ - public Types.ChatCompletion generateTextAsOpenAI(String prompt) { - return generateTextAsOpenAI(prompt, null); - } - - /** - * Generate text (non-streaming) with OpenAI Chat Completions output. - * - * @param prompt Plain text prompt. - * @param options Optional typed {@link Types.GenerateTextOptions}, or {@code null}. - * @return Decoded {@link Types.ChatCompletion}. - */ - public Types.ChatCompletion generateTextAsOpenAI(String prompt, Types.GenerateTextOptions options) { - return generateTextAsOpenAIParts(encode(prompt), encodeOptions(options), options); - } - - /** - * Generate text from a multi-role message list with OpenAI Chat Completions output. - * - * @param messages Conversation as a list of {@link Types.ModelMessage}s. - * @return Decoded {@link Types.ChatCompletion}. - */ - public Types.ChatCompletion generateTextAsOpenAI(List messages) { - return generateTextAsOpenAI(messages, null); - } - - /** - * Generate text from a multi-role message list with OpenAI Chat Completions output. - * - * @param messages Conversation as a list of {@link Types.ModelMessage}s. - * @param options Optional typed {@link Types.GenerateTextOptions}, or {@code null}. - * @return Decoded {@link Types.ChatCompletion}. - */ - public Types.ChatCompletion generateTextAsOpenAI(List messages, Types.GenerateTextOptions options) { - return generateTextAsOpenAIParts(encode(messages), encodeOptions(options), options); - } - - /** - * A {@code ChatCompletion} has no {@code invalid} marker to repair from, so - * with a hook the native result is generated, repaired, then converted — - * its tool calls carry the repaired arguments. - */ - private Types.ChatCompletion generateTextAsOpenAIParts(String promptJson, String optsJson, - Types.GenerateTextOptions options) { - if (hook(options) == null) { - return decodeChatCompletion(raw.generateTextAsOpenAI(promptJson, optsJson)); - } - String resultJson = repaired(raw.generateText(promptJson, optsJson), promptJson, optsJson, options); - return decodeChatCompletion(raw.generateTextResultAsOpenAI(resultJson)); - } - - private Types.ChatCompletion decodeChatCompletion(String resultJson) { - try { - return Types.AimuxJson.MAPPER.readValue(resultJson, Types.ChatCompletion.class); - } catch (IOException e) { - throw new IllegalStateException("aimux: failed to decode ChatCompletion: " + e.getMessage(), e); - } - } - - // ── OpenAI-compatible output (RFC-0026) — stream ─────────────────────── - - /** - * Stream text with OpenAI Chat Completions output, delivering typed chunks. - * - * @param prompt Plain text prompt. - * @param onPart Called for each decoded {@link Types.ChatCompletionChunk}. - * @param onDone Called once when the stream completes normally. - * @param onError Called with a message on a stream or decode error. - */ - public void streamTextAsOpenAI(String prompt, Consumer onPart, - Runnable onDone, Consumer onError) { - streamTextAsOpenAI(prompt, null, onPart, onDone, onError); - } - - /** - * Stream text with OpenAI Chat Completions output, delivering typed chunks. - * - * @param prompt Plain text prompt. - * @param options Optional typed {@link Types.GenerateTextOptions}, or {@code null}. - * @param onPart Called for each decoded {@link Types.ChatCompletionChunk}. - * @param onDone Called once when the stream completes normally. - * @param onError Called with a message on a stream or decode error. - */ - public void streamTextAsOpenAI(String prompt, Types.GenerateTextOptions options, - Consumer onPart, - Runnable onDone, Consumer onError) { - streamTextAsOpenAIChunks(encode(prompt), encodeOptions(options), onPart, onDone, onError); - } - - /** - * Stream text from a multi-role message list with OpenAI Chat Completions output. - * - * @param messages Conversation as a list of {@link Types.ModelMessage}s. - * @param onPart Called for each decoded {@link Types.ChatCompletionChunk}. - * @param onDone Called once when the stream completes normally. - * @param onError Called with a message on a stream or decode error. - */ - public void streamTextAsOpenAI(List messages, Consumer onPart, - Runnable onDone, Consumer onError) { - streamTextAsOpenAI(messages, null, onPart, onDone, onError); - } - - /** - * Stream text from a multi-role message list with OpenAI Chat Completions output. - * - * @param messages Conversation as a list of {@link Types.ModelMessage}s. - * @param options Optional typed {@link Types.GenerateTextOptions}, or {@code null}. - * @param onPart Called for each decoded {@link Types.ChatCompletionChunk}. - * @param onDone Called once when the stream completes normally. - * @param onError Called with a message on a stream or decode error. - */ - public void streamTextAsOpenAI(List messages, Types.GenerateTextOptions options, - Consumer onPart, - Runnable onDone, Consumer onError) { - streamTextAsOpenAIChunks(encode(messages), encodeOptions(options), onPart, onDone, onError); - } - - private void streamTextAsOpenAIChunks(String promptJson, String optsJson, - Consumer onPart, - Runnable onDone, Consumer onError) { - raw.streamTextAsOpenAI( - promptJson, optsJson, - chunkJson -> { - try { - onPart.accept(decodeChunk(chunkJson)); - } catch (RuntimeException e) { - onError.accept("failed to decode ChatCompletionChunk: " + e.getMessage()); - } - }, - onDone); - } - - /** - * Stream text with OpenAI Chat Completions output as a lazy typed stream. - * - * @param prompt Plain text prompt. - * @return Lazy stream of decoded chunks. - */ - public Stream streamTextAsOpenAIStream(String prompt) { - return streamTextAsOpenAIStream(prompt, null); - } - - /** - * Stream text with OpenAI Chat Completions output as a lazy typed stream. - * - * @param prompt Plain text prompt. - * @param options Optional typed {@link Types.GenerateTextOptions}, or {@code null}. - * @return Lazy stream of decoded chunks. - */ - public Stream streamTextAsOpenAIStream(String prompt, Types.GenerateTextOptions options) { - return streamTextAsOpenAIChunksStream(encode(prompt), encodeOptions(options)); - } - - /** - * Stream text from a multi-role message list with OpenAI Chat Completions output. - * - * @param messages Conversation as a list of {@link Types.ModelMessage}s. - * @return Lazy stream of decoded chunks. - */ - public Stream streamTextAsOpenAIStream(List messages) { - return streamTextAsOpenAIStream(messages, null); - } - - /** - * Stream text from a multi-role message list with OpenAI Chat Completions output. - * - * @param messages Conversation as a list of {@link Types.ModelMessage}s. - * @param options Optional typed {@link Types.GenerateTextOptions}, or {@code null}. - * @return Lazy stream of decoded chunks. - */ - public Stream streamTextAsOpenAIStream(List messages, Types.GenerateTextOptions options) { - return streamTextAsOpenAIChunksStream(encode(messages), encodeOptions(options)); - } - - private Stream streamTextAsOpenAIChunksStream(final String promptJson, final String optsJson) { - final Object END = new Object(); - return StreamSupport.stream( - new java.util.Spliterators.AbstractSpliterator( - Long.MAX_VALUE, Spliterator.ORDERED) { - private final LinkedBlockingQueue parts = new LinkedBlockingQueue<>(); - private final AtomicBoolean started = new AtomicBoolean(false); - private final AtomicReference error = new AtomicReference<>(); - private boolean exhausted; - - @Override - public boolean tryAdvance(Consumer action) { - if (!started.getAndSet(true)) { - try { - streamTextAsOpenAIChunks(promptJson, optsJson, - parts::add, - () -> parts.add(END), - err -> { - error.set(err); - parts.add(END); - }); - } catch (RuntimeException e) { - parts.add(END); - throw e; - } - } - if (exhausted) { - return false; - } - final Object part; - try { - part = parts.take(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException("interrupted while streaming", e); - } - if (part == END) { - exhausted = true; - String err = error.get(); - if (err != null) { - throw new AimuxException(err); - } - return false; - } - action.accept((Types.ChatCompletionChunk) part); - return true; - } - }, - false); - } - - // ── Companion factories ─────────────────────────────────────────────── - - /** Wrap an existing {@link Model}; the caller retains ownership of the handle. */ - public static TypedModel of(Model model) { - return new TypedModel(model, false); - } - - /** Create an OpenAI-backed {@link TypedModel} that owns its {@link Model}. */ - public static TypedModel openai(String apiKey, String modelId) { - return new TypedModel(Model.openai(apiKey, modelId), true); - } - - /** Create an OpenAI-backed {@link TypedModel} with a custom base URL; owns its {@link Model}. */ - public static TypedModel openaiWithBase(String apiKey, String modelId, String baseUrl) { - return new TypedModel(Model.openaiWithBase(apiKey, modelId, baseUrl), true); - } - - /** Create an Anthropic-backed {@link TypedModel} that owns its {@link Model}. */ - public static TypedModel anthropic(String apiKey, String modelId) { - return new TypedModel(Model.anthropic(apiKey, modelId), true); - } - - /** Create an Anthropic-backed {@link TypedModel} with a custom base URL; owns its {@link Model}. */ - public static TypedModel anthropicWithBase(String apiKey, String modelId, String baseUrl) { - return new TypedModel(Model.anthropicWithBase(apiKey, modelId, baseUrl), true); - } - - /** - * Create a registry-backed {@link TypedModel} (RFC-0017 phase 4) that owns - * its {@link Model}. See {@link Model#provider(String, String, String, String)}. - */ - public static TypedModel provider(String name, String apiKey, String modelId, String configJson) { - return new TypedModel(Model.provider(name, apiKey, modelId, configJson), true); - } - - // ── helpers ─────────────────────────────────────────────────────────── - - /** - * Host-side tool-call repair (RFC-0035) over a result document, before it - * is decoded. Without a hook the JSON is returned untouched. - */ - private static String repaired(String resultJson, String promptJson, String optsJson, - Types.GenerateTextOptions options) { - return ToolCallRepairs.repairResult(resultJson, promptJson, optsJson, hook(options)); - } - - private static ToolCallRepair hook(Types.GenerateTextOptions options) { - return options == null ? null : options.getRepairToolCall(); - } - - private static String encodeOptions(Types.GenerateTextOptions options) { - return options == null ? null : encode(options); - } - - private static String encode(Object value) { - try { - return Types.AimuxJson.MAPPER.writeValueAsString(value); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException("Failed to serialize typed input", e); - } - } - - private static Types.StreamPart decodePart(String partJson) { - try { - return Types.AimuxJson.MAPPER.readValue(partJson, Types.StreamPart.class); - } catch (IOException e) { - throw new IllegalStateException("aimux: failed to decode StreamPart: " + e.getMessage(), e); - } - } - - private static Types.ChatCompletionChunk decodeChunk(String chunkJson) { - try { - return Types.AimuxJson.MAPPER.readValue(chunkJson, Types.ChatCompletionChunk.class); - } catch (IOException e) { - throw new IllegalStateException("aimux: failed to decode ChatCompletionChunk: " + e.getMessage(), e); + @Override public T get(long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + try { return decode(source.get(timeout, unit), type); } + catch (RuntimeException e) { + if (e instanceof java.util.concurrent.CancellationException) throw e; + throw new ExecutionException(e); + } } } } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/Types.java b/bindings/java/src/main/java/ai/arcships/aimux/Types.java index 2cfcee252..907ff6018 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/Types.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/Types.java @@ -2,7 +2,6 @@ import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; @@ -29,22 +28,13 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.function.Supplier; /** - * aimux — typed data classes mirroring the Kotlin binding in - * `bindings/kotlin` (which in turn mirrors the ts-rs output in - * `bindings/node/src/types`). - * - * Field names are camelCase in Java and on the wire (the AI SDK JSON), so - * {@link JsonProperty} only pins them. The raw JSON boundary is handled by - * {@link TypedModel} — callers of this layer never parse JSON by hand. + * Typed data classes for the aimux wire format. Field names are camelCase + * in Java and on the wire. * * These types are intentionally lenient on decode (unknown keys ignored, every * field has a default) so that future provider/engine additions do not break @@ -53,18 +43,6 @@ public final class Types { private Types() {} - // ───────────────────────────────────────────────────────────────────────────── - // Shared ObjectMapper. - // - // - FAIL_ON_UNKNOWN_PROPERTIES disabled : tolerate forward-compatible fields. - // - NON_NULL inclusion : omit null fields on encode so - // GenerateTextOptions only carries - // fields the caller actually set. - // - FIELD visibility ANY : read/write fields directly; the - // package-private @JsonCreator ctor - // handles instantiation. - // ───────────────────────────────────────────────────────────────────────────── - /** * Check the {@code type} tag of a value that always carries one (a * standalone tool call or source). JSON written before the tag has none. @@ -79,29 +57,13 @@ static void requireTypeTag(String expected, String actual) { public static final class AimuxJson { private AimuxJson() {} - public static final ObjectMapper MAPPER = createMapper(); + public static final ObjectMapper MAPPER = configure(new ObjectMapper(), true); /** - * Inner mapper used by the `type`-tagged polymorphic serializers - * ({@link ContentPartSerializer}, {@link StreamPartSerializer}, - * {@link GenerateContentSerializer}) to serialize the concrete variant - * as a plain POJO. It deliberately does NOT register those four - * polymorphic serializers, so {@code valueToTree} on a concrete subtype - * (e.g. {@code StreamPart.TextDelta}) uses default field serialization - * instead of re-entering the polymorphic serializer — which would - * otherwise infinite-recurse ({@code StackOverflowError}). Non-recursing - * serializers that concrete variants may transitively contain - * ({@link ToolChoice}, {@link FileBytes}, {@link GeneratedFileData}) are kept. + * Serializes concrete variants without re-entering their polymorphic + * serializers. Non-recursing serializers remain registered. */ - public static final ObjectMapper INNER_MAPPER = createInnerMapper(); - - private static ObjectMapper createMapper() { - return configure(new ObjectMapper(), true); - } - - private static ObjectMapper createInnerMapper() { - return configure(new ObjectMapper(), false); - } + public static final ObjectMapper INNER_MAPPER = configure(new ObjectMapper(), false); private static ObjectMapper configure(ObjectMapper m, boolean polymorphic) { m.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); @@ -1926,8 +1888,8 @@ public int hashCode() { /** * A tool call as the provider emitted it: {@code input} is the raw argument - * TEXT, not the parsed object. This is what a {@link ToolCallRepair} - * inspects and what it returns as the replacement. Its JSON carries + * TEXT, not the parsed object. {@link ToolCallRepairs} inspects this data + * and validates replacements. Its JSON carries * {@code "type": "tool-call"}, as the AI SDK's {@code LanguageModelV4ToolCall}. */ public static class RawToolCall { @@ -1989,7 +1951,7 @@ public RawToolCall build() { } /** - * The argument handed to a {@link ToolCallRepair} — the aimux equivalent of + * The context returned by {@link ToolCallRepairs} — the aimux equivalent of * the AI SDK {@code repairToolCall} context. Built by the library from the * invalid call plus the prompt and options the call was generated with, so * {@link #getMessages()} is the conversation the model actually saw. @@ -2042,9 +2004,6 @@ public static class GenerateTextOptions { @JsonProperty("includeRawChunks") private Boolean includeRawChunks; @JsonProperty("timeout") private TimeoutConfiguration timeout; @JsonProperty("sessionId") private String sessionId; - // Host-side only (RFC-0035): a function cannot cross the C ABI, and the - // native layer must never see this key. - @JsonIgnore private ToolCallRepair repairToolCall; @JsonCreator GenerateTextOptions() {} @@ -2097,29 +2056,6 @@ private GenerateTextOptions(Long maxOutputTokens, Double temperature, ListApplies to {@link TypedModel} calls only and is never serialized - * into the options JSON. {@code generateTextAsOpenAI} repairs the - * native result before converting it; {@code streamTextAsOpenAI} does - * not reflect repair (its tool-argument deltas are the provider's - * text, as in the AI SDK). - * - *

If a repair fails at the boundary while streaming (not in the - * hook — a throwing hook is a {@code ToolCallRepair} error on the - * call), {@code onError} is told and the unrepaired part is still - * delivered. The stream waits for the hook, without a timeout. - * - *

A stream hook may call back into the model being streamed, but - * only from the thread it is invoked on: that thread borrows the - * stream's read hold on the model, while extra threads the hook starts - * itself do not and would deadlock against a concurrent - * {@link Model#close()}. - */ - public ToolCallRepair getRepairToolCall() { return repairToolCall; } - public static Builder builder() { return new Builder(); } public static class Builder { @@ -2142,7 +2078,6 @@ public static class Builder { private Boolean includeRawChunks; private TimeoutConfiguration timeout; private String sessionId; - private ToolCallRepair repairToolCall; public Builder maxOutputTokens(Long v) { this.maxOutputTokens = v; return this; } public Builder temperature(Double v) { this.temperature = v; return this; } @@ -2163,17 +2098,12 @@ public static class Builder { public Builder includeRawChunks(Boolean v) { this.includeRawChunks = v; return this; } public Builder timeout(TimeoutConfiguration v) { this.timeout = v; return this; } public Builder sessionId(String v) { this.sessionId = v; return this; } - /** See {@link GenerateTextOptions#getRepairToolCall()} (RFC-0035). */ - public Builder repairToolCall(ToolCallRepair v) { this.repairToolCall = v; return this; } public GenerateTextOptions build() { GenerateTextOptions options = new GenerateTextOptions(maxOutputTokens, temperature, stopSequences, topP, topK, presencePenalty, frequencyPenalty, output, seed, tools, toolChoice, headers, providerOptions, reasoning, instructions, maxRetries, includeRawChunks, timeout, sessionId); - // Set outside the constructor: the hook is host-side state, not - // part of the serialized option set. - options.repairToolCall = repairToolCall; return options; } } @@ -3782,7 +3712,7 @@ public boolean equals(Object o) { // // `StreamPart` is tagged on `type` (`{"type":"text-delta",...}`, // `{"type":"tool-call",...}`, ...). It mirrors the TextStreamPart that - // `aimux_stream_text` yields. Unrecognized variants fall back to StreamPart.Unknown for + // `text.stream` yields in event frames. Unrecognized variants fall back to StreamPart.Unknown for // forward compatibility. // ───────────────────────────────────────────────────────────────────────────── diff --git a/bindings/java/src/main/java/ai/arcships/aimux/VideoModel.java b/bindings/java/src/main/java/ai/arcships/aimux/VideoModel.java index 50424eb97..d25c4a7ed 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/VideoModel.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/VideoModel.java @@ -1,101 +1,17 @@ package ai.arcships.aimux; -import com.sun.jna.Pointer; -import com.sun.jna.ptr.LongByReference; -import com.sun.jna.ptr.PointerByReference; -import java.io.Closeable; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Generates videos from prompts. Wraps a Rust {@code Arc}. - * - *

Implements {@link Closeable} — call {@link #close()} (or use a - * try-with-resources block) to release the native handle. - * - *

{@code
- * try (VideoModel model = VideoModel.google("sk-...", "veo-3.0")) {
- *     String result = model.generate("{\"prompt\":\"a sunset\",\"n\":1}");
- * }
- * }
- */ -public final class VideoModel implements Closeable { - - private final AtomicLong handle; - - private VideoModel(long handle) { - this.handle = new AtomicLong(handle); - } - - /** Release the native handle. Idempotent and thread-safe. */ - @Override - public void close() { - long h = handle.getAndSet(0L); - if (h != 0L) AimuxFFI.INSTANCE.aimux_drop_handle(h); - } - - /** Best-effort backstop; try-with-resources is the primary release path. */ - @Override - protected void finalize() throws Throwable { - close(); - super.finalize(); - } - - private long requireHandle() { - long h = handle.get(); - if (h == 0L) throw new IllegalStateException("VideoModel is closed"); - return h; - } - - /** - * Create a Google video model. - * - * @param apiKey Google API key. - * @param modelId Model ID (e.g. {@code veo-3.0}). - * @return a new VideoModel. - * @throws AimuxException on failure. - */ - public static VideoModel google(String apiKey, String modelId) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_google_video_new(apiKey, modelId, out); - return new VideoModel(AimuxResult.extractHandle(e, out, "Failed to create Google video model")); - } - - /** - * Create a Google video model with a custom base URL. - * - * @param apiKey Google API key. - * @param modelId Model ID. - * @param baseUrl Custom base URL. - * @return a new VideoModel. - * @throws AimuxException on failure. - */ - public static VideoModel googleWithBase(String apiKey, String modelId, String baseUrl) { - Objects.requireNonNull(apiKey, "apiKey"); - Objects.requireNonNull(modelId, "modelId"); - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_google_video_new_with_base(apiKey, modelId, baseUrl, out); - return new VideoModel(AimuxResult.extractHandle(e, out, "Failed to create Google video model")); - } - - /** - * Generate videos from the given options. - * - * @param optsJson JSON-serialized {@code VideoCallOptions}. - * Required: carries the input. - * @return JSON-serialized {@code VideoResult}. - * @throws NullPointerException if {@code optsJson} is null. - * @throws IllegalArgumentException if {@code optsJson} is blank or malformed JSON. - * @throws AimuxException on engine / transport failure. - */ - public String generate(String optsJson) { - AimuxResult.requireJsonNonNull(optsJson, "optsJson"); - PointerByReference out = new PointerByReference(); - return AimuxResult.extractString( - AimuxFFI.INSTANCE.aimux_video_generate(requireHandle(), optsJson, out), - out, - "video_generate"); +import ai.arcships.aimux.generated.AimuxOps; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; + +/** Typed resource facade; request JSON uses the existing core wire DTO. */ +public final class VideoModel extends NativeHandle { + VideoModel(Aimux aimux, long id) { super(aimux, id); } + public JsonNode info() { return call(AimuxOps.OP_MODEL_INFO, "{}"); } + public JsonNode generate(String paramsJson) { return call(AimuxOps.OP_VIDEO_GENERATE, paramsJson); } + public Future generateAsync(String paramsJson, Executor executor) { + return callAsync(AimuxOps.OP_VIDEO_GENERATE, paramsJson, executor); } } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/internal/Cancellation.java b/bindings/java/src/main/java/ai/arcships/aimux/internal/Cancellation.java new file mode 100644 index 000000000..74f22138d --- /dev/null +++ b/bindings/java/src/main/java/ai/arcships/aimux/internal/Cancellation.java @@ -0,0 +1,46 @@ +package ai.arcships.aimux.internal; + +import java.util.Objects; +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Executor-driven calls with cancellation forwarded to the native abort signal. */ +public final class Cancellation { + private Cancellation() {} + + /** + * Schedules work on the supplied executor. The abort action must be bounded + * and is called at most once, only when cancellation wins over completion. + * Executor rejection is reported directly to the caller. + */ + public static Future submit(Executor executor, Callable work, Runnable abort) { + Objects.requireNonNull(executor, "executor"); + Objects.requireNonNull(work, "work"); + Objects.requireNonNull(abort, "abort"); + FutureTask task = new AbortableTask<>(work, abort); + executor.execute(task); + return task; + } + + private static final class AbortableTask extends FutureTask { + private final Runnable abort; + private final AtomicBoolean aborted = new AtomicBoolean(); + + AbortableTask(Callable work, Runnable abort) { + super(work); + this.abort = abort; + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + boolean cancelled = super.cancel(mayInterruptIfRunning); + if (cancelled && aborted.compareAndSet(false, true)) { + abort.run(); + } + return cancelled; + } + } +} diff --git a/bindings/java/src/test/java/ai/arcships/aimux/AbiContractTest.java b/bindings/java/src/test/java/ai/arcships/aimux/AbiContractTest.java new file mode 100644 index 000000000..10cebbc39 --- /dev/null +++ b/bindings/java/src/test/java/ai/arcships/aimux/AbiContractTest.java @@ -0,0 +1,424 @@ +package ai.arcships.aimux; + +import ai.arcships.aimux.generated.AimuxOps; +import com.fasterxml.jackson.databind.JsonNode; +import com.sun.jna.Library; +import com.sun.jna.Native; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import java.math.BigInteger; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; + +/** Runs through JNA into a test-only C library; no provider network or Rust ABI stub is used. */ +class AbiContractTest { + interface Fixture extends Library { + int fixture_entered(); + int fixture_live_strings(); + int fixture_live_errors(); + int fixture_drop_in_callback(); + int fixture_drops(int id); + int fixture_finalizers(int id); + int fixture_signals(int id); + int fixture_callbacks(); + long fixture_last_handle(); + long fixture_last_length(); + long fixture_last_timeout(); + int fixture_provider_calls(); + void fixture_abi(int version); + void fixture_ops(int mismatch); + void fixture_digest(int mismatch); + void fixture_poll(int state); + void fixture_stream(int mode); + void fixture_reset(); + } + + private Fixture fixture; + private Aimux aimux; + private ExecutorService executor; + + @BeforeEach void setUp() { + fixture = Native.load(System.getProperty("aimux.testLibrary"), Fixture.class); + fixture.fixture_reset(); + aimux = Aimux.load(System.getProperty("aimux.testLibrary")); + executor = Executors.newSingleThreadExecutor(task -> { + Thread thread = new Thread(task, "aimux-contract-test"); + thread.setDaemon(true); + return thread; + }); + } + + @AfterEach void tearDown() throws Exception { + executor.shutdown(); + assertThat(executor.awaitTermination(3, TimeUnit.SECONDS)).isTrue(); + await(() -> fixture.fixture_live_strings() == 0 && fixture.fixture_live_errors() == 0); + assertThat(fixture.fixture_live_strings()).isZero(); + assertThat(fixture.fixture_live_errors()).isZero(); + } + + private static void await(BooleanSupplier condition) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + while (!condition.getAsBoolean() && System.nanoTime() < deadline) Thread.sleep(2); + assertThat(condition.getAsBoolean()).as("condition before deadline").isTrue(); + } + + @Test void declaresOnlyTheFourteenPrimitivesWithPortableSizeT() { + assertThat(Arrays.stream(AimuxFFI.class.getDeclaredMethods()).map(m -> m.getName())) + .containsExactlyInAnyOrder("aimux_abi_version", "aimux_manifest_json", "aimux_call", "aimux_stream", + "aimux_operation_push", "aimux_operation_next_part", "aimux_abort", "aimux_drop", "aimux_handle_finalizer", + "aimux_free_string", "aimux_error_free", "aimux_error_code", "aimux_error_message", "aimux_error_json"); + assertThat(Native.getNativeSize(AimuxFFI.SizeT.class)).isEqualTo(Native.SIZE_T_SIZE); + assertThatThrownBy(() -> new AimuxFFI.SizeT(-1)).isInstanceOf(IllegalArgumentException.class); + } + + @Test void rejectsAbiAndOpsMismatchBeforeCreatingAnyProvider() { + fixture.fixture_abi(2 << 16); + assertThatThrownBy(() -> Aimux.load(System.getProperty("aimux.testLibrary"))) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("ABI"); + fixture.fixture_abi(Aimux.ABI_VERSION); + fixture.fixture_ops(1); + assertThatThrownBy(() -> Aimux.load(System.getProperty("aimux.testLibrary"))) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("1.1").hasMessageContaining(Aimux.OPS_VERSION); + fixture.fixture_ops(0); + fixture.fixture_digest(1); + assertThatThrownBy(() -> Aimux.load(System.getProperty("aimux.testLibrary"))) + .isInstanceOf(IllegalStateException.class).hasMessageContainingAll("wrong-digest", AimuxOps.MANIFEST_DIGEST); + assertThat(fixture.fixture_provider_calls()).isZero(); + } + + private java.lang.ref.WeakReference abandon(int id, boolean close) { + Resource resource = aimux.own(Integer.toString(id)); + if (close) { resource.close(); resource.close(); } + return new java.lang.ref.WeakReference<>(resource); + } + + @Test void unreachableHandlesUseTheNonBlockingNativeFinalizerExactlyOnce() throws Exception { + java.lang.ref.WeakReference leaked = abandon(4001, false); + java.lang.ref.WeakReference closed = abandon(4002, true); + await(() -> { + System.gc(); + return leaked.get() == null && closed.get() == null && fixture.fixture_finalizers(4001) == 1; + }); + assertThat(fixture.fixture_drops(4001)).isEqualTo(1); + assertThat(fixture.fixture_drops(4002)).isEqualTo(1); + assertThat(fixture.fixture_finalizers(4002)).isZero(); + } + + @Test void callbackCopyRejectsOversizedAndNullFramesBeforeDecoding() { + try (Resource resource = aimux.own("4003")) { + fixture.fixture_stream(6); + assertThatThrownBy(() -> resource.stream(AimuxOps.OP_TEXT_STREAM, "{}", f -> fail("Unexpected frame"))) + .isInstanceOf(ProtocolException.ResourceLimit.class).hasMessageContaining("budget"); + fixture.fixture_stream(7); + assertThatThrownBy(() -> resource.stream(AimuxOps.OP_TEXT_STREAM, "{}", f -> fail("Unexpected frame"))) + .isInstanceOf(BindingException.CallbackFailure.class).hasRootCauseMessage("NULL stream frame"); + } + } + + @Test void consumerResourceLimitStillReportsCallbackFailure() { + try (Resource resource = aimux.own("4004")) { + assertThatThrownBy(() -> resource.stream(AimuxOps.OP_TEXT_STREAM, "{}", f -> { + throw envelope("protocol", 305, "ResourceLimit"); + })).isInstanceOf(BindingException.CallbackFailure.class) + .hasCauseInstanceOf(ProtocolException.ResourceLimit.class); + } + } + + @Test void roundTripsUtf8AndUnsigned64BitHandlesWithoutNumericJson() { + try (Resource resource = aimux.own("18446744073709551615")) { + assertThat(resource.getHandle()).isEqualTo("18446744073709551615"); + JsonNode result = resource.call("test.echo", "{\"prompt\":\"你好 🌏\\u0000\"}"); + assertThat(result.path("prompt").asText()).isEqualTo("你好 🌏\0"); + assertThat(fixture.fixture_last_handle()).isEqualTo(-1L); + } + assertThatThrownBy(() -> aimux.own("18446744073709551616")).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> aimux.own("0")).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> AimuxResult.handle(AimuxResult.parse("9007199254740993"))) + .isInstanceOf(IllegalStateException.class); + } + + @Test void rejectsInvalidControlJsonBeforeEnteringNativeCode() { + for (String json : new String[] {"", "{} garbage", "[]", "null", "{broken"}) { + assertThatThrownBy(() -> aimux.call("test.echo", json)).isInstanceOf(IllegalArgumentException.class); + } + assertThatThrownBy(() -> aimux.call("test.echo", null)).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> aimux.call("test\0echo", "{}")).isInstanceOf(IllegalArgumentException.class); + // Unknown control fields remain available for the native DTO's deny_unknown_fields validation. + assertThat(aimux.call("test.echo", "{\"unexpected\":true}").path("unexpected").asBoolean()).isTrue(); + } + + @Test void mapsOwnedErrorsFromEnvelopeAndRetainsOpenPayload() { + Throwable error = catchThrowable(() -> aimux.call("test.fail", "{}")); + assertThat(error).isInstanceOf(AimuxException.APICallError.class); + AimuxException api = (AimuxException) error; + assertThat(api.isRetryable()).isTrue(); + assertThat(api.getStatusCode()).isEqualTo(429); + assertThat(api.getRetryAfterMs()).isEqualTo(BigInteger.ZERO); + assertThat(api.getError().path("responseHeaders").path("x-request-id").asText()).isEqualTo("r1"); + assertThatThrownBy(() -> aimux.call("test.binding", "{}")) + .isInstanceOf(BindingException.InvalidHandle.class); + assertThatThrownBy(() -> aimux.call("test.protocol", "{}")) + .isInstanceOf(ProtocolException.InvalidOp.class); + } + + @Test void exceptionSubtypesCoverEveryDomainVariantAndUseNameDiscriminator() { + String[] names = {"AI_Error", "AI_JSONParseError", "AI_InvalidResponseDataError", + "AI_ToolCallNotFoundForApprovalError", "AI_InvalidArgumentError", "AI_InvalidPromptError", + "AI_TokenExpiredError", "AI_UnsupportedFunctionalityError", "AI_NoSuchModelError", + "AI_NoSuchProviderError", "AI_APICallError", "TimeoutError", "AbortError", "AI_RetryError", + "AI_NoSuchToolError", "AI_InvalidToolInputError", "AI_ToolCallRepairError", "AI_NoOutputGeneratedError", + "AI_LoadAPIKeyError", "AI_LoadSettingError", "AI_MissingToolResultsError", "AI_ToolChoiceViolationError"}; + java.util.Set> classes = new java.util.HashSet<>(); + for (String name : names) { + NativeException error = envelope("aimux", 1, name); + assertThat(error).isInstanceOf(AimuxException.class); + assertThat(error.getErrorName()).isEqualTo(name); + classes.add(error.getClass()); + } + assertThat(classes).hasSize(names.length); + assertVariants("binding", 200, "NullPointer", "InvalidUtf8", "InvalidWireJson", "InvalidHandle", + "ReentrantCall", "ResultSerialization", "CallbackFailure"); + assertVariants("protocol", 300, "InvalidOp", "InvalidParams", "UnsupportedOpsVersion", + "ConcurrentConsumer", "InvalidState", "ResourceLimit", "HandleExhausted"); + assertVariants("recording", 100, "Init", "OpenFile", "Spawn", "WriterGone", "FlushTimeout", "Write"); + NativeException absent = envelope("aimux", 11, "AI_APICallError"); + assertThat(absent.getRetryAfterMs()).isNull(); + NativeException max = AimuxResult.decode(AimuxResult.parse("{\"kind\":\"aimux\",\"code\":11,\"message\":\"x\"," + + "\"retryAfterMs\":18446744073709551615,\"error\":{\"name\":\"AI_APICallError\"}}")); + assertThat(max.getRetryAfterMs()).isEqualTo(new BigInteger("18446744073709551615")); + } + + private static NativeException envelope(String kind, int code, String name) { + return AimuxResult.decode(AimuxResult.parse("{\"kind\":\"" + kind + "\",\"code\":" + code + + ",\"message\":\"x\",\"error\":{\"name\":\"" + name + "\",\"extra\":42}}")); + } + + private static void assertVariants(String kind, int first, String... names) { + java.util.Set> classes = new java.util.HashSet<>(); + for (int i = 0; i < names.length; i++) { + NativeException error = envelope(kind, first + i, names[i]); + assertThat(error.getCode()).isEqualTo(first + i); + assertThat(error.getError().path("extra").asInt()).isEqualTo(42); + classes.add(error.getClass()); + } + assertThat(classes).hasSize(names.length); + } + + @Test void preservesUnrecognizedVariantsWithinEachErrorDomain() { + assertThat(envelope("aimux", 21, "AI_NewError")).isInstanceOf(AimuxException.Unknown.class); + assertThat(envelope("binding", 207, "NewBindingError")).isInstanceOf(BindingException.Unknown.class); + assertThat(envelope("protocol", 307, "NewProtocolError")).isInstanceOf(ProtocolException.Unknown.class); + NativeException error = envelope("recording", 106, "NewRecordingError"); + assertThat(error).isInstanceOf(RecordingException.Unknown.class); + assertThat(error.getErrorName()).isEqualTo("NewRecordingError"); + assertThat(error.getError().path("extra").asInt()).isEqualTo(42); + } + + @Test void closeFromConsumerDoesNotWaitAndDropsAfterNativeReturn() { + Resource resource = aimux.own("120"); + AtomicInteger delivered = new AtomicInteger(); + assertTimeoutPreemptively(Duration.ofSeconds(2), () -> resource.stream(AimuxOps.OP_TEXT_STREAM, "{}", frame -> { + delivered.incrementAndGet(); + resource.close(); + resource.close(); + assertThat(fixture.fixture_drops(120)).isZero(); + })); + assertThat(delivered.get()).isEqualTo(3); + assertThat(fixture.fixture_drops(120)).isEqualTo(1); + assertThat(fixture.fixture_drop_in_callback()).isZero(); + assertThatThrownBy(() -> resource.call(AimuxOps.OP_MODEL_INFO, "{}")).isInstanceOf(IllegalStateException.class); + } + + @Test void copiesFramesAndRetainsConsumerThroughSlowCallbacksAndGc() { + List frames = new ArrayList<>(); + try (Resource resource = aimux.own("121")) { + resource.stream(AimuxOps.OP_TEXT_STREAM, "{}", frame -> { + frames.add(frame); + System.gc(); + try { Thread.sleep(20); } catch (InterruptedException e) { throw new AssertionError(e); } + assertThat(fixture.fixture_callbacks()).isEqualTo(frames.size() - 1); + }); + } + assertThat(frames).hasSize(3); + assertThat(frames.get(0).getEvent().path("delta").asText()).isEqualTo("你好 🌏"); + assertThat(frames.get(2).getOutcome()).isEqualTo(StreamFrame.Outcome.SUCCESS); + } + + @Test void latchesConsumerThrowableSignalsAbortAndThrowsOnceAfterNativeReturns() { + AssertionError cause = new AssertionError("consumer broke"); + AtomicInteger deliveries = new AtomicInteger(); + try (Resource resource = aimux.own("122"); Abort abort = aimux.createAbort()) { + Throwable thrown = catchThrowable(() -> resource.stream(AimuxOps.OP_TEXT_STREAM, "{}", abort, frame -> { + deliveries.incrementAndGet(); + throw cause; + })); + assertThat(thrown).isInstanceOf(BindingException.CallbackFailure.class).hasCause(cause); + assertThat(fixture.fixture_callbacks()).isEqualTo(3); // native returned after all three callbacks + assertThat(deliveries.get()).isEqualTo(1); + assertThat(fixture.fixture_signals(Integer.parseInt(abort.getHandle()))).isEqualTo(1); + } + } + + @Test void callbackCanSignalAbortWithoutClosingTheModel() { + try (Resource resource = aimux.own("123"); Abort abort = aimux.createAbort()) { + assertThatThrownBy(() -> resource.stream(AimuxOps.OP_TEXT_STREAM, "{}", abort, frame -> abort.signal())) + .isInstanceOf(CancellationException.class); + assertThat(resource.call("test.echo", "{\"stillOpen\":true}").path("stillOpen").asBoolean()).isTrue(); + } + } + + @Test void reportsPreacceptanceAndTerminalErrorsAndInvalidNativeTermination() { + try (Resource resource = aimux.own("124")) { + fixture.fixture_stream(1); + assertThatThrownBy(() -> resource.stream(AimuxOps.OP_TEXT_STREAM, "{}", frame -> fail("no frame expected"))) + .isInstanceOf(ProtocolException.InvalidOp.class); + fixture.fixture_stream(3); + List frames = new ArrayList<>(); + assertThatThrownBy(() -> resource.stream(AimuxOps.OP_TEXT_STREAM, "{}", frames::add)) + .isInstanceOf(AimuxException.TimeoutError.class); + assertThat(frames).hasSize(2); + fixture.fixture_stream(4); + assertThatThrownBy(() -> resource.stream(AimuxOps.OP_TEXT_STREAM, "{}", frame -> {})) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("without End"); + fixture.fixture_stream(5); + assertThatThrownBy(() -> resource.stream(AimuxOps.OP_TEXT_STREAM, "{}", frame -> {})) + .isInstanceOf(BindingException.CallbackFailure.class); + } + } + + @Test void cancellationOfRunningCallSignalsNativeAbortWithoutInterruptingThread() throws Exception { + try (Resource resource = aimux.own("125")) { + Future future = resource.callAsync("test.wait", "{}", executor); + await(() -> fixture.fixture_entered() == 1); + resource.close(); + assertThat(fixture.fixture_drops(125)).isZero(); + assertThat(future.cancel(false)).isTrue(); + assertThat(future.cancel(true)).isFalse(); + assertThatThrownBy(future::get).isInstanceOf(CancellationException.class); + await(() -> fixture.fixture_drops(125) == 1); + } + } + + @Test void cancellationOfRunningStreamAndCloseHaveIndependentLifetimes() throws Exception { + fixture.fixture_stream(2); + Resource resource = aimux.own("126"); + CountDownLatch callback = new CountDownLatch(1); + Future future = resource.streamAsync(AimuxOps.OP_TEXT_STREAM, "{}", f -> callback.countDown(), executor); + assertThat(callback.await(2, TimeUnit.SECONDS)).isTrue(); + resource.close(); + assertThat(fixture.fixture_drops(126)).isZero(); + assertThat(future.cancel(true)).isTrue(); + await(() -> fixture.fixture_drops(126) == 1); + assertThat(fixture.fixture_callbacks()).isEqualTo(3); + } + + @Test void cancellationBeforeExecutorStartsReleasesQueuedAdmissionAndNeverCallsNative() { + AtomicReference queued = new AtomicReference<>(); + Resource resource = aimux.own("127"); + Future future = resource.callAsync("test.wait", "{}", queued::set); + resource.close(); + assertThat(fixture.fixture_drops(127)).isZero(); + assertThat(future.cancel(true)).isTrue(); + queued.get().run(); + assertThat(fixture.fixture_entered()).isZero(); + assertThat(fixture.fixture_drops(127)).isEqualTo(1); + } + + @Test void executorRejectionCleansAbortAndAdmission() { + Resource resource = aimux.own("128"); + assertThatThrownBy(() -> resource.callAsync("test.wait", "{}", r -> { + throw new RejectedExecutionException("full"); + })).isInstanceOf(RejectedExecutionException.class); + resource.close(); + assertThat(fixture.fixture_drops(128)).isEqualTo(1); + assertThat(fixture.fixture_entered()).isZero(); + } + + @Test void directExecutorAndCompletedFutureDoNotSignalSpuriousCancellation() throws Exception { + Future future = aimux.callAsync("test.echo", "{\"value\":42}", Runnable::run); + assertThat(future.get().path("value").asInt()).isEqualTo(42); + assertThat(future.cancel(true)).isFalse(); + } + + @Test void closeAndSignalOfSharedAbortWaitOnlyForAdmittedNativeUse() throws Exception { + Resource resource = aimux.own("129"); + Abort abort = aimux.createAbort(); + CountDownLatch hold = new CountDownLatch(1); + CountDownLatch entered = new CountDownLatch(1); + Future stream = executor.submit(() -> resource.stream(AimuxOps.OP_TEXT_STREAM, "{}", abort, frame -> { + if (frame.isEnd()) return; + entered.countDown(); + try { if (!hold.await(2, TimeUnit.SECONDS)) throw new AssertionError("deadline"); } + catch (InterruptedException error) { throw new AssertionError(error); } + })); + assertThat(entered.await(2, TimeUnit.SECONDS)).isTrue(); + abort.close(); + resource.close(); + assertThat(fixture.fixture_drops(Integer.parseInt(abort.getHandle()))).isZero(); + hold.countDown(); + stream.get(2, TimeUnit.SECONDS); + assertThat(fixture.fixture_drops(Integer.parseInt(abort.getHandle()))).isEqualTo(1); + assertThat(fixture.fixture_drops(129)).isEqualTo(1); + } + + @Test void allEightModalitiesAndFilesUseTheSameOwnedProviderPrimitives() { + try (ProviderHandle provider = aimux.createProvider("{\"package\":\"@ai-sdk/openai\",\"settings\":{}}")) { + try (Model m = provider.languageModel("m")) { assertThat(m.info()).isNotNull(); } + try (EmbeddingModel m = provider.embeddingModel("m")) { + assertThat(m.embed("{\"value\":\"x\"}").path("value").asText()).isEqualTo("x"); + assertThat(m.embedMany("{\"values\":[\"a\",\"b\"]}").path("values")).hasSize(2); + } + try (ImageModel m = provider.imageModel("m")) { assertThat(m.generate("{}")).isNotNull(); } + try (SpeechModel m = provider.speechModel("m")) { assertThat(m.generate("{}")).isNotNull(); } + try (TranscriptionModel m = provider.transcriptionModel("m")) { assertThat(m.generate("{}")).isNotNull(); } + try (RerankingModel m = provider.rerankingModel("m")) { assertThat(m.rerank("{}")).isNotNull(); } + try (VideoModel m = provider.videoModel("m")) { assertThat(m.generate("{}")).isNotNull(); } + try (SearchModel m = provider.searchModel("m")) { assertThat(m.search("{}")).isNotNull(); } + try (DecisionModel m = provider.decisionModel("m")) { assertThat(m.capabilities()).isNotNull(); } + try (Files files = provider.files()) { assertThat(files.upload("{}")).isNotNull(); } + } + } + + @Test void typedFacadeUsesCoreResultsAndForwardsFutureCancellation() throws Exception { + try (TypedModel typed = new TypedModel(new Model(aimux, 130))) { + assertThat(typed.generate("{}").getText()).isEqualTo("你好 🌏"); + assertThat(typed.generateAsync("{}", executor).get(2, TimeUnit.SECONDS).getText()).isEqualTo("你好 🌏"); + AtomicInteger parts = new AtomicInteger(); + typed.stream("{}", part -> parts.incrementAndGet()); + assertThat(parts.get()).isEqualTo(2); + } + } + + @Test void binaryPushAndPollUseNativeSizeTAndSignedInt64Timeout() { + try (TranscriptionSession operation = new TranscriptionSession(aimux, 131)) { + operation.push(new byte[] {42, 0, -1}); + assertThat(fixture.fixture_last_length()).isEqualTo(3); + operation.push(new byte[0]); + assertThat(fixture.fixture_last_length()).isZero(); + assertThat(operation.next(-1).getState()).isEqualTo(TranscriptionSession.PollResult.State.PART); + assertThat(fixture.fixture_last_timeout()).isEqualTo(-1); + fixture.fixture_poll(3); + assertThat(operation.next(0).getState()).isEqualTo(TranscriptionSession.PollResult.State.TIMEOUT); + fixture.fixture_poll(2); + assertThat(operation.next(Long.MAX_VALUE).getState()).isEqualTo(TranscriptionSession.PollResult.State.ENDED); + assertThat(fixture.fixture_last_timeout()).isEqualTo(Long.MAX_VALUE); + fixture.fixture_poll(0); + assertThatThrownBy(() -> operation.next(0)).isInstanceOf(ProtocolException.InvalidState.class); + } + } +} diff --git a/bindings/java/src/test/java/ai/arcships/aimux/AimuxExceptionTest.java b/bindings/java/src/test/java/ai/arcships/aimux/AimuxExceptionTest.java deleted file mode 100644 index 1b0bf2876..000000000 --- a/bindings/java/src/test/java/ai/arcships/aimux/AimuxExceptionTest.java +++ /dev/null @@ -1,343 +0,0 @@ -package ai.arcships.aimux; - -import com.sun.jna.Pointer; -import com.sun.jna.ptr.LongByReference; -import com.sun.jna.ptr.PointerByReference; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Tests for {@link AimuxException#fromC} / {@link AimuxException#of} mapping - * and the {@link AimuxResult} decoders. An error is an opaque - * {@code aimux_error_t *} returned by the failed call, so the decoder - * probes drive real errors obtained offline (unknown provider, a 401 from the - * loopback mock server). Bridge-layer misuse is absorbed by the binding and - * surfaces as native Java exceptions, never {@link AimuxException}: malformed - * raw JSON is caught before the C call ({@link IllegalArgumentException}); - * codes 200–206 are binding - * invariant ({@link IllegalStateException}). - */ -class AimuxExceptionTest { - - /** Real C ABI error: {@code aimux_openai_new(NULL, ...)}. */ - private static Pointer nullPointerError() { - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_openai_new(null, "gpt-4o-mini", out); - assertThat(e).isNotNull(); - assertThat(out.getValue()).isZero(); - return e; - } - - /** Real NO_SUCH_PROVIDER error: {@code aimux_provider_new("no-such-provider", ...)}. */ - private static Pointer noSuchProviderError() { - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_provider_new("no-such-provider", "sk-x", "m", null, out); - assertThat(e).isNotNull(); - assertThat(out.getValue()).isZero(); - return e; - } - - @Test - void expectAimuxErrorMapsAiMuxErrorAndFreesIt() { - Pointer eh = noSuchProviderError(); - assertThat(AimuxFFI.INSTANCE.aimux_error_code(eh)) - .isEqualTo(AimuxException.AIMUX_E_NO_SUCH_PROVIDER); - RuntimeException e = AimuxResult.expectAimuxError(eh, null); - assertThat(e).isInstanceOf(AimuxException.NoSuchProviderError.class); - AimuxException ae = (AimuxException) e; - assertThat(ae.getCode()).isEqualTo(AimuxException.AIMUX_E_NO_SUCH_PROVIDER); - assertThat(ae.getStatusCode()).isEqualTo(-1); - assertThat(ae.getRetryMs()).isEqualTo(-1L); - assertThat(ae.isRetryable()).isFalse(); - assertThat(ae.getMessage()).isNotEmpty(); - } - - @Test - void expectAimuxErrorPrefixesContext() { - RuntimeException e = AimuxResult.expectAimuxError(noSuchProviderError(), "Failed to create model"); - assertThat(e.getMessage()).startsWith("Failed to create model: "); - } - - // ── C ABI failures: native Java exceptions, not AimuxException ────────── - - @Test - void nullStringArgIsBindingInvariant() { - Pointer eh = nullPointerError(); - // A C ABI error has its own code and a message … - assertThat(AimuxFFI.INSTANCE.aimux_error_code(eh)).isEqualTo(200); - assertThat(AimuxResult.takeString(AimuxFFI.INSTANCE.aimux_error_message(eh))).isNotEmpty(); - // … and decodes to the invariant error. - RuntimeException e = AimuxResult.expectAimuxError(eh, "ctx"); - assertThat(e).isInstanceOf(IllegalStateException.class).isNotInstanceOf(AimuxException.class); - assertThat(e.getMessage()).startsWith("ctx: aimux ffi: ").contains("api_key"); - } - - @Test - void deadHandleIsBindingInvariant() { - PointerByReference out = new PointerByReference(); - Pointer eh = AimuxFFI.INSTANCE.aimux_provider_list_models(0x7FFF_FFFF_FFFFL, out); - assertThat(eh).isNotNull(); - assertThat(out.getValue()).isNull(); - assertThat(AimuxFFI.INSTANCE.aimux_error_code(eh)).isEqualTo(203); - RuntimeException e = AimuxResult.expectAimuxError(eh, null); - assertThat(e).isInstanceOf(IllegalStateException.class).isNotInstanceOf(AimuxException.class); - assertThat(e.getMessage()).startsWith("aimux ffi: "); - } - - @Test - void ffiInvalidWireJsonIsBindingInvariant() { - Pointer eh = AimuxFFI.INSTANCE.aimux_init_proxy("{not json"); - assertThat(eh).isNotNull(); - assertThat(AimuxFFI.INSTANCE.aimux_error_code(eh)).isEqualTo(202); - RuntimeException e = AimuxResult.expectAimuxError(eh, "ctx"); - assertThat(e).isInstanceOf(IllegalStateException.class).isNotInstanceOf(AimuxException.class); - assertThat(e.getMessage()).startsWith("ctx: aimux ffi: ").contains("config_json"); - } - - @Test - void expectFfiErrorReadsMessageOnly() { - RuntimeException e = AimuxResult.expectFfiError(nullPointerError(), "ctx"); - assertThat(e).isInstanceOf(IllegalStateException.class); - assertThat(e.getMessage()).startsWith("ctx: aimux ffi: ").contains("api_key"); - } - - @Test - void expectRecordingErrorRejectsAnAiMuxErrorCode() { - RuntimeException e = AimuxResult.expectRecordingError(noSuchProviderError(), "ctx"); - assertThat(e).isInstanceOf(IllegalStateException.class).isNotInstanceOf(RecordingException.class); - assertThat(e.getMessage()).startsWith("ctx: aimux ffi: "); - } - - @Test - void trailingGarbageJsonIsIllegalArgument() { - assertThatThrownBy(() -> Aimux.initProxy("{} garbage")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("configJson"); - assertThatThrownBy(() -> Aimux.initProxy("")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("configJson: invalid JSON: empty"); - assertThatThrownBy(() -> Aimux.initProxy(null)) - .isInstanceOf(NullPointerException.class); - } - - @Test - void malformedConfigJsonIsIllegalArgument() { - assertThatThrownBy(() -> Aimux.initProxy("{not json")) - .isInstanceOf(IllegalArgumentException.class) - .isNotInstanceOf(AimuxException.class) - .hasMessageContaining("configJson"); - } - - @Test - void malformedPromptJsonIsIllegalArgumentBeforeTheCall() { - try (Model model = Model.openai("sk-test", "gpt-4o-mini")) { - assertThatThrownBy(() -> model.generateText("{not json")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("promptJson"); - assertThatThrownBy(() -> model.generateText("\"hi\"", "{not json")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("optsJson"); - } - } - - @Test - void fromCMapsHttp401ToApiCallWithStatus() { - try (MockProviderServer server = new MockProviderServer(); - Model model = Model.openaiWithBase("sk-bad", "gpt-4o-mini", server.baseUrl())) { - server.setStatus(401); - server.setResponseBody("{\"error\":{\"message\":\"bad key\",\"type\":\"invalid_request_error\"}}"); - try { - model.generateText("\"hi\""); - throw new AssertionError("expected APICallError"); - } catch (AimuxException.APICallError e) { - assertThat(e.getCode()).isEqualTo(AimuxException.AIMUX_E_API_CALL); - assertThat(e.getStatusCode()).isEqualTo(401); - assertThat(e.getMessage()).contains("401"); - } - } - } - - @Test - void fromCThreadsProviderIdToNoSuchProvider() { - try { - Model.provider("no-such-provider", "sk-x", "m", null); - throw new AssertionError("expected NoSuchProviderError"); - } catch (AimuxException.NoSuchProviderError e) { - assertThat(e.getCode()).isEqualTo(AimuxException.AIMUX_E_NO_SUCH_PROVIDER); - assertThat(e.getProviderId()).isEqualTo("no-such-provider"); - assertThat(e.isRetryable()).isFalse(); - } - } - - @Test - void decodingNullIsAContractViolation() { - assertThatThrownBy(() -> AimuxResult.expectAimuxError(null, null)) - .isInstanceOf(IllegalStateException.class); - assertThatThrownBy(() -> AimuxResult.expectRecordingError(null, null)) - .isInstanceOf(IllegalStateException.class); - assertThatThrownBy(() -> AimuxResult.expectFfiError(null, null)) - .isInstanceOf(IllegalStateException.class); - } - - @Test - void ofDefaultsStatusAndRetry() { - AimuxException e = AimuxException.of( - AimuxException.AIMUX_E_INVALID_ARGUMENT, "bad arg"); - assertThat(e).isInstanceOf(AimuxException.InvalidArgumentError.class); - assertThat(e.getStatusCode()).isEqualTo(-1); - assertThat(e.getRetryMs()).isEqualTo(-1L); - assertThat(e.getMessage()).isEqualTo("bad arg"); - assertThat(e.isRetryable()).isFalse(); - } - - @Test - void ofMapsEveryVariantToExpectedClass() { - assertThat(AimuxException.of(AimuxException.AIMUX_E_JSON_PARSE, "m")) - .isInstanceOf(AimuxException.JSONParseError.class); - assertThat(AimuxException.of(AimuxException.AIMUX_E_INVALID_RESPONSE_DATA, "m")) - .isInstanceOf(AimuxException.InvalidResponseDataError.class); - assertThat(AimuxException.of(AimuxException.AIMUX_E_NO_SUCH_TOOL, "m")) - .isInstanceOf(AimuxException.NoSuchToolError.class); - assertThat(AimuxException.of(AimuxException.AIMUX_E_INVALID_TOOL_INPUT, "m")) - .isInstanceOf(AimuxException.InvalidToolInputError.class); - assertThat(AimuxException.of(AimuxException.AIMUX_E_TOOL_CALL_REPAIR, "m")) - .isInstanceOf(AimuxException.ToolCallRepairError.class); - assertThat(AimuxException.of(AimuxException.AIMUX_E_INVALID_ARGUMENT, "m")) - .isInstanceOf(AimuxException.InvalidArgumentError.class); - assertThat(AimuxException.of(AimuxException.AIMUX_E_INVALID_PROMPT, "m")) - .isInstanceOf(AimuxException.InvalidPromptError.class); - assertThat(AimuxException.of(AimuxException.AIMUX_E_TOKEN_EXPIRED, "m")) - .isInstanceOf(AimuxException.TokenExpiredError.class); - assertThat(AimuxException.of(AimuxException.AIMUX_E_UNSUPPORTED_FUNCTIONALITY, "m")) - .isInstanceOf(AimuxException.UnsupportedFunctionalityError.class); - assertThat(AimuxException.of(AimuxException.AIMUX_E_NO_SUCH_MODEL, "m")) - .isInstanceOf(AimuxException.NoSuchModelError.class); - assertThat(AimuxException.of(AimuxException.AIMUX_E_NO_SUCH_PROVIDER, "m")) - .isInstanceOf(AimuxException.NoSuchProviderError.class); - assertThat(AimuxException.of(AimuxException.AIMUX_E_API_CALL, "m")) - .isInstanceOf(AimuxException.APICallError.class); - assertThat(AimuxException.of(AimuxException.AIMUX_E_TIMEOUT, "m")) - .isInstanceOf(AimuxException.TimeoutError.class); - assertThat(AimuxException.of(AimuxException.AIMUX_E_ABORTED, "m")) - .isInstanceOf(AimuxException.RequestAbortedError.class); - assertThat(AimuxException.of(AimuxException.AIMUX_E_OTHER, "m")) - .isInstanceOf(AimuxException.OtherError.class); - assertThat(AimuxException.of(AimuxException.AIMUX_E_RETRY, "m")) - .isInstanceOf(AimuxException.RetryError.class); - assertThat(AimuxException.of(AimuxException.AIMUX_E_LOAD_API_KEY, "m")) - .isInstanceOf(AimuxException.LoadAPIKeyError.class); - assertThat(AimuxException.of(AimuxException.AIMUX_E_LOAD_SETTING, "m")) - .isInstanceOf(AimuxException.LoadSettingError.class); - } - - @Test - void loadErrorsHaveTheirOwnCodesNamesAndNoEnvVarLocally() { - assertThat(AimuxException.AIMUX_E_LOAD_API_KEY).isEqualTo(19); - assertThat(AimuxException.AIMUX_E_LOAD_SETTING).isEqualTo(20); - assertThat(AimuxException.codeName(19)).isEqualTo("LoadApiKey"); - assertThat(AimuxException.codeName(20)).isEqualTo("LoadSetting"); - AimuxException key = AimuxException.of(AimuxException.AIMUX_E_LOAD_API_KEY, "no key"); - assertThat(key.getCode()).isEqualTo(19); - assertThat(((AimuxException.LoadAPIKeyError) key).getEnvVar()).isNull(); - AimuxException setting = AimuxException.of(AimuxException.AIMUX_E_LOAD_SETTING, "no region"); - assertThat(setting.getCode()).isEqualTo(20); - assertThat(((AimuxException.LoadSettingError) setting).getEnvVar()).isNull(); - } - - @Test - void codesOutsideTheRustEnumAreRejected() { - // Tool errors occupy 15..17, 18 is NoOutputGenerated and 19 / 20 - // are the missing key / setting; 4 is retired and 21 is unassigned. - assertThat(AimuxException.of(15, "")).isInstanceOf(AimuxException.NoSuchToolError.class); - assertThat(AimuxException.AIMUX_E_NO_OUTPUT_GENERATED).isEqualTo(18); - assertThat(AimuxException.codeName(18)).isEqualTo("NoOutputGenerated"); - assertThat(AimuxException.of(18, "")).isInstanceOf(AimuxException.NoOutputGeneratedError.class); - assertThatThrownBy(() -> AimuxException.of(4, "")) - .isInstanceOf(IllegalStateException.class); - assertThatThrownBy(() -> AimuxException.of(21, "")) - .isInstanceOf(IllegalStateException.class); - assertThatThrownBy(() -> AimuxException.of(999, "m")) - .isInstanceOf(IllegalStateException.class); - } - - @Test - void tokenExpiredDefaultsTo401() { - assertThat(AimuxException.of(AimuxException.AIMUX_E_TOKEN_EXPIRED, "expired").getStatusCode()) - .isEqualTo(401); - } - - @Test - void messageOnlyConstructorKeepsBackwardCompat() { - AimuxException e = new AimuxException("decode failed"); - assertThat(e.getCode()).isEqualTo(AimuxException.AIMUX_E_OTHER); - assertThat(e.getStatusCode()).isEqualTo(-1); - assertThat(e.getRetryMs()).isEqualTo(-1L); - assertThat(e.getMessage()).isEqualTo("decode failed"); - } - - @Test - void extractHandleErrorThrowsMappedException() { - LongByReference out = new LongByReference(); - Pointer e = AimuxFFI.INSTANCE.aimux_provider_new("no-such-provider", "sk-x", "m", null, out); - try { - AimuxResult.extractHandle(e, out, "Failed to create provider"); - throw new AssertionError("expected AimuxException"); - } catch (AimuxException.NoSuchProviderError ex) { - assertThat(ex.getMessage()).startsWith("Failed to create provider"); - assertThat(ex.getProviderId()).isEqualTo("no-such-provider"); - } - } - - @Test - void extractHandleNullErrorPassesThrough() { - LongByReference out = new LongByReference(42L); - assertThat(AimuxResult.extractHandle(null, out, "ctx")).isEqualTo(42L); - } - - @Test - void codeNameCoversKnownCodes() { - assertThat(AimuxException.codeName(AimuxException.AIMUX_OK)).isEqualTo("OK"); - assertThat(AimuxException.codeName(AimuxException.AIMUX_E_API_CALL)).isEqualTo("ApiCall"); - assertThat(AimuxException.codeName(AimuxException.AIMUX_E_RETRY)).isEqualTo("Retry"); - assertThat(AimuxException.codeName(999)).startsWith("Code("); - } - - @Test - void recordingCodesOutsideTheRustEnumAreRejected() { - assertThatThrownBy(() -> RecordingErrorCode.fromC(999)) - .isInstanceOf(IllegalStateException.class); - } - - @Test - void payloadIsNullForLocalFailures() { - AimuxException.NoSuchProviderError local = (AimuxException.NoSuchProviderError) - AimuxException.of(AimuxException.AIMUX_E_NO_SUCH_PROVIDER, "nope"); - assertThat(local.getProviderId()).isNull(); - AimuxException.APICallError api = (AimuxException.APICallError) - AimuxException.of(AimuxException.AIMUX_E_API_CALL, "x"); - assertThat(api.getProviderCode()).isNull(); - assertThat(api.getResponseBody()).isNull(); - assertThat(api.getUrl()).isNull(); - assertThat(api.getRequestBodyValues()).isNull(); - assertThat(api.getResponseHeaders()).isNull(); - assertThat(api.getData()).isNull(); - assertThat(api.isRetryable()).isFalse(); - } - - /** Code 14 locally: reason defaults, the single-attempt fallback keeps the type total. */ - @Test - void retryErrorCarriesReasonAndHistory() { - AimuxException.RetryError e = (AimuxException.RetryError) - AimuxException.of(AimuxException.AIMUX_E_RETRY, "Failed after 2 attempts. Last error: x"); - assertThat(e.getCode()).isEqualTo(AimuxException.AIMUX_E_RETRY); - assertThat(e.getReason()).isEqualTo(AimuxException.RetryErrorReason.MAX_RETRIES_EXCEEDED); - assertThat(e.getErrors()).hasSize(1); - assertThat(e.getLastError()).isSameAs(e.getErrors().get(0)); - assertThat(e.getStatusCode()).isEqualTo(-1); - assertThatThrownBy(() -> new AimuxException.RetryError( - "m", AimuxException.RetryErrorReason.ERROR_NOT_RETRYABLE, java.util.Collections.emptyList())) - .isInstanceOf(IllegalArgumentException.class); - } -} diff --git a/bindings/java/src/test/java/ai/arcships/aimux/AimuxTest.java b/bindings/java/src/test/java/ai/arcships/aimux/AimuxTest.java deleted file mode 100644 index 031c083a6..000000000 --- a/bindings/java/src/test/java/ai/arcships/aimux/AimuxTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package ai.arcships.aimux; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Tests for the top-level {@link Aimux} entry point. - * - *

T11: {@link Aimux#initRecordingRing(long)} must reject {@code cap <= 0} - * before the FFI call so a negative Java {@code long} is never reinterpreted by - * JNA / the C ABI as a huge {@code uint64_t}. The cases below throw before - * touching {@code AimuxFFI.INSTANCE}, so they need no native library. - */ -class AimuxTest { - - @Test - void initRecordingRingRejectsZeroCap() { - assertThatThrownBy(() -> Aimux.initRecordingRing(0L)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("initRecordingRing: cap must be > 0"); - } - - @Test - void initRecordingRingRejectsNegativeCap() { - assertThatThrownBy(() -> Aimux.initRecordingRing(-1L)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("initRecordingRing: cap must be > 0"); - } - - @Test - void initRecordingRingNoArgUsesDefault() { - // The no-arg overload uses the library default capacity (FFI - // aimux_init_recording_ring_default). Unlike the cap-validation tests - // above, this one reaches the FFI and requires the native library on - // java.library.path / LD_LIBRARY_PATH. - assertThatCode(() -> Aimux.initRecordingRing()).doesNotThrowAnyException(); - Aimux.recordingStop(); - } -} diff --git a/bindings/java/src/test/java/ai/arcships/aimux/ContractTestRunner.java b/bindings/java/src/test/java/ai/arcships/aimux/ContractTestRunner.java new file mode 100644 index 000000000..b20406505 --- /dev/null +++ b/bindings/java/src/test/java/ai/arcships/aimux/ContractTestRunner.java @@ -0,0 +1,22 @@ +package ai.arcships.aimux; + +import java.io.PrintWriter; +import org.junit.platform.launcher.Launcher; +import org.junit.platform.launcher.core.LauncherFactory; +import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder; +import org.junit.platform.launcher.listeners.SummaryGeneratingListener; +import org.junit.platform.engine.discovery.DiscoverySelectors; +public final class ContractTestRunner { + public static void main(String[] args) { + Launcher launcher = LauncherFactory.create(); + SummaryGeneratingListener listener = new SummaryGeneratingListener(); + launcher.registerTestExecutionListeners(listener); + launcher.execute(LauncherDiscoveryRequestBuilder.request() + .selectors(DiscoverySelectors.selectPackage("ai.arcships.aimux")).build()); + listener.getSummary().printTo(new PrintWriter(System.out)); + listener.getSummary().printFailuresTo(new PrintWriter(System.out)); + if (listener.getSummary().getTestsFailedCount() > 0 || listener.getSummary().getTestsSucceededCount() == 0) { + throw new AssertionError("JUnit tests failed or no tests ran"); + } + } +} diff --git a/bindings/java/src/test/java/ai/arcships/aimux/DecisionModelTest.java b/bindings/java/src/test/java/ai/arcships/aimux/DecisionModelTest.java deleted file mode 100644 index 304bcc562..000000000 --- a/bindings/java/src/test/java/ai/arcships/aimux/DecisionModelTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package ai.arcships.aimux; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.sun.net.httpserver.HttpServer; -import java.net.InetSocketAddress; -import java.nio.file.Paths; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; - -class DecisionModelTest { - @Test void providerDecisionModelOwnsItsHandle() throws Exception { - ProviderHandle provider = Model.createProvider("openai", "test-key", null); - try (DecisionModel model = provider.decisionModel("gpt-6-luna")) { - provider.close(); - JsonNode description = new ObjectMapper().readTree(model.describe()); - assertEquals("v4", description.get("specificationVersion").asText()); - assertEquals("openai", description.get("provider").asText()); - assertEquals("gpt-6-luna", description.get("modelId").asText()); - assertThrows(IllegalStateException.class, () -> provider.decisionModel("gpt-6-luna")); - } finally { provider.close(); } - } - - @Test void officialStructuredContractAndDescription() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - JsonNode fixture = mapper.readTree(Paths.get("../../contract-tests/fixtures/decision-native.json").toFile()); - AtomicReference captured = new AtomicReference<>(); - HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - server.createContext("/v1/systemone", exchange -> { - captured.set(mapper.readTree(exchange.getRequestBody())); - byte[] body = fixture.get("response").toString().getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().set("Content-Type", "application/json"); - exchange.sendResponseHeaders(200, body.length); - exchange.getResponseBody().write(body); - exchange.close(); - }); - server.start(); - try (DecisionModel model = DecisionModel.jev("test-key", "jev-latest", - "http://127.0.0.1:" + server.getAddress().getPort() + "/v1/systemone", "native")) { - JsonNode description = mapper.readTree(model.describe()); - assertEquals("jev", description.get("provider").asText()); - assertEquals(3, description.get("supportedQuestionTypes").size()); - assertNull(captured.get()); - JsonNode result = mapper.readTree(model.decide(fixture.get("request").toString())); - assertEquals(0.9, result.get("answers").get("urgent").get("probability").asDouble()); - assertEquals(2, result.get("rounding").get("scoreDecimals").asInt()); - assertEquals(mapper.readTree("{\"inputTokens\":50,\"outputTokens\":10,\"totalTokens\":60}"), - result.get("usage")); - assertEquals(fixture.get("request").get("questions").get("urgent").get("criteria"), - captured.get().get("questions").get("urgent").get("criteria")); - assertEquals(fixture.get("request").get("questions").get("severity").get("criteria"), - captured.get().get("questions").get("severity").get("criteria")); - assertThrows(IllegalArgumentException.class, () -> model.decide("{")); - model.close(); - assertThrows(IllegalStateException.class, model::describe); - } finally { server.stop(0); } - assertThrows(AimuxException.class, () -> DecisionModel.jev("test-key", "jev-latest", null, "unknown")); - } - - @Test void openaiMediaAndTypedValues() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - JsonNode fixture = mapper.readTree(Paths.get("../../contract-tests/fixtures/decision-openai-full.json").toFile()); - AtomicReference captured = new AtomicReference<>(); - HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - server.createContext("/v1/decisions", exchange -> { - captured.set(mapper.readTree(exchange.getRequestBody())); - byte[] body = fixture.get("response").toString().getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().set("Content-Type", "application/json"); - exchange.sendResponseHeaders(200, body.length); - exchange.getResponseBody().write(body); - exchange.close(); - }); - server.start(); - String config = mapper.createObjectNode().put("base_url", "http://127.0.0.1:" + server.getAddress().getPort() + "/v1").toString(); - try (ProviderHandle provider = Model.createProvider("openai", "test-key", config); - DecisionModel model = provider.decisionModel("gpt-6-luna")) { - JsonNode result = mapper.readTree(model.decide(fixture.get("options").toString())); - assertEquals(fixture.get("request"), captured.get()); - assertEquals("boolean_true", result.get("answers").get("choice").get("choice").asText()); - JsonNode value = result.get("providerMetadata").get("openai").get("answers").get("choice").get("value"); - assertTrue(value.isBoolean()); - assertTrue(value.asBoolean()); - assertEquals(0.75, result.get("answers").get("score").get("score").asDouble()); - } finally { server.stop(0); } - } -} diff --git a/bindings/java/src/test/java/ai/arcships/aimux/FfiAritySmokeTest.java b/bindings/java/src/test/java/ai/arcships/aimux/FfiAritySmokeTest.java deleted file mode 100644 index fa6f7d6e5..000000000 --- a/bindings/java/src/test/java/ai/arcships/aimux/FfiAritySmokeTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package ai.arcships.aimux; - -import com.sun.jna.Pointer; -import com.sun.jna.ptr.PointerByReference; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Arity smoke tests: exercise FFI symbols that no other test touches through - * the real library. Each call is expected to fail (bad handle / bad args), but - * it must go through JNA — a C-signature/arity mismatch would crash or throw - * an UnsatisfiedLinkError instead of returning a clean error. - */ -class FfiAritySmokeTest { - - /** Call with an invalid handle and assert code 203 is a binding invariant. */ - private static void assertInvalidHandle(Pointer err, PointerByReference out) { - assertThat(err).isNotNull(); - assertThat(out.getValue()).isNull(); - assertThat(AimuxFFI.INSTANCE.aimux_error_code(err)).isEqualTo(203); - RuntimeException e = AimuxResult.expectAimuxError(err, null); - assertThat(e).isInstanceOf(IllegalStateException.class).isNotInstanceOf(AimuxException.class); - assertThat(e.getMessage()).startsWith("aimux ffi: "); - } - - @Test - void unknownProviderCarriesProviderId() { - try { - Model.provider("definitely-not-a-provider", "sk-x", "some-model", null); - throw new AssertionError("expected AimuxException"); - } catch (AimuxException.NoSuchProviderError e) { - assertThat(e.getProviderId()).isEqualTo("definitely-not-a-provider"); - } - } - - @Test - void transcriptionGenerateBadHandle() { - PointerByReference out = new PointerByReference(); - assertInvalidHandle( - AimuxFFI.INSTANCE.aimux_transcription_generate(0L, "aGk=", "audio/wav", null, out), out); - } - - @Test - void fileUploadBadHandle() { - PointerByReference out = new PointerByReference(); - assertInvalidHandle( - AimuxFFI.INSTANCE.aimux_file_upload(0L, "aGk=", "text/plain", null, out), out); - } - - @Test - void videoGenerateBadHandle() { - PointerByReference out = new PointerByReference(); - assertInvalidHandle( - AimuxFFI.INSTANCE.aimux_video_generate(0L, "{}", out), out); - } - - @Test - void providerListModelsBadHandle() { - PointerByReference out = new PointerByReference(); - assertInvalidHandle( - AimuxFFI.INSTANCE.aimux_provider_list_models(0L, out), out); - } - - @Test - void getModelSpecsUnreachableUrlFails() { - // Unroutable local port: fails fast without touching the network default. - assertThatThrownBy(() -> Model.getModelSpecs("http://127.0.0.1:1/specs")) - .isInstanceOf(AimuxException.class); - } - - @Test - void mockReplayNewRejectsGarbageJsonl() { - // Malformed JSONL is rejected by the binding before the C call. - assertThatThrownBy(() -> Model.mockReplay("not json at all")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("recordingsJsonl"); - } -} diff --git a/bindings/java/src/test/java/ai/arcships/aimux/MockProviderServer.java b/bindings/java/src/test/java/ai/arcships/aimux/MockProviderServer.java deleted file mode 100644 index 85a754ebf..000000000 --- a/bindings/java/src/test/java/ai/arcships/aimux/MockProviderServer.java +++ /dev/null @@ -1,148 +0,0 @@ -package ai.arcships.aimux; - -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpServer; - -import java.io.ByteArrayOutputStream; -import java.io.Closeable; -import java.io.IOException; -import java.io.InputStream; -import java.net.InetSocketAddress; -import java.nio.charset.StandardCharsets; -import java.util.ArrayDeque; -import java.util.Deque; - -/** - * A tiny mock HTTP server that records the last request body and replays a - * preset response. Listens on a random loopback port (port of the Kotlin - * binding's {@code MockProviderServer}, built on the JDK's built-in - * {@link HttpServer} — no extra dependency). - */ -public class MockProviderServer implements Closeable { - - private final HttpServer server; - - /** Last received request body (raw UTF-8 string). */ - private volatile String lastRequestBody = ""; - - /** Last received request method (e.g. "POST"). */ - private volatile String lastRequestMethod = ""; - - /** Last received request path, used to verify the selected API. */ - private volatile String lastRequestPath = ""; - - /** Status code to return. */ - private volatile int status = 200; - - /** Value of the Content-Type response header. */ - private volatile String contentType = "application/json"; - - /** Body bytes to return (single-response mode; used when the queue is empty). */ - private volatile String responseBody = ""; - - /** Response queue — each request pops the next response (FIFO). */ - private final Deque responseQueue = new ArrayDeque<>(); - - public MockProviderServer() { - try { - server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - } catch (IOException e) { - throw new RuntimeException("Failed to create mock provider server", e); - } - server.createContext("/", this::handle); - server.start(); - } - - private void handle(HttpExchange exchange) throws IOException { - // Capture the inbound request body for assertions. - byte[] reqBytes = readAll(exchange.getRequestBody()); - lastRequestBody = new String(reqBytes, StandardCharsets.UTF_8); - lastRequestMethod = exchange.getRequestMethod(); - lastRequestPath = exchange.getRequestURI().getPath(); - - // Pop the next queued response if any, otherwise fall back to the - // single-response field (backward compatible with existing tests). - String resp; - synchronized (responseQueue) { - resp = responseQueue.isEmpty() ? responseBody : responseQueue.removeFirst(); - } - byte[] respBytes = resp.getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", contentType); - // SSE streams use chunked transfer encoding (response length 0) so the - // client's streaming reader observes a proper end-of-stream. A fixed - // content length can leave the streaming reader blocked waiting for - // more data. Plain JSON responses use a fixed length. - long responseLength = - contentType.contains("text/event-stream") ? 0L : (long) respBytes.length; - exchange.sendResponseHeaders(status, responseLength); - exchange.getResponseBody().write(respBytes); - exchange.getResponseBody().flush(); - exchange.getResponseBody().close(); - exchange.close(); - } - - private static byte[] readAll(InputStream in) throws IOException { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - byte[] buf = new byte[8192]; - int n; - while ((n = in.read(buf)) != -1) { - out.write(buf, 0, n); - } - return out.toByteArray(); - } - - /** Set the list of responses to return (FIFO). */ - public void setResponses(String... responses) { - synchronized (responseQueue) { - responseQueue.clear(); - for (String r : responses) { - responseQueue.addLast(r); - } - } - } - - public int port() { - return server.getAddress().getPort(); - } - - public String baseUrl() { - return "http://127.0.0.1:" + port(); - } - - /** - * Provider config for an OpenAI-compatible chat-completions model pointed - * at this server (the OpenAI factory itself speaks the Responses API). - */ - public String chatConfig() { - return "{\"baseUrl\":\"" + baseUrl() + "\"}"; - } - - public String lastRequestBody() { - return lastRequestBody; - } - - public String lastRequestMethod() { - return lastRequestMethod; - } - - public String lastRequestPath() { - return lastRequestPath; - } - - public void setStatus(int status) { - this.status = status; - } - - public void setContentType(String contentType) { - this.contentType = contentType; - } - - public void setResponseBody(String responseBody) { - this.responseBody = responseBody; - } - - @Override - public void close() { - server.stop(0); - } -} diff --git a/bindings/java/src/test/java/ai/arcships/aimux/ModelTest.java b/bindings/java/src/test/java/ai/arcships/aimux/ModelTest.java deleted file mode 100644 index dfbcdc70b..000000000 --- a/bindings/java/src/test/java/ai/arcships/aimux/ModelTest.java +++ /dev/null @@ -1,308 +0,0 @@ -package ai.arcships.aimux; - -import org.json.JSONArray; -import org.json.JSONObject; -import org.junit.jupiter.api.Test; - -import java.time.Duration; -import java.util.List; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Collectors; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertTimeout; - -/** - * Raw JSON layer tests (mirror of the Kotlin binding's ModelTest.kt). - * - *

Tests that call the native library point an OpenAI model at a local - * {@link MockProviderServer} (127.0.0.1) replaying canned OpenAI wire-format - * responses — no real network access. - */ -class ModelTest { - - // ── construction ──────────────────────────────────────────────────────── - - @Test - void openaiCreatesModelInstance() { - // Even with a fake key, the provider should construct. A non-zero - // handle is guaranteed by the factory (it throws AimuxException - // if the FFI returns 0). - try (Model model = Model.openai("sk-test-fake-key", "gpt-4o-mini")) { - assertThat(model).isNotNull(); - } - } - - @Test - void anthropicCreatesModelInstance() { - try (Model model = Model.anthropic("sk-ant-test-fake-key", "claude-3-5-sonnet-20241022")) { - assertThat(model).isNotNull(); - } - } - - @Test - void deepseekCreatesModelInstance() { - try (Model model = Model.deepseek("sk-test-fake-key", "deepseek-chat")) { - assertThat(model).isNotNull(); - } - } - - @Test - void providerCreatesModelInstanceFromRegistry() { - // Registry-backed construction (deepseek is in the provider registry); - // the key is validated on the first API call, not construction. - try (Model model = Model.provider("deepseek", "sk-test-fake-key", "deepseek-chat", null)) { - assertThat(model).isNotNull(); - } - } - - // ── generation ────────────────────────────────────────────────────────── - - @Test - void generateTextReturnsJsonString() { - try (MockProviderServer server = new MockProviderServer(); - Model model = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o-mini", server.chatConfig())) { - server.setResponseBody(plainOpenAiResponse()); - - String resultJson = model.generateText("\"Hello\""); - - JSONObject result = new JSONObject(resultJson); - assertThat(result.getString("text")) - .isEqualTo("Rust is a systems programming language."); - assertThat(server.lastRequestMethod()).isEqualTo("POST"); - assertThat(server.lastRequestPath()).isEqualTo("/chat/completions"); - assertThat(server.lastRequestBody()).contains("gpt-4o-mini"); - } - } - - @Test - void responseTimestampIsToIsoString() { - // The response timestamp is a Date (provider - // language-model-v4-response-metadata.ts:10), built as - // new Date(created * 1000); its JSON is toISOString(). - try (MockProviderServer server = new MockProviderServer(); - Model model = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o-mini", server.chatConfig())) { - server.setResponseBody(new JSONObject(plainOpenAiResponse()).put("created", 1741269019).toString()); - - JSONObject result = new JSONObject(model.generateText("\"Hello\"")); - assertThat(result.getJSONObject("response").getString("timestamp")) - .isEqualTo("2025-03-06T13:50:19.000Z"); - } - } - - @Test - void generateTextRejectsInvalidPrompt() { - try (Model model = Model.openai("sk-test-fake-key", "gpt-4o-mini")) { - // Malformed raw JSON is rejected by the binding before the C call. - assertThatThrownBy(() -> model.generateText("{invalid json}")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("promptJson"); - } - } - - @Test - void closeThenCallThrows() { - Model model = Model.openai("sk-test-fake-key", "gpt-4o-mini"); - model.close(); - // Calling close twice should not crash (idempotent). - model.close(); - - assertThatThrownBy(() -> model.generateText("\"hello\"")) - .isInstanceOf(IllegalStateException.class) - .hasMessage("Model is closed"); - } - - // ── streaming ─────────────────────────────────────────────────────────── - - @Test - void streamTextInvokesOnDone() { - try (MockProviderServer server = new MockProviderServer(); - Model model = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o-mini", server.chatConfig())) { - server.setContentType("text/event-stream"); - server.setResponseBody(openAiSseResponse()); - - LinkedBlockingQueue parts = new LinkedBlockingQueue<>(); - AtomicBoolean done = new AtomicBoolean(false); - AtomicReference error = new AtomicReference<>(); - - // Run the blocking FFI stream call on a separate daemon thread so - // the main thread can observe callbacks as they arrive (same - // pattern as the Kotlin E2E test). - Thread streamThread = new Thread(() -> { - try { - model.streamText("\"Hello\"", null, parts::add, () -> done.set(true)); - } catch (RuntimeException e) { - error.set(e.getMessage()); - } - }); - streamThread.setDaemon(true); - streamThread.start(); - - assertTimeout(Duration.ofSeconds(30), () -> { - long deadline = System.currentTimeMillis() + 30_000; - while (!done.get() && error.get() == null && System.currentTimeMillis() < deadline) { - Thread.sleep(50); - } - }); - streamThread.join(5_000); - - assertThat(error.get()).as("stream error").isNull(); - assertThat(done.get()).isTrue(); - assertThat(parts).isNotEmpty(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException(e); - } - } - - @Test - void streamTextStreamIteratesParts() { - try (MockProviderServer server = new MockProviderServer(); - Model model = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o-mini", server.chatConfig())) { - server.setContentType("text/event-stream"); - server.setResponseBody(openAiSseResponse()); - - List parts = assertTimeout(Duration.ofSeconds(30), - () -> model.streamTextStream("\"Hello\"").collect(Collectors.toList())); - - assertThat(parts).isNotEmpty(); - // Content deltas from the SSE stream surface in the parts. - assertThat(parts.stream().collect(Collectors.joining())).contains("Hello"); - } - } - - @Test - void streamTextStreamReturnsAStream() { - // Smoke test only: verifies the Stream can be created without network. - // Full stream consumption is covered by streamTextStreamIteratesParts - // (mock-server driven) and TypedModelTest.streamTextStreamDecodesTypedParts. - try (Model model = Model.openai("sk-test-fake-key", "gpt-4o-mini")) { - assertThat(model.streamTextStream("\"hello\"")).isNotNull(); - } - } - - // ── concurrency (T6: close vs. in-flight FFI call) ───────────────────── - - /** - * Smoke test for the read/write-lock close-race fix (T6): one thread loops - * {@code generateText} (each call holds the read lock for the whole FFI - * call) while another calls {@code close()} (write lock). The write lock - * must wait for the in-flight read to finish, so {@code close()} never drops - * a handle out from under an active call — i.e. no use-after-free / native - * crash. After {@code close()} completes, further calls must throw a - * predictable {@link IllegalStateException}. A use-after-free would crash - * the JVM (SIGSEGV) and fail the whole test process, not just this method. - */ - @Test - void concurrentCloseAndGenerateDoesNotCrash() throws InterruptedException { - try (MockProviderServer server = new MockProviderServer(); - Model model = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o-mini", server.chatConfig())) { - server.setResponseBody(plainOpenAiResponse()); - - final int iterations = 300; - final AtomicReference unexpected = new AtomicReference<>(null); - final AtomicBoolean sawClosed = new AtomicBoolean(false); - - // Worker: loop generateText. Pre-close calls succeed against the - // mock; post-close calls throw IllegalStateException (closed). Any - // other throwable (incl. a native crash, which surfaces as an Error) - // is recorded and fails the test. - Thread worker = new Thread(() -> { - for (int i = 0; i < iterations; i++) { - try { - model.generateText("\"Hello\""); - } catch (IllegalStateException e) { - sawClosed.set(true); - } catch (AimuxException e) { - // A transient transport hiccup during teardown is - // acceptable; keep looping. - } catch (Throwable t) { - unexpected.set(t); - break; - } - } - }); - worker.setDaemon(true); - worker.start(); - - // Closer: close after a brief delay so it races with in-flight calls. - Thread closer = new Thread(() -> { - try { - Thread.sleep(15); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - model.close(); - }); - closer.setDaemon(true); - closer.start(); - - worker.join(60_000); - closer.join(5_000); - - assertThat(unexpected.get()).as("unexpected exception in worker").isNull(); - // After close, calls must report closed predictably (close-then-use - // ordering assertion). - assertThatThrownBy(() -> model.generateText("\"Hello\"")) - .isInstanceOf(IllegalStateException.class) - .hasMessage("Model is closed"); - } - } - - // ── canned OpenAI responses ──────────────────────────────────────────── - - /** Plain OpenAI chat-completions response (no tool calls). */ - private static String plainOpenAiResponse() { - return new JSONObject() - .put("id", "chatcmpl-test") - .put("model", "gpt-4o") - .put("choices", new JSONArray().put( - new JSONObject() - .put("message", new JSONObject() - .put("role", "assistant") - .put("content", "Rust is a systems programming language.")) - .put("finish_reason", "stop"))) - .put("usage", new JSONObject() - .put("prompt_tokens", 10) - .put("completion_tokens", 8) - .put("total_tokens", 18)) - .toString(); - } - - /** OpenAI SSE stream: two content deltas + finish + [DONE]. */ - private static String openAiSseResponse() { - StringBuilder sb = new StringBuilder(); - sb.append("data: ").append(new JSONObject() - .put("id", "1") - .put("model", "gpt-4o") - .put("choices", new JSONArray().put( - new JSONObject().put("delta", new JSONObject() - .put("role", "assistant") - .put("content", "Hello"))))) - .append("\n\n"); - sb.append("data: ").append(new JSONObject() - .put("id", "1") - .put("model", "gpt-4o") - .put("choices", new JSONArray().put( - new JSONObject().put("delta", new JSONObject() - .put("content", " world"))))) - .append("\n\n"); - sb.append("data: ").append(new JSONObject() - .put("id", "1") - .put("model", "gpt-4o") - .put("choices", new JSONArray().put( - new JSONObject().put("delta", new JSONObject()) - .put("finish_reason", "stop"))) - .put("usage", new JSONObject() - .put("prompt_tokens", 5) - .put("completion_tokens", 2) - .put("total_tokens", 7))) - .append("\n\n"); - sb.append("data: [DONE]\n\n"); - return sb.toString(); - } -} diff --git a/bindings/java/src/test/java/ai/arcships/aimux/MultimodalCoverageTest.java b/bindings/java/src/test/java/ai/arcships/aimux/MultimodalCoverageTest.java deleted file mode 100644 index 7dc60c2ef..000000000 --- a/bindings/java/src/test/java/ai/arcships/aimux/MultimodalCoverageTest.java +++ /dev/null @@ -1,185 +0,0 @@ -package ai.arcships.aimux; - -import org.json.JSONObject; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Coverage tests for the multimodal binding — addresses gaps found in the - * independent review: - * - *

    - *
  • Provider variant construction (Cohere/Google embedding, Google image, - * all non-WithBase factories) — ~15 C ABI symbols previously untested.
  • - *
  • AiMuxError values → {@link AimuxException} (C error-handle path).
  • - *
  • Lifecycle: close-then-call throws, double-close is safe.
  • - *
  • Search {@code answer} field assertion (previously only {@code results} - * was checked).
  • - *
- * - *

No real network access — every request hits 127.0.0.1. - */ -class MultimodalCoverageTest { - - private MockProviderServer server; - - @BeforeEach - void setUp() { - server = new MockProviderServer(); - } - - @AfterEach - void tearDown() { - server.close(); - } - - // ── Provider variant construction ─────────────────────────────────────── - // These verify the C ABI symbol mapping for variants that were never - // instantiated by the E2E suite. Construction only (no HTTP); the handle - // just needs to be live. - - @Test - void allEmbeddingProviderVariantsConstruct() { - try (EmbeddingModel m = EmbeddingModel.openai("sk-test", "text-embedding-3-small")) { - assertThat(m).isNotNull(); - } - try (EmbeddingModel m = EmbeddingModel.cohere("sk-test", "embed-english-v3.0")) { - assertThat(m).isNotNull(); - } - try (EmbeddingModel m = EmbeddingModel.cohereWithBase("sk-test", "embed-english-v3.0", server.baseUrl())) { - assertThat(m).isNotNull(); - } - try (EmbeddingModel m = EmbeddingModel.google("sk-test", "gemini-embedding-001")) { - assertThat(m).isNotNull(); - } - try (EmbeddingModel m = EmbeddingModel.googleWithBase("sk-test", "gemini-embedding-001", server.baseUrl())) { - assertThat(m).isNotNull(); - } - } - - @Test - void allImageProviderVariantsConstruct() { - try (ImageModel m = ImageModel.openai("sk-test", "dall-e-3")) { - assertThat(m).isNotNull(); - } - try (ImageModel m = ImageModel.google("sk-test", "gemini-2.5-flash-image")) { - assertThat(m).isNotNull(); - } - try (ImageModel m = ImageModel.googleWithBase("sk-test", "gemini-2.5-flash-image", server.baseUrl())) { - assertThat(m).isNotNull(); - } - } - - @Test - void allNonWithBaseFactoriesConstruct() { - try (SpeechModel m = SpeechModel.openai("sk-test", "tts-1")) { assertThat(m).isNotNull(); } - try (TranscriptionModel m = TranscriptionModel.openai("sk-test", "whisper-1")) { assertThat(m).isNotNull(); } - try (Files m = Files.openai("sk-test")) { assertThat(m).isNotNull(); } - try (RerankingModel m = RerankingModel.cohere("sk-test", "rerank-v3.0")) { assertThat(m).isNotNull(); } - try (VideoModel m = VideoModel.google("sk-test", "veo-3.0")) { assertThat(m).isNotNull(); } - try (SearchModel m = SearchModel.tavily("sk-test")) { assertThat(m).isNotNull(); } - } - - // ── Invalid raw JSON → IllegalArgumentException (before the C call) ───── - - @Test - void embeddingInvalidJsonThrowsIllegalArgument() { - try (EmbeddingModel model = EmbeddingModel.openaiWithBase("sk-test", "text-embedding-3-small", server.baseUrl())) { - assertThatThrownBy(() -> model.embed("not-valid-json")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("valuesJson"); - } - } - - @Test - void speechInvalidJsonThrowsIllegalArgument() { - try (SpeechModel model = SpeechModel.openaiWithBase("sk-test", "tts-1", server.baseUrl())) { - assertThatThrownBy(() -> model.generate("not-valid-json")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("optsJson"); - } - } - - @Test - void rerankInvalidJsonThrowsIllegalArgument() { - try (RerankingModel model = RerankingModel.cohereWithBase("sk-test", "rerank-v3.0", server.baseUrl())) { - assertThatThrownBy(() -> model.rerank("not-valid-json")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("optsJson"); - } - } - - // ── Lifecycle: close-then-call, double-close ──────────────────────────── - - @Test - void embeddingCloseThenCallThrows() { - EmbeddingModel model = EmbeddingModel.openai("sk-test", "text-embedding-3-small"); - model.close(); - assertThatThrownBy(() -> model.embed("[\"hi\"]")) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("closed"); - } - - @Test - void multimodalDoubleCloseIsSafe() { - EmbeddingModel m1 = EmbeddingModel.openai("sk-test", "text-embedding-3-small"); - m1.close(); - m1.close(); // must not throw - - ImageModel m2 = ImageModel.openai("sk-test", "dall-e-3"); - m2.close(); - m2.close(); - - Files m3 = Files.openai("sk-test"); - m3.close(); - m3.close(); - } - - @Test - void searchCloseThenCallThrows() { - SearchModel model = SearchModel.tavily("sk-test"); - model.close(); - assertThatThrownBy(() -> model.search("{}")) - .isInstanceOf(IllegalStateException.class); - } - - // ── Search answer field (previously unasserted) ───────────────────────── - - @Test - void searchParsesAnswerField() { - server.setResponseBody( - "{\"results\":[{\"title\":\"Rust\",\"url\":\"https://rust-lang.org\",\"content\":\"Rust is...\"}]," - + "\"answer\":\"Rust is a systems language.\"}"); - - try (SearchModel model = SearchModel.tavilyWithBase("sk-test", server.baseUrl())) { - JSONObject opts = new JSONObject() - .put("query", "What is Rust?") - .put("maxResults", 5) - .put("providerOptions", new JSONObject()); - JSONObject result = new JSONObject(model.search(opts.toString())); - - assertThat(result.getJSONArray("results").length()).isEqualTo(1); - assertThat(result.getString("answer")).isEqualTo("Rust is a systems language."); - } - } - - // ── DeepSeek generateText (previously only construct-tested) ───────────── - - @Test - void deepseekGenerateTextViaMock() { - server.setResponseBody( - "{\"id\":\"chatcmpl-1\",\"object\":\"chat.completion\",\"model\":\"deepseek-chat\"," - + "\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"Hi\"},\"finish_reason\":\"stop\"}]," - + "\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}"); - - try (Model model = Model.provider("deepseek", "sk-test-fake-key", "deepseek-chat", server.chatConfig())) { - String result = model.generateText("\"Say hi\""); - assertThat(new JSONObject(result).getString("text")).isEqualTo("Hi"); - assertThat(server.lastRequestPath()).isEqualTo("/chat/completions"); - } - } -} diff --git a/bindings/java/src/test/java/ai/arcships/aimux/MultimodalE2ETest.java b/bindings/java/src/test/java/ai/arcships/aimux/MultimodalE2ETest.java deleted file mode 100644 index 0fc35466f..000000000 --- a/bindings/java/src/test/java/ai/arcships/aimux/MultimodalE2ETest.java +++ /dev/null @@ -1,223 +0,0 @@ -package ai.arcships.aimux; - -import org.json.JSONArray; -import org.json.JSONObject; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -// ───────────────────────────────────────────────────────────────────────────── -// Multimodal end-to-end tests for the Java binding. -// -// Mirrors `bindings/kotlin/src/test/kotlin/aimux/MultimodalE2ETest.kt`: each -// modality is exercised against a local mock HTTP server (MockProviderServer) -// that replays a canned provider response. The binding transforms that -// response into the aimux wire-format result, which we parse back and assert -// on. -// -// Google Video uses a multi-step async API (POST predict → poll operation → -// fetch result) that a single-response mock server can't drive, so its test -// checks construction + result parsing only — exactly like the Kotlin test. -// -// No real network access is performed — every request hits 127.0.0.1. -// ───────────────────────────────────────────────────────────────────────────── - -class MultimodalE2ETest { - - private MockProviderServer server; - - @BeforeEach - void setUp() { - server = new MockProviderServer(); - } - - @AfterEach - void tearDown() { - server.close(); - } - - // ── Embedding ────────────────────────────────────────────────────────── - - @Test - void embedParsesEmbeddings() { - server.setResponseBody( - "{\"data\":[{\"embedding\":[0.1,0.2,0.3],\"index\":0}]," - + "\"model\":\"text-embedding-3-small\"," - + "\"usage\":{\"prompt_tokens\":3,\"total_tokens\":3}}"); - - try (EmbeddingModel model = EmbeddingModel.openaiWithBase( - "sk-test", "text-embedding-3-small", server.baseUrl())) { - JSONObject result = - new JSONObject(model.embed(new JSONArray().put("hello").toString())); - - // Wire format: {"embeddings":[[0.1,0.2,0.3]], ...} - JSONArray embeddings = result.getJSONArray("embeddings"); - assertThat(embeddings.length()).isEqualTo(1); - assertThat(embeddings.getJSONArray(0).length()).isEqualTo(3); - } - } - - // ── Speech (TTS) ─────────────────────────────────────────────────────── - - @Test - void speechGenerateReturnsBinaryAudio() { - // OpenAI TTS returns raw audio bytes with content-type audio/mpeg. The - // mock body below is ASCII-safe (a base64-looking string carried as raw - // bytes), so it round-trips through the String-based mock unchanged — - // the same trick the Kotlin test uses. The core then wraps the raw - // bytes as AudioData::Binary. - server.setContentType("audio/mpeg"); - server.setResponseBody("SGVsbG8gd29ybGQ="); - - try (SpeechModel model = SpeechModel.openaiWithBase( - "sk-test", "tts-1", server.baseUrl())) { - JSONObject opts = new JSONObject() - .put("text", "Hi") - .put("voice", "alloy") - .put("outputFormat", "mp3") - .put("providerOptions", new JSONObject()); - - JSONObject result = new JSONObject(model.generate(opts.toString())); - - // SpeechModelV4Result.audio is a Uint8Array upstream - // (speech-model-v4-result.ts:16): a base64 string in the JSON. - assertThat(result.getString("audio")).isEqualTo(java.util.Base64.getEncoder() - .encodeToString("SGVsbG8gd29ybGQ=".getBytes(java.nio.charset.StandardCharsets.US_ASCII))); - } - } - - // ── Image ────────────────────────────────────────────────────────────── - - @Test - void imageGenerateParsesBase64Images() { - server.setResponseBody("{\"data\":[{\"b64_json\":\"aW1hZ2Ux\"}]}"); - - try (ImageModel model = ImageModel.openaiWithBase( - "sk-test", "dall-e-3", server.baseUrl())) { - JSONObject opts = new JSONObject() - .put("prompt", "otter") - .put("n", 1) - .put("providerOptions", new JSONObject()); - - JSONObject result = new JSONObject(model.generate(opts.toString())); - - // Wire format: {"images":["aW1hZ2Ux"], ...} (untagged: strings = base64) - JSONArray base64 = result.getJSONArray("images"); - assertThat(base64.length()).isEqualTo(1); - assertThat(base64.getString(0)).isEqualTo("aW1hZ2Ux"); - } - } - - // ── Transcription (STT) ──────────────────────────────────────────────── - - @Test - void transcriptionGenerateParsesText() { - server.setResponseBody("{\"text\":\"Hello world\"}"); - - try (TranscriptionModel model = TranscriptionModel.openaiWithBase( - "sk-test", "whisper-1", server.baseUrl())) { - JSONObject result = new JSONObject(model.generate("dGVzdA==", "audio/mp3")); - - // Wire format: {"text":"Hello world", ...} - assertThat(result.getString("text")).isEqualTo("Hello world"); - } - } - - // ── Reranking ────────────────────────────────────────────────────────── - - @Test - void rerankParsesRanking() { - server.setResponseBody( - "{\"results\":[{\"index\":1,\"relevance_score\":0.95}," - + "{\"index\":0,\"relevance_score\":0.3}]}"); - - try (RerankingModel model = RerankingModel.cohereWithBase( - "sk-test", "rerank-v3.0", server.baseUrl())) { - JSONObject opts = new JSONObject() - .put("query", "which?") - .put("documents", new JSONObject("{\"type\":\"text\",\"values\":[\"doc1\",\"doc2\"]}")) - .put("topN", 2) - .put("providerOptions", new JSONObject()); - - JSONObject result = new JSONObject(model.rerank(opts.toString())); - - // Wire format: {"ranking":[{"index":1,"relevanceScore":0.95}, ...], ...} - JSONArray ranking = result.getJSONArray("ranking"); - assertThat(ranking.length()).isEqualTo(2); - JSONObject first = ranking.getJSONObject(0); - assertThat(first.getInt("index")).isEqualTo(1); - assertThat(first.getDouble("relevanceScore")).isEqualTo(0.95); - } - } - - // ── Search ───────────────────────────────────────────────────────────── - - @Test - void searchParsesResults() { - server.setResponseBody( - "{\"results\":[{\"title\":\"Rust\",\"url\":\"https://rust-lang.org\"," - + "\"content\":\"Rust is...\"}]," - + "\"answer\":\"Rust is a systems language.\"}"); - - try (SearchModel model = SearchModel.tavilyWithBase( - "sk-test", server.baseUrl())) { - JSONObject opts = new JSONObject() - .put("query", "What is Rust?") - .put("maxResults", 5) - .put("providerOptions", new JSONObject()); - - JSONObject result = new JSONObject(model.search(opts.toString())); - - // Wire format: {"results":[{"title":"Rust", ...}], "answer":"...", ...} - JSONArray results = result.getJSONArray("results"); - assertThat(results.length()).isEqualTo(1); - assertThat(results.getJSONObject(0).getString("title")).isEqualTo("Rust"); - } - } - - // ── Files ────────────────────────────────────────────────────────────── - - @Test - void uploadFileParsesProviderReference() { - server.setResponseBody( - "{\"id\":\"file-abc\",\"object\":\"file\",\"bytes\":1024," - + "\"created_at\":1234,\"filename\":\"test.pdf\"," - + "\"purpose\":\"assistants\"}"); - - try (Files files = Files.openaiWithBase("sk-test", server.baseUrl())) { - JSONObject result = new JSONObject(files.uploadFile("dGVzdA==", "application/pdf")); - - // Wire format: {"providerReference":{"openai":"file-abc"}, ...} - assertThat(result.getJSONObject("providerReference").getString("openai")) - .isEqualTo("file-abc"); - } - } - - // ── Video (construction + result parsing only) ───────────────────────── - - @Test - void videoConstructionAndResultParsing() { - // Google Video uses a multi-step async API (POST predict → poll - // operation → fetch result). A single-response mock server can't drive - // the full flow, so — like the Kotlin test — we only verify - // construction (via the WithBase factory) and parsing of a canned - // VideoResult. - try (VideoModel model = VideoModel.googleWithBase( - "sk-test", "veo-3.0", server.baseUrl())) { - // Construction succeeded; the native handle is live and will be - // released by the try-with-resources block on exit. - assertThat(model).isNotNull(); - } - - // Wire format: {"videos":[{"type":"url","url":"...","mediaType":"..."}], ...} - JSONObject parsed = new JSONObject( - "{\"videos\":[{\"type\":\"url\",\"url\":\"https://example.com/v.mp4\"," - + "\"mediaType\":\"video/mp4\"}]}"); - JSONArray videos = parsed.getJSONArray("videos"); - assertThat(videos.length()).isEqualTo(1); - assertThat(videos.getJSONObject(0).getString("url")) - .isEqualTo("https://example.com/v.mp4"); - } -} diff --git a/bindings/java/src/test/java/ai/arcships/aimux/NativeLibraryTest.java b/bindings/java/src/test/java/ai/arcships/aimux/NativeLibraryTest.java new file mode 100644 index 000000000..9e0dacd49 --- /dev/null +++ b/bindings/java/src/test/java/ai/arcships/aimux/NativeLibraryTest.java @@ -0,0 +1,204 @@ +package ai.arcships.aimux; + +import ai.arcships.aimux.generated.AimuxOps; +import com.fasterxml.jackson.databind.JsonNode; +import com.sun.jna.NativeLibrary; +import com.sun.jna.Pointer; +import com.sun.jna.ptr.IntByReference; +import com.sun.jna.ptr.PointerByReference; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +/** Exercises the built Rust library without provider credentials or network access. */ +class NativeLibraryTest { + private Aimux aimux; + private static final String PROVIDER = "{\"package\":\"openai\",\"settings\":{\"apiKey\":\"local-test\"}}"; + private static final String TEXT = "{\"prompt\":\"你好 🌏\",\"options\":{\"maxRetries\":0}}"; + + @BeforeEach void load() { aimux = Aimux.load(); } + + @Test void loadsTheGeneratedAbiAndAllFourteenPrimitives() { + JsonNode manifest = aimux.getManifest(); + assertThat(manifest.path("abiVersion").asLong()).isEqualTo(AimuxOps.ABI_VERSION); + assertThat(manifest.path("opsVersion").asText()).isEqualTo(AimuxOps.OPS_VERSION); + List names = new ArrayList<>(); + manifest.path("ops").forEach(op -> names.add(op.path("name").asText())); + assertThat(names).contains(AimuxOps.OP_DECISION_DECIDE, AimuxOps.OP_TRANSCRIPTION_START, + AimuxOps.OP_TEXT_STREAM, AimuxOps.OP_PROVIDER_CREATE); + NativeLibrary library = NativeLibrary.getInstance("aimux_ffi"); + for (java.lang.reflect.Method method : AimuxFFI.class.getDeclaredMethods()) { + assertThat(library.getFunction(method.getName())).isNotNull(); + } + } + + @Test void reportsRealProtocolAndBindingEnvelopesAndDropsIdempotently() { + assertThatThrownBy(() -> aimux.call("unknown.operation", "{}")) + .isInstanceOf(ProtocolException.InvalidOp.class); + assertThatThrownBy(() -> aimux.call(AimuxOps.OP_ABORT_CREATE, "{\"unexpected\":true}")) + .isInstanceOf(ProtocolException.InvalidParams.class); + try (Resource signal = aimux.create(AimuxOps.OP_ABORT_CREATE, "{}")) { + long id = Long.parseUnsignedLong(signal.getHandle()); + assertThatThrownBy(() -> signal.call(AimuxOps.OP_MODEL_INFO, "{}")) + .isInstanceOf(BindingException.InvalidHandle.class); + assertThat(signal.call(AimuxOps.OP_ABORT_SIGNAL, "{}").isNull()).isTrue(); + signal.close(); + signal.close(); + assertThatThrownBy(() -> aimux.call(id, AimuxOps.OP_ABORT_SIGNAL, "{}", 0)) + .isInstanceOf(BindingException.InvalidHandle.class); + aimux.ffi.aimux_drop(id); + aimux.ffi.aimux_drop(0); + } + } + + @Test void providerChildrenRetainTheirModelsAfterTheProviderCloses() { + ProviderHandle provider = aimux.createProvider(PROVIDER); + try (Model model = provider.languageModel("你好-model"); + EmbeddingModel embedding = provider.embeddingModel("text-embedding-3-small"); + ImageModel image = provider.imageModel("gpt-image-1"); + SpeechModel speech = provider.speechModel("tts-1"); + TranscriptionModel transcription = provider.transcriptionModel("whisper-1"); + Files files = provider.files()) { + provider.close(); + assertThat(model.info().path("modelId").asText()).isEqualTo("你好-model"); + assertThat(embedding.info().path("modelId").asText()).isEqualTo("text-embedding-3-small"); + assertThat(image.info()).isNotNull(); + assertThat(speech.info()).isNotNull(); + assertThat(transcription.info()).isNotNull(); + } finally { provider.close(); } + } + + @Test void preservesDecisionThroughTheDataConfiguredProvider() { + try (ProviderHandle provider = aimux.createProvider("{\"package\":\"jev\",\"settings\":{\"apiKey\":\"local-test\"}}"); + DecisionModel model = provider.decisionModel("decision-test")) { + assertThat(model.capabilities().isObject()).isTrue(); + assertThatThrownBy(() -> model.decide("{}")) + .isInstanceOf(ProtocolException.InvalidParams.class); + } + } + + @Test void genericResourcesResolveModelsThroughAnExplicitRegistry() { + ProviderHandle provider = aimux.createProvider(PROVIDER); + String entries = "{\"namespace\":\"java\",\"entries\":[{\"key\":\"local\",\"provider\":\"" + + provider.getHandle() + "\"}]}"; + try (Resource registry = aimux.create(AimuxOps.OP_REGISTRY_CREATE, entries)) { + provider.close(); + try (Resource model = registry.create(AimuxOps.OP_MODEL_RESOLVE, + "{\"modelRef\":{\"namespace\":\"java\",\"key\":\"local\"," + + "\"method\":\"languageModel\",\"modelId\":\"test-model\"}}")) { + registry.close(); + assertThat(model.call(AimuxOps.OP_MODEL_INFO, "{}").path("modelId").asText()) + .isEqualTo("test-model"); + } + } finally { provider.close(); } + } + + @Test void genericRecorderUsesRealCreateFlushAndExportShapes() { + try (Resource recorder = aimux.create(AimuxOps.OP_RECORDER_CREATE, + "{\"backend\":{\"type\":\"ring\",\"capacity\":8},\"capturePolicy\":\"full\"}")) { + recorder.call(AimuxOps.OP_RECORDER_FLUSH, "{}"); + assertThat(recorder.call(AimuxOps.OP_RECORDER_EXPORT, "{}").path("jsonl").asText()).isEmpty(); + assertThat(recorder.call(AimuxOps.OP_RECORDER_STATS, "{}").isObject()).isTrue(); + } + } + + @Test void cancellationOfQueuedWorkReleasesTheRealNativeAdmission() { + AtomicReference queued = new AtomicReference<>(); + Resource signal = aimux.create(AimuxOps.OP_ABORT_CREATE, "{}"); + long id = Long.parseUnsignedLong(signal.getHandle()); + Future future = signal.callAsync(AimuxOps.OP_ABORT_SIGNAL, "{}", queued::set); + signal.close(); + assertThat(aimux.call(id, AimuxOps.OP_ABORT_SIGNAL, "{}", 0).isNull()).isTrue(); + assertThat(future.cancel(false)).isTrue(); + queued.get().run(); + assertThatThrownBy(future::get).isInstanceOf(CancellationException.class); + assertThatThrownBy(() -> aimux.call(id, AimuxOps.OP_ABORT_SIGNAL, "{}", 0)) + .isInstanceOf(BindingException.InvalidHandle.class); + } + + @Test void preabortedNativeStreamDeliversOneTerminalAndAllowsCloseInConsumer() { + try (ProviderHandle provider = aimux.createProvider(PROVIDER); + Model model = provider.languageModel("local-test"); Abort abort = aimux.createAbort()) { + abort.signal(); + List frames = new ArrayList<>(); + assertThatThrownBy(() -> model.stream(AimuxOps.OP_TEXT_STREAM, TEXT, abort, frame -> { + frames.add(frame); + model.close(); + model.close(); + System.gc(); + try { Thread.sleep(20); } + catch (InterruptedException error) { throw new AssertionError(error); } + })).isInstanceOf(CancellationException.class); + assertThat(frames).hasSize(1); + assertThat(frames.get(0).isEnd()).isTrue(); + assertThat(frames.get(0).getOutcome()).isEqualTo(StreamFrame.Outcome.CANCELLED); + assertThat(frames.get(0).getJson().path("type").asText()).isEqualTo("end"); + } + } + + @Test void nativeCallbackFailureIsLatchedUntilTheNativeStreamReturns() { + try (ProviderHandle provider = aimux.createProvider(PROVIDER); + Model model = provider.languageModel("local-test"); Abort abort = aimux.createAbort()) { + abort.signal(); + AssertionError cause = new AssertionError("consumer failed"); + assertThatThrownBy(() -> model.stream(AimuxOps.OP_TEXT_STREAM, TEXT, abort, frame -> { throw cause; })) + .isInstanceOf(BindingException.CallbackFailure.class).hasCause(cause); + assertThat(model.info()).isNotNull(); + } + } + + @Test void callbackReentryIsReportedAfterTheNativeStreamReturns() { + try (ProviderHandle provider = aimux.createProvider(PROVIDER); + Model model = provider.languageModel("local-test"); Abort abort = aimux.createAbort()) { + abort.signal(); + assertThatThrownBy(() -> model.stream(AimuxOps.OP_TEXT_STREAM, TEXT, abort, frame -> model.info())) + .isInstanceOf(BindingException.CallbackFailure.class) + .hasCauseInstanceOf(BindingException.ReentrantCall.class); + } + } + + @Test void preacceptanceStreamErrorsProduceNoCallbacks() { + try (ProviderHandle provider = aimux.createProvider(PROVIDER); + Model model = provider.languageModel("local-test")) { + List frames = new ArrayList<>(); + assertThatThrownBy(() -> model.stream("{}", frames::add)) + .isInstanceOf(ProtocolException.InvalidParams.class); + assertThat(frames).isEmpty(); + } + } + + @Test void primitiveErrorsResetOutputsAndHonorBinaryLengthAndSignedTimeout() { + PointerByReference out = new PointerByReference(new Pointer(1)); + IntByReference state = new IntByReference(99); + Pointer error = aimux.ffi.aimux_operation_next_part(0, -1, out, state); + assertThat(out.getValue()).isNull(); + assertThat(state.getValue()).isZero(); + assertThatThrownBy(() -> AimuxResult.check(aimux.ffi, error)) + .isInstanceOf(ProtocolException.InvalidParams.class); + assertThatThrownBy(() -> AimuxResult.check(aimux.ffi, + aimux.ffi.aimux_operation_push(0, new byte[0], new AimuxFFI.SizeT(1048577)))) + .isInstanceOf(ProtocolException.ResourceLimit.class); + assertThatThrownBy(() -> AimuxResult.check(aimux.ffi, + aimux.ffi.aimux_operation_push(0, new byte[0], new AimuxFFI.SizeT(0)))) + .isInstanceOf(BindingException.InvalidHandle.class); + } + + @Test void finalizerPrimitiveAcceptsNullAndReleasesAnOwnedAbort() { + JsonNode result = aimux.call(AimuxOps.OP_ABORT_CREATE, "{}"); + long id = AimuxResult.handle(result); + aimux.ffi.aimux_handle_finalizer(new Pointer(id)); + aimux.ffi.aimux_handle_finalizer(null); + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(2); + while (System.nanoTime() < deadline) { + try { aimux.call(id, AimuxOps.OP_ABORT_SIGNAL, "{}", 0); } + catch (BindingException.InvalidHandle released) { return; } + Thread.yield(); + } + fail("Native finalizer did not release the handle"); + } +} diff --git a/bindings/java/src/test/java/ai/arcships/aimux/RecordingTest.java b/bindings/java/src/test/java/ai/arcships/aimux/RecordingTest.java deleted file mode 100644 index dd9693b22..000000000 --- a/bindings/java/src/test/java/ai/arcships/aimux/RecordingTest.java +++ /dev/null @@ -1,51 +0,0 @@ -package ai.arcships.aimux; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * {@link Aimux#initRecording(String)} / {@link Aimux#recordingTryFlush()}: recording - * failures are reported as {@link RecordingException} — its own type, not an - * {@link AimuxException}. - */ -class RecordingTest { - - @AfterEach - void stopRecording() { - Aimux.recordingStop(); - } - - @Test - void tryFlushSucceedsWhenNothingIsRecording() { - Aimux.recordingStop(); - assertThatCode(Aimux::recordingTryFlush).doesNotThrowAnyException(); - } - - @Test - void initRecordingReportsInitForUnwritableDir(@TempDir Path tmp) throws IOException { - // Parent path is a regular file: the recorder cannot create its - // directory → init fails with INIT and nothing is installed. - Path blocker = tmp.resolve("blocker"); - Files.write(blocker, new byte[] {'x'}); - Aimux.recordingStop(); - - assertThatThrownBy(() -> Aimux.initRecording(blocker.resolve("rec").toString())) - .isInstanceOf(RecordingException.class) - .isNotInstanceOf(AimuxException.class) - .satisfies(e -> assertThat(((RecordingException) e).getCode()) - .isEqualTo(RecordingErrorCode.INIT)) - .hasMessageStartingWith("initRecording: "); - - // Nothing recording after the failed init → checked flush succeeds. - assertThatCode(Aimux::recordingTryFlush).doesNotThrowAnyException(); - } -} diff --git a/bindings/java/src/test/java/ai/arcships/aimux/StructuredE2ETest.java b/bindings/java/src/test/java/ai/arcships/aimux/StructuredE2ETest.java deleted file mode 100644 index aafd6e5ac..000000000 --- a/bindings/java/src/test/java/ai/arcships/aimux/StructuredE2ETest.java +++ /dev/null @@ -1,382 +0,0 @@ -package ai.arcships.aimux; - -import org.json.JSONArray; -import org.json.JSONObject; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertTimeout; - -/** - * Structured end-to-end tests for the raw JSON {@link Model} layer (mirror of - * the Kotlin binding's StructuredE2ETest.kt). - * - *

These spin up a local mock HTTP server speaking the OpenAI - * chat-completions wire format, point an OpenAI-compatible {@code Model} at it - * through the provider registry, and assert that the binding correctly: - *

    - *
  1. parses toolCalls out of a provider response,
  2. - *
  3. forwards multi-role messages,
  4. - *
  5. forwards {@code toolChoice},
  6. - *
  7. parses tool-call stream parts from an SSE stream,
  8. - *
  9. runs a full tool-call round-trip (tool-call + tool-result + final answer).
  10. - *
- * - * No real network access is performed — every request hits 127.0.0.1. - */ -class StructuredE2ETest { - - private MockProviderServer server; - - @BeforeEach - void setUp() { - server = new MockProviderServer(); - } - - @AfterEach - void tearDown() { - server.close(); - } - - // ── canned OpenAI responses ─────────────────────────────────────────── - - /** Plain OpenAI text response (no tool calls). */ - private static String plainOpenAiResponse() { - return new JSONObject() - .put("id", "chatcmpl-test") - .put("model", "gpt-4o") - .put("choices", new JSONArray().put( - new JSONObject() - .put("message", new JSONObject() - .put("role", "assistant") - .put("content", "Rust is a systems programming language.")) - .put("finish_reason", "stop"))) - .put("usage", new JSONObject() - .put("prompt_tokens", 10) - .put("completion_tokens", 8) - .put("total_tokens", 18)) - .toString(); - } - - /** OpenAI response that requests a tool call (get_weather). */ - private static String toolCallOpenAiResponse() { - return new JSONObject() - .put("id", "chatcmpl-tc") - .put("model", "gpt-4o") - .put("choices", new JSONArray().put( - new JSONObject() - .put("message", new JSONObject() - .put("role", "assistant") - .put("content", JSONObject.NULL) - .put("tool_calls", new JSONArray().put( - new JSONObject() - .put("id", "call_abc") - .put("type", "function") - .put("function", new JSONObject() - .put("name", "get_weather") - .put("arguments", "{\"location\":\"Tokyo\"}"))))) - .put("finish_reason", "tool_calls"))) - .put("usage", new JSONObject() - .put("prompt_tokens", 20) - .put("completion_tokens", 10) - .put("total_tokens", 30)) - .toString(); - } - - /** A plain text response used as the second leg of the tool-call round-trip. */ - private static String finalTextResponse() { - return new JSONObject() - .put("id", "chatcmpl-2") - .put("model", "gpt-4o") - .put("choices", new JSONArray().put( - new JSONObject() - .put("message", new JSONObject() - .put("role", "assistant") - .put("content", "The weather in Tokyo is sunny.")) - .put("finish_reason", "stop"))) - .put("usage", new JSONObject() - .put("prompt_tokens", 30) - .put("completion_tokens", 8) - .put("total_tokens", 38)) - .toString(); - } - - /** OpenAI SSE stream: tool_calls name delta -> arguments delta -> finish -> [DONE]. */ - private static String toolCallSseResponse() { - StringBuilder sb = new StringBuilder(); - sb.append("data: ").append(new JSONObject() - .put("id", "1") - .put("model", "gpt-4o") - .put("choices", new JSONArray().put( - new JSONObject().put("delta", new JSONObject() - .put("role", "assistant") - .put("tool_calls", new JSONArray().put( - new JSONObject() - .put("index", 0) - .put("id", "call_xyz") - .put("type", "function") - .put("function", new JSONObject() - .put("name", "get_weather") - .put("arguments", "")))))))) - .append("\n\n"); - sb.append("data: ").append(new JSONObject() - .put("id", "1") - .put("model", "gpt-4o") - .put("choices", new JSONArray().put( - new JSONObject().put("delta", new JSONObject() - .put("tool_calls", new JSONArray().put( - new JSONObject() - .put("index", 0) - .put("function", new JSONObject() - .put("arguments", "{\"location\":\"Tokyo\"}")))))))) - .append("\n\n"); - sb.append("data: ").append(new JSONObject() - .put("id", "1") - .put("model", "gpt-4o") - .put("choices", new JSONArray().put( - new JSONObject().put("delta", new JSONObject()) - .put("finish_reason", "tool_calls"))) - .put("usage", new JSONObject() - .put("prompt_tokens", 5) - .put("completion_tokens", 2) - .put("total_tokens", 7))) - .append("\n\n"); - sb.append("data: [DONE]\n\n"); - return sb.toString(); - } - - /** A {@code tools} option describing a single get_weather function tool. */ - private static String weatherToolsOpts(String includeToolChoice) { - JSONObject opts = new JSONObject(); - opts.put("tools", new JSONArray().put( - new JSONObject() - .put("type", "function") - .put("name", "get_weather") - .put("inputSchema", new JSONObject() - .put("type", "object") - .put("properties", new JSONObject() - .put("location", new JSONObject().put("type", "string")))))); - if (includeToolChoice != null) { - opts.put("toolChoice", includeToolChoice); - } - return opts.toString(); - } - - private static String weatherToolsOpts() { - return weatherToolsOpts(null); - } - - // ── Tests ───────────────────────────────────────────────────────────── - - @Test - void generateTextParsesToolCalls() { - server.setResponseBody(toolCallOpenAiResponse()); - - try (Model model = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { - String resultJson = model.generateText("\"What is the weather in Tokyo?\"", weatherToolsOpts()); - JSONObject result = new JSONObject(resultJson); - - // toolCalls[0].toolName == "get_weather" - JSONArray toolCalls = result.getJSONArray("toolCalls"); - assertThat(toolCalls.length()).isGreaterThan(0); - JSONObject firstCall = toolCalls.getJSONObject(0); - assertThat(firstCall.getString("toolName")).isEqualTo("get_weather"); - assertThat(firstCall.getString("toolCallId")).isEqualTo("call_abc"); - assertThat(firstCall.getJSONObject("input").getString("location")).isEqualTo("Tokyo"); - - // content contains a "tool-call" part. - JSONArray content = result.getJSONArray("content"); - boolean hasToolCallVariant = false; - for (int i = 0; i < content.length(); i++) { - if ("tool-call".equals(content.getJSONObject(i).optString("type"))) { - hasToolCallVariant = true; - break; - } - } - assertThat(hasToolCallVariant).isTrue(); - } - } - - @Test - void multiRoleMessagesReachProvider() { - server.setResponseBody(plainOpenAiResponse()); - - // [{role:"system",content:"You are a helpful assistant."}, - // {role:"user",content:"What is Rust?"}] - JSONArray prompt = new JSONArray() - .put(new JSONObject().put("role", "system").put("content", "You are a helpful assistant.")) - .put(new JSONObject().put("role", "user").put("content", "What is Rust?")); - - try (Model model = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { - String resultJson = model.generateText(prompt.toString()); - JSONObject result = new JSONObject(resultJson); - assertThat(result.getString("text")) - .isEqualTo("Rust is a systems programming language."); - - // The provider received both messages (system + user). - JSONObject reqBody = new JSONObject(server.lastRequestBody()); - assertThat(reqBody.getString("model")).isEqualTo("gpt-4o"); - JSONArray messages = reqBody.getJSONArray("messages"); - assertThat(messages.length()).isEqualTo(2); - assertThat(messages.getJSONObject(0).getString("role")).isEqualTo("system"); - assertThat(messages.getJSONObject(1).getString("role")).isEqualTo("user"); - } - } - - @Test - void toolChoiceReachesProvider() { - server.setResponseBody(toolCallOpenAiResponse()); - - try (Model model = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { - model.generateText("\"What is the weather in Tokyo?\"", weatherToolsOpts("required")); - - JSONObject reqBody = new JSONObject(server.lastRequestBody()); - assertThat(reqBody.getString("tool_choice")).isEqualTo("required"); - // tools were forwarded too. - assertThat(reqBody.has("tools")).isTrue(); - } - } - - @Test - void streamTextParsesToolCallStreamParts() { - // SSE: tool_calls delta (name) -> arguments delta -> finish_reason. - server.setContentType("text/event-stream"); - server.setResponseBody(toolCallSseResponse()); - - try (Model model = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { - // Run the blocking FFI stream call on a separate thread so the JNA - // callbacks (which attach to the calling thread) don't deadlock with - // tokio's block_on. The main thread collects parts via a queue. - LinkedBlockingQueue parts = new LinkedBlockingQueue<>(); - Thread streamThread = new Thread(() -> { - try { - model.streamText("\"What is the weather in Tokyo?\"", weatherToolsOpts(), - parts::add, - () -> { try { parts.put("__done__"); } catch (InterruptedException ignored) { } }); - } catch (RuntimeException e) { - try { parts.put("__done__"); } catch (InterruptedException ignored) { } - } - }); - streamThread.setDaemon(true); - streamThread.start(); - - List collected = new ArrayList<>(); - assertTimeout(Duration.ofSeconds(30), () -> { - while (true) { - String part = parts.poll(30, TimeUnit.SECONDS); - if (part == null || "__done__".equals(part)) { - break; - } - collected.add(part); - } - }); - try { - streamThread.join(5000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException(e); - } - - assertThat(collected).isNotEmpty(); - // Stream must surface tool-call activity: either a complete tool-call - // part or tool-input-delta deltas (or both). - boolean hasToolPart = false; - for (String part : collected) { - if (part.contains("\"type\":\"tool-call\"") || part.contains("\"type\":\"tool-input-delta\"")) { - hasToolPart = true; - break; - } - } - assertThat(hasToolPart) - .as("expected a tool-call or tool-input-delta stream part; got: %s", collected) - .isTrue(); - - // The finish part should also be present, carrying the tool-calls - // finish reason. - boolean hasFinish = false; - for (String part : collected) { - if (part.contains("\"type\":\"finish\"")) { - hasFinish = true; - break; - } - } - assertThat(hasFinish).isTrue(); - } - } - - @Test - void toolCallFullRoundTrip() { - // A tool-call round-trip = two generateText calls with a ToolResult - // back-filled between them. The mock server returns a tool-call - // response for the first request, then a plain text response for the - // second (FIFO queue). - server.setResponses(toolCallOpenAiResponse(), finalTextResponse()); - - try (Model model = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { - // 1st call: provider asks to call get_weather. - String firstResultJson = - model.generateText("\"What's the weather in Tokyo?\"", weatherToolsOpts()); - JSONObject firstResult = new JSONObject(firstResultJson); - JSONArray toolCalls = firstResult.getJSONArray("toolCalls"); - assertThat(toolCalls.length()).isGreaterThan(0); - JSONObject firstCall = toolCalls.getJSONObject(0); - assertThat(firstCall.getString("toolName")).isEqualTo("get_weather"); - assertThat(firstCall.getString("toolCallId")).isEqualTo("call_abc"); - assertThat(firstCall.getJSONObject("input").getString("location")) - .isEqualTo("Tokyo"); - - // 2nd call: echo the full conversation back, including the - // assistant's tool-call and the tool-result we synthesised. - // Input uses the AI SDK ContentPart variants (tool-call / - // tool-result); the engine converts these to the OpenAI wire - // format on the outbound request. The tool-result part carries - // `output` (a ToolResultOutput union), never `result`. - JSONArray messages = new JSONArray() - .put(new JSONObject().put("role", "user").put("content", "What's the weather in Tokyo?")) - .put(new JSONObject().put("role", "assistant") - .put("content", new JSONArray().put( - new JSONObject() - .put("type", "tool-call") - .put("toolCallId", "call_abc") - .put("toolName", "get_weather") - .put("input", new JSONObject().put("location", "Tokyo"))))) - .put(new JSONObject().put("role", "tool") - .put("content", new JSONArray().put( - new JSONObject() - .put("type", "tool-result") - .put("toolCallId", "call_abc") - .put("toolName", "get_weather") - .put("output", new JSONObject() - .put("type", "json") - .put("value", new JSONObject() - .put("temperature", 22) - .put("condition", "sunny")))))); - - String secondResultJson = model.generateText(messages.toString(), weatherToolsOpts()); - JSONObject secondResult = new JSONObject(secondResultJson); - assertThat(secondResult.getString("text")) - .isEqualTo("The weather in Tokyo is sunny."); - - // The provider received 3 messages; the last is the tool result, - // carrying the matching tool_call_id. - JSONObject reqBody = new JSONObject(server.lastRequestBody()); - JSONArray reqMessages = reqBody.getJSONArray("messages"); - assertThat(reqMessages.length()).isEqualTo(3); - JSONObject lastMsg = reqMessages.getJSONObject(2); - assertThat(lastMsg.getString("role")).isEqualTo("tool"); - assertThat(lastMsg.getString("tool_call_id")).isEqualTo("call_abc"); - JSONObject output = new JSONObject(lastMsg.getString("content")); - assertThat(output.getInt("temperature")).isEqualTo(22); - assertThat(output.getString("condition")).isEqualTo("sunny"); - } - } -} - diff --git a/bindings/java/src/test/java/ai/arcships/aimux/TypedModelTest.java b/bindings/java/src/test/java/ai/arcships/aimux/TypedModelTest.java deleted file mode 100644 index e58a3d766..000000000 --- a/bindings/java/src/test/java/ai/arcships/aimux/TypedModelTest.java +++ /dev/null @@ -1,509 +0,0 @@ -package ai.arcships.aimux; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import org.json.JSONArray; -import org.json.JSONObject; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.time.Duration; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertTimeout; - -/** - * Tests for the typed {@link TypedModel} wrapper over the raw JSON-string - * {@link Model} (mirror of the Kotlin binding's TypedModelTest.kt). - * - *

Like {@link StructuredE2ETest}, these spin up the shared - * {@link MockProviderServer} (OpenAI chat-completions wire format) and assert - * that the typed wrapper: - *

    - *
  1. returns a {@link Types.GenerateTextResult} object (no manual JSON parsing),
  2. - *
  3. surfaces {@code .text}, {@code .toolCalls[0].toolName}, and {@code .content},
  4. - *
  5. forwards typed {@code tools} / {@code tool_choice} onto the provider request,
  6. - *
  7. forwards multi-role {@link Types.ModelMessage} conversations,
  8. - *
  9. decodes SSE stream parts into typed {@link Types.StreamPart}s.
  10. - *
- * - * No real network access — every request hits 127.0.0.1. - */ -class TypedModelTest { - - private MockProviderServer server; - - @BeforeEach - void setUp() { - server = new MockProviderServer(); - } - - @AfterEach - void tearDown() { - server.close(); - } - - // ── canned OpenAI responses (mirror StructuredE2ETest) ─────────────── - - /** Plain OpenAI text response (no tool calls). */ - private static String plainOpenAiResponse() { - return new JSONObject() - .put("id", "chatcmpl-test") - .put("model", "gpt-4o") - .put("choices", new JSONArray().put( - new JSONObject() - .put("message", new JSONObject() - .put("role", "assistant") - .put("content", "Rust is a systems programming language.")) - .put("finish_reason", "stop"))) - .put("usage", new JSONObject() - .put("prompt_tokens", 10) - .put("completion_tokens", 8) - .put("total_tokens", 18)) - .toString(); - } - - /** OpenAI response that requests a tool call (get_weather). */ - private static String toolCallOpenAiResponse() { - return new JSONObject() - .put("id", "chatcmpl-tc") - .put("model", "gpt-4o") - .put("choices", new JSONArray().put( - new JSONObject() - .put("message", new JSONObject() - .put("role", "assistant") - .put("content", JSONObject.NULL) - .put("tool_calls", new JSONArray().put( - new JSONObject() - .put("id", "call_abc") - .put("type", "function") - .put("function", new JSONObject() - .put("name", "get_weather") - .put("arguments", "{\"location\":\"Tokyo\"}"))))) - .put("finish_reason", "tool_calls"))) - .put("usage", new JSONObject() - .put("prompt_tokens", 20) - .put("completion_tokens", 10) - .put("total_tokens", 30)) - .toString(); - } - - /** OpenAI SSE stream: tool_calls name delta -> arguments delta -> finish -> [DONE]. */ - private static String toolCallSseResponse() { - StringBuilder sb = new StringBuilder(); - sb.append("data: ").append(new JSONObject() - .put("id", "1") - .put("model", "gpt-4o") - .put("choices", new JSONArray().put( - new JSONObject().put("delta", new JSONObject() - .put("role", "assistant") - .put("tool_calls", new JSONArray().put( - new JSONObject() - .put("index", 0) - .put("id", "call_xyz") - .put("type", "function") - .put("function", new JSONObject() - .put("name", "get_weather") - .put("arguments", "")))))))) - .append("\n\n"); - sb.append("data: ").append(new JSONObject() - .put("id", "1") - .put("model", "gpt-4o") - .put("choices", new JSONArray().put( - new JSONObject().put("delta", new JSONObject() - .put("tool_calls", new JSONArray().put( - new JSONObject() - .put("index", 0) - .put("function", new JSONObject() - .put("arguments", "{\"location\":\"Tokyo\"}")))))))) - .append("\n\n"); - sb.append("data: ").append(new JSONObject() - .put("id", "1") - .put("model", "gpt-4o") - .put("choices", new JSONArray().put( - new JSONObject().put("delta", new JSONObject()) - .put("finish_reason", "tool_calls"))) - .put("usage", new JSONObject() - .put("prompt_tokens", 5) - .put("completion_tokens", 2) - .put("total_tokens", 7))) - .append("\n\n"); - sb.append("data: [DONE]\n\n"); - return sb.toString(); - } - - // ── typed builders ────────────────────────────────────────────────── - - /** A JSON Schema for the get_weather tool's `location` argument. */ - private static Types.Tool weatherTool() { - ObjectMapper m = new ObjectMapper(); - ObjectNode schema = m.createObjectNode(); - schema.put("type", "object"); - ObjectNode props = m.createObjectNode(); - props.set("location", m.createObjectNode().put("type", "string")); - schema.set("properties", props); - return Types.Tool.Function.builder().name("get_weather").inputSchema(schema).build(); - } - - // ── Tests ─────────────────────────────────────────────────────────── - - // LanguageModelUsage is the AI SDK's (ai types/usage.ts:10-67): camelCase - // counts and detail objects, an unknown count absent. - @Test - void languageModelUsageIsCamelCase() throws Exception { - String wire = "{\"inputTokens\":3,\"inputTokenDetails\":{\"cacheReadTokens\":1}," - + "\"outputTokens\":2,\"outputTokenDetails\":{},\"totalTokens\":5}"; - Types.LanguageModelUsage usage = Types.AimuxJson.MAPPER.readValue(wire, Types.LanguageModelUsage.class); - assertThat(usage.getInputTokens()).isEqualTo(3L); - assertThat(usage.getInputTokenDetails().getCacheReadTokens()).isEqualTo(1L); - assertThat(usage.getTotalTokens()).isEqualTo(5L); - assertThat(Types.AimuxJson.MAPPER.readTree(Types.AimuxJson.MAPPER.writeValueAsString(usage))) - .isEqualTo(Types.AimuxJson.MAPPER.readTree(wire)); - } - - // The text stream's tool parts are the AI SDK's TypedToolResult, - // TypedToolError, TypedToolOutputDenied and ToolApprovalResponseOutput, - // camelCase (ai generate-text/tool-result.ts:10-42, tool-error.ts:6-36, - // tool-output-denied.ts:7-15, tool-approval-response-output.ts:7-35). - @Test - void streamPartsDecodeToolResultsAndToolErrors() throws Exception { - Types.StreamPart result = Types.AimuxJson.MAPPER.readValue( - "{\"type\":\"tool-result\",\"toolCallId\":\"srv-1\",\"toolName\":\"web_search\",\"input\":{\"q\":\"rust\"}," - + "\"output\":[{\"url\":\"https://example.com\"}],\"providerExecuted\":true,\"dynamic\":true}", - Types.StreamPart.class); - assertThat(result).isInstanceOf(Types.StreamPart.ToolResult.class); - Types.StreamPart.ToolResult toolResult = (Types.StreamPart.ToolResult) result; - assertThat(toolResult.getInput().get("q").asText()).isEqualTo("rust"); - assertThat(toolResult.getOutput().get(0).get("url").asText()).isEqualTo("https://example.com"); - assertThat(toolResult.getProviderExecuted()).isTrue(); - - Types.StreamPart error = Types.AimuxJson.MAPPER.readValue( - "{\"type\":\"tool-error\",\"toolCallId\":\"call-1\",\"toolName\":\"weather\",\"input\":{\"city\":\"Paris\"}," - + "\"error\":\"no such tool\",\"dynamic\":true}", - Types.StreamPart.class); - assertThat(error).isInstanceOf(Types.StreamPart.ToolError.class); - assertThat(((Types.StreamPart.ToolError) error).getError().asText()).isEqualTo("no such tool"); - assertThat(((Types.StreamPart.ToolError) error).getProviderExecuted()).isNull(); - - Types.StreamPart denied = Types.AimuxJson.MAPPER.readValue( - "{\"type\":\"tool-output-denied\",\"toolCallId\":\"c\",\"toolName\":\"t\"}", Types.StreamPart.class); - assertThat(denied).isEqualTo(new Types.StreamPart.ToolOutputDenied("c", "t", null, null)); - assertThat(Types.variantTag(denied)).isEqualTo("tool-output-denied"); - String json = Types.AimuxJson.MAPPER.writeValueAsString(denied); - assertThat(json).contains("\"toolCallId\":\"c\"").doesNotContain("tool_call_id"); - assertThat(Types.AimuxJson.MAPPER.readValue(json, Types.StreamPart.class)).isEqualTo(denied); - - Types.StreamPart response = Types.AimuxJson.MAPPER.readValue( - "{\"type\":\"tool-approval-response\",\"approvalId\":\"a\",\"toolCall\":{\"type\":\"tool-call\",\"toolCallId\":\"c\",\"toolName\":\"t\"," - + "\"input\":{}},\"approved\":false}", - Types.StreamPart.class); - assertThat(response).isInstanceOf(Types.StreamPart.ToolApprovalResponse.class); - assertThat(((Types.StreamPart.ToolApprovalResponse) response).getApproved()).isFalse(); - } - - @Test - void providerToolDeferredResultsRoundTrip() throws Exception { - ObjectMapper mapper = Types.AimuxJson.MAPPER; - for (Boolean supported : new Boolean[] {null, true, false}) { - Types.ProviderTool original = Types.ProviderTool.builder() - .id("anthropic.code_execution_20250522").name("code_execution") - .supportsDeferredResults(supported).build(); - String json = mapper.writeValueAsString(original); - assertThat(mapper.readTree(json).has("supportsDeferredResults")).isEqualTo(supported != null); - Types.ProviderTool decoded = mapper.readValue(json, Types.ProviderTool.class); - assertThat(decoded.getSupportsDeferredResults()).isEqualTo(supported); - assertThat(decoded).isEqualTo(original); - - Types.Tool.Provider tool = Types.Tool.Provider.from(original); - String toolJson = mapper.writeValueAsString(tool); - assertThat(mapper.readTree(toolJson).has("supportsDeferredResults")).isEqualTo(supported != null); - Types.Tool.Provider decodedTool = (Types.Tool.Provider) mapper.readValue(toolJson, Types.Tool.class); - assertThat(decodedTool.getSupportsDeferredResults()).isEqualTo(supported); - assertThat(decodedTool).isEqualTo(tool); - } - } - - @Test - void topLevelToolCallProviderMetadataRoundTrips() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - ObjectNode metadata = mapper.createObjectNode(); - metadata.set("google", mapper.createObjectNode().put("cache_id", "cache-1")); - Types.ToolCall original = Types.ToolCall.builder() - .toolCallId("call_1") - .toolName("get_weather") - .providerMetadata(metadata) - .build(); - - String json = Types.AimuxJson.MAPPER.writeValueAsString(original); - Types.ToolCall decoded = Types.AimuxJson.MAPPER.readValue(json, Types.ToolCall.class); - - assertThat(json).contains("\"providerMetadata\""); - assertThat(decoded.getProviderMetadata()).isEqualTo(metadata); - assertThat(decoded).isEqualTo(original); - } - - @Test - void generateTextReturnsTypedGenerateTextResultWithTextAndRawContent() { - server.setResponseBody(plainOpenAiResponse()); - - try (TypedModel model = - TypedModel.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { - Types.GenerateTextResult result = model.generateText("What is Rust?"); - - // No manual parsing: the wrapper returned a typed object. - assertThat(result).isInstanceOf(Types.GenerateTextResult.class); - - // .text is a plain String. - assertThat(result.getText()).isEqualTo("Rust is a systems programming language."); - - // A plain-text response carries no tool calls. - assertThat(result.getToolCalls()).isEmpty(); - - // .content is accessible as a list (no JSON digging required). - assertThat(result.getContent()).isNotEmpty(); - // Everything else is derived from the steps. - assertThat(result.getSteps()).hasSize(1); - assertThat(result.getFinishReason()).isEqualTo(Types.FinishReasonUnified.STOP); - } - } - - @Test - void generateTextParsesToolCallsIntoTypedObjects() { - server.setResponseBody(toolCallOpenAiResponse()); - - Types.GenerateTextOptions options = Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(weatherTool())) - .toolChoice(Types.ToolChoice.AUTO) - .build(); - - try (TypedModel model = - TypedModel.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { - Types.GenerateTextResult result = - model.generateText("What is the weather in Tokyo?", options); - - // .toolCalls[0].toolName / .toolCallId / .input — all typed. - assertThat(result.getToolCalls()).hasSize(1); - Types.ToolCall call = result.getToolCalls().get(0); - assertThat(call.getToolName()).isEqualTo("get_weather"); - assertThat(call.getToolCallId()).isEqualTo("call_abc"); - // input is a JsonNode (tool arguments); inspect it directly. - assertThat(call.getInput().get("location").asText()).isEqualTo("Tokyo"); - - // .content carries the tool-call part. - assertThat(result.getContent()).anyMatch(part -> "tool-call".equals(part.path("type").asText())); - assertThat(result.getFinishReason()).isEqualTo(Types.FinishReasonUnified.TOOL_CALLS); - } - } - - @Test - void typedToolsAndToolChoiceReachProvider() { - server.setResponseBody(toolCallOpenAiResponse()); - - Types.GenerateTextOptions options = Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(weatherTool())) - .toolChoice(Types.ToolChoice.REQUIRED) - .build(); - - try (TypedModel model = - TypedModel.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { - model.generateText("What is the weather in Tokyo?", options); - - // The serialized options crossed the JSON boundary as the engine's - // camelCase shape with only the fields the caller set. - JSONObject reqBody = new JSONObject(server.lastRequestBody()); - assertThat(reqBody.getString("tool_choice")).isEqualTo("required"); - assertThat(reqBody.has("tools")).isTrue(); - JSONArray tools = reqBody.getJSONArray("tools"); - assertThat(tools.length()).isEqualTo(1); - JSONObject tool = tools.getJSONObject(0); - // The engine forwards the typed tool to the provider in OpenAI's - // wire format: {type:"function", function:{name, parameters}}. - assertThat(tool.getString("type")).isEqualTo("function"); - assertThat(tool.getJSONObject("function").getString("name")).isEqualTo("get_weather"); - assertThat(tool.getJSONObject("function").getJSONObject("parameters").getString("type")) - .isEqualTo("object"); - } - } - - @Test - void multiRoleModelMessageListReachesProvider() { - server.setResponseBody(plainOpenAiResponse()); - - List messages = Arrays.asList( - Types.ModelMessage.text(Types.Role.SYSTEM, "You are a helpful assistant."), - Types.ModelMessage.text(Types.Role.USER, "What is Rust?")); - - try (TypedModel model = - TypedModel.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { - Types.GenerateTextResult result = model.generateText(messages); - - assertThat(result.getText()).isEqualTo("Rust is a systems programming language."); - - // The provider received both messages (system + user), in order. - JSONObject reqBody = new JSONObject(server.lastRequestBody()); - assertThat(reqBody.getString("model")).isEqualTo("gpt-4o"); - JSONArray reqMessages = reqBody.getJSONArray("messages"); - assertThat(reqMessages.length()).isEqualTo(2); - assertThat(reqMessages.getJSONObject(0).getString("role")).isEqualTo("system"); - assertThat(reqMessages.getJSONObject(0).getString("content")) - .isEqualTo("You are a helpful assistant."); - assertThat(reqMessages.getJSONObject(1).getString("role")).isEqualTo("user"); - assertThat(reqMessages.getJSONObject(1).getString("content")).isEqualTo("What is Rust?"); - } - } - - @Test - void streamTextStreamDecodesTypedParts() { - // SSE: tool_calls delta (name) -> arguments delta -> finish_reason. - server.setContentType("text/event-stream"); - server.setResponseBody(toolCallSseResponse()); - - Types.GenerateTextOptions options = Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(weatherTool())) - .build(); - - try (TypedModel model = - TypedModel.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { - List parts = assertTimeout(Duration.ofSeconds(30), - () -> model.streamTextStream("What is the weather in Tokyo?", options) - .collect(Collectors.toList())); - - assertThat(parts).isNotEmpty(); - // The stream must surface tool-call activity: either a complete - // ToolCall part or ToolInputDelta deltas (or both). - boolean hasToolPart = parts.stream().anyMatch( - p -> p instanceof Types.StreamPart.ToolCall - || p instanceof Types.StreamPart.ToolInputDelta); - assertThat(hasToolPart) - .as("expected a ToolCall or ToolInputDelta stream part; got: %s", parts) - .isTrue(); - - // The stream opens with Start and StartStep and closes with - // FinishStep and Finish, carrying the tool-calls finish reason. - assertThat(parts.get(0)).isInstanceOf(Types.StreamPart.Start.class); - assertThat(parts.get(1)).isInstanceOf(Types.StreamPart.StartStep.class); - assertThat(parts.get(parts.size() - 2)).isInstanceOf(Types.StreamPart.FinishStep.class); - Types.StreamPart last = parts.get(parts.size() - 1); - assertThat(last).isInstanceOf(Types.StreamPart.Finish.class); - assertThat(((Types.StreamPart.Finish) last).getFinishReason()) - .isEqualTo(Types.FinishReasonUnified.TOOL_CALLS); - } - } - - // ── Coverage gaps from review: callback stream, of(Model), List overload ── - - @Test - void streamTextCallbackDeliversTypedParts() { - server.setContentType("text/event-stream"); - server.setResponseBody(toolCallSseResponse()); - - Types.GenerateTextOptions options = Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(weatherTool())) - .build(); - - java.util.List collected = new java.util.concurrent.CopyOnWriteArrayList<>(); - java.util.concurrent.atomic.AtomicBoolean done = new java.util.concurrent.atomic.AtomicBoolean(false); - - try (TypedModel model = - TypedModel.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { - assertTimeout(Duration.ofSeconds(30), () -> - model.streamText("What is the weather in Tokyo?", options, - collected::add, // onPart — typed StreamPart - () -> done.set(true), // onDone - err -> { throw new RuntimeException(err); })); - } - - assertThat(collected).isNotEmpty(); - assertThat(done.get()).isTrue(); - // Callback delivers typed parts (not raw JSON strings). - assertThat(collected.stream().anyMatch( - p -> p instanceof Types.StreamPart.ToolCall - || p instanceof Types.StreamPart.ToolInputDelta)).isTrue(); - } - - @Test - void ofModelDoesNotOwnHandle() { - server.setResponseBody(plainOpenAiResponse()); - - Model raw = Model.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig()); - // of() wraps without owning — closing the TypedModel must NOT close the raw Model. - TypedModel typed = TypedModel.of(raw); - typed.close(); - - // The raw model is still usable because of() did not drop the handle. - Types.GenerateTextResult result = typed.generateText("Still alive?"); - assertThat(result.getText()).isEqualTo("Rust is a systems programming language."); - raw.close(); - } - - @Test - void streamTextStreamFromMessageList() { - server.setContentType("text/event-stream"); - server.setResponseBody(toolCallSseResponse()); - - List messages = Arrays.asList( - Types.ModelMessage.text(Types.Role.USER, "What is the weather in Tokyo?")); - - Types.GenerateTextOptions options = Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(weatherTool())) - .build(); - - try (TypedModel model = - TypedModel.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { - List parts = assertTimeout(Duration.ofSeconds(30), - () -> model.streamTextStream(messages, options).collect(Collectors.toList())); - - assertThat(parts).isNotEmpty(); - assertThat(parts.stream().anyMatch(p -> p instanceof Types.StreamPart.Finish)).isTrue(); - } - } - - // ── Coverage gap from review #2: TypedModel error paths ───────────────── - - @Test - void generateTextInvalidPromptThrowsAimuxException() { - // The typed layer JSON-encodes the prompt, so any string is valid wire - // JSON; the failure is the mock provider's response → AimuxException. - try (TypedModel model = - TypedModel.provider("deepseek", "sk-test-fake-key", "gpt-4o", server.chatConfig())) { - assertThatThrownBy(() -> model.generateText("not-valid-json")) - .isInstanceOf(AimuxException.class); - } - } - - // Note: mid-stream provider errors are not deterministically triggerable - // via a single-response mock; terminal stream failures throw AimuxException - // from the C return/err path (same as raw Model.streamTextStream). - - @Test - void namespaceMapsKeepInsertionOrder() throws Exception { - // Provider options, provider metadata and provider references are JS - // objects upstream (@ai-sdk/provider shared-v4-provider-options.ts:24, - // shared-v4-provider-metadata.ts:24, shared-v4-provider-reference.ts:20), - // so their keys keep insertion order, through Rust and back (#229 NP2-1). - String metadata = "{\"zeta\":{\"z\":1,\"a\":2},\"alpha\":{},\"mid\":{}}"; - String invalid = "{\"toolCallId\":\"call-1\",\"toolName\":\"weather\",\"input\":{\"town\":\"SG\"}," - + "\"dynamic\":true,\"invalid\":true,\"providerMetadata\":" + metadata + "," - + "\"error\":{\"name\":\"AI_InvalidToolInputError\",\"toolName\":\"weather\"," - + "\"toolInput\":\"{\\\"town\\\":\\\"SG\\\"}\",\"cause\":\"missing city\"}}"; - String opts = "{\"tools\":[{\"type\":\"function\",\"name\":\"weather\"," - + "\"inputSchema\":{\"type\":\"object\"}}]}"; - String out = ToolCallRepairs.apply(invalid, opts, "{\"type\":\"unchanged\"}"); - ObjectMapper t = Types.AimuxJson.MAPPER; - Types.ToolCall call = t.readValue(out, Types.ToolCall.class); - assertThat(t.writeValueAsString(call.getProviderMetadata())).isEqualTo(metadata); - - ObjectMapper m = MultimodalTypes.AimuxJson.MAPPER; - String reference = "{\"openai\":\"file-abc123\",\"anthropic\":\"file-xyz789\",\"google\":\"files/1\"}"; - MultimodalTypes.UploadFileResult upload = - m.readValue("{\"providerReference\":" + reference + "}", MultimodalTypes.UploadFileResult.class); - assertThat(upload.getProviderReference().keySet()).containsExactly("openai", "anthropic", "google"); - assertThat(m.writeValueAsString(upload)).startsWith("{\"providerReference\":" + reference); - } -} diff --git a/bindings/java/src/test/java/ai/arcships/aimux/internal/CancellationTest.java b/bindings/java/src/test/java/ai/arcships/aimux/internal/CancellationTest.java new file mode 100644 index 000000000..a78b4220d --- /dev/null +++ b/bindings/java/src/test/java/ai/arcships/aimux/internal/CancellationTest.java @@ -0,0 +1,100 @@ +package ai.arcships.aimux.internal; + +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class CancellationTest { + @Test + void queuedCancellationAbortsOnceAndSkipsWork() { + AtomicReference queued = new AtomicReference<>(); + AtomicInteger calls = new AtomicInteger(); + AtomicInteger aborts = new AtomicInteger(); + Future task = Cancellation.submit(queued::set, calls::incrementAndGet, aborts::incrementAndGet); + assertTrue(task.cancel(false)); + assertFalse(task.cancel(true)); + queued.get().run(); + assertEquals(0, calls.get()); + assertEquals(1, aborts.get()); + assertThrows(CancellationException.class, task::get); + } + + @Test + void completionAndFailureCannotBeRewrittenByLateCancellation() throws Exception { + AtomicInteger aborts = new AtomicInteger(); + Future success = Cancellation.submit(Runnable::run, () -> 7, aborts::incrementAndGet); + assertEquals(7, success.get().intValue()); + assertFalse(success.cancel(true)); + IllegalStateException failure = new IllegalStateException("native failure"); + Future failed = Cancellation.submit(Runnable::run, () -> { throw failure; }, aborts::incrementAndGet); + assertSame(failure, assertThrows(ExecutionException.class, failed::get).getCause()); + assertFalse(failed.cancel(false)); + assertEquals(0, aborts.get()); + } + + @Test + void runningCancellationSignalsAbortWithoutRequiringInterruption() throws Exception { + ExecutorService executor = Executors.newSingleThreadExecutor(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch aborted = new CountDownLatch(1); + CountDownLatch returned = new CountDownLatch(1); + AtomicInteger aborts = new AtomicInteger(); + try { + Future task = Cancellation.submit(executor, () -> { + started.countDown(); + try { + assertTrue(aborted.await(5, TimeUnit.SECONDS)); + return 1; + } finally { + returned.countDown(); + } + }, () -> { aborts.incrementAndGet(); aborted.countDown(); }); + assertTrue(started.await(5, TimeUnit.SECONDS)); + ExecutorService cancellers = Executors.newFixedThreadPool(4); + try { + for (int i = 0; i < 16; i++) cancellers.submit(() -> task.cancel(false)); + } finally { + cancellers.shutdown(); + assertTrue(cancellers.awaitTermination(5, TimeUnit.SECONDS)); + } + assertTrue(returned.await(5, TimeUnit.SECONDS)); + assertEquals(1, aborts.get()); + assertTrue(task.isCancelled()); + } finally { + aborted.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void executorRejectionIsSynchronous() { + AtomicInteger aborts = new AtomicInteger(); + assertThrows(RejectedExecutionException.class, () -> Cancellation.submit( + task -> { throw new RejectedExecutionException(); }, () -> 1, aborts::incrementAndGet)); + assertEquals(0, aborts.get()); + } + + @Test + void throwingAbortIsNotRetried() { + AtomicInteger aborts = new AtomicInteger(); + Future task = Cancellation.submit(ignored -> {}, () -> 1, () -> { + aborts.incrementAndGet(); + throw new IllegalStateException("abort failure"); + }); + assertThrows(IllegalStateException.class, () -> task.cancel(false)); + assertTrue(task.isCancelled()); + assertFalse(task.cancel(false)); + assertEquals(1, aborts.get()); + } +} diff --git a/bindings/java/src/test/native/abi_fixture.c b/bindings/java/src/test/native/abi_fixture.c new file mode 100644 index 000000000..8c1e33133 --- /dev/null +++ b/bindings/java/src/test/native/abi_fixture.c @@ -0,0 +1,188 @@ +/* Test-only native implementation of the frozen ABI, with deterministic scripted calls. */ +#include +#include +#include +#include +#include +#include +#include +#include "aimux.h" + +struct aimux_error { int32_t code; const char *json; }; +static _Atomic uint64_t next_id = 1000; +static _Atomic int signals[4096]; +static _Atomic int drops[4096]; +static _Atomic int entered; +static _Atomic int live_strings; +static _Atomic int live_errors; +static _Thread_local int callback_depth; +static _Atomic int drop_in_callback; +static _Atomic uint64_t last_handle; +static _Atomic uint64_t last_length; +static _Atomic int64_t last_timeout; +static _Atomic uint32_t abi_version = AIMUX_ABI_VERSION; +static _Atomic int ops_mismatch; +static _Atomic int digest_mismatch; +static _Atomic int finalizers[4096]; +static _Atomic int poll_state = 1; +static _Atomic int stream_mode; +static _Atomic int callback_count; +static _Atomic int provider_calls; + +static char *owned(const char *text) { + atomic_fetch_add(&live_strings, 1); + return strdup(text); +} +static aimux_error_t *failure(int code, const char *json) { + aimux_error_t *error = malloc(sizeof(*error)); + error->code = code; error->json = json; + atomic_fetch_add(&live_errors, 1); + return error; +} +static void pause_millis(long ms) { + struct timespec delay = { ms / 1000, (ms % 1000) * 1000000 }; + nanosleep(&delay, NULL); +} +uint32_t aimux_abi_version(void) { return atomic_load(&abi_version); } +aimux_error_t *aimux_manifest_json(char **out) { + char json[256]; + snprintf(json, sizeof json, "{\"abiVersion\":%u,\"opsVersion\":\"%s\",\"digest\":\"%s\",\"ops\":[]}", + AIMUX_ABI_VERSION, atomic_load(&ops_mismatch) ? "1.1" : AIMUX_OPS_VERSION, + atomic_load(&digest_mismatch) ? "wrong-digest" : AIMUX_MANIFEST_DIGEST); + *out = owned(json); + return NULL; +} +aimux_error_t *aimux_call(uint64_t handle, const char *op, const char *params, + uint64_t abort, char **out) { + *out = NULL; + atomic_store(&last_handle, handle); + if (!strcmp(op, AIMUX_OP_ABORT_CREATE)) { + char result[80]; + uint64_t id = atomic_fetch_add(&next_id, 1); + snprintf(result, sizeof result, "{\"handle\":\"%llu\"}", (unsigned long long) id); + *out = owned(result); + } else if (!strcmp(op, AIMUX_OP_PROVIDER_CREATE)) { + atomic_fetch_add(&provider_calls, 1); + *out = owned("{\"handle\":\"100\"}"); + } else if (!strcmp(op, AIMUX_OP_PROVIDER_MODEL) || !strcmp(op, AIMUX_OP_PROVIDER_FILES) + || !strcmp(op, AIMUX_OP_TRANSCRIPTION_START)) { + char result[80]; + uint64_t id = atomic_fetch_add(&next_id, 1); + snprintf(result, sizeof result, "{\"handle\":\"%llu\"}", (unsigned long long) id); + *out = owned(result); + } else if (!strcmp(op, "test.wait")) { + atomic_fetch_add(&entered, 1); + while (abort < 4096 && !atomic_load(&signals[abort])) pause_millis(1); + *out = owned("null"); + } else if (!strcmp(op, "test.fail")) { + return failure(11, "{\"kind\":\"aimux\",\"code\":11,\"message\":\"busy\",\"error\":{\"name\":\"AI_APICallError\",\"isRetryable\":true,\"statusCode\":429,\"responseHeaders\":{\"x-request-id\":\"r1\"}},\"retryAfterMs\":0}"); + } else if (!strcmp(op, "test.binding")) { + return failure(203, "{\"kind\":\"binding\",\"code\":203,\"message\":\"wrong handle\",\"error\":{\"name\":\"InvalidHandle\",\"expected\":[\"Language\"],\"actual\":\"Image\"}}"); + } else if (!strcmp(op, "test.protocol")) { + return failure(300, "{\"kind\":\"protocol\",\"code\":300,\"message\":\"unknown op\",\"error\":{\"name\":\"InvalidOp\"}}"); + } else if (!strcmp(op, AIMUX_OP_TEXT_GENERATE) || !strcmp(op, AIMUX_OP_TEXT_CONSUME)) { + *out = owned("{\"text\":\"你好 🌏\"}"); + } else { + *out = owned(params); + } + return NULL; +} +static void emit(aimux_event_cb cb, void *context, const char *frame) { + char buffer[1024]; + snprintf(buffer, sizeof buffer, "%s", frame); + callback_depth++; + cb(buffer, context); + callback_depth--; + atomic_fetch_add(&callback_count, 1); + memset(buffer, 'X', strlen(buffer)); /* callback data expires immediately */ +} +aimux_error_t *aimux_stream(uint64_t handle, const char *op, const char *params, + uint64_t abort, aimux_event_cb cb, void *context) { + (void) handle; (void) op; (void) params; + int mode = atomic_load(&stream_mode); + if (mode == 1) return failure(300, "{\"kind\":\"protocol\",\"code\":300,\"message\":\"unknown op\",\"error\":{\"name\":\"InvalidOp\"}}"); + atomic_fetch_add(&entered, 1); + if (mode == 6 || mode == 7) { + if (mode == 6) { + /* No NUL within the scan budget: do not scan or copy beyond this allocation. */ + char *frame = malloc((1 << 20) + 1); + memset(frame, 'X', (1 << 20) + 1); + cb(frame, context); + free(frame); + } else cb(NULL, context); + return NULL; + } + emit(cb, context, "{\"type\":\"event\",\"seq\":\"1\",\"event\":{\"type\":\"text-delta\",\"id\":\"0\",\"delta\":\"你好 🌏\"}}"); + if (mode == 2) { + while (!atomic_load(&signals[abort])) pause_millis(1); + } + if (mode == 3) { + emit(cb, context, "{\"type\":\"end\",\"seq\":\"2\",\"outcome\":\"error\",\"error\":{\"kind\":\"aimux\",\"code\":12,\"message\":\"timeout\",\"error\":{\"name\":\"TimeoutError\"}}}"); + } else if (mode == 4) { + /* Deliberately malformed native stream. */ + } else if (mode == 5) { + emit(cb, context, "{\"type\":\"end\",\"seq\":\"2\",\"outcome\":\"success\"}"); + emit(cb, context, "{\"type\":\"end\",\"seq\":\"3\",\"outcome\":\"success\"}"); + } else { + /* Keep emitting after cancellation to test the first-failure latch. */ + emit(cb, context, "{\"type\":\"event\",\"seq\":\"2\",\"event\":{\"type\":\"text-delta\",\"id\":\"0\",\"delta\":\"second\"}}"); + emit(cb, context, atomic_load(&signals[abort]) + ? "{\"type\":\"end\",\"seq\":\"3\",\"outcome\":\"cancelled\"}" + : "{\"type\":\"end\",\"seq\":\"3\",\"outcome\":\"success\"}"); + } + return NULL; +} +aimux_error_t *aimux_operation_push(uint64_t operation, const uint8_t *data, size_t length) { + atomic_store(&last_handle, operation); atomic_store(&last_length, length); + if (length && (!data || data[0] != 42)) abort(); + return NULL; +} +aimux_error_t *aimux_operation_next_part(uint64_t operation, int64_t timeout_ms, + char **out_part, int32_t *out_state) { + atomic_store(&last_handle, operation); atomic_store(&last_timeout, timeout_ms); + *out_state = atomic_load(&poll_state); *out_part = NULL; + if (*out_state == 1) *out_part = owned("{\"type\":\"transcript\",\"text\":\"你好\"}"); + if (*out_state == 0) return failure(304, "{\"kind\":\"protocol\",\"code\":304,\"message\":\"ended\",\"error\":{\"name\":\"InvalidState\"}}"); + return NULL; +} +void aimux_abort(uint64_t abort) { if (abort < 4096) atomic_fetch_add(&signals[abort], 1); } +void aimux_drop(uint64_t handle) { + if (callback_depth) atomic_fetch_add(&drop_in_callback, 1); + if (handle < 4096) atomic_fetch_add(&drops[handle], 1); +} +void aimux_handle_finalizer(void *token) { + uint64_t id = (uint64_t)(uintptr_t)token; + if (id < 4096) atomic_fetch_add(&finalizers[id], 1); + aimux_drop(id); +} +void aimux_free_string(char *text) { if (text) { atomic_fetch_sub(&live_strings, 1); free(text); } } +void aimux_error_free(aimux_error_t *error) { if (error) { atomic_fetch_sub(&live_errors, 1); free(error); } } +int32_t aimux_error_code(const aimux_error_t *error) { return error ? error->code : 0; } +char *aimux_error_message(const aimux_error_t *error) { return error ? owned("fixture error") : NULL; } +char *aimux_error_json(const aimux_error_t *error) { return error ? owned(error->json) : NULL; } + +/* Test controls live in their own interface, never in production JNA declarations. */ +int fixture_entered(void) { return atomic_load(&entered); } +int fixture_live_strings(void) { return atomic_load(&live_strings); } +int fixture_live_errors(void) { return atomic_load(&live_errors); } +int fixture_drop_in_callback(void) { return atomic_load(&drop_in_callback); } +int fixture_finalizers(int id) { return atomic_load(&finalizers[id]); } +int fixture_drops(int id) { return atomic_load(&drops[id]); } +int fixture_signals(int id) { return atomic_load(&signals[id]); } +int fixture_callbacks(void) { return atomic_load(&callback_count); } +uint64_t fixture_last_handle(void) { return atomic_load(&last_handle); } +uint64_t fixture_last_length(void) { return atomic_load(&last_length); } +int64_t fixture_last_timeout(void) { return atomic_load(&last_timeout); } +int fixture_provider_calls(void) { return atomic_load(&provider_calls); } +void fixture_abi(uint32_t version) { atomic_store(&abi_version, version); } +void fixture_digest(int mismatch) { atomic_store(&digest_mismatch, mismatch); } +void fixture_ops(int mismatch) { atomic_store(&ops_mismatch, mismatch); } +void fixture_poll(int state) { atomic_store(&poll_state, state); } +void fixture_stream(int mode) { atomic_store(&stream_mode, mode); } +void fixture_reset(void) { + for (int i = 0; i < 4096; i++) { atomic_store(&signals[i], 0); atomic_store(&drops[i], 0); atomic_store(&finalizers[i], 0); } + atomic_store(&entered, 0); atomic_store(&callback_count, 0); + atomic_store(&drop_in_callback, 0); atomic_store(&provider_calls, 0); + atomic_store(&abi_version, AIMUX_ABI_VERSION); atomic_store(&ops_mismatch, 0); atomic_store(&digest_mismatch, 0); + atomic_store(&poll_state, 1); atomic_store(&stream_mode, 0); +} diff --git a/bindings/java/verify-local.py b/bindings/java/verify-local.py new file mode 100644 index 000000000..48aa49dbb --- /dev/null +++ b/bindings/java/verify-local.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Compile Java 8 bytecode and run JUnit with a supplied cached classpath. + +Usage: mise exec java@temurin-17 gradle@8 flutter@3 -- python3 verify-local.py /path/to/classpath.txt +The classpath must contain JNA 5.14, Jackson 2.17, JUnit Jupiter/Platform, AssertJ and org.json. +This fallback avoids Gradle's lock-listener socket when local sandbox networking is unavailable. +""" +from pathlib import Path +import os +import platform +import subprocess +import sys + +root = Path(__file__).resolve().parent +classpath = Path(sys.argv[1]).read_text().strip() +out = root / 'build' / 'verify' +for directory in ('main', 'test', 'native'): + (out / directory).mkdir(parents=True, exist_ok=True) +fixture = out / 'native' / ('libaimux_abi_fixture.dylib' if platform.system() == 'Darwin' else 'libaimux_abi_fixture.so') +shared = '-dynamiclib' if platform.system() == 'Darwin' else '-shared' +commands = [ + ['cc', '-std=c11', '-D_POSIX_C_SOURCE=200809L', '-Wall', '-Wextra', '-Werror', shared, + '-fPIC', '-I' + str(root / '../../aimux-ffi/include'), str(root / 'src/test/native/abi_fixture.c'), '-o', str(fixture)], + ['javac', '-J-XX:ActiveProcessorCount=2', '--release', '8', '-encoding', 'UTF-8', '-cp', classpath, + '-d', str(out / 'main')] + [str(p) for p in sorted(list((root / 'src/main/java').rglob('*.java')) + list((root / 'generated').rglob('*.java')))], + ['javac', '-J-XX:ActiveProcessorCount=2', '--release', '8', '-encoding', 'UTF-8', '-cp', + str(out / 'main') + os.pathsep + classpath, '-d', str(out / 'test')] + + [str(p) for p in sorted((root / 'src/test/java').rglob('*.java'))], + ['java', '-XX:ActiveProcessorCount=2', '-Daimux.testLibrary=' + str(fixture), + '-Djna.library.path=' + os.environ.get('AIMUX_FFI_LIB_DIR', str(root / '../../target/debug')), '-cp', + os.pathsep.join((str(out / 'main'), str(out / 'test'), classpath)), + 'ai.arcships.aimux.ContractTestRunner'], +] +for command in commands: + print('Running:', command[0], flush=True) + subprocess.run(command, cwd=root, check=True) diff --git a/bindings/kotlin/README.md b/bindings/kotlin/README.md new file mode 100644 index 000000000..faee68cc0 --- /dev/null +++ b/bindings/kotlin/README.md @@ -0,0 +1,102 @@ +# aimux Kotlin coroutine facade + +The Kotlin artifact depends on `ai.arcships:aimux-java` at the same version. +Java owns native loading, ABI/ops validation, resources, wire DTOs and error +decoding. Kotlin adds coroutines, bounded Flow collection and a sealed exception +facade in `ai.arcships.aimux.kotlin`. + +```kotlin +import ai.arcships.aimux.Aimux +import ai.arcships.aimux.StreamFrame +import ai.arcships.aimux.kotlin.aimuxCall +import ai.arcships.aimux.kotlin.aimuxFlow +import kotlinx.coroutines.flow.collect + +suspend fun generate() { + val aimux = Aimux.load() + aimux.createProvider("""{"package":"openai","settings":{}}""").use { provider -> + provider.languageModel("gpt-4o-mini").use { model -> + val params = """{"prompt":"Hello","options":{"maxRetries":0}}""" + val result = aimuxCall { executor -> model.generateAsync(params, executor) } + println(result) + aimuxFlow { executor, consumer -> + model.streamAsync(params, consumer, executor) + }.collect { frame -> println(frame.json) } + } + } +} +``` + +Use the Java provider/model, `Types`, and `MultimodalTypes` objects directly. +They pass unchanged between Java and Kotlin; Java retains ownership and +`close()` semantics. Generic Java `Resource` operations expose all registered +ops, including Decision. For example, obtain a Decision resource from a Jev +provider with `provider.create("provider.model", request)`, where the request is +`{"method":"decision","modelId":"..."}`, then call `decision.capabilities` or +`decision.decide` on that resource with their manifest request DTOs. + +`aimuxCall` starts and awaits Java Futures on `Dispatchers.IO`. +`Future.awaitAimux()` adapts an already started Java operation. Coroutine +cancellation invokes `Future.cancel(true)`, which signals the Java-owned Abort. +Java retains native resources until its work actually returns. + +`aimuxFlow` is cold. Each collection starts its own Java operation. The supplied +executor must run all callback work, and the start function must return its +cancellation-aware Future promptly. Its channel holds 64 frames by default, +configurable from 1 through 256. A full channel blocks callbacks on IO without +dropping frames. Cancellation, `take(n)` and collector failures release blocked +callbacks and cancel the Future. Flow collection waits for the submitted work +to return even when cancellation has already completed the Future. + +Java callbacks include both Event and End frames. Buffered frames precede a +terminal exception; successful Java completion closes the Flow. A business +error event remains ordinary data. Native callbacks stop before the executor's +task returns. A native operation that ignores Abort can delay Flow cleanup. +The channel bounds frame count; native stream limits govern frame byte sizes. + +The sealed `AimuxException` delegates `code`, `kind`, `errorName`, `error`, +`envelope` and nullable unsigned `retryAfterMs` (`BigInteger`) to Java. All 22 +known core subtypes have corresponding Kotlin variants with typed Java +`details`. `BindingError`, `ProtocolError` and `RecordingError` retain their Java +domain errors. `UnknownError` preserves a future Java exception subtype. +`javaError` is also the cause. Kotlin performs no JSON error decoding. Java's +current decoder rejects unknown wire variants before a Kotlin projection can +occur; those decoder failures propagate unchanged. + +## Breaking changes + +The former independent Kotlin implementation in `ai.arcships.aimux` is removed, +including its JNA declarations, native loader, provider-specific constructors, +wire DTO copies and callback/Sequence streams. Import Java objects from that +package and Kotlin additions from `ai.arcships.aimux.kotlin`. No compatibility +aliases are provided. Pass the actual request DTO shape described by the native +manifest; for example, text ops use `{"prompt":...,"options":...}`. + +## Build and test + +Build `aimux-ffi` first and set `AIMUX_LIBRARY_PATH` to the absolute cdylib path. +The Gradle test task also accepts `AIMUX_FFI_LIB_DIR` for JNA's search path, +defaulting to `target/debug`. + +```sh +AIMUX_LIBRARY_PATH=/absolute/path/libaimux_ffi.dylib \ + mise exec java@temurin-17 gradle@8 -- gradle -p bindings/kotlin test +mise exec java@temurin-17 gradle@8 -- gradle -p bindings/kotlin dependencies --configuration compileClasspath +``` + +The composite build substitutes the sibling Java source build for the Maven +coordinate. Version verification runs before Kotlin compilation; published +metadata retains the strict same-version Java dependency. + +When sandbox restrictions prevent Gradle's lock socket, a cached-jar harness +can compile Java 8 bytecode, compile Kotlin/JVM 17 and invoke JUnit test methods +with real assertions. Set `AIMUX_LIBRARY_PATH` to include native tests; without +it the harness reports native tests as not run. + +```sh +AIMUX_LIBRARY_PATH=/absolute/path/libaimux_ffi.dylib \ + mise exec java@temurin-17 gradle@8 -- python3 bindings/kotlin/scripts/test-offline.py +``` + +This harness does not verify Gradle resolution, publishing metadata, or the +JUnit engine. It requires cached Kotlin, coroutine, Jackson, JNA and JUnit jars. diff --git a/bindings/kotlin/build.gradle.kts b/bindings/kotlin/build.gradle.kts index e2fa32782..44be6962a 100644 --- a/bindings/kotlin/build.gradle.kts +++ b/bindings/kotlin/build.gradle.kts @@ -1,6 +1,6 @@ plugins { kotlin("jvm") version "2.0.0" - kotlin("plugin.serialization") version "2.0.0" + `java-library` `maven-publish` signing id("org.jetbrains.dokka") version "1.9.20" @@ -14,48 +14,48 @@ repositories { } dependencies { - implementation("net.java.dev.jna:jna:5.14.0") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0") + api("ai.arcships:aimux-java:$version") { + version { strictly(project.version.toString()) } + } + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") + // Java's decoded error payload types are part of the Kotlin public facade. + api("com.fasterxml.jackson.core:jackson-databind:2.17.2") testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") - testImplementation("org.assertj:assertj-core:3.26.0") - testImplementation("org.json:json:20240303") } -// Declare the JVM the binding is built and tested against. Without this the -// bytecode level of a published artifact depends on whichever JDK happened to -// run the release, which is not something a Maven Central consumer can read -// off the POM. 17 is what CI runs, so the declaration and the verification -// agree — see bindings/java for the alternative shape, where an explicit -// 1.8 target is backed by a four-JDK matrix. +val verifyJavaArtifact by tasks.registering { + doLast { + val javaArtifact = configurations["compileClasspath"].incoming.resolutionResult + .allComponents.mapNotNull { it.moduleVersion } + .single { it.group == "ai.arcships" && it.name == "aimux-java" } + check(javaArtifact.version == project.version.toString()) { + "Kotlin ${project.version} requires the same Java artifact version, got $javaArtifact" + } + } +} + +tasks.named("compileKotlin") { + dependsOn(verifyJavaArtifact) +} + +// Keep published bytecode and tests on JVM 17. kotlin { jvmToolchain(17) } tasks.test { useJUnitPlatform() - // AIMUX_FFI_LIB_DIR points at the directory holding libaimux_ffi (e.g. a - // custom CARGO_TARGET_DIR/debug); the default is the in-tree release build. systemProperty( "jna.library.path", - System.getenv("AIMUX_FFI_LIB_DIR") ?: rootProject.projectDir.resolve("../../target/release").canonicalPath, + System.getenv("AIMUX_FFI_LIB_DIR") ?: projectDir.resolve("../../target/debug").canonicalPath ) - // Gradle prints only "there were failing tests" by default; the names live - // in an HTML report that CI logs never surface. Without this, a red build - // tells you nothing about which test broke. + System.getenv("AIMUX_LIBRARY_PATH")?.let { systemProperty("aimux.library", it) } testLogging { events("failed") exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL } } -// Package the native library into the JAR for distribution. -// In production, use per-platform JARs (like napi-rs does for Node). -tasks.jar { - from("src/main/resources") { - into("native") - } -} - val sourcesJar by tasks.registering(Jar::class) { archiveClassifier.set("sources") from(sourceSets["main"].allSource) @@ -66,18 +66,7 @@ val javadocJar by tasks.registering(Jar::class) { from(tasks.named("dokkaJavadoc")) } -// ───────────────────────────────────────────────────────────────────────────── -// Maven Central (Sonatype OSSRH) publishing — same layout as aimux-java. -// -// Secrets are NEVER stored in the repo. They are read from Gradle project -// properties — set either as `ORG_GRADLE_PROJECT_` environment variables -// or `-P=...` system properties: -// signingKey / signingPassword ASCII-armored GPG private key + passphrase -// ossrhUsername / ossrhPassword Sonatype (s01.oss.sonatype.org) credentials -// When the keys are absent, signing/publishing configuration stays inert, so -// local builds and `gradle test` are unaffected. -// ───────────────────────────────────────────────────────────────────────────── - +// Publishing credentials come from Gradle properties; absent keys leave signing inert. val ossrhUsername = findProperty("ossrhUsername") as String? val ossrhPassword = findProperty("ossrhPassword") as String? val signingKey = findProperty("signingKey") as String? @@ -119,13 +108,6 @@ publishing { repositories { if (ossrhUsername != null && ossrhPassword != null) { - // Central Publisher Portal — OSSRH Staging API compatibility - // service (s01.oss.sonatype.org is deprecated; it returns 402). - // After uploading, release.yml POSTs /manual/upload/defaultRepository - // to move the deployment into the Portal. - // Snapshot versions go to the snapshots repository; release - // versions to the staging service (the snapshots repo rejects - // non-SNAPSHOT versions with 403). if (version.toString().endsWith("SNAPSHOT")) { maven { name = "CentralPortal" diff --git a/bindings/kotlin/generated/AimuxOps.kt b/bindings/kotlin/generated/AimuxOps.kt new file mode 100644 index 000000000..03804b768 --- /dev/null +++ b/bindings/kotlin/generated/AimuxOps.kt @@ -0,0 +1,80 @@ +// Generated by aimux-codegen. Do not edit. Manifest SHA-256: 2bc7cb7ec77a0ed20496411f0dbd199b949b9887cc74d119e11627c8573d82a2 +package ai.arcships.aimux.generated + +object AimuxKotlinOps { + const val ABI_VERSION: Long = 65536 + const val OPS_VERSION = "1.0" + const val MANIFEST_DIGEST = "2bc7cb7ec77a0ed20496411f0dbd199b949b9887cc74d119e11627c8573d82a2" + const val OP_ABORT_CREATE = "abort.create" + const val OP_ABORT_SIGNAL = "abort.signal" + const val OP_AUTH_CODEX_REFRESH = "auth.codexRefresh" + const val OP_CATALOGUE_FETCH = "catalogue.fetch" + const val OP_COMPOSITE_MOA = "composite.moa" + const val OP_COMPOSITE_ROUTER = "composite.router" + const val OP_CREDENTIAL_CREATE = "credential.create" + const val OP_DECISION_CAPABILITIES = "decision.capabilities" + const val OP_DECISION_DECIDE = "decision.decide" + const val OP_EMBEDDING_EMBED = "embedding.embed" + const val OP_EMBEDDING_EMBED_MANY = "embedding.embedMany" + const val OP_FILES_UPLOAD = "files.upload" + const val OP_HANDLE_DROP = "handle.drop" + const val OP_IMAGE_GENERATE = "image.generate" + const val OP_MIDDLEWARE_CREATE = "middleware.create" + const val OP_MODEL_INFO = "model.info" + const val OP_MODEL_RESOLVE = "model.resolve" + const val OP_MODEL_WRAP = "model.wrap" + const val OP_OPERATION_CANCEL = "operation.cancel" + const val OP_OPERATION_INPUT_DONE = "operation.inputDone" + const val OP_OPERATION_NEXT = "operation.next" + const val OP_OPERATION_PUSH = "operation.push" + const val OP_OPERATION_STATUS = "operation.status" + const val OP_OUTPUT_TO_OPEN_AI = "output.toOpenAI" + const val OP_PROVIDER_CREATE = "provider.create" + const val OP_PROVIDER_CUSTOM = "provider.custom" + const val OP_PROVIDER_DEFAULT = "provider.default" + const val OP_PROVIDER_FILES = "provider.files" + const val OP_PROVIDER_INVOKE = "provider.invoke" + const val OP_PROVIDER_MODEL = "provider.model" + const val OP_PROVIDER_WRAP = "provider.wrap" + const val OP_RECORDER_ATTACH = "recorder.attach" + const val OP_RECORDER_CREATE = "recorder.create" + const val OP_RECORDER_EXPORT = "recorder.export" + const val OP_RECORDER_FLUSH = "recorder.flush" + const val OP_RECORDER_STATS = "recorder.stats" + const val OP_RECORDER_STOP = "recorder.stop" + const val OP_REGISTRY_CREATE = "registry.create" + const val OP_REGISTRY_PROVIDER = "registry.provider" + const val OP_REPLAY_CAPABILITIES = "replay.capabilities" + const val OP_REPLAY_CREATE = "replay.create" + const val OP_REPLAY_DRY_RUN = "replay.dryRun" + const val OP_REPLAY_INSPECT = "replay.inspect" + const val OP_REPLAY_LIVE = "replay.live" + const val OP_RERANKING_RERANK = "reranking.rerank" + const val OP_RUNTIME_CONFIGURE = "runtime.configure" + const val OP_RUNTIME_DESCRIBE = "runtime.describe" + const val OP_SEARCH_SEARCH = "search.search" + const val OP_SESSION_STORE_ATTACH = "sessionStore.attach" + const val OP_SESSION_STORE_CALLS = "sessionStore.calls" + const val OP_SESSION_STORE_CREATE = "sessionStore.create" + const val OP_SESSION_STORE_LIST = "sessionStore.list" + const val OP_SPEECH_GENERATE = "speech.generate" + const val OP_TEXT_CONSUME = "text.consume" + const val OP_TEXT_GENERATE = "text.generate" + const val OP_TEXT_GENERATE_OBJECT = "text.generateObject" + const val OP_TEXT_GENERATE_OPEN_AI = "text.generateOpenAI" + const val OP_TEXT_STREAM = "text.stream" + const val OP_TEXT_STREAM_OPEN_AI = "text.streamOpenAI" + const val OP_TOOL_REPAIR_APPLY = "toolRepair.apply" + const val OP_TOOL_REPAIR_APPLY_RESULT = "toolRepair.applyResult" + const val OP_TOOL_REPAIR_CONTEXT = "toolRepair.context" + const val OP_TRACE_AGGREGATE = "trace.aggregate" + const val OP_TRACE_CLEAR = "trace.clear" + const val OP_TRACE_CREATE = "trace.create" + const val OP_TRACE_EXPORT = "trace.export" + const val OP_TRACE_SESSION_CHAIN = "trace.sessionChain" + const val OP_TRACE_SESSION_TRAJECTORY = "trace.sessionTrajectory" + const val OP_TRANSCRIPTION_GENERATE = "transcription.generate" + const val OP_TRANSCRIPTION_START = "transcription.start" + const val OP_TRANSPORT_CREATE = "transport.create" + const val OP_VIDEO_GENERATE = "video.generate" +} diff --git a/bindings/kotlin/scripts/test-offline.py b/bindings/kotlin/scripts/test-offline.py new file mode 100644 index 000000000..18b2824cc --- /dev/null +++ b/bindings/kotlin/scripts/test-offline.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Compile and run bridge tests from cached jars when Gradle cannot bind its lock socket.""" +from pathlib import Path +import os +import shutil +import subprocess +import textwrap + +binding = Path(__file__).resolve().parents[1] +cache = Path.home() / '.gradle/caches/modules-2/files-2.1' +out = Path(os.environ.get('AIMUX_KOTLIN_CHECK_DIR', binding / 'build/offline-check')) +java_home = os.environ.get('JAVA_HOME') +if not java_home: + raise SystemExit('JAVA_HOME is required; run through mise exec java@temurin-17 gradle@8') +java_classes = out / 'java' +kotlin_classes = out / 'kotlin' +for classes in (java_classes, kotlin_classes): + if classes.exists(): + shutil.rmtree(classes) +java_classes.mkdir(parents=True, exist_ok=True) +kotlin_classes.mkdir(parents=True, exist_ok=True) + + +def jar(group, name, version): + matches = list((cache / group / name / version).glob(f'*/{name}-{version}.jar')) + if len(matches) != 1: + raise SystemExit(f'Expected one cached {group}:{name}:{version}, found {len(matches)}') + return str(matches[0]) + + +def run(*args): + executable = Path(java_home) / 'bin' / args[0] + print('Running:', executable, flush=True) + timeout = int(os.environ.get('AIMUX_KOTLIN_COMMAND_TIMEOUT', '180')) + subprocess.run([str(executable), *map(str, args[1:])], check=True, timeout=timeout) + + +runtime = [ + jar('org.jetbrains.kotlin', 'kotlin-stdlib', '2.0.0'), + jar('org.jetbrains.kotlinx', 'kotlinx-coroutines-core-jvm', '1.7.3'), + jar('net.java.dev.jna', 'jna', '5.14.0'), + *[jar('com.fasterxml.jackson.core', name, '2.17.2') for name in + ('jackson-annotations', 'jackson-core', 'jackson-databind')], + jar('org.junit.jupiter', 'junit-jupiter-api', '5.10.0'), + jar('org.junit.platform', 'junit-platform-commons', '1.10.0'), + jar('org.opentest4j', 'opentest4j', '1.3.0'), + jar('org.apiguardian', 'apiguardian-api', '1.1.2'), + jar('org.jetbrains', 'annotations', '13.0'), +] +compiler = [ + jar('org.jetbrains.kotlin', 'kotlin-compiler-embeddable', '2.0.0'), + jar('org.jetbrains.kotlin', 'kotlin-script-runtime', '2.0.0'), + jar('org.jetbrains.kotlin', 'kotlin-reflect', '1.6.10'), + jar('org.jetbrains.intellij.deps', 'trove4j', '1.0.20200330'), + *runtime, +] +cp = os.pathsep.join(runtime) +existing_jar = os.environ.get('AIMUX_JAVA_TEST_JAR') +if existing_jar: + java_jar = Path(existing_jar).resolve(strict=True) +else: + sources = sorted((binding.parent / 'java/src/main/java').rglob('*.java')) + sources += sorted((binding.parent / 'java/generated').rglob('*.java')) + run('javac', '--release', '8', '-encoding', 'UTF-8', '-cp', cp, '-d', java_classes, *sources) + java_jar = out / 'aimux-java.jar' + run('jar', 'cf', java_jar, '-C', java_classes, '.') +cp = os.pathsep.join([str(java_jar), *runtime]) +fixtures = sorted((binding / 'src/test/java').rglob('*.java')) +run('javac', '--release', '8', '-encoding', 'UTF-8', '-cp', cp, '-d', kotlin_classes, *fixtures) +sources = sorted((binding / 'src/main/kotlin').rglob('*.kt')) +sources += sorted((binding / 'src/test/kotlin').rglob('*.kt')) +cp = os.pathsep.join([str(kotlin_classes), cp]) +run('java', '-cp', os.pathsep.join(compiler), 'org.jetbrains.kotlin.cli.jvm.K2JVMCompiler', + '-no-stdlib', '-no-reflect', '-jvm-target', '17', '-classpath', cp, + '-d', kotlin_classes, *sources) + +# Run the actual @Test methods with JUnit assertions. +runner = out / 'OfflineTestRunner.java' +runner.write_text(textwrap.dedent('''\ + import java.lang.reflect.*; + import java.util.*; + import org.junit.jupiter.api.Test; + public final class OfflineTestRunner { + public static void main(String[] args) throws Exception { + int passed = 0; + int failed = 0; + for (String name : args) { + Class type = Class.forName(name); + Method[] methods = type.getDeclaredMethods(); + Arrays.sort(methods, Comparator.comparing(Method::getName)); + for (Method method : methods) { + if (!method.isAnnotationPresent(Test.class)) continue; + try { + method.invoke(type.getDeclaredConstructor().newInstance()); + System.out.println("PASS " + type.getSimpleName() + "." + method.getName()); + passed++; + } catch (InvocationTargetException error) { + failed++; + System.err.println("FAIL " + type.getSimpleName() + "." + method.getName()); + error.getCause().printStackTrace(); + } + } + } + System.out.println("Tests: " + passed + " passed, " + failed + " failed"); + if (failed != 0 || passed == 0) System.exit(1); + } + } +''')) +run('javac', '-cp', cp, '-d', kotlin_classes, runner) +tests = ['ai.arcships.aimux.kotlin.ErrorsTest', 'ai.arcships.aimux.kotlin.CoroutinesTest', + 'ai.arcships.aimux.internal.FlowBridgeTest'] +library = os.environ.get('AIMUX_LIBRARY_PATH') +if library: + tests.append('ai.arcships.aimux.kotlin.NativeIntegrationTest') +else: + print('NativeIntegrationTest not run: set AIMUX_LIBRARY_PATH to the built cdylib.', flush=True) +run('java', *(['-Daimux.library=' + library] if library else []), '-cp', cp, + 'OfflineTestRunner', *tests) diff --git a/bindings/kotlin/settings.gradle.kts b/bindings/kotlin/settings.gradle.kts index 23cd8b7c4..355944f9e 100644 --- a/bindings/kotlin/settings.gradle.kts +++ b/bindings/kotlin/settings.gradle.kts @@ -1 +1,9 @@ rootProject.name = "aimux-kotlin" + +// Resolve the published coordinate against the matching source artifact in this checkout. +// The dependency in the POM remains ai.arcships:aimux-java:. +includeBuild("../java") { + dependencySubstitution { + substitute(module("ai.arcships:aimux-java")).using(project(":")) + } +} diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/DecisionModel.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/DecisionModel.kt deleted file mode 100644 index 96800df0e..000000000 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/DecisionModel.kt +++ /dev/null @@ -1,48 +0,0 @@ -package ai.arcships.aimux - -import java.io.Closeable -import java.util.concurrent.atomic.AtomicLong - -/** A DecisionModelV4 model using the core JSON contract: DecideOptions in, DecideResult out. */ -class DecisionModel internal constructor(handle: Long) : Closeable { - private val handle = AtomicLong(handle) - - companion object { - /** Endpoint is a complete POST URL; null uses the official API. */ - @JvmStatic - @JvmOverloads - fun jev(apiKey: String, modelId: String, endpoint: String? = null, - probabilitySource: String? = null): DecisionModel = - DecisionModel(handleResult("jev decision") { out -> - FFI.lib.aimux_jev_decision_new_with_probability_source(apiKey, modelId, endpoint, probabilitySource, out) - }) - } - - /** Optional abort handle follows the existing C ABI signal lifecycle. */ - @JvmOverloads - fun decide(optsJson: String, abortHandle: Long = 0L): String { - requireJsonRequired("optsJson", optsJson) - return stringResult("decide") { out -> - FFI.lib.aimux_decide_with_abort(requireHandle(), optsJson, abortHandle, out) - } - } - - /** - * The model's DecisionModelV4 properties as JSON - * ({specificationVersion, provider, modelId, supportedQuestionTypes}), without HTTP. - */ - fun describe(): String = stringResult("decision model description") { out -> - FFI.lib.aimux_decision_capabilities(requireHandle(), out) - } - - private fun requireHandle(): Long = handle.get().also { - if (it == 0L) throw IllegalStateException("DecisionModel is closed") - } - - override fun close() { - val value = handle.getAndSet(0L) - if (value != 0L) FFI.lib.aimux_drop_handle(value) - } - - protected fun finalize() { close() } -} diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Errors.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Errors.kt deleted file mode 100644 index 45dae1a81..000000000 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Errors.kt +++ /dev/null @@ -1,576 +0,0 @@ -package ai.arcships.aimux - -import com.sun.jna.Pointer -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive - -/** - * Machine-readable codes matching aimux-ffi `aimux_error_code_t` (aimux-error.h). - * 1..20 mirror the 19 core variants (1 is the catch-all `Other`; - * 4 is retired — the legacy `Tool` variant; 14 = `Retry`; 19 / 20 = a - * missing API key / provider setting). The - * per-status codes (Provider, Http, RateLimited, Auth, ModelNotFound) are - * gone, every HTTP-shaped failure arrives as [AIMUX_E_API_CALL]. - */ -const val AIMUX_OK: Int = 0 -const val AIMUX_E_JSON_PARSE: Int = 2 -const val AIMUX_E_INVALID_RESPONSE_DATA: Int = 3 -const val AIMUX_E_INVALID_ARGUMENT: Int = 5 -const val AIMUX_E_INVALID_PROMPT: Int = 6 -const val AIMUX_E_TOKEN_EXPIRED: Int = 7 -const val AIMUX_E_UNSUPPORTED_FUNCTIONALITY: Int = 8 -const val AIMUX_E_NO_SUCH_MODEL: Int = 9 -const val AIMUX_E_NO_SUCH_PROVIDER: Int = 10 -const val AIMUX_E_API_CALL: Int = 11 -const val AIMUX_E_TIMEOUT: Int = 12 -const val AIMUX_E_ABORTED: Int = 13 -const val AIMUX_E_NO_SUCH_TOOL: Int = 15 -const val AIMUX_E_INVALID_TOOL_INPUT: Int = 16 -const val AIMUX_E_TOOL_CALL_REPAIR: Int = 17 -const val AIMUX_E_NO_OUTPUT_GENERATED: Int = 18 -const val AIMUX_E_LOAD_API_KEY: Int = 19 -const val AIMUX_E_LOAD_SETTING: Int = 20 -const val AIMUX_E_OTHER: Int = 1 -const val AIMUX_E_RETRY: Int = 14 - -/** - * Base exception for every core `AiMuxError`. - * Recorder failures are [RecordingException] (no shared base). Codes 200..206 - * surface as plain - * [IllegalArgumentException] (pre-validation) / [IllegalStateException] - * (`"aimux ffi: …"`, see [expectAimuxError]). - * - * Sealed: every subclass lives in this file, so a Kotlin `when` over the - * hierarchy is exhaustive. Java callers can still `catch (AimuxException e)`. - * Subclasses mirror Node's hierarchy — one class per core `AiMuxError` variant. - * - * ```kotlin - * try { - * model.generateText("\"hi\"") - * } catch (e: APICallError) { - * // Classification is the status field: 429 → rate limited (e.retryMs), - * // 401 → auth, 404 → model not found, -1 → no HTTP response observed - * } catch (e: AimuxException) { - * // e.code, e.status, e.retryMs - * } - * ``` - * - * Transport: Rust → C `aimux_error_t *` with code 1..20 → [fromC]. - * Primary path is not a JSON - * error envelope. - */ -sealed class AimuxException( - message: String, - val code: Int = AIMUX_E_OTHER, - val status: Int = -1, - val retryMs: Long = -1, - cause: Throwable? = null, - /** - * The `AiMuxError` retry verdict: true when retrying may help. - * - * Not derivable from [status] — two failures can both report -1 and - * disagree: a transport failure (the request went out, the connection was - * reset) is retryable, a missing API key (the request never went out) is - * not. - */ - val retryable: Boolean = false, -) : RuntimeException(message, cause) { - - companion object { - /** - * Build a typed exception from a returned `const aimux_error_t *`, prefixing - * [prefix] to the message. - * - * Reads code, message and retryable for every code, the payload - * getters only under their owning code, and frees every returned - * string. Does not own the pointer: the caller ([expectAimuxError]) frees - * the returned error afterwards (retry attempt errors are new owned - * copies and are freed here). Code [AIMUX_OK] or a code outside - * 1..20 is a header/library mismatch and throws [IllegalStateException]. - */ - @JvmStatic - internal fun fromC(error: Pointer, prefix: String = ""): AimuxException { - val lib = FFI.lib - val code = lib.aimux_error_code(error) - check(code != AIMUX_OK) { "${prefix}non-null aimux error carries AIMUX_OK" } - val msg = prefix + (takeString(lib.aimux_error_message(error))?.ifEmpty { null } ?: "aimux: ${codeName(code)}") - val retryable = lib.aimux_error_retryable(error) != 0 - return when (code) { - AIMUX_E_API_CALL -> createByCode( - code, - msg, - lib.aimux_error_status(error), - lib.aimux_error_retry_ms(error), - retryable = retryable, - providerCode = takeString(lib.aimux_error_provider_code(error)), - providerMessage = takeString(lib.aimux_error_provider_message(error)), - responseBody = takeString(lib.aimux_error_response_body(error)), - url = takeString(lib.aimux_error_url(error)), - requestBodyValues = parseJson(takeString(lib.aimux_error_request_body_values(error))), - responseHeaders = headerMap(takeString(lib.aimux_error_response_headers(error))), - data = parseJson(takeString(lib.aimux_error_provider_data(error))), - ) - AIMUX_E_RETRY -> createByCode( - code, - msg, - retryable = retryable, - retryReason = RetryErrorReason.fromWire(takeString(lib.aimux_error_retry_reason(error))), - retryErrors = retryHistory(error), - ) - AIMUX_E_NO_SUCH_MODEL -> createByCode( - code, - msg, - retryable = retryable, - modelId = takeString(lib.aimux_error_model_id(error)) ?: "", - modelType = takeString(lib.aimux_error_model_type(error)) ?: "", - ) - AIMUX_E_NO_SUCH_PROVIDER -> createByCode( - code, - msg, - retryable = retryable, - providerId = takeString(lib.aimux_error_provider_id(error)) ?: "", - ) - AIMUX_E_NO_SUCH_TOOL -> createByCode( - code, - msg, - retryable = retryable, - toolName = takeString(lib.aimux_error_tool_name(error)) ?: "", - availableTools = takeString(lib.aimux_error_available_tools(error)) - ?.let { AimuxJson.decodeFromString(ListSerializer(String.serializer()), it) }, - ) - AIMUX_E_INVALID_TOOL_INPUT -> createByCode( - code, - msg, - retryable = retryable, - toolName = takeString(lib.aimux_error_tool_name(error)) ?: "", - toolInput = takeString(lib.aimux_error_tool_input(error)) ?: "", - ) - AIMUX_E_TOOL_CALL_REPAIR -> createByCode( - code, - msg, - retryable = retryable, - originalError = takeString(lib.aimux_error_original_error(error)) - ?.let(AimuxJson::parseToJsonElement), - ) - AIMUX_E_LOAD_API_KEY, AIMUX_E_LOAD_SETTING -> createByCode( - code, - msg, - retryable = retryable, - // The consulted environment variable rides the provider-code getter. - envVar = takeString(lib.aimux_error_provider_code(error)) ?: "", - ) - else -> createByCode(code, msg, retryable = retryable) - } - } - - /** - * Decode the per-attempt history of an [AIMUX_E_RETRY] error. Each - * attempt is a new owned `aimux_error_t *` (index 0 = oldest) that can - * itself be any AiMuxError — including a nested Retry — and is freed - * here, independently of the parent. - */ - private fun retryHistory(error: Pointer): List { - val lib = FFI.lib - return (0 until lib.aimux_error_retry_count(error)).mapNotNull { i -> - lib.aimux_error_retry_error_at(error, i)?.let { attempt -> - try { - fromC(attempt) - } finally { - lib.aimux_error_free(attempt) - } - } - } - } - - /** Parse a getter-returned JSON string; null (absent) stays null. */ - private fun parseJson(json: String?): JsonElement? = try { - json?.let(Json::parseToJsonElement) - } catch (_: Exception) { - null - } - - /** Response headers arrive as one JSON object string of string→string pairs. */ - private fun headerMap(json: String?): Map? = - (parseJson(json) as? JsonObject)?.mapValues { (_, value) -> - (value as? JsonPrimitive)?.content ?: value.toString() - } - - /** - * Build the subclass for a core / C error code (1..20). - * - * Any other code — [AIMUX_OK] on a failure path or a code this binding - * does not know — is a header/library mismatch and throws - * [IllegalStateException] rather than mapping to an error type. - */ - @JvmStatic - fun createByCode( - code: Int, - message: String, - status: Int = -1, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, - providerCode: String? = null, - providerMessage: String? = null, - responseBody: String? = null, - url: String? = null, - requestBodyValues: JsonElement? = null, - responseHeaders: Map? = null, - data: JsonElement? = null, - modelId: String = "", - modelType: String = "", - providerId: String = "", - retryReason: RetryErrorReason = RetryErrorReason.MAX_RETRIES_EXCEEDED, - retryErrors: List = emptyList(), - toolName: String = "", - availableTools: List? = null, - toolInput: String = "", - originalError: JsonElement? = null, - envVar: String = "", - ): AimuxException = when (code) { - AIMUX_E_JSON_PARSE -> JSONParseError(message, status, retryMs, cause, retryable) - AIMUX_E_INVALID_RESPONSE_DATA -> InvalidResponseDataError(message, status, retryMs, cause, retryable) - AIMUX_E_INVALID_ARGUMENT -> InvalidArgumentError(message, status, retryMs, cause, retryable) - AIMUX_E_INVALID_PROMPT -> InvalidPromptError(message, status, retryMs, cause, retryable) - AIMUX_E_LOAD_API_KEY -> LoadAPIKeyError(message, status, retryMs, cause, retryable, envVar) - AIMUX_E_LOAD_SETTING -> LoadSettingError(message, status, retryMs, cause, retryable, envVar) - AIMUX_E_TOKEN_EXPIRED -> TokenExpiredError( - message, - status = if (status == -1) 401 else status, - retryMs = retryMs, - cause = cause, - retryable = retryable, - ) - AIMUX_E_UNSUPPORTED_FUNCTIONALITY -> UnsupportedFunctionalityError(message, status, retryMs, cause, retryable) - AIMUX_E_NO_SUCH_MODEL -> NoSuchModelError(message, status, retryMs, cause, retryable, modelId, modelType) - AIMUX_E_NO_SUCH_PROVIDER -> NoSuchProviderError(message, status, retryMs, cause, retryable, providerId) - AIMUX_E_API_CALL -> APICallError( - message, status, retryMs, cause, retryable, - providerCode, providerMessage, responseBody, - url, requestBodyValues, responseHeaders, data, - ) - AIMUX_E_RETRY -> RetryError( - message, - retryReason, - // A deserialized Retry may carry no attempts; keep RetryError total. - retryErrors.ifEmpty { listOf(OtherError(message)) }, - cause, - ) - AIMUX_E_TIMEOUT -> TimeoutError(message, status, retryMs, cause, retryable) - AIMUX_E_ABORTED -> RequestAbortedError(message, status, retryMs, cause, retryable) - AIMUX_E_NO_SUCH_TOOL -> NoSuchToolError(message, status, retryMs, cause, retryable, toolName, availableTools) - AIMUX_E_INVALID_TOOL_INPUT -> InvalidToolInputError(message, status, retryMs, cause, retryable, toolName, toolInput) - AIMUX_E_TOOL_CALL_REPAIR -> ToolCallRepairError(message, status, retryMs, cause, retryable, originalError) - AIMUX_E_NO_OUTPUT_GENERATED -> NoOutputGeneratedError(message, status, retryMs, cause, retryable) - AIMUX_E_OTHER -> OtherError(message, status, retryMs, cause, retryable) - else -> throw IllegalStateException("Unknown aimux_error_code_t: $code") - } - - /** Core / C code → short name. */ - @JvmStatic - fun codeName(code: Int): String = when (code) { - AIMUX_OK -> "OK" - AIMUX_E_JSON_PARSE -> "JsonParse" - AIMUX_E_INVALID_RESPONSE_DATA -> "InvalidResponseData" - AIMUX_E_INVALID_ARGUMENT -> "InvalidArgument" - AIMUX_E_INVALID_PROMPT -> "InvalidPrompt" - AIMUX_E_LOAD_API_KEY -> "LoadApiKey" - AIMUX_E_LOAD_SETTING -> "LoadSetting" - AIMUX_E_TOKEN_EXPIRED -> "TokenExpired" - AIMUX_E_UNSUPPORTED_FUNCTIONALITY -> "UnsupportedFunctionality" - AIMUX_E_NO_SUCH_MODEL -> "NoSuchModel" - AIMUX_E_NO_SUCH_PROVIDER -> "NoSuchProvider" - AIMUX_E_API_CALL -> "ApiCall" - AIMUX_E_TIMEOUT -> "Timeout" - AIMUX_E_ABORTED -> "Aborted" - AIMUX_E_NO_SUCH_TOOL -> "NoSuchTool" - AIMUX_E_INVALID_TOOL_INPUT -> "InvalidToolInput" - AIMUX_E_TOOL_CALL_REPAIR -> "ToolCallRepair" - AIMUX_E_NO_OUTPUT_GENERATED -> "NoOutputGenerated" - AIMUX_E_OTHER -> "Other" - AIMUX_E_RETRY -> "Retry" - else -> "Code($code)" - } - } -} - -class NoOutputGeneratedError( - message: String, - status: Int = -1, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, -) : AimuxException(message, AIMUX_E_NO_OUTPUT_GENERATED, status, retryMs, cause, retryable) - -class JSONParseError( - message: String, - status: Int = -1, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, -) : AimuxException(message, AIMUX_E_JSON_PARSE, status, retryMs, cause, retryable) - -class InvalidResponseDataError( - message: String, - status: Int = -1, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, -) : AimuxException(message, AIMUX_E_INVALID_RESPONSE_DATA, status, retryMs, cause, retryable) - -class InvalidArgumentError( - message: String, - status: Int = -1, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, -) : AimuxException(message, AIMUX_E_INVALID_ARGUMENT, status, retryMs, cause, retryable) - -class InvalidPromptError( - message: String, - status: Int = -1, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, -) : AimuxException(message, AIMUX_E_INVALID_PROMPT, status, retryMs, cause, retryable) - -/** - * No API key was passed and the fallback environment variable is unset (AI SDK - * `LoadAPIKeyError`). No request was made. - */ -class LoadAPIKeyError( - message: String, - status: Int = -1, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, - /** The environment variable that was consulted, e.g. "OPENAI_API_KEY" ("" when synthesized locally). */ - val envVar: String = "", -) : AimuxException(message, AIMUX_E_LOAD_API_KEY, status, retryMs, cause, retryable) - -/** - * A required provider setting was not passed and its fallback environment - * variable is unset (AI SDK `LoadSettingError`). - */ -class LoadSettingError( - message: String, - status: Int = -1, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, - /** The environment variable that was consulted ("" when synthesized locally). */ - val envVar: String = "", -) : AimuxException(message, AIMUX_E_LOAD_SETTING, status, retryMs, cause, retryable) - -/** Access token expired and must be refreshed (HTTP 401, but retry-after-refresh). */ -class TokenExpiredError( - message: String, - status: Int = 401, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, -) : AimuxException(message, AIMUX_E_TOKEN_EXPIRED, status, retryMs, cause, retryable) - -class UnsupportedFunctionalityError( - message: String, - status: Int = -1, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, -) : AimuxException(message, AIMUX_E_UNSUPPORTED_FUNCTIONALITY, status, retryMs, cause, retryable) - -class NoSuchModelError( - message: String, - status: Int = -1, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, - /** The model id that was asked for ("" when synthesized locally). */ - val modelId: String = "", - /** The model type it was asked for as ("" when synthesized locally). */ - val modelType: String = "", -) : AimuxException(message, AIMUX_E_NO_SUCH_MODEL, status, retryMs, cause, retryable) - -class NoSuchProviderError( - message: String, - status: Int = -1, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, - /** The provider id that was asked for ("" when synthesized locally). */ - val providerId: String = "", -) : AimuxException(message, AIMUX_E_NO_SUCH_PROVIDER, status, retryMs, cause, retryable) - -/** - * Every HTTP-shaped provider failure (AI SDK `APICallError` analogue). - * - * Classification is [status], not the class: 401 auth, 404 model not found, - * 429 rate limited (with [retryMs]); -1 means no HTTP response was ever - * observed — a missing API key, an error built without a request, or a - * transport failure — and says nothing about whether a retry would help: read - * [retryable] for that, never the status sentinel. - * The provider's own detail, when the response carried it: [providerCode], - * [providerMessage], [responseBody], [url], [requestBodyValues], - * [responseHeaders], [data] (null when absent or synthesized locally). - */ -class APICallError( - message: String, - status: Int = -1, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, - /** Provider's own error code, e.g. "insufficient_quota". */ - val providerCode: String? = null, - /** The failure's own text without the composed prefix [message] carries, e.g. "slow down". */ - val providerMessage: String? = null, - /** Raw response body. */ - val responseBody: String? = null, - /** Sanitized request URL. */ - val url: String? = null, - /** Sanitized request body values (any JSON type). */ - val requestBodyValues: JsonElement? = null, - /** Sanitized response headers (also carry provider request ids). */ - val responseHeaders: Map? = null, - /** Parsed provider error data (AI SDK `APICallError.data`). */ - val data: JsonElement? = null, -) : AimuxException(message, AIMUX_E_API_CALL, status, retryMs, cause, retryable) - -/** Why the retry loop gave up ([RetryError.reason]); wire names are the core's serde camelCase. */ -enum class RetryErrorReason(val wireValue: String) { - /** Every permitted attempt failed with a retryable error. */ - MAX_RETRIES_EXCEEDED("maxRetriesExceeded"), - - /** A later attempt failed with a non-retryable error. */ - ERROR_NOT_RETRYABLE("errorNotRetryable"); - - companion object { - fun fromWire(value: String?): RetryErrorReason = - if (value == ERROR_NOT_RETRYABLE.wireValue) ERROR_NOT_RETRYABLE else MAX_RETRIES_EXCEEDED - } -} - -/** - * The retry loop gave up (AI SDK `RetryError` analogue): [reason] says why, - * [errors] is the per-attempt history (oldest first), [lastError] the final - * attempt. - */ -class RetryError( - message: String, - val reason: RetryErrorReason, - val errors: List, - cause: Throwable? = null, -) : AimuxException(message, AIMUX_E_RETRY, -1, -1, cause) { - init { - require(errors.isNotEmpty()) { "RetryError requires at least one error" } - } - - val lastError: AimuxException = errors.last() -} - -class TimeoutError( - message: String, - status: Int = -1, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, -) : AimuxException(message, AIMUX_E_TIMEOUT, status, retryMs, cause, retryable) - -/** Request aborted (not a Java interruption); the message is the abort payload. */ -class RequestAbortedError( - message: String = "request aborted", - status: Int = -1, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, -) : AimuxException(message, AIMUX_E_ABORTED, status, retryMs, cause, retryable) - -/** The model called a tool that is not in the supplied tool set. */ -class NoSuchToolError( - message: String, - status: Int = -1, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, - /** The tool name the model called ("" when synthesized locally). */ - val toolName: String = "", - /** The available tool names, or null when no tool set was supplied. */ - val availableTools: List? = null, -) : AimuxException(message, AIMUX_E_NO_SUCH_TOOL, status, retryMs, cause, retryable) - -/** The model's tool arguments failed to parse or validate against the schema. */ -class InvalidToolInputError( - message: String, - status: Int = -1, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, - /** The tool name the model called ("" when synthesized locally). */ - val toolName: String = "", - /** The raw argument text the model produced ("" when synthesized locally). */ - val toolInput: String = "", -) : AimuxException(message, AIMUX_E_INVALID_TOOL_INPUT, status, retryMs, cause, retryable) - -/** Tool-call repair itself failed. */ -class ToolCallRepairError( - message: String, - status: Int = -1, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, - /** - * The original lookup/parse/validation error as name-tagged wire - * JSON (the same encoding as [ToolCall.error]); null when synthesized - * locally. - */ - val originalError: JsonElement? = null, -) : AimuxException(message, AIMUX_E_TOOL_CALL_REPAIR, status, retryMs, cause, retryable) - -class OtherError( - message: String, - status: Int = -1, - retryMs: Long = -1, - cause: Throwable? = null, - retryable: Boolean = false, -) : AimuxException(message, AIMUX_E_OTHER, status, retryMs, cause, retryable) - -/** - * Code of a recording failure. Kotlin keeps the six values independent of - * the C transport codes 100..105. - * [INIT], [OPEN_FILE] and [SPAWN] are reported by [initRecording]; - * [WRITER_GONE], [FLUSH_TIMEOUT] and [WRITE] by [recordingTryFlush]. - */ -enum class RecordingErrorCode { - INIT, OPEN_FILE, SPAWN, WRITER_GONE, FLUSH_TIMEOUT, WRITE; - - internal companion object { - fun isCCode(code: Int): Boolean = code in 100..105 - - fun fromC(code: Int): RecordingErrorCode = entries.getOrNull(code - 100) - ?: throw IllegalStateException("Unknown aimux_error_code_t: $code") - } -} - -/** - * Recording failure reported by [initRecording] and [recordingTryFlush]. Independent of - * [AimuxException] — the recording subsystem has its own error type in Rust - * and in the C ABI's 100..105 range, so it does too here. - */ -class RecordingException(val code: RecordingErrorCode, message: String) : RuntimeException(message) { - internal companion object { - /** - * Build from a returned `const aimux_error_t *`. Does not own it: the caller - * ([expectRecordingError]) frees the returned error. - */ - fun fromC(error: Pointer, prefix: String = ""): RecordingException { - val lib = FFI.lib - val code = RecordingErrorCode.fromC(lib.aimux_error_code(error)) - val msg = takeString(lib.aimux_error_message(error))?.ifEmpty { null } - ?: "aimux: recording $code" - return RecordingException(code, prefix + msg) - } - } -} diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Logging.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Logging.kt deleted file mode 100644 index e65ec3f76..000000000 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Logging.kt +++ /dev/null @@ -1,18 +0,0 @@ -package ai.arcships.aimux - -/** - * Initialize the global logger (RFC-0014). - * - * Idempotent — safe to call any number of times from any thread; only the - * first call has an effect. If the host already registered its own `tracing` - * subscriber, this is a no-op (aimux never overrides a consumer's logger). - * - * @param level "off" | "error" | "warn" | "info" | "debug" | "trace"; - * empty defaults to "warn". The `AIMUX_LOG` and `AIMUX_LOG_LEVEL` - * environment variables take precedence when set. Logs go to - * stderr. - */ -fun initLogging(level: String = "warn") { - val effective = level.ifEmpty { "warn" } - FFI.lib.aimux_init_logging(effective)?.let { throw expectFfiError(it, "initLogging") } -} diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Model.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Model.kt deleted file mode 100644 index ac106152d..000000000 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Model.kt +++ /dev/null @@ -1,1106 +0,0 @@ -/** - * aimux — Unified LLM service layer for Kotlin/JVM (Rust core, 172+ providers). - * - * Uses JNA to call the aimux-ffi C ABI. This is the C ABI path (§3.2). - * The native library (libaimux_ffi.so / .dylib / .dll) must be on the - * library path or bundled in the JAR's native/ directory. - * - * Errors: every fallible C call returns an `aimux_error_t *` ([Pointer]?): - * null = success, result written to the trailing out-parameter - * ([LongByReference] for handles, [PointerByReference] for JSON strings); - * non-null = failure. Its unified code identifies [AimuxException] (1..20), - * [RecordingException] (100..105), or a C ABI failure (200..206). The last - * range maps to `IllegalStateException("aimux ffi: …")`. A decoder releases - * the returned pointer with `aimux_error_free`. - * No JSON error envelope on the primary path. - */ - -package ai.arcships.aimux - -import com.sun.jna.Callback -import com.sun.jna.Library -import com.sun.jna.Native -import com.sun.jna.Pointer -import com.sun.jna.ptr.IntByReference -import com.sun.jna.ptr.LongByReference -import com.sun.jna.ptr.PointerByReference -import java.io.Closeable -import java.util.concurrent.locks.ReentrantReadWriteLock - -// ───────────────────────────────────────────────────────────────────────────── -// JNA interface — direct mapping to the C ABI (aimux_error_t * return, -// uint64_t *out_handle / char **out_json out-params). -// ───────────────────────────────────────────────────────────────────────────── - -internal interface AimuxFFI : Library { - fun aimux_jev_decision_new_with_probability_source(apiKey: String, modelId: String, endpoint: String?, probabilitySource: String?, outHandle: LongByReference): Pointer? - fun aimux_decide_with_abort(handle: Long, optsJson: String, abortHandle: Long, outJson: PointerByReference): Pointer? - fun aimux_decision_capabilities(handle: Long, outJson: PointerByReference): Pointer? - - // apiKey nullable only so tests can exercise the NULL-argument C ABI failure; Model.openai never passes null. - fun aimux_openai_new(apiKey: String?, modelId: String, outHandle: LongByReference): Pointer? - fun aimux_anthropic_new(apiKey: String, modelId: String, outHandle: LongByReference): Pointer? - fun aimux_openai_new_with_base(apiKey: String, modelId: String, baseUrl: String, outHandle: LongByReference): Pointer? - fun aimux_anthropic_new_with_base(apiKey: String, modelId: String, baseUrl: String, outHandle: LongByReference): Pointer? - fun aimux_cohere_new(apiKey: String, modelId: String, outHandle: LongByReference): Pointer? - fun aimux_cohere_new_with_base(apiKey: String, modelId: String, baseUrl: String, outHandle: LongByReference): Pointer? - fun aimux_mistral_new(apiKey: String, modelId: String, outHandle: LongByReference): Pointer? - fun aimux_mistral_new_with_base(apiKey: String, modelId: String, baseUrl: String, outHandle: LongByReference): Pointer? - fun aimux_xai_new(apiKey: String, modelId: String, outHandle: LongByReference): Pointer? - fun aimux_xai_new_with_base(apiKey: String, modelId: String, baseUrl: String, outHandle: LongByReference): Pointer? - fun aimux_bedrock_new( - accessKeyId: String, secretAccessKey: String, region: String, modelId: String, outHandle: LongByReference - ): Pointer? - fun aimux_bedrock_new_with_base( - accessKeyId: String, secretAccessKey: String, region: String, modelId: String, baseUrl: String, outHandle: LongByReference - ): Pointer? - fun aimux_vertex_new( - accessToken: String, project: String, location: String, modelId: String, outHandle: LongByReference - ): Pointer? - fun aimux_vertex_new_with_base( - accessToken: String, project: String, location: String, modelId: String, baseUrl: String, outHandle: LongByReference - ): Pointer? - fun aimux_anthropic_aws_new(apiKey: String, region: String, modelId: String, outHandle: LongByReference): Pointer? - fun aimux_anthropic_aws_new_with_base( - apiKey: String, region: String, modelId: String, baseUrl: String, outHandle: LongByReference - ): Pointer? - fun aimux_azure_new( - apiKey: String, resourceName: String, deployment: String, apiVersion: String?, outHandle: LongByReference - ): Pointer? - fun aimux_azure_new_with_base( - apiKey: String, baseUrl: String, deployment: String, apiVersion: String?, outHandle: LongByReference - ): Pointer? - - // ── Registry provider (RFC-0017 phase 4) ─────────────────────────────── - fun aimux_provider_new(name: String, apiKey: String?, modelId: String, configJson: String?, outHandle: LongByReference): Pointer? - fun aimux_provider_from_env(name: String, modelId: String, outHandle: LongByReference): Pointer? - - // ── Provider handles (RFC-0027) ───────────────────────────────────────── - fun aimux_provider_handle_new(name: String, apiKey: String?, configJson: String?, outHandle: LongByReference): Pointer? - fun aimux_provider_list_models(handle: Long, outModelsJson: PointerByReference): Pointer? - fun aimux_provider_decision_model(handle: Long, modelId: String, outHandle: LongByReference): Pointer? - fun aimux_provider_model(handle: Long, modelId: String, outHandle: LongByReference): Pointer? - fun aimux_get_model_specs(sourceUrl: String?, outSpecsJson: PointerByReference): Pointer? - - fun aimux_generate_text(handle: Long, promptJson: String, optsJson: String?, outJson: PointerByReference): Pointer? - fun aimux_generate_object(handle: Long, promptJson: String, optsJson: String?, outJson: PointerByReference): Pointer? - fun aimux_consume_stream_text(handle: Long, promptJson: String, optsJson: String?, outJson: PointerByReference): Pointer? - // NULL after on_done = clean end; non-NULL = failure (no on_done). - fun aimux_stream_text( - handle: Long, - promptJson: String, - optsJson: String?, - onPart: Callback?, - onDone: Callback?, - streamCtx: Pointer?, - ): Pointer? - - // ── OpenAI-compatible output (RFC-0026) ───────────────────────────────── - fun aimux_generate_text_as_openai(handle: Long, promptJson: String, optsJson: String?, outJson: PointerByReference): Pointer? - fun aimux_generate_text_result_as_openai(handle: Long, resultJson: String, outJson: PointerByReference): Pointer? - fun aimux_stream_text_as_openai( - handle: Long, - promptJson: String, - optsJson: String?, - onPart: Callback?, - onDone: Callback?, - streamCtx: Pointer?, - ): Pointer? - - // ── Host-side tool-call repair (RFC-0035) ────────────────────────────── - // Pure functions: no handle, no I/O. promptJson/optsJson are the SAME - // strings passed to aimux_generate_text / aimux_stream_text. - fun aimux_tool_call_repair_context( - toolCallJson: String, promptJson: String, optsJson: String?, outJson: PointerByReference - ): Pointer? - fun aimux_apply_tool_call_repair( - toolCallJson: String, optsJson: String?, replyJson: String, outJson: PointerByReference - ): Pointer? - fun aimux_apply_tool_call_repair_to_result( - resultJson: String, optsJson: String?, toolCallId: String, replyJson: String, outJson: PointerByReference - ): Pointer? - - fun aimux_drop_handle(handle: Long) - fun aimux_free_string(ptr: Pointer?) - - // ── Returned error (aimux-error.h): aimux_error_t *. Release exactly once - // with aimux_error_free (NULL-safe). - // Errors are not handles: never pass one to aimux_drop_handle. - fun aimux_error_free(err: Pointer?) - // ── Unified code and getters (const aimux_error_t *; NULL-safe). Strings are - // owned: aimux_free_string. Payload getters return NULL / -1 under any other code. - fun aimux_error_code(error: Pointer?): Int - fun aimux_error_message(error: Pointer?): Pointer? - fun aimux_error_retryable(error: Pointer?): Int - fun aimux_error_status(error: Pointer?): Int - fun aimux_error_retry_ms(error: Pointer?): Long - fun aimux_error_provider_code(error: Pointer?): Pointer? - fun aimux_error_provider_message(error: Pointer?): Pointer? - fun aimux_error_response_body(error: Pointer?): Pointer? - fun aimux_error_url(error: Pointer?): Pointer? - fun aimux_error_request_body_values(error: Pointer?): Pointer? - fun aimux_error_response_headers(error: Pointer?): Pointer? - fun aimux_error_provider_data(error: Pointer?): Pointer? - // AIMUX_E_RETRY payload: reason wire name, attempt count, and each attempt - // as a NEW OWNED error the caller frees with aimux_error_free. - fun aimux_error_retry_reason(error: Pointer?): Pointer? - fun aimux_error_retry_count(error: Pointer?): Int - fun aimux_error_retry_error_at(error: Pointer?, index: Int): Pointer? - fun aimux_error_model_id(error: Pointer?): Pointer? - fun aimux_error_model_type(error: Pointer?): Pointer? - fun aimux_error_provider_id(error: Pointer?): Pointer? - fun aimux_error_tool_name(error: Pointer?): Pointer? - fun aimux_error_available_tools(error: Pointer?): Pointer? - fun aimux_error_tool_input(error: Pointer?): Pointer? - fun aimux_error_original_error(error: Pointer?): Pointer? - - // ── Embedding ────────────────────────────────────────────────────────── - fun aimux_openai_embedding_new(apiKey: String, modelId: String, outHandle: LongByReference): Pointer? - fun aimux_openai_embedding_new_with_base(apiKey: String, modelId: String, baseUrl: String, outHandle: LongByReference): Pointer? - fun aimux_cohere_embedding_new(apiKey: String, modelId: String, outHandle: LongByReference): Pointer? - fun aimux_cohere_embedding_new_with_base(apiKey: String, modelId: String, baseUrl: String, outHandle: LongByReference): Pointer? - fun aimux_google_embedding_new(apiKey: String, modelId: String, outHandle: LongByReference): Pointer? - fun aimux_google_embedding_new_with_base(apiKey: String, modelId: String, baseUrl: String, outHandle: LongByReference): Pointer? - fun aimux_embed(handle: Long, valuesJson: String, optsJson: String?, outJson: PointerByReference): Pointer? - - // ── Speech (TTS) ─────────────────────────────────────────────────────── - fun aimux_openai_speech_new(apiKey: String, modelId: String, outHandle: LongByReference): Pointer? - fun aimux_openai_speech_new_with_base(apiKey: String, modelId: String, baseUrl: String, outHandle: LongByReference): Pointer? - fun aimux_speech_generate(handle: Long, optsJson: String, outJson: PointerByReference): Pointer? - - // ── Image ────────────────────────────────────────────────────────────── - fun aimux_openai_image_new(apiKey: String, modelId: String, outHandle: LongByReference): Pointer? - fun aimux_openai_image_new_with_base(apiKey: String, modelId: String, baseUrl: String, outHandle: LongByReference): Pointer? - fun aimux_google_image_new(apiKey: String, modelId: String, outHandle: LongByReference): Pointer? - fun aimux_google_image_new_with_base(apiKey: String, modelId: String, baseUrl: String, outHandle: LongByReference): Pointer? - fun aimux_image_generate(handle: Long, optsJson: String, outJson: PointerByReference): Pointer? - - // ── Transcription (STT) ──────────────────────────────────────────────── - fun aimux_openai_transcription_new(apiKey: String, modelId: String, outHandle: LongByReference): Pointer? - fun aimux_openai_transcription_new_with_base( - apiKey: String, modelId: String, baseUrl: String, outHandle: LongByReference - ): Pointer? - fun aimux_transcription_generate( - handle: Long, audioBase64: String, mediaType: String, optsJson: String?, outJson: PointerByReference - ): Pointer? - - // ── Files ────────────────────────────────────────────────────────────── - fun aimux_openai_files_new(apiKey: String, outHandle: LongByReference): Pointer? - fun aimux_openai_files_new_with_base(apiKey: String, baseUrl: String, outHandle: LongByReference): Pointer? - fun aimux_file_upload( - handle: Long, dataBase64: String, mediaType: String, optsJson: String?, outJson: PointerByReference - ): Pointer? - - // ── Reranking ────────────────────────────────────────────────────────── - fun aimux_cohere_reranking_new(apiKey: String, modelId: String, outHandle: LongByReference): Pointer? - fun aimux_cohere_reranking_new_with_base(apiKey: String, modelId: String, baseUrl: String, outHandle: LongByReference): Pointer? - fun aimux_rerank(handle: Long, optsJson: String, outJson: PointerByReference): Pointer? - - // ── Video ────────────────────────────────────────────────────────────── - fun aimux_google_video_new(apiKey: String, modelId: String, outHandle: LongByReference): Pointer? - fun aimux_google_video_new_with_base(apiKey: String, modelId: String, baseUrl: String, outHandle: LongByReference): Pointer? - fun aimux_video_generate(handle: Long, optsJson: String, outJson: PointerByReference): Pointer? - - // ── Search ───────────────────────────────────────────────────────────── - fun aimux_tavily_search_new(apiKey: String, modelId: String, outHandle: LongByReference): Pointer? - fun aimux_tavily_search_new_with_base(apiKey: String, modelId: String, baseUrl: String, outHandle: LongByReference): Pointer? - fun aimux_search(handle: Long, optsJson: String, outJson: PointerByReference): Pointer? - - // Logging (RFC-0014). [C ABI]: only C ABI failures. - fun aimux_init_logging(level: String): Pointer? - - // ── Recording + mock replay (RFC-0023) ────────────────────────────────── - // [RecordingError] NULL = ok; otherwise code 100..102 (previous recorder untouched). - fun aimux_init_recording(dir: String): Pointer? - // [AiMuxError] cap == 0 is AIMUX_E_INVALID_ARGUMENT. - fun aimux_init_recording_ring(cap: Long): Pointer? - fun aimux_init_recording_ring_default() - fun aimux_recording_stop() - fun aimux_recording_flush() - // [RecordingError] NULL = data on disk (also when recording was never initialised). - fun aimux_recording_try_flush(): Pointer? - fun aimux_mock_replay_new(recordingsJsonl: String, outHandle: LongByReference): Pointer? - - // [AiMuxError] Replace the default transport's proxy configuration (M6, RFC-0016). - fun aimux_init_proxy(configJson: String): Pointer? - - // [AiMuxError] Create a RouterModel (RFC-0021) over child handles (LongArray is - // pinned to uint64_t* by JNA). configJson may be null. - fun aimux_router_new(handles: LongArray, len: Long, configJson: String?, outHandle: LongByReference): Pointer? - - // [AiMuxError] Create a MoaModel (RFC-0022) over reference handles + one aggregator. - // referenceHandles may be null/empty (degrades to aggregator-only). - fun aimux_moa_new(referenceHandles: LongArray?, refLen: Long, aggregator: Long, configJson: String?, outHandle: LongByReference): Pointer? - - // Transcription streaming sessions (RFC-0028). - - fun aimux_transcription_session_new(modelHandle: Long, abortHandle: Long, optsJson: String?, outHandle: LongByReference): Pointer? - - // [AiMuxError] Blocking while the channel is full. - fun aimux_transcription_push_audio(session: Long, data: ByteArray?, len: Long): Pointer? - - // [C ABI] Fails only for a dead handle. - fun aimux_transcription_input_done(session: Long): Pointer? - - // [AiMuxError] NULL + *outState PART (outPart holds JSON) / ENDED / TIMEOUT (outPart NULL). - fun aimux_transcription_next_part(session: Long, timeoutMs: Long, outPart: PointerByReference, outState: IntByReference): Pointer? - - fun aimux_transcription_session_drop(session: Long) -} - -internal object FFI { - // Strings go out as UTF-8, matching every read (`getString(0, "UTF-8")`). - // JNA otherwise encodes with the platform's native encoding, so on a - // Windows code page or under LANG=C a non-ASCII prompt would arrive - // corrupted. - val lib: AimuxFFI = Native.load( - "aimux_ffi", - AimuxFFI::class.java, - mapOf(Library.OPTION_STRING_ENCODING to "UTF-8"), - ) -} - -/** `aimux_transcription_next_part_state_t` values written to `outState` (`int32_t*` in the header). */ -internal const val TRANSCRIPTION_NEXT_PART_PART = 1 -internal const val TRANSCRIPTION_NEXT_PART_ENDED = 2 -internal const val TRANSCRIPTION_NEXT_PART_TIMEOUT = 3 - -// ───────────────────────────────────────────────────────────────────────────── -// Error decoding — one decoder per call site, chosen by what the call can produce. -// ───────────────────────────────────────────────────────────────────────────── - -/** Copy an owned C string and free it; null stays null. */ -internal fun takeString(p: Pointer?): String? = p?.let { - try { - it.getString(0, "UTF-8") - } finally { - FFI.lib.aimux_free_string(it) - } -} - -private fun prefixOf(context: String): String = if (context.isEmpty()) "" else "$context: " - -/** Read the returned error's message; does not free. */ -private fun ffiError(e: Pointer, prefix: String): IllegalStateException { - val msg = takeString(FFI.lib.aimux_error_message(e))?.ifEmpty { null } ?: "C ABI failure" - return IllegalStateException("${prefix}aimux ffi: $msg") -} - -/** - * Decode an error from a call that may return `AiMuxError`: 1..20 → - * [AimuxException]; 200..206 → [IllegalStateException]. Frees [e]. - */ -internal fun expectAimuxError(e: Pointer, context: String = ""): RuntimeException { - val prefix = prefixOf(context) - try { - val code = FFI.lib.aimux_error_code(e) - if (isFfiCode(code)) return ffiError(e, prefix) - // Code 4 is retired; Retry is 14, tool errors are 15..17 and a missing - // API key / setting are 19 / 20. - check(code in AIMUX_E_OTHER..AIMUX_E_LOAD_SETTING && code != 4) { - "${prefix}aimux ffi: expected AiMuxError code, got $code" - } - return AimuxException.fromC(e, prefix) - } finally { - FFI.lib.aimux_error_free(e) - } -} - -/** - * Decode an error from a recording call: 100..105 → [RecordingException]; - * 200..206 → [IllegalStateException]. Frees [e]. - */ -internal fun expectRecordingError(e: Pointer, context: String = ""): RuntimeException { - val prefix = prefixOf(context) - try { - val code = FFI.lib.aimux_error_code(e) - if (isFfiCode(code)) return ffiError(e, prefix) - check(RecordingErrorCode.isCCode(code)) { - "${prefix}aimux ffi: expected RecordingError code, got $code" - } - return RecordingException.fromC(e, prefix) - } finally { - FFI.lib.aimux_error_free(e) - } -} - -/** - * Decode an error from a call that only exposes C ABI failures: message → - * [IllegalStateException]. Deletes [e]. - */ -internal fun expectFfiError(e: Pointer, context: String = ""): RuntimeException { - try { - val code = FFI.lib.aimux_error_code(e) - check(isFfiCode(code)) { - "${prefixOf(context)}aimux ffi: expected C ABI failure code, got $code" - } - return ffiError(e, prefixOf(context)) - } finally { - FFI.lib.aimux_error_free(e) - } -} - -private fun isFfiCode(code: Int): Boolean = code in 200..206 - -/** - * Reject malformed raw JSON before it crosses the C ABI, so a caller's - * bad input is an [IllegalArgumentException] naming the parameter rather than - * a C ABI failure. [requireJson] is for optional (`String?`) parameters — - * null / empty means "default"; [requireJsonRequired] is for required - * (`String`) parameters, which must not be blank. - */ -internal fun requireJson(name: String, json: String?) { - if (json.isNullOrEmpty()) return - requireJsonRequired(name, json) -} - -/** Required-parameter variant: distinct name because `String`/`String?` share one JVM signature. */ -internal fun requireJsonRequired(name: String, json: String) { - // ponytail: full parse (the FFI parses again); swap for a syntax-only scan if large prompts show up in profiles. - require(json.isNotBlank()) { "$name: invalid JSON: empty" } - try { - AimuxJson.parseToJsonElement(json) - } catch (e: kotlinx.serialization.SerializationException) { - throw IllegalArgumentException("$name: ${e.message}", e) - } -} - -/** Run a constructor: null error → the handle written to `out`; otherwise throw [expectAimuxError]. */ -internal inline fun handleResult(context: String = "", block: (LongByReference) -> Pointer?): Long { - val out = LongByReference(0) - block(out)?.let { throw expectAimuxError(it, context) } - return out.value -} - -/** Run a JSON-result call: null error → the caller-owned string written to `out` (freed here); otherwise throw [expectAimuxError]. */ -internal inline fun stringResult(context: String = "", block: (PointerByReference) -> Pointer?): String { - val out = PointerByReference() - block(out)?.let { throw expectAimuxError(it, context) } - return takeString(out.value) - ?: throw IllegalStateException("${if (context.isEmpty()) "" else "$context: "}aimux ffi: success with NULL result") -} - -// ───────────────────────────────────────────────────────────────────────────── -// Model — Closeable wrapper around a C ABI handle. -// ───────────────────────────────────────────────────────────────────────────── - -// The model whose read hold the current thread was lent, if any — see -// Model.withLentReadHold. File-private rather than per-instance: it is a -// property of the thread, not of the model. -private val lentReadHold = ThreadLocal() - -/** - * A model instance backed by a Rust `Arc`. - * - * Implements [Closeable] — you MUST call [close] (or use `use {}`) to release - * the native handle and avoid memory leaks. - * - * **Thread-safety / concurrency.** [Model] is safe for concurrent use. It - * guards the native handle with a Go-style [ReentrantReadWriteLock] (fair, - * FIFO): every FFI call ([generateText], [streamText], streaming variants) - * holds the _read_ lock for its entire duration, and [close] takes the _write_ - * lock. As a result [close] blocks until all in-flight calls finish before - * dropping the native handle — this closes the use-after-free race where a - * caller could observe a non-zero handle and then race with [close]'s drop. - * Because a streaming call holds the read lock until the stream completes, - * [close] will not interrupt or drop a handle out from under an active stream. - * Do not call [close] from within a stream callback, or from a - * `repairToolCall` hook running for a stream on this model (either would - * self-deadlock). - * - * ```kotlin - * Model.openai("sk-...", "gpt-4o-mini").use { model -> - * val result = model.generateText("\"Hello!\"") - * } - * ``` - */ -class Model internal constructor(handle: Long) : Closeable { - // Go-style read/write lock: every FFI call holds the read lock for its - // entire duration; close() takes the write lock and thus blocks until all - // in-flight calls finish before dropping the native handle. This closes the - // read-then-drop use-after-free race the AtomicLong version had. Fair - // (FIFO) so a pending close is not starved by barging readers — matches - // Go's sync.RWMutex writer-priority semantics. - private val lock = ReentrantReadWriteLock(true) - private var handle: Long = handle - private var closed: Boolean = false - - /** - * Release the native handle. Idempotent and thread-safe: subsequent calls - * are no-ops, and every other method throws [IllegalStateException]. - * - * Acquires the write lock and therefore _blocks until all in-flight FFI - * calls_ (which hold the read lock) finish before dropping the native - * handle — prevents a use-after-free race between a concurrent caller and - * [close]. A streaming call holds the read lock for the entire stream, so - * [close] blocks until the stream completes. Do not call [close] from - * within a stream callback (would self-deadlock). - */ - override fun close() { - lock.writeLock().lock() - try { - if (closed || handle == 0L) { - return - } - val h = handle - handle = 0L - closed = true - FFI.lib.aimux_drop_handle(h) - } finally { - lock.writeLock().unlock() - } - } - - // Caller MUST already hold the read lock (each public FFI method acquires - // it and releases it in a finally after the FFI call returns). Holding the - // read lock across the FFI call is what lets close()'s write lock wait for - // the call to finish, closing the use-after-free race. - private fun requireHandleLocked(): Long { - if (closed || handle == 0L) throw IllegalStateException("Model is closed") - return handle - } - - /** - * Run [block] with the native handle, holding the read lock — unless this - * thread was *lent* the hold of a blocked caller (see [withLentReadHold]), - * in which case the hold already exists and re-taking it would deadlock. - */ - private inline fun withRead(block: (Long) -> T): T { - val lent = lentReadHold.get() === this - if (!lent) lock.readLock().lock() - try { - return block(requireHandleLocked()) - } finally { - if (!lent) lock.readLock().unlock() - } - } - - /** - * Run [block] on this thread as if it held this model's read lock, without - * taking it. - * - * Only legal while ANOTHER thread is blocked inside a call on this model - * holding the read lock, and is waiting for [block] to finish: that thread's - * hold is what keeps the handle alive, and because the lock is fair a fresh - * reader here would queue behind any waiting `close()` writer and deadlock - * the three of them (blocked caller → this thread → closer → blocked - * caller). - * - * The thread-local cannot leak: the only caller creates a thread per repair - * and that thread dies when [block] returns. - */ - internal fun withLentReadHold(block: () -> T): T { - val previous = lentReadHold.get() - lentReadHold.set(this) - try { - return block() - } finally { - lentReadHold.set(previous) - } - } - - /** Internal handle read for composite-model factories (router/moa). */ - internal fun handle(): Long = withRead { it } - - protected fun finalize() { - close() - } - - // ── Generation ───────────────────────────────────────────────────────── - - /** - * Generate text (non-streaming). - * - * @param promptJson JSON prompt string (bare value or {"prompt": ...}). - * @param optsJson Optional JSON-serialized GenerateTextOptions. - * @return JSON-serialized GenerateTextResult. - * @throws AimuxException on AiMuxError (typed subclass via C AimuxError). - * @throws IllegalArgumentException when a raw JSON argument is malformed; IllegalStateException after [close]. - */ - fun generateText(promptJson: String, optsJson: String? = null): String { - requireJsonRequired("promptJson", promptJson) - requireJson("optsJson", optsJson) - return withRead { h -> - stringResult { out -> - FFI.lib.aimux_generate_text(h, promptJson, optsJson, out) - } - } - } - - /** - * Generate a structured JSON object (M12, RFC-0016). - * - * Same signature as [generateText]; returns a JSON-serialized - * `GenerateObjectResult`. Pass `"output": {"type": "object", "schema": ...}` via - * [optsJson] to describe the object; the model's text must parse as JSON - * and match the schema. - * - * @param promptJson JSON prompt string (bare value or {"prompt": ...}). - * @param optsJson Optional JSON-serialized GenerateTextOptions. - * @return JSON-serialized GenerateObjectResult. - * @throws AimuxException on AiMuxError. - * @throws IllegalArgumentException when a raw JSON argument is malformed; IllegalStateException after [close]. - */ - fun generateObject(promptJson: String, optsJson: String? = null): String { - requireJsonRequired("promptJson", promptJson) - requireJson("optsJson", optsJson) - return withRead { h -> - stringResult { out -> - FFI.lib.aimux_generate_object(h, promptJson, optsJson, out) - } - } - } - - /** - * Consume a stream to completion and return the aggregated result - * (M11, RFC-0016). Synchronous (blocks until the stream finishes). - * - * Same signature as [generateText]; returns a JSON-serialized - * `GenerateTextResult`. - * - * @param promptJson JSON prompt string (bare value or {"prompt": ...}). - * @param optsJson Optional JSON-serialized GenerateTextOptions. - * @return JSON-serialized GenerateTextResult. - * @throws AimuxException on AiMuxError. - * @throws IllegalArgumentException when a raw JSON argument is malformed; IllegalStateException after [close]. - */ - fun consumeStreamText(promptJson: String, optsJson: String? = null): String { - requireJsonRequired("promptJson", promptJson) - requireJson("optsJson", optsJson) - return withRead { h -> - stringResult { out -> - FFI.lib.aimux_consume_stream_text(h, promptJson, optsJson, out) - } - } - } - - /** - * Stream text from the model. - * - * Blocks the calling thread until the stream completes. Recoverable - * frame errors (a malformed SSE frame) arrive as `error` stream parts - * data parts and the stream continues; only transport/Core failures - * throw [AimuxException] (on_done is not invoked on failure), after any - * parts already delivered to [onPart]. - * - * @param promptJson JSON prompt string. - * @param optsJson Optional JSON-serialized GenerateTextOptions. - * @param onPart Called for each StreamPart (JSON string). - * @param onDone Called when the stream completes normally. - */ - fun streamText( - promptJson: String, - optsJson: String? = null, - onPart: (String) -> Unit, - onDone: () -> Unit, - ) { - requireJsonRequired("promptJson", promptJson) - requireJson("optsJson", optsJson) - // JNA callbacks — must be held in variables to prevent GC. - // C ABI: on_part(const char* json, void* stream_ctx), on_done(void* stream_ctx). - val partCb = object : Callback { - @Suppress("unused") - fun callback(jsonPtr: Pointer?, @Suppress("UNUSED_PARAMETER") streamCtx: Pointer?) { - if (jsonPtr != null) { - onPart(jsonPtr.getString(0, "UTF-8")) - } - } - } - val doneCb = object : Callback { - @Suppress("unused") - fun callback(@Suppress("UNUSED_PARAMETER") streamCtx: Pointer?) { - onDone() - } - } - - // Hold the read lock for the whole blocking stream so close() cannot - // drop the handle mid-stream (it blocks on the write lock until the - // stream completes). - withRead { h -> - FFI.lib.aimux_stream_text(h, promptJson, optsJson, partCb, doneCb, null) - ?.let { throw expectAimuxError(it, "streamText") } - } - } - - /** - * Stream text as a Sequence of StreamPart JSON strings. - * - * Usage: - * ```kotlin - * model.streamTextSequence("\"Write a haiku\"").forEach { part -> - * println(part) - * } - * ``` - */ - fun streamTextSequence( - promptJson: String, - optsJson: String? = null, - ): Sequence = sequence { - // LinkedBlockingQueue rejects null, so end-of-stream is a sentinel object. - val eos = Any() - val parts = java.util.concurrent.LinkedBlockingQueue() - var streamError: AimuxException? = null - - try { - streamText( - promptJson = promptJson, - optsJson = optsJson, - onPart = { parts.put(it) }, - onDone = { parts.put(eos) }, - ) - } catch (e: AimuxException) { - streamError = e - parts.put(eos) - } - - while (true) { - val part = parts.take() - if (part === eos) break - yield(part as String) - } - - streamError?.let { throw it } - } - - // ── OpenAI-compatible output (RFC-0026) ───────────────────────────────── - - /** - * Generate text (non-streaming) with OpenAI Chat Completions output. - * - * @return JSON-serialized ChatCompletion. - * @throws AimuxException on AiMuxError. - * @throws IllegalArgumentException when a raw JSON argument is malformed; IllegalStateException after [close]. - */ - fun generateTextAsOpenAI(promptJson: String, optsJson: String? = null): String { - requireJsonRequired("promptJson", promptJson) - requireJson("optsJson", optsJson) - return withRead { h -> - stringResult { out -> - FFI.lib.aimux_generate_text_as_openai(h, promptJson, optsJson, out) - } - } - } - - /** - * Convert a serialized GenerateTextResult into a serialized ChatCompletion — - * the conversion half of [generateTextAsOpenAI]. - * - * For repairing on the host (RFC-0035): patch the native result, then - * convert it, so the completion's tool calls are the repaired ones. This - * model only supplies the fallback model id; nothing is generated. - * - * @throws AimuxException when [resultJson] is not a GenerateTextResult. - * @throws IllegalArgumentException when [resultJson] is malformed; IllegalStateException after [close]. - */ - fun generateTextResultAsOpenAI(resultJson: String): String { - requireJsonRequired("resultJson", resultJson) - return withRead { h -> - stringResult { out -> - FFI.lib.aimux_generate_text_result_as_openai(h, resultJson, out) - } - } - } - - /** - * Stream text with OpenAI Chat Completions output. - * Each [onPart] receives a serialized ChatCompletionChunk JSON string. - * - * @throws AimuxException on stream failure (on_done not invoked). - */ - fun streamTextAsOpenAI( - promptJson: String, - optsJson: String? = null, - onPart: (String) -> Unit, - onDone: () -> Unit, - ) { - requireJsonRequired("promptJson", promptJson) - requireJson("optsJson", optsJson) - val partCb = object : Callback { - @Suppress("unused") - fun callback(jsonPtr: Pointer?, @Suppress("UNUSED_PARAMETER") streamCtx: Pointer?) { - if (jsonPtr != null) { - onPart(jsonPtr.getString(0, "UTF-8")) - } - } - } - val doneCb = object : Callback { - @Suppress("unused") - fun callback(@Suppress("UNUSED_PARAMETER") streamCtx: Pointer?) { - onDone() - } - } - - // Hold the read lock for the whole blocking stream so close() cannot - // drop the handle mid-stream. - withRead { h -> - FFI.lib.aimux_stream_text_as_openai(h, promptJson, optsJson, partCb, doneCb, null) - ?.let { throw expectAimuxError(it, "streamTextAsOpenAI") } - } - } - - /** - * Stream text with OpenAI Chat Completions output as a [Sequence] of - * ChatCompletionChunk JSON strings (RFC-0026). - */ - fun streamTextAsOpenAISequence( - promptJson: String, - optsJson: String? = null, - ): Sequence = sequence { - // LinkedBlockingQueue rejects null, so end-of-stream is a sentinel object. - val eos = Any() - val parts = java.util.concurrent.LinkedBlockingQueue() - var streamError: AimuxException? = null - - try { - streamTextAsOpenAI( - promptJson = promptJson, - optsJson = optsJson, - onPart = { parts.put(it) }, - onDone = { parts.put(eos) }, - ) - } catch (e: AimuxException) { - streamError = e - parts.put(eos) - } - - while (true) { - val part = parts.take() - if (part === eos) break - yield(part as String) - } - - streamError?.let { throw it } - } - - // ── Provider constructors ────────────────────────────────────────────── - - companion object { - /** Create an OpenAI model instance. */ - fun openai(apiKey: String, modelId: String): Model = - Model(handleResult { out -> FFI.lib.aimux_openai_new(apiKey, modelId, out) }) - - /** Create an Anthropic model instance. */ - fun anthropic(apiKey: String, modelId: String): Model = - Model(handleResult { out -> FFI.lib.aimux_anthropic_new(apiKey, modelId, out) }) - - /** Create an OpenAI model instance with a custom base URL. */ - fun openai(apiKey: String, modelId: String, baseUrl: String): Model = - Model(handleResult { out -> - FFI.lib.aimux_openai_new_with_base(apiKey, modelId, baseUrl, out) - }) - - /** Create an Anthropic model instance with a custom base URL. */ - fun anthropic(apiKey: String, modelId: String, baseUrl: String): Model = - Model(handleResult { out -> - FFI.lib.aimux_anthropic_new_with_base(apiKey, modelId, baseUrl, out) - }) - - /** Create a Cohere model instance. */ - fun cohere(apiKey: String, modelId: String): Model = - Model(handleResult { out -> FFI.lib.aimux_cohere_new(apiKey, modelId, out) }) - - /** Create a Cohere model instance with a custom base URL. */ - fun cohere(apiKey: String, modelId: String, baseUrl: String): Model = - Model(handleResult { out -> - FFI.lib.aimux_cohere_new_with_base(apiKey, modelId, baseUrl, out) - }) - - /** Create a Mistral model instance. */ - fun mistral(apiKey: String, modelId: String): Model = - Model(handleResult { out -> FFI.lib.aimux_mistral_new(apiKey, modelId, out) }) - - /** Create a Mistral model instance with a custom base URL. */ - fun mistral(apiKey: String, modelId: String, baseUrl: String): Model = - Model(handleResult { out -> - FFI.lib.aimux_mistral_new_with_base(apiKey, modelId, baseUrl, out) - }) - - /** Create an xAI model instance. */ - fun xai(apiKey: String, modelId: String): Model = - Model(handleResult { out -> FFI.lib.aimux_xai_new(apiKey, modelId, out) }) - - /** Create an xAI model instance with a custom base URL. */ - fun xai(apiKey: String, modelId: String, baseUrl: String): Model = - Model(handleResult { out -> - FFI.lib.aimux_xai_new_with_base(apiKey, modelId, baseUrl, out) - }) - - /** Create a Bedrock model instance (AWS SigV4 credentials). */ - fun bedrock(accessKeyId: String, secretAccessKey: String, region: String, modelId: String): Model = - Model(handleResult { out -> - FFI.lib.aimux_bedrock_new(accessKeyId, secretAccessKey, region, modelId, out) - }) - - /** Create a Bedrock model instance with a custom base URL. */ - fun bedrock( - accessKeyId: String, secretAccessKey: String, region: String, modelId: String, baseUrl: String - ): Model = - Model(handleResult { out -> - FFI.lib.aimux_bedrock_new_with_base(accessKeyId, secretAccessKey, region, modelId, baseUrl, out) - }) - - /** Create a Vertex AI model instance (GCP bearer token). */ - fun vertex(accessToken: String, project: String, location: String, modelId: String): Model = - Model(handleResult { out -> - FFI.lib.aimux_vertex_new(accessToken, project, location, modelId, out) - }) - - /** Create a Vertex AI model instance with a custom base URL. */ - fun vertex( - accessToken: String, project: String, location: String, modelId: String, baseUrl: String - ): Model = - Model(handleResult { out -> - FFI.lib.aimux_vertex_new_with_base(accessToken, project, location, modelId, baseUrl, out) - }) - - /** Create an Anthropic-on-AWS model instance (API key + region). */ - fun anthropicAws(apiKey: String, region: String, modelId: String): Model = - Model(handleResult { out -> - FFI.lib.aimux_anthropic_aws_new(apiKey, region, modelId, out) - }) - - /** Create an Anthropic-on-AWS model instance with a custom base URL. */ - fun anthropicAws(apiKey: String, region: String, modelId: String, baseUrl: String): Model = - Model(handleResult { out -> - FFI.lib.aimux_anthropic_aws_new_with_base(apiKey, region, modelId, baseUrl, out) - }) - - /** Create an Azure OpenAI model instance (API key + resource name). */ - fun azure(apiKey: String, resourceName: String, deployment: String): Model = - Model(handleResult { out -> - FFI.lib.aimux_azure_new(apiKey, resourceName, deployment, null, out) - }) - - /** Create an Azure OpenAI model instance with an explicit api-version. */ - fun azureWithVersion(apiKey: String, resourceName: String, deployment: String, apiVersion: String): Model = - Model(handleResult { out -> - FFI.lib.aimux_azure_new(apiKey, resourceName, deployment, apiVersion, out) - }) - - /** Create an Azure OpenAI model instance with a custom base URL. */ - fun azureWithBase(apiKey: String, baseUrl: String, deployment: String): Model = - Model(handleResult { out -> - FFI.lib.aimux_azure_new_with_base(apiKey, baseUrl, deployment, null, out) - }) - - /** - * Create a model from the provider registry by name (RFC-0017 phase 4). - * - * @param name Registry provider name (e.g. `"deepseek"`, `"groq"`). - * @param apiKey API key, or null to read the provider's env var from - * the registry entry. - * @param modelId Model id. - * @param configJson Optional JSON object of settings (`baseUrl`, `headers`, - * `organization`, `project`, `params`); any other key is - * rejected as an invalid argument. Null for defaults. - */ - fun provider(name: String, apiKey: String? = null, modelId: String, configJson: String? = null): Model { - requireJson("configJson", configJson) - return Model(handleResult { out -> - FFI.lib.aimux_provider_new(name, apiKey, modelId, configJson, out) - }) - } - - /** Create a model from the provider registry, reading the API key from the provider's env var. */ - fun providerFromEnv(name: String, modelId: String): Model = - Model(handleResult { out -> - FFI.lib.aimux_provider_from_env(name, modelId, out) - }) - - /** - * Create a **provider handle** (RFC-0027) for a registry-backed provider. - * - * Unlike [provider] (which binds to a single modelId), this returns a - * [ProviderHandle] that supports [ProviderHandle.listModels] and - * [ProviderHandle.model]. - */ - fun createProvider(name: String, apiKey: String? = null, configJson: String? = null): ProviderHandle { - requireJson("configJson", configJson) - return ProviderHandle(handleResult { out -> - FFI.lib.aimux_provider_handle_new(name, apiKey, configJson, out) - }) - } - - /** - * Create a mock replay model from recorded JSONL (RFC-0023). - * - * @param recordingsJsonl One `Recording` JSON per line. - * @throws IllegalArgumentException if a non-blank line is not valid JSON. - */ - fun mockReplay(recordingsJsonl: String): Model { - recordingsJsonl.lineSequence().filter { it.isNotBlank() }.forEach { requireJsonRequired("recordingsJsonl", it) } - return Model(handleResult { out -> - FFI.lib.aimux_mock_replay_new(recordingsJsonl, out) - }) - } - - /** - * Create a RouterModel (RFC-0021) over the given child models. The - * returned model routes each call to one child and falls back across - * the rest on error (per `configJson`). - * - * @param models child models (must be non-empty). - * @param configJson optional config: `{"router": "rule"|"weighted", - * "weights": [...], "fallback": "on_error"|"none", - * "providerName", "modelId"}` — all optional. - * @throws IllegalArgumentException if `configJson` is malformed JSON. - * @throws AimuxException on AiMuxError. - */ - fun router(models: List, configJson: String? = null): Model { - require(models.isNotEmpty()) { "router: models must be non-empty" } - requireJson("configJson", configJson) - val handles = LongArray(models.size) { models[it].handle() } - return Model(handleResult { out -> - FFI.lib.aimux_router_new(handles, handles.size.toLong(), configJson, out) - }) - } - - /** - * Create a MoaModel (RFC-0022) over reference models + one aggregator. - * References fan out in parallel, then the aggregator synthesizes a - * final answer. - * - * @param references reference models (may be empty — runs aggregator only). - * @param aggregator the aggregator model. - * @param configJson optional MoaConfig. - * @throws IllegalArgumentException if `configJson` is malformed JSON. - * @throws AimuxException on AiMuxError. - */ - fun moa( - references: List, - aggregator: Model, - configJson: String? = null, - ): Model { - requireJson("configJson", configJson) - val refHandles = - if (references.isEmpty()) null - else LongArray(references.size) { references[it].handle() } - val refLen = refHandles?.size?.toLong() ?: 0L - return Model(handleResult { out -> - FFI.lib.aimux_moa_new(refHandles, refLen, aggregator.handle(), configJson, out) - }) - } - - /** - * Replace the default transport with one routed by this proxy - * configuration (M6, RFC-0016). Requests started afterwards use it; - * calling it again replaces the previous configuration. - * - * @param configJson ProxyConfig JSON (`http_url`, `https_url`, - * `all_url`, `no_proxy` — all optional). - * @throws IllegalArgumentException if `configJson` is blank or malformed JSON. - * @throws AimuxException on AiMuxError (wrong shape). - */ - fun initProxy(configJson: String) { - requireJsonRequired("configJson", configJson) - FFI.lib.aimux_init_proxy(configJson)?.let { throw expectAimuxError(it, "initProxy") } - } - } -} - -/** - * A provider handle — created by `Model.createProvider`, supports `listModels()` - * (runtime discovery) and `model()` (build a model from a discovered id). - * - * **Thread-safety / concurrency.** Safe for concurrent use. The native handle - * is guarded by a Go-style [ReentrantReadWriteLock] (fair, FIFO): - * [listModels] and [model] hold the _read_ lock for the entire FFI call, and - * [close] takes the _write_ lock. [close] therefore blocks until in-flight - * calls finish before dropping the native handle — closes the check-then-use - * use-after-free race where a caller could pass the closed check and then race - * with [close]'s drop. - */ -class ProviderHandle internal constructor(handle: Long) : AutoCloseable { - - // Go-style read/write lock — see Model for the rationale. Each FFI call - // (listModels/model) holds the read lock for its whole duration; close() - // takes the write lock and waits for in-flight calls before dropping the - // handle, preventing a use-after-free. - private val lock = ReentrantReadWriteLock(true) - private var handle: Long = handle - private var closed: Boolean = false - - /** - * Release the native handle. Idempotent and thread-safe: subsequent calls - * are no-ops. Acquires the write lock and blocks until in-flight - * [listModels] / [model] calls finish before dropping the native handle - * (prevents use-after-free). - */ - override fun close() { - lock.writeLock().lock() - try { - if (closed || handle == 0L) { - return - } - val h = handle - handle = 0L - closed = true - FFI.lib.aimux_drop_handle(h) - } finally { - lock.writeLock().unlock() - } - } - - protected fun finalize() = close() - - // Caller MUST already hold the read lock; held across the FFI call so - // close() cannot drop the handle mid-call. - private fun requireHandleLocked(): Long { - if (closed || handle == 0L) throw IllegalStateException("ProviderHandle is closed") - return handle - } - - /** - * List models available on this provider (runtime discovery + anya2a spec). - * Returns a JSON array of ResolvedModel. - */ - fun listModels(): String { - lock.readLock().lock() - try { - val h = requireHandleLocked() - return stringResult { out -> - FFI.lib.aimux_provider_list_models(h, out) - } - } finally { - lock.readLock().unlock() - } - } - - /** Build a decision model with the provider's connection configuration. */ - fun decisionModel(modelId: String): DecisionModel { - lock.readLock().lock() - try { - val h = requireHandleLocked() - return DecisionModel(handleResult("decision model") { out -> - FFI.lib.aimux_provider_decision_model(h, modelId, out) - }) - } finally { - lock.readLock().unlock() - } - } - - /** Build a language model from a discovered model id. */ - fun model(modelId: String): Model { - lock.readLock().lock() - try { - val h = requireHandleLocked() - val newHandle = handleResult { out -> - FFI.lib.aimux_provider_model(h, modelId, out) - } - return Model(newHandle) - } finally { - lock.readLock().unlock() - } - } -} - -/** - * Fetch the community model catalogue (anya2a). Returns a JSON-serialized - * Catalogue string. Thin fetch — no caching. - * - * @param sourceUrl Optional URL override (null = default endpoint). - */ -fun getModelSpecs(sourceUrl: String? = null): String = - stringResult { out -> - FFI.lib.aimux_get_model_specs(sourceUrl, out) - } diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Multimodal.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Multimodal.kt deleted file mode 100644 index 940d2dce3..000000000 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Multimodal.kt +++ /dev/null @@ -1,631 +0,0 @@ -/** - * aimux — multimodal model wrappers for Kotlin/JVM (Rust core via the C ABI). - * - * Each modality (embedding / speech / image / transcription / files / - * reranking / video / search) is a [Closeable] class wrapping a native handle, - * exactly like [Model]. The handle is acquired via a provider-specific factory - * (companion object) and released via [close]. All C ABI data uses - * JSON strings (base64 for binary), matching the C ABI wire format. - * - * Every fallible C call returns an `aimux_error_t *` (null = success) that - * [expectAimuxError] decodes codes 1..20 as [AimuxException] and 200..206 as - * [IllegalStateException] `"aimux ffi: …"` (malformed raw JSON is - * caught before the C call by [requireJson] as [IllegalArgumentException]). - * No JSON envelope on the primary path. - * - * Implements [Closeable] — you MUST call [close] (or use `use {}`) to release - * the native handle and avoid memory leaks. - * - * ```kotlin - * EmbeddingModel.openai("sk-...", "text-embedding-3-small").use { model -> - * val result = model.embed("[\"hello\"]") - * } - * ``` - * - * Reference implementation: `bindings/go/multimodal.go`. - */ - -package ai.arcships.aimux - -import com.sun.jna.ptr.IntByReference -import com.sun.jna.ptr.PointerByReference -import java.io.Closeable -import java.util.concurrent.atomic.AtomicLong - -// ───────────────────────────────────────────────────────────────────────────── -// EmbeddingModel -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Generates vector embeddings for text. Wraps a Rust `Arc`. - * - * Implements [Closeable] — call [close] (or `use {}`) to release the handle. - * - * ```kotlin - * EmbeddingModel.openai("sk-...", "text-embedding-3-small").use { model -> - * val result = model.embed("[\"hello\",\"world\"]") - * } - * ``` - */ -class EmbeddingModel private constructor(handle: Long) : Closeable { - private val handle = AtomicLong(handle) - - override fun close() { - val h = handle.getAndSet(0L) - if (h != 0L) { - FFI.lib.aimux_drop_handle(h) - } - } - - private fun requireHandle(): Long = handle.get().also { - if (it == 0L) throw IllegalStateException("EmbeddingModel is closed") - } - - protected fun finalize() { - close() - } - - companion object { - /** Create an OpenAI embedding model (e.g. `text-embedding-3-small`). */ - fun openai(apiKey: String, modelId: String): EmbeddingModel = - EmbeddingModel(handleResult { out -> - FFI.lib.aimux_openai_embedding_new(apiKey, modelId, out) - }) - - /** Create an OpenAI embedding model with a custom base URL. */ - fun openai(apiKey: String, modelId: String, baseUrl: String): EmbeddingModel = - EmbeddingModel(handleResult { out -> - FFI.lib.aimux_openai_embedding_new_with_base(apiKey, modelId, baseUrl, out) - }) - - /** Create a Cohere embedding model (e.g. `embed-english-v3.0`). */ - fun cohere(apiKey: String, modelId: String): EmbeddingModel = - EmbeddingModel(handleResult { out -> - FFI.lib.aimux_cohere_embedding_new(apiKey, modelId, out) - }) - - /** Create a Cohere embedding model with a custom base URL. */ - fun cohere(apiKey: String, modelId: String, baseUrl: String): EmbeddingModel = - EmbeddingModel(handleResult { out -> - FFI.lib.aimux_cohere_embedding_new_with_base(apiKey, modelId, baseUrl, out) - }) - - /** Create a Google embedding model (e.g. `gemini-embedding-001`). */ - fun google(apiKey: String, modelId: String): EmbeddingModel = - EmbeddingModel(handleResult { out -> - FFI.lib.aimux_google_embedding_new(apiKey, modelId, out) - }) - - /** Create a Google embedding model with a custom base URL. */ - fun google(apiKey: String, modelId: String, baseUrl: String): EmbeddingModel = - EmbeddingModel(handleResult { out -> - FFI.lib.aimux_google_embedding_new_with_base(apiKey, modelId, baseUrl, out) - }) - } - - /** - * Generate embeddings for the given text values. - * - * @param valuesJson JSON array of strings to embed (e.g. `["a","b"]`). - * @param optsJson Optional JSON-serialized `EmbeddingCallOptions`. - * @return JSON-serialized `EmbeddingResult`. - * @throws AimuxException on AiMuxError failure. - * @throws IllegalArgumentException when a raw JSON argument is malformed; IllegalStateException after [close]. - */ - fun embed(valuesJson: String, optsJson: String? = null): String { - requireJsonRequired("valuesJson", valuesJson) - requireJson("optsJson", optsJson) - return stringResult { out -> FFI.lib.aimux_embed(requireHandle(), valuesJson, optsJson, out) } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// SpeechModel (TTS) -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Converts text to speech audio. Wraps a Rust `Arc`. - * - * Implements [Closeable] — call [close] (or `use {}`) to release the handle. - */ -class SpeechModel private constructor(handle: Long) : Closeable { - private val handle = AtomicLong(handle) - - override fun close() { - val h = handle.getAndSet(0L) - if (h != 0L) { - FFI.lib.aimux_drop_handle(h) - } - } - - private fun requireHandle(): Long = handle.get().also { - if (it == 0L) throw IllegalStateException("SpeechModel is closed") - } - - protected fun finalize() { - close() - } - - companion object { - /** Create an OpenAI speech (TTS) model. */ - fun openai(apiKey: String, modelId: String): SpeechModel = - SpeechModel(handleResult { out -> - FFI.lib.aimux_openai_speech_new(apiKey, modelId, out) - }) - - /** Create an OpenAI speech model with a custom base URL. */ - fun openai(apiKey: String, modelId: String, baseUrl: String): SpeechModel = - SpeechModel(handleResult { out -> - FFI.lib.aimux_openai_speech_new_with_base(apiKey, modelId, baseUrl, out) - }) - } - - /** - * Generate speech audio from the given options. - * - * @param optsJson JSON-serialized `SpeechCallOptions`. - * @return JSON-serialized `SpeechResult`. - * @throws AimuxException on AiMuxError failure. - * @throws IllegalArgumentException when a raw JSON argument is malformed; IllegalStateException after [close]. - */ - fun generate(optsJson: String): String { - requireJsonRequired("optsJson", optsJson) - return stringResult { out -> FFI.lib.aimux_speech_generate(requireHandle(), optsJson, out) } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// TranscriptionModel (STT) -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Converts audio to text. Wraps a Rust `Arc`. - * - * Implements [Closeable] — call [close] (or `use {}`) to release the handle. - */ -class TranscriptionModel private constructor(handle: Long) : Closeable { - private val handle = AtomicLong(handle) - - override fun close() { - val h = handle.getAndSet(0L) - if (h != 0L) { - FFI.lib.aimux_drop_handle(h) - } - } - - private fun requireHandle(): Long = handle.get().also { - if (it == 0L) throw IllegalStateException("TranscriptionModel is closed") - } - - protected fun finalize() { - close() - } - - companion object { - /** Create an OpenAI transcription (STT) model. */ - fun openai(apiKey: String, modelId: String): TranscriptionModel = - TranscriptionModel(handleResult { out -> - FFI.lib.aimux_openai_transcription_new(apiKey, modelId, out) - }) - - /** Create an OpenAI transcription model with a custom base URL. */ - fun openai(apiKey: String, modelId: String, baseUrl: String): TranscriptionModel = - TranscriptionModel(handleResult { out -> - FFI.lib.aimux_openai_transcription_new_with_base(apiKey, modelId, baseUrl, out) - }) - } - - /** - * Transcribe audio (base64-encoded) to text. - * - * @param audioBase64 Base64-encoded audio bytes. - * @param mediaType Media type of the audio (e.g. `audio/wav`). - * @param optsJson Optional JSON-serialized `TranscriptionCallOptions`. - * @return JSON-serialized `TranscriptionResult`. - * @throws AimuxException on AiMuxError failure. - * @throws IllegalArgumentException when a raw JSON argument is malformed; IllegalStateException after [close]. - */ - fun generate(audioBase64: String, mediaType: String, optsJson: String? = null): String { - requireJson("optsJson", optsJson) - return stringResult { out -> FFI.lib.aimux_transcription_generate(requireHandle(), audioBase64, mediaType, optsJson, out) } - } - - /** - * Start a streaming transcription session (RFC-0028) on this model. - * Requires a model that supports streaming (realtime models). - * - * @param optsJson optional session options JSON (`inputAudioFormat` / - * `providerOptions` / `headers` / `includeRawChunks`). - * @param abortHandle abort handle (from `Model.abortSignalNew()`), or 0. - * @return a new live session. - */ - fun startStream(optsJson: String? = null, abortHandle: Long = 0L): TranscriptionSession { - requireJson("optsJson", optsJson) - val h = requireHandle() - return TranscriptionSession( - handleResult { out -> - FFI.lib.aimux_transcription_session_new(h, abortHandle, optsJson, out) - } - ) - } -} - -/** - * A live streaming-transcription session (RFC-0028): push audio chunks with - * [pushAudio], mark end-of-audio with [inputDone], then pull transcription - * parts (JSON `TranscriptionStreamPart`) with [nextPart]. [close] releases - * the session (aborts the driver; idempotent). - */ -// Internal constructor: sessions are created via TranscriptionModel.startStream. -class TranscriptionSession internal constructor(handle: Long) : Closeable { - private val handle = AtomicLong(handle) - - override fun close() { - val h = handle.getAndSet(0L) - if (h != 0L) { - FFI.lib.aimux_transcription_session_drop(h) - } - } - - private fun requireHandle(): Long = handle.get().also { - if (it == 0L) throw IllegalStateException("TranscriptionSession is closed") - } - - protected fun finalize() { - close() - } - - /** - * Push one binary audio chunk. **Blocks** while the internal channel is - * full (backpressure propagation). - */ - fun pushAudio(audio: ByteArray) { - val h = requireHandle() - FFI.lib.aimux_transcription_push_audio(h, audio, audio.size.toLong()) - ?.let { throw expectAimuxError(it, "pushAudio") } - } - - /** Signal end-of-audio (idempotent). */ - fun inputDone() { - val h = requireHandle() - FFI.lib.aimux_transcription_input_done(h)?.let { throw expectFfiError(it, "inputDone") } - } - - /** - * Pull the next transcription part (JSON string). - * - * @param timeoutMs wait bound: >0 wait at most; 0 immediate poll; <0 wait - * indefinitely. - * @return the part JSON. - * @throws AimuxTranscriptionEndedException the stream finished normally. - * @throws AimuxTranscriptionTimeoutException no part arrived in time — - * **retryable**: the session stays live, call again - * (RFC-0028: state TIMEOUT, not an error). This is NOT an - * [AimuxException] / [TimeoutError]; catch the sentinel - * explicitly. - * @throws AimuxException the stream failed. - * @throws IllegalStateException the session is closed. - */ - fun nextPart(timeoutMs: Long): String { - val h = requireHandle() - val outPart = PointerByReference() - val outState = IntByReference() - FFI.lib.aimux_transcription_next_part(h, timeoutMs, outPart, outState) - ?.let { throw expectAimuxError(it, "nextPart") } - return when (val state = outState.value) { - TRANSCRIPTION_NEXT_PART_PART -> takeString(outPart.value) - ?: throw IllegalStateException("nextPart: aimux ffi: PART state with NULL part") - TRANSCRIPTION_NEXT_PART_ENDED -> throw AimuxTranscriptionEndedException() - // Not an error: nothing to decode, the session stays live. - TRANSCRIPTION_NEXT_PART_TIMEOUT -> throw AimuxTranscriptionTimeoutException() - else -> throw IllegalStateException("nextPart: aimux ffi: unknown next_part state $state") - } - } - - /** The transcription stream ended normally. */ - class AimuxTranscriptionEndedException : - RuntimeException("transcription stream ended") - - /** - * No transcription part arrived within the timeout — **retryable**: the - * session stays live, call [nextPart] again (RFC-0028). Deliberately not - * an [AimuxException]: a timeout is not a stream failure, so generic - * AimuxError handling must not swallow it — same shape as the Go / Java - * / Swift / Flutter sentinels. - */ - class AimuxTranscriptionTimeoutException : - RuntimeException("transcription part timeout") -} - -// ───────────────────────────────────────────────────────────────────────────── -// ImageModel -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Generates images from prompts. Wraps a Rust `Arc`. - * - * Implements [Closeable] — call [close] (or `use {}`) to release the handle. - */ -class ImageModel private constructor(handle: Long) : Closeable { - private val handle = AtomicLong(handle) - - override fun close() { - val h = handle.getAndSet(0L) - if (h != 0L) { - FFI.lib.aimux_drop_handle(h) - } - } - - private fun requireHandle(): Long = handle.get().also { - if (it == 0L) throw IllegalStateException("ImageModel is closed") - } - - protected fun finalize() { - close() - } - - companion object { - /** Create an OpenAI image model (e.g. `dall-e-3`). */ - fun openai(apiKey: String, modelId: String): ImageModel = - ImageModel(handleResult { out -> - FFI.lib.aimux_openai_image_new(apiKey, modelId, out) - }) - - /** Create an OpenAI image model with a custom base URL. */ - fun openai(apiKey: String, modelId: String, baseUrl: String): ImageModel = - ImageModel(handleResult { out -> - FFI.lib.aimux_openai_image_new_with_base(apiKey, modelId, baseUrl, out) - }) - - /** Create a Google image model (e.g. `gemini-2.5-flash-image`). */ - fun google(apiKey: String, modelId: String): ImageModel = - ImageModel(handleResult { out -> - FFI.lib.aimux_google_image_new(apiKey, modelId, out) - }) - - /** Create a Google image model with a custom base URL. */ - fun google(apiKey: String, modelId: String, baseUrl: String): ImageModel = - ImageModel(handleResult { out -> - FFI.lib.aimux_google_image_new_with_base(apiKey, modelId, baseUrl, out) - }) - } - - /** - * Generate images from the given options. - * - * @param optsJson JSON-serialized `ImageCallOptions`. - * @return JSON-serialized `ImageResult`. - * @throws AimuxException on AiMuxError failure. - * @throws IllegalArgumentException when a raw JSON argument is malformed; IllegalStateException after [close]. - */ - fun generate(optsJson: String): String { - requireJsonRequired("optsJson", optsJson) - return stringResult { out -> FFI.lib.aimux_image_generate(requireHandle(), optsJson, out) } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// VideoModel -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Generates videos from prompts. Wraps a Rust `Arc`. - * - * Implements [Closeable] — call [close] (or `use {}`) to release the handle. - */ -class VideoModel private constructor(handle: Long) : Closeable { - private val handle = AtomicLong(handle) - - override fun close() { - val h = handle.getAndSet(0L) - if (h != 0L) { - FFI.lib.aimux_drop_handle(h) - } - } - - private fun requireHandle(): Long = handle.get().also { - if (it == 0L) throw IllegalStateException("VideoModel is closed") - } - - protected fun finalize() { - close() - } - - companion object { - /** Create a Google video model (e.g. `veo-3.0`). */ - fun google(apiKey: String, modelId: String): VideoModel = - VideoModel(handleResult { out -> - FFI.lib.aimux_google_video_new(apiKey, modelId, out) - }) - - /** Create a Google video model with a custom base URL. */ - fun google(apiKey: String, modelId: String, baseUrl: String): VideoModel = - VideoModel(handleResult { out -> - FFI.lib.aimux_google_video_new_with_base(apiKey, modelId, baseUrl, out) - }) - } - - /** - * Generate videos from the given options. - * - * @param optsJson JSON-serialized `VideoCallOptions`. - * @return JSON-serialized `VideoResult`. - * @throws AimuxException on AiMuxError failure. - * @throws IllegalArgumentException when a raw JSON argument is malformed; IllegalStateException after [close]. - */ - fun generate(optsJson: String): String { - requireJsonRequired("optsJson", optsJson) - return stringResult { out -> FFI.lib.aimux_video_generate(requireHandle(), optsJson, out) } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// RerankingModel -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Reranks documents by relevance to a query. Wraps a Rust `Arc`. - * - * Implements [Closeable] — call [close] (or `use {}`) to release the handle. - */ -class RerankingModel private constructor(handle: Long) : Closeable { - private val handle = AtomicLong(handle) - - override fun close() { - val h = handle.getAndSet(0L) - if (h != 0L) { - FFI.lib.aimux_drop_handle(h) - } - } - - private fun requireHandle(): Long = handle.get().also { - if (it == 0L) throw IllegalStateException("RerankingModel is closed") - } - - protected fun finalize() { - close() - } - - companion object { - /** Create a Cohere reranking model (e.g. `rerank-v3.0`). */ - fun cohere(apiKey: String, modelId: String): RerankingModel = - RerankingModel(handleResult { out -> - FFI.lib.aimux_cohere_reranking_new(apiKey, modelId, out) - }) - - /** Create a Cohere reranking model with a custom base URL. */ - fun cohere(apiKey: String, modelId: String, baseUrl: String): RerankingModel = - RerankingModel(handleResult { out -> - FFI.lib.aimux_cohere_reranking_new_with_base(apiKey, modelId, baseUrl, out) - }) - } - - /** - * Rerank documents against a query. - * - * @param optsJson JSON-serialized `RerankingCallOptions`. - * @return JSON-serialized `RerankingResult`. - * @throws AimuxException on AiMuxError failure. - * @throws IllegalArgumentException when a raw JSON argument is malformed; IllegalStateException after [close]. - */ - fun rerank(optsJson: String): String { - requireJsonRequired("optsJson", optsJson) - return stringResult { out -> FFI.lib.aimux_rerank(requireHandle(), optsJson, out) } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// SearchModel -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Performs web search. Wraps a Rust `Arc`. - * - * Implements [Closeable] — call [close] (or `use {}`) to release the handle. - */ -class SearchModel private constructor(handle: Long) : Closeable { - private val handle = AtomicLong(handle) - - override fun close() { - val h = handle.getAndSet(0L) - if (h != 0L) { - FFI.lib.aimux_drop_handle(h) - } - } - - private fun requireHandle(): Long = handle.get().also { - if (it == 0L) throw IllegalStateException("SearchModel is closed") - } - - protected fun finalize() { - close() - } - - companion object { - /** - * Create a Tavily search model. Tavily uses a fixed endpoint, so no - * model ID is needed (the C ABI still takes one, so an empty string is - * passed and ignored). - */ - fun tavily(apiKey: String): SearchModel = - SearchModel(handleResult { out -> - FFI.lib.aimux_tavily_search_new(apiKey, "", out) - }) - - /** Create a Tavily search model with a custom base URL (e.g. for mocks). */ - fun tavily(apiKey: String, baseUrl: String): SearchModel = - SearchModel(handleResult { out -> - FFI.lib.aimux_tavily_search_new_with_base(apiKey, "", baseUrl, out) - }) - } - - /** - * Perform a web search. - * - * @param optsJson JSON-serialized `SearchCallOptions`. - * @return JSON-serialized `SearchResult`. - * @throws AimuxException on AiMuxError failure. - * @throws IllegalArgumentException when a raw JSON argument is malformed; IllegalStateException after [close]. - */ - fun search(optsJson: String): String { - requireJsonRequired("optsJson", optsJson) - return stringResult { out -> FFI.lib.aimux_search(requireHandle(), optsJson, out) } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Files -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Manages file uploads to providers. Wraps a Rust `Arc`. - * - * Implements [Closeable] — call [close] (or `use {}`) to release the handle. - */ -class Files private constructor(handle: Long) : Closeable { - private val handle = AtomicLong(handle) - - override fun close() { - val h = handle.getAndSet(0L) - if (h != 0L) { - FFI.lib.aimux_drop_handle(h) - } - } - - private fun requireHandle(): Long = handle.get().also { - if (it == 0L) throw IllegalStateException("Files is closed") - } - - protected fun finalize() { - close() - } - - companion object { - /** Create an OpenAI files manager. */ - fun openai(apiKey: String): Files = - Files(handleResult { out -> - FFI.lib.aimux_openai_files_new(apiKey, out) - }) - - /** Create an OpenAI files manager with a custom base URL. */ - fun openai(apiKey: String, baseUrl: String): Files = - Files(handleResult { out -> - FFI.lib.aimux_openai_files_new_with_base(apiKey, baseUrl, out) - }) - } - - /** - * Upload a file (base64-encoded) to the provider. - * - * @param dataBase64 Base64-encoded file bytes. - * @param mediaType Media type of the file (e.g. `application/pdf`). - * @param optsJson Optional JSON-serialized `UploadFileCallOptions`. - * @return JSON-serialized `UploadFileResult`. - * @throws AimuxException on AiMuxError failure. - * @throws IllegalArgumentException when a raw JSON argument is malformed; IllegalStateException after [close]. - */ - fun uploadFile(dataBase64: String, mediaType: String, optsJson: String? = null): String { - requireJson("optsJson", optsJson) - return stringResult { out -> FFI.lib.aimux_file_upload(requireHandle(), dataBase64, mediaType, optsJson, out) } - } -} diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/MultimodalTypes.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/MultimodalTypes.kt deleted file mode 100644 index 3605dcfdc..000000000 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/MultimodalTypes.kt +++ /dev/null @@ -1,602 +0,0 @@ -/** - * aimux — typed multimodal data structures mirroring the aimux-core wire format. - * - * Same shapes as the ts-rs generated `.ts` types in `bindings/node/src/types/`: - * camelCase field names (Kotlin property names equal the wire names), unions - * tagged on `type`, optional fields absent rather than null. - * - * Decode is lenient (unknown keys ignored, every result field has a default) - * so future engine additions don't break existing clients. The serialization - * config lives in [AimuxJson]. Decode a JSON string returned by a - * [Multimodal][aimux] model with, for example: - * - * ```kotlin - * val result: EmbeddingResult = AimuxJson.decodeFromString(EmbeddingResult.serializer(), jsonStr) - * ``` - * - * [AudioData] and [ImageOutputs] are untagged unions on the wire (a string is - * base64, an array is binary), so each has a small custom serializer; the - * `Array | string` input fields reuse [FileBytes]. - */ - -package ai.arcships.aimux - -import kotlinx.serialization.EncodeDefault -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.SerializationException -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.descriptors.buildClassSerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonDecoder -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonEncoder -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive - -// ───────────────────────────────────────────────────────────────────────────── -// Shared types. -// ───────────────────────────────────────────────────────────────────────────── - -/** A pixel size. Mirrors `Size.ts`: serialized as the string `"WxH"` (e.g. `"1024x1024"`). */ -@Serializable(with = SizeSerializer::class) -data class Size(val width: Int, val height: Int) - -/** An aspect ratio. Mirrors `AspectRatio.ts`: serialized as the string `"W:H"` (e.g. `"16:9"`). */ -@Serializable(with = AspectRatioSerializer::class) -data class AspectRatio(val width: Int, val height: Int) - -private fun parsePair(kind: String, sep: Char, text: String): Pair { - val parts = text.split(sep) - val w = parts.getOrNull(0)?.toIntOrNull() - val h = parts.getOrNull(1)?.toIntOrNull() - if (parts.size != 2 || w == null || h == null) { - throw SerializationException("$kind must look like \"W${sep}H\", got: \"$text\"") - } - return w to h -} - -object SizeSerializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("aimux.Size", PrimitiveKind.STRING) - override fun deserialize(decoder: Decoder): Size = - parsePair("Size", 'x', decoder.decodeString()).let { Size(it.first, it.second) } - override fun serialize(encoder: Encoder, value: Size) = encoder.encodeString("${value.width}x${value.height}") -} - -object AspectRatioSerializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("aimux.AspectRatio", PrimitiveKind.STRING) - override fun deserialize(decoder: Decoder): AspectRatio = - parsePair("AspectRatio", ':', decoder.decodeString()).let { AspectRatio(it.first, it.second) } - override fun serialize(encoder: Encoder, value: AspectRatio) = encoder.encodeString("${value.width}:${value.height}") -} - -// ───────────────────────────────────────────────────────────────────────────── -// Embedding -// ───────────────────────────────────────────────────────────────────────────── - -/** Token usage for an embedding call (input tokens only). */ -@Serializable -data class EmbeddingUsage( - val tokens: Long? = null, -) - -/** Provider response metadata for embeddings. */ -@Serializable -data class EmbeddingResponse( - val headers: Map? = null, - @Serializable(with = JsonValuePresentSerializer::class) - val body: JsonElement? = null, -) - -/** Result of an embedding call. */ -@Serializable -data class EmbeddingResult( - val embeddings: List> = emptyList(), - val usage: EmbeddingUsage? = null, - val providerMetadata: ProviderMetadata? = null, - val response: EmbeddingResponse? = null, - val warnings: List = emptyList(), -) - -/** Options for an embedding call. */ -@Serializable -data class EmbeddingCallOptions( - val values: List = emptyList(), - val maxRetries: Long? = null, - val timeout: TimeoutConfiguration? = null, - val providerOptions: ProviderOptions? = null, - val headers: Map? = null, -) - -// ───────────────────────────────────────────────────────────────────────────── -// Speech (TTS) -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Generated audio: a base64 string or raw binary bytes. - * - * Wire format (`AudioData.ts`): a base64 string (`SpeechModelV4Result.audio`, - * `speech-model-v4-result.ts:16`). [Binary] is written as base64; parsing - * yields [Base64]. - */ -@Serializable(with = AudioDataSerializer::class) -sealed interface AudioData { - /** Base64-encoded audio. */ - data class Base64(val value: String) : AudioData - - /** Raw binary audio bytes (each element is a 0–255 byte value). */ - data class Binary(val value: List) : AudioData -} - -/** Request metadata for speech generation. */ -@Serializable -data class SpeechRequest( - @Serializable(with = JsonValuePresentSerializer::class) - val body: JsonElement? = null, -) - -/** Provider response metadata for speech. */ -@Serializable -data class SpeechResponse( - val timestamp: String? = null, - val modelId: String? = null, - val headers: Map? = null, - @Serializable(with = JsonValuePresentSerializer::class) - val body: JsonElement? = null, -) - -/** Result of a speech generation call. */ -@Serializable -data class SpeechResult( - val audio: AudioData = AudioData.Base64(""), - val warnings: List = emptyList(), - val request: SpeechRequest? = null, - val response: SpeechResponse = SpeechResponse(), - val providerMetadata: ProviderMetadata? = null, -) - -/** Options for speech generation. */ -@Serializable -data class SpeechCallOptions( - val text: String = "", - val voice: String? = null, - val outputFormat: String? = null, - val instructions: String? = null, - val speed: Double? = null, - val language: String? = null, - val maxRetries: Long? = null, - val timeout: TimeoutConfiguration? = null, - val providerOptions: ProviderOptions? = null, - val headers: Map? = null, -) - -// ───────────────────────────────────────────────────────────────────────────── -// Image -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Generated images: all base64 strings or all binary byte arrays. - * - * Wire format (`ImageOutputs.ts`): base64 strings `["...", ...]` - * (`image-model-v4-result.ts:23`). [Binary] is written as base64; parsing - * yields [Base64]. - */ -@Serializable(with = ImageOutputsSerializer::class) -sealed interface ImageOutputs { - /** Base64-encoded images. */ - data class Base64(val value: List) : ImageOutputs - - /** Raw binary images (each element is a list of 0–255 byte values). */ - data class Binary(val value: List>) : ImageOutputs -} - -/** Token usage for image generation (if reported). */ -@Serializable -data class ImageUsage( - val inputTokens: Long? = null, - val outputTokens: Long? = null, - val totalTokens: Long? = null, -) - -/** Provider response metadata for images. */ -@Serializable -data class ImageResponse( - val timestamp: String? = null, - val modelId: String? = null, - val headers: Map? = null, -) - -/** Result of an image generation call. */ -@Serializable -data class ImageResult( - val images: ImageOutputs = ImageOutputs.Base64(emptyList()), - val warnings: List = emptyList(), - val providerMetadata: ProviderMetadata? = null, - val response: ImageResponse = ImageResponse(), - val usage: ImageUsage? = null, -) - -/** An input image (edit source or mask), tagged on `type`. Mirrors `ImageFile.ts`. */ -@Serializable -sealed interface ImageFile { - @Serializable - @SerialName("file") - data class File(val mediaType: String, val data: FileBytes) : ImageFile - - @Serializable - @SerialName("url") - data class Url(val url: String) : ImageFile -} - -/** - * Options for image generation. `n` and `providerOptions` are required on the - * wire, so they are always encoded (defaults: 1 image, no options). - */ -@OptIn(ExperimentalSerializationApi::class) -@Serializable -data class ImageCallOptions( - val prompt: String? = null, - @EncodeDefault val n: Int = 1, - val size: Size? = null, - val aspectRatio: AspectRatio? = null, - val seed: Long? = null, - val files: List? = null, - val mask: ImageFile? = null, - @EncodeDefault val providerOptions: ProviderOptions = emptyMap(), - val maxRetries: Long? = null, - val timeout: TimeoutConfiguration? = null, - val headers: Map? = null, -) - -// ───────────────────────────────────────────────────────────────────────────── -// Transcription (STT) -// ───────────────────────────────────────────────────────────────────────────── - -/** A transcript segment with timing. */ -@Serializable -data class TranscriptionSegment( - val text: String = "", - val startSecond: Double = 0.0, - val endSecond: Double = 0.0, -) - -/** Request metadata for transcription. */ -@Serializable -data class TranscriptionRequest( - val body: String? = null, -) - -/** Provider response metadata for transcription. */ -@Serializable -data class TranscriptionResponse( - val timestamp: String? = null, - val modelId: String? = null, - val headers: Map? = null, - @Serializable(with = JsonValuePresentSerializer::class) - val body: JsonElement? = null, -) - -/** Result of a transcription call. */ -@Serializable -data class TranscriptionResult( - val text: String = "", - val segments: List = emptyList(), - val language: String? = null, - val durationInSeconds: Double? = null, - val warnings: List = emptyList(), - val request: TranscriptionRequest? = null, - val response: TranscriptionResponse = TranscriptionResponse(), - val providerMetadata: ProviderMetadata? = null, -) - -/** Options for transcription. `audio` is raw bytes or a base64 string. */ -@Serializable -data class TranscriptionCallOptions( - val audio: FileBytes = FileBytes.Base64(""), - val mediaType: String = "", - val maxRetries: Long? = null, - val timeout: TimeoutConfiguration? = null, - val providerOptions: ProviderOptions? = null, - val headers: Map? = null, -) - -// ───────────────────────────────────────────────────────────────────────────── -// Reranking -// ───────────────────────────────────────────────────────────────────────────── - -/** A single reranked entry. */ -@Serializable -data class RerankingRank( - val index: Int = 0, - val relevanceScore: Double = 0.0, -) - -/** Provider response metadata for reranking. */ -@Serializable -data class RerankingResponse( - val id: String? = null, - val timestamp: String? = null, - val modelId: String? = null, - val headers: Map? = null, - @Serializable(with = JsonValuePresentSerializer::class) - val body: JsonElement? = null, -) - -/** Result of a reranking call. */ -@Serializable -data class RerankingResult( - val ranking: List = emptyList(), - val providerMetadata: ProviderMetadata? = null, - val warnings: List? = null, - val response: RerankingResponse? = null, -) - -/** The documents to rerank, tagged on `type`. Mirrors `RerankingDocuments.ts`. */ -@Serializable -sealed interface RerankingDocuments { - @Serializable - @SerialName("text") - data class Text(val values: List) : RerankingDocuments - - @Serializable - @SerialName("object") - data class Objects(val values: List) : RerankingDocuments -} - -/** Options for reranking. */ -@Serializable -data class RerankingCallOptions( - val documents: RerankingDocuments, - val query: String = "", - val topN: Int? = null, - val maxRetries: Long? = null, - val timeout: TimeoutConfiguration? = null, - val providerOptions: ProviderOptions? = null, - val headers: Map? = null, -) - -// ───────────────────────────────────────────────────────────────────────────── -// Video -// ───────────────────────────────────────────────────────────────────────────── - -/** Generated video, tagged on `type`. Mirrors `VideoData.ts`. */ -@Serializable -sealed interface VideoData { - val mediaType: String - - /** A URL pointing at the generated video. */ - @Serializable - @SerialName("url") - data class Url(val url: String, override val mediaType: String) : VideoData - - /** Base64-encoded video. */ - @Serializable - @SerialName("base64") - data class Base64(val data: String, override val mediaType: String) : VideoData - - /** - * Raw binary video bytes (each element is a 0–255 byte value), a base64 - * string on the wire (`video-model-v4-result.ts:28-29`). - */ - @Serializable - @SerialName("binary") - data class Binary( - @Serializable(with = Base64ByteListSerializer::class) val data: List, - override val mediaType: String, - ) : VideoData -} - -/** An input video/image, tagged on `type`. Mirrors `VideoFile.ts`. */ -@Serializable -sealed interface VideoFile { - @Serializable - @SerialName("file") - data class File(val mediaType: String, val data: FileBytes) : VideoFile - - @Serializable - @SerialName("url") - data class Url(val url: String, val mediaType: String? = null) : VideoFile -} - -@Serializable -enum class VideoFrameType { - @SerialName("first_frame") FIRST_FRAME, - @SerialName("last_frame") LAST_FRAME, -} - -/** An image pinned to a frame of the video. Mirrors `VideoFrameImage.ts`. */ -@Serializable -data class VideoFrameImage(val image: VideoFile, val frameType: VideoFrameType) - -/** Provider response metadata for video. */ -@Serializable -data class VideoResponse( - val timestamp: String? = null, - val modelId: String? = null, - val headers: Map? = null, -) - -/** Result of a video generation call. */ -@Serializable -data class VideoResult( - val videos: List = emptyList(), - val warnings: List = emptyList(), - val providerMetadata: ProviderMetadata? = null, - val response: VideoResponse = VideoResponse(), -) - -/** Per-call pacing overrides for the Core-owned video status poll loop. */ -@Serializable -data class VideoPollOptions( - val intervalMs: Long? = null, - val timeoutMs: Long? = null, -) - -/** Options for video generation. `n` and `providerOptions` are always encoded. */ -@OptIn(ExperimentalSerializationApi::class) -@Serializable -data class VideoCallOptions( - val prompt: String? = null, - @EncodeDefault val n: Int = 1, - val aspectRatio: AspectRatio? = null, - val resolution: Size? = null, - val duration: Double? = null, - val fps: Double? = null, - val seed: Long? = null, - val image: VideoFile? = null, - val frameImages: List? = null, - val inputReferences: List? = null, - val generateAudio: Boolean? = null, - @EncodeDefault val providerOptions: ProviderOptions = emptyMap(), - val maxRetries: Long? = null, - val poll: VideoPollOptions? = null, - val timeout: TimeoutConfiguration? = null, - val headers: Map? = null, -) - -// ───────────────────────────────────────────────────────────────────────────── -// Search -// ───────────────────────────────────────────────────────────────────────────── - -/** A single search result. */ -@Serializable -data class SearchResultItem( - val title: String? = null, - val url: String? = null, - val content: String? = null, - val rawContent: String? = null, - val score: Double? = null, - val providerMetadata: ProviderMetadata? = null, -) - -/** Provider response metadata for search. */ -@Serializable -data class SearchResponse( - val headers: Map? = null, - @Serializable(with = JsonValuePresentSerializer::class) - val body: JsonElement? = null, -) - -/** Result of a search call. */ -@Serializable -data class SearchResult( - val results: List = emptyList(), - val answer: String? = null, - val providerMetadata: ProviderMetadata? = null, - val warnings: List = emptyList(), - val response: SearchResponse? = null, -) - -/** Options for a search call. */ -@Serializable -data class SearchCallOptions( - val query: String = "", - val maxResults: Int? = null, - val includeRawContent: Boolean? = null, - val timeRange: String? = null, - val includeDomains: List? = null, - val excludeDomains: List? = null, - val maxRetries: Long? = null, - val timeout: TimeoutConfiguration? = null, - val providerOptions: ProviderOptions? = null, - val headers: Map? = null, -) - -// ───────────────────────────────────────────────────────────────────────────── -// Files -// ───────────────────────────────────────────────────────────────────────────── - -/** Result of a file upload. */ -@Serializable -data class UploadFileResult( - val providerReference: Map = emptyMap(), - val mediaType: String? = null, - val filename: String? = null, - val providerMetadata: ProviderMetadata? = null, - val warnings: List = emptyList(), -) - -/** Upload payload, tagged on `type`. Mirrors `UploadFileData.ts`. */ -@Serializable -sealed interface UploadFileData { - @Serializable - @SerialName("data") - data class Data(val data: FileBytes) : UploadFileData - - @Serializable - @SerialName("text") - data class Text(val text: String) : UploadFileData -} - -/** Options for a file upload. */ -@Serializable -data class UploadFileCallOptions( - val data: UploadFileData = UploadFileData.Data(FileBytes.Base64("")), - val mediaType: String = "", - val filename: String? = null, - val providerOptions: ProviderOptions? = null, -) - -// ───────────────────────────────────────────────────────────────────────────── -// Custom serializers for the byte unions (base64 strings on the wire). -// ───────────────────────────────────────────────────────────────────────────── - -/** (De)serializer for [AudioData]: a base64 string. */ -object AudioDataSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("aimux.AudioData") - - override fun deserialize(decoder: Decoder): AudioData { - val json = decoder as? JsonDecoder - ?: throw SerializationException("AudioData can only be decoded from JSON") - val el = json.decodeJsonElement() - val text = (el as? JsonPrimitive)?.takeIf { it.isString }?.content - ?: throw SerializationException("AudioData must be a base64 string, got: $el") - return AudioData.Base64(text) - } - - override fun serialize(encoder: Encoder, value: AudioData) { - val json = encoder as? JsonEncoder - ?: throw SerializationException("AudioData can only be encoded to JSON") - json.encodeJsonElement( - when (value) { - is AudioData.Base64 -> JsonPrimitive(value.value) - is AudioData.Binary -> JsonPrimitive(Base64Bytes.encode(value.value)) - } - ) - } -} - -/** (De)serializer for [ImageOutputs]: `["...",...]` (base64 strings). */ -object ImageOutputsSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("aimux.ImageOutputs") - - override fun deserialize(decoder: Decoder): ImageOutputs { - val json = decoder as? JsonDecoder - ?: throw SerializationException("ImageOutputs can only be decoded from JSON") - val items = json.decodeJsonElement().jsonArray - return ImageOutputs.Base64( - items.map { - (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content - ?: throw SerializationException("ImageOutputs must be base64 strings, got: $it") - }, - ) - } - - override fun serialize(encoder: Encoder, value: ImageOutputs) { - val json = encoder as? JsonEncoder - ?: throw SerializationException("ImageOutputs can only be encoded to JSON") - json.encodeJsonElement( - when (value) { - is ImageOutputs.Base64 -> JsonArray(value.value.map { JsonPrimitive(it) }) - is ImageOutputs.Binary -> JsonArray(value.value.map { JsonPrimitive(Base64Bytes.encode(it)) }) - } - ) - } -} diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Recording.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Recording.kt deleted file mode 100644 index 8b424684f..000000000 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Recording.kt +++ /dev/null @@ -1,63 +0,0 @@ -package ai.arcships.aimux - -/** - * Recording + mock replay (RFC-0023). - * - * Recording is **opt-in** and global: nothing is recorded until - * [initRecording] or [initRecordingRing] is called. Calling one of the init - * functions again replaces the global recorder. - */ - -/** - * Start recording: the complete `Recording` JSONL is written to - * `{dir}/recordings.jsonl` (dir is auto-created). - * - * @throws IllegalArgumentException if `dir` is empty. - * @throws RecordingException (code `INIT` — dir could not be created, - * `OPEN_FILE` or `SPAWN`) if the recorder could not be constructed. On - * failure the previous recorder (if any) stays in place. - */ -fun initRecording(dir: String) { - if (dir.isEmpty()) throw IllegalArgumentException("initRecording: dir must not be empty") - FFI.lib.aimux_init_recording(dir)?.let { throw expectRecordingError(it, "initRecording") } -} - -/** - * Start in-memory bounded recording (`RingRecorder`, FIFO eviction). - * - * @param cap Maximum number of entries held in memory; `null` (default) uses - * the library default capacity (FFI `aimux_init_recording_ring_default`). - * @throws IllegalArgumentException if `cap <= 0` (checked before the C call so a - * negative value is never reinterpreted as a huge `uint64_t`). - */ -fun initRecordingRing(cap: Int? = null) { - if (cap == null) { - FFI.lib.aimux_init_recording_ring_default() - return - } - if (cap <= 0) throw IllegalArgumentException("initRecordingRing: cap must be > 0") - FFI.lib.aimux_init_recording_ring(cap.toLong())?.let { throw expectAimuxError(it, "initRecordingRing") } -} - -/** Stop recording: the global recorder becomes None (new calls are unrecorded). */ -fun recordingStop() { - FFI.lib.aimux_recording_stop() -} - -/** - * Flush the global recorder (blocks until the JSONL is on disk; no-op for the - * ring recorder). - */ -fun recordingFlush() { - FFI.lib.aimux_recording_flush() -} - -/** - * Checked flush: like [recordingFlush] but reports failures. Throws - * [RecordingException] (code `WRITE`, `WRITER_GONE` or `FLUSH_TIMEOUT`) — not - * an [AimuxException]. Returns normally when nothing is recording. The legacy - * [recordingFlush] stays and never reports. - */ -fun recordingTryFlush() { - FFI.lib.aimux_recording_try_flush()?.let { throw expectRecordingError(it, "recordingTryFlush") } -} diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/ToolCallRepair.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/ToolCallRepair.kt deleted file mode 100644 index 476a2a245..000000000 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/ToolCallRepair.kt +++ /dev/null @@ -1,282 +0,0 @@ -/** - * aimux — host-side tool-call repair (RFC-0035). - * - * Core is single-call: an unparseable tool call never fails generation, it - * comes back as `ToolCall(invalid = true, error = …)`. Repair therefore happens - * here, after the call returns: [TypedModel] finds the invalid calls, runs the - * caller's [RepairToolCall] on the JVM, and hands the answer back to three pure - * native functions that re-validate and patch. Nothing but JSON crosses the C - * ABI — no callbacks, no handles, no sessions — so a repair function is free to - * call back into aimux (typically: ask a model to fix the arguments). - */ - -package ai.arcships.aimux - -import kotlinx.serialization.EncodeDefault -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.put -import java.util.concurrent.ExecutionException -import java.util.concurrent.FutureTask - -// ───────────────────────────────────────────────────────────────────────────── -// Types -// ───────────────────────────────────────────────────────────────────────────── - -/** - * A provider-shaped tool call, as the model emitted it. - * - * Mirrors core `RawToolCall`. Unlike [ToolCall], `input` is the **raw argument - * text** (the string the provider sent), not a parsed object — that is what a - * repair function reads and what it returns a replacement for. Its JSON - * carries `"type": "tool-call"`, as the AI SDK's `LanguageModelV4ToolCall`. - */ -@OptIn(ExperimentalSerializationApi::class) -@Serializable -data class RawToolCall( - val toolCallId: String, - val toolName: String, - val input: String, - val providerExecuted: Boolean? = null, - val dynamic: Boolean? = null, - val providerMetadata: ProviderMetadata? = null, -) { - /** Always `"tool-call"`. */ - @EncodeDefault - val type: String = "tool-call" - - init { - requireTypeTag("tool-call", type) - } -} - -/** - * Everything a repair function is given about one invalid tool call. - * - * Mirrors the AI SDK `repairToolCall` argument. [error] is the serialized - * `AiMuxError` that made the call invalid (e.g. `{"name":"AI_InvalidToolInputError",…}`), - * carried verbatim from the call rather than re-derived. [inputSchema] is the - * JSON Schema of the named function tool, or the AI SDK's empty-object schema - * when the name resolves to no function tool. [messages] and [instructions] - * are the prompt the call was generated from. - */ -@Serializable -data class ToolCallRepairContext( - val toolCall: RawToolCall, - val error: JsonElement, - val inputSchema: JsonElement, - val tools: List = emptyList(), - val messages: List = emptyList(), - val instructions: String? = null, -) - -/** - * A caller-supplied repair attempt for one invalid tool call. - * - * Return a replacement [RawToolCall] to have core re-parse and re-validate it, - * or `null` to leave the call invalid with its original error. Throwing is - * reported as a failed repair: the call stays invalid and carries a - * [ToolCallRepairError] whose cause is the exception's message. - * - * Called on the thread that issued the generation, outside any native call, so - * it may block and may itself call aimux. - */ -typealias RepairToolCall = (ToolCallRepairContext) -> RawToolCall? - -// ───────────────────────────────────────────────────────────────────────────── -// Raw native entry points — JSON in, JSON out, no handle. -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Build the repair argument for one invalid tool call. - * - * @param toolCallJson One `toolCalls` entry with `"invalid": true`. - * @param promptJson The prompt the call was generated with (the same string - * passed to `generate_text` / `stream_text`). - * @param optsJson The options the call was generated with (same rule). - * @return A JSON [ToolCallRepairContext], or the literal `null` when the call - * was made without a tool set — AI SDK never repairs those, so skip it. - */ -fun toolCallRepairContext(toolCallJson: String, promptJson: String, optsJson: String? = null): String { - requireJsonRequired("toolCallJson", toolCallJson) - requireJsonRequired("promptJson", promptJson) - requireJson("optsJson", optsJson) - return stringResult("toolCallRepairContext") { out -> - FFI.lib.aimux_tool_call_repair_context(toolCallJson, promptJson, optsJson, out) - } -} - -/** - * Resolve one invalid tool call against a repair reply, returning the final - * `ToolCall` JSON — valid, or invalid: with its original error (`unchanged`), - * the replacement's own parse or validation error (`repaired`), or a nested - * `ToolCallRepair` error (`failed`). - * - * @param replyJson `{"type":"repaired","toolCall":…}` | `{"type":"unchanged"}` - * | `{"type":"failed","message":…}`. - * @throws InvalidArgumentError when the options carry no tools, or the call is - * not an invalid one. - */ -fun applyToolCallRepair(toolCallJson: String, optsJson: String?, replyJson: String): String { - requireJsonRequired("toolCallJson", toolCallJson) - requireJson("optsJson", optsJson) - requireJsonRequired("replyJson", replyJson) - return stringResult("applyToolCallRepair") { out -> - FFI.lib.aimux_apply_tool_call_repair(toolCallJson, optsJson, replyJson, out) - } -} - -/** - * Apply a repair reply to a whole serialized `GenerateTextResult`, generated - * or consumed from a stream (a `GenerateObjectResult` has no tool calls). - * - * Rewrites both `toolCalls` and the matching `responseMessages` tool-call - * part — the transcript must agree with the repaired call, or the next turn - * sends the model the unrepaired arguments again. - * - * @throws InvalidArgumentError when `toolCallId` matches no entry, the matched - * call is valid, or the options carry no tools. - */ -fun applyToolCallRepairToResult( - resultJson: String, - optsJson: String?, - toolCallId: String, - replyJson: String, -): String { - requireJsonRequired("resultJson", resultJson) - requireJson("optsJson", optsJson) - requireJsonRequired("replyJson", replyJson) - return stringResult("applyToolCallRepairToResult") { out -> - FFI.lib.aimux_apply_tool_call_repair_to_result(resultJson, optsJson, toolCallId, replyJson, out) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Host loop — one helper for results, one for stream parts. -// ───────────────────────────────────────────────────────────────────────────── - -private const val REPLY_UNCHANGED = """{"type":"unchanged"}""" - -/** Run the caller's function and encode its outcome as a `ToolCallRepairReply`. */ -private fun repairReply(repair: RepairToolCall, contextJson: String): String { - val context = try { - AimuxJson.decodeFromString(ToolCallRepairContext.serializer(), contextJson) - } catch (e: Exception) { - throw InvalidArgumentError( - "failed to decode ToolCallRepairContext: ${e.message ?: e::class.simpleName}", - cause = e, - ) - } - return try { - val replacement = repair(context) ?: return REPLY_UNCHANGED - buildJsonObject { - put("type", "repaired") - put("toolCall", AimuxJson.encodeToJsonElement(RawToolCall.serializer(), replacement)) - }.toString() - } catch (e: Exception) { - // A host exception has no typed counterpart in core; only its message - // survives, as the cause of ToolCallRepairError. - buildJsonObject { - put("type", "failed") - put("message", e.message ?: e::class.simpleName ?: "tool call repair failed") - }.toString() - } -} - -private fun isInvalid(call: JsonObject): Boolean = - (call["invalid"] as? JsonPrimitive)?.booleanOrNull == true - -/** - * Give every invalid tool call in a result document one repair attempt, in - * order, and return the patched result JSON. - * - * Ids are read from the document as it arrived: patching one call never - * touches another's id, and one attempt per call is the AI SDK rule. - */ -internal fun repairToolCallsInResult( - resultJson: String, - promptJson: String, - optsJson: String?, - repair: RepairToolCall, -): String { - val root = AimuxJson.parseToJsonElement(resultJson) as? JsonObject ?: return resultJson - val invalid = (root["toolCalls"] as? JsonArray) - ?.mapNotNull { it as? JsonObject } - ?.filter(::isInvalid) - ?: return resultJson - - var patched = resultJson - for (call in invalid) { - val contextJson = toolCallRepairContext(call.toString(), promptJson, optsJson) - if (AimuxJson.parseToJsonElement(contextJson) is JsonNull) continue - val id = call["toolCallId"]?.jsonPrimitive?.content ?: continue - patched = applyToolCallRepairToResult(patched, optsJson, id, repairReply(repair, contextJson)) - } - return patched -} - -/** - * Wrap [repair] so it runs on a fresh thread while the caller waits. - * - * Invariant: the FFI re-entrancy guard is a THREAD-LOCAL flag set on the - * thread that entered `aimux_stream_text` — the very thread the stream - * callbacks run on. Any aimux call that blocks on the runtime (every generate - * / stream entry point) fails there with `AIMUX_E_FFI_REENTRANT_CALL` (204), - * and asking a model to fix the arguments is the canonical repair. So the - * hook must not run on the callback thread. The three repair functions - * themselves are pure and stay where they are. Part ordering is unchanged: - * the callback thread blocks until the hook returns. - * - * [model] is the streaming model, whose read hold is lent to the worker for - * the duration of the hook: the callback thread is blocked in `task.get()` - * still holding it, so the handle cannot be dropped, while a fresh read - * acquisition here would queue behind a waiting `close()` writer (the lock is - * fair) and deadlock all three. The lend covers [model] only, and only on the - * worker thread — a thread the hook starts itself still takes the lock. - */ -internal fun offCallbackThread(model: Model, repair: RepairToolCall): RepairToolCall = { context -> - val task = FutureTask { model.withLentReadHold { repair(context) } } - // Daemon, like Java's: a hook that never returns must not keep the JVM up. - Thread(task, "aimux-tool-call-repair").apply { isDaemon = true }.start() - try { - task.get() - } catch (e: ExecutionException) { - // Rethrow what the hook threw, so "throw means failed" still maps to - // the hook's own message rather than to the wrapper's. - throw e.cause ?: e - } catch (e: InterruptedException) { - // The repair becomes a failed reply; the caller's interrupt survives. - Thread.currentThread().interrupt() - throw e - } -} - -/** - * Repair an invalid `tool-call` stream part, returning the replacement part - * JSON. Any other part (tool-input deltas included) is returned untouched, so - * deltas still reach the caller immediately — AI SDK behaviour. - */ -internal fun repairToolCallStreamPart( - partJson: String, - promptJson: String, - optsJson: String?, - repair: RepairToolCall, -): String { - // A tool-call part is the ToolCall itself plus `"type": "tool-call"`. - val part = AimuxJson.parseToJsonElement(partJson) as? JsonObject ?: return partJson - if ((part["type"] as? JsonPrimitive)?.content != "tool-call" || !isInvalid(part)) return partJson - - val contextJson = toolCallRepairContext(part.toString(), promptJson, optsJson) - if (AimuxJson.parseToJsonElement(contextJson) is JsonNull) return partJson - val repaired = applyToolCallRepair(part.toString(), optsJson, repairReply(repair, contextJson)) - return JsonObject(mapOf("type" to JsonPrimitive("tool-call")) + (AimuxJson.parseToJsonElement(repaired) as JsonObject)).toString() -} diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/TypedModel.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/TypedModel.kt deleted file mode 100644 index c97f6a194..000000000 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/TypedModel.kt +++ /dev/null @@ -1,546 +0,0 @@ -/** - * aimux — typed wrapper over the raw JSON-string [Model] API. - * - * [TypedModel] eliminates the JSON string boundary: inputs and outputs are - * Kotlin data classes (see [Types.kt]). The raw [Model] (JNA → C ABI) is left - * untouched and remains available for callers that need the untyped escape - * hatch. - * - * ```kotlin - * TypedModel.openai("sk-...", "gpt-4o", baseUrl).use { model -> - * val result = model.generateText("What is Rust?") - * println(result.text) - * } - * ``` - */ - -package ai.arcships.aimux - -import kotlinx.serialization.encodeToString -import java.io.Closeable - -/** - * A typed view over a [Model]. Delegates every call to [raw] and (de)serializes - * the JSON boundary with [AimuxJson]. - * - * The wrapped [Model] is NOT closed by this wrapper — the caller owns the - * underlying handle. If you created the [Model] solely for this wrapper, close - * the [TypedModel] (which closes the underlying [Model]); otherwise prefer - * constructing via the companion factories (`openai`/`anthropic`) which own the - * handle. - */ -class TypedModel(private val raw: Model, private val ownsModel: Boolean = false) : Closeable { - - override fun close() { - if (ownsModel) raw.close() - } - - // ── generateText ────────────────────────────────────────────────────── - - /** - * Generate text from a simple string prompt. - * - * @param prompt Plain text prompt (encoded as a JSON string on the wire). - * @param options Optional typed [GenerateTextOptions]. - * @return Decoded [GenerateTextResult]. - */ - fun generateText(prompt: String, options: GenerateTextOptions? = null): GenerateTextResult { - val promptJson = AimuxJson.encodeToString(prompt) - val optsJson = options?.let { AimuxJson.encodeToString(GenerateTextOptions.serializer(), it) } - val resultJson = repaired(raw.generateText(promptJson, optsJson), promptJson, optsJson, options) - return decodeResult(resultJson) - } - - /** - * Generate text from a multi-role message list. - * - * @param messages Conversation as a list of [ModelMessage]s. - * @param options Optional typed [GenerateTextOptions]. - * @return Decoded [GenerateTextResult]. - */ - fun generateText(messages: List, options: GenerateTextOptions? = null): GenerateTextResult { - val promptJson = AimuxJson.encodeToString( - kotlinx.serialization.builtins.ListSerializer(ModelMessage.serializer()), - messages, - ) - val optsJson = options?.let { AimuxJson.encodeToString(GenerateTextOptions.serializer(), it) } - val resultJson = repaired(raw.generateText(promptJson, optsJson), promptJson, optsJson, options) - return decodeResult(resultJson) - } - - /** - * Give every invalid tool call in a result one repair attempt (RFC-0035), - * before the JSON is decoded — the patched document is what the caller - * sees, transcript included. A no-op unless [GenerateTextOptions.repairToolCall] - * is set. - */ - private fun repaired( - resultJson: String, - promptJson: String, - optsJson: String?, - options: GenerateTextOptions?, - ): String { - val repair = options?.repairToolCall ?: return resultJson - return repairToolCallsInResult(resultJson, promptJson, optsJson, repair) - } - - private fun decodeResult(resultJson: String): GenerateTextResult { - // AiMuxError values throw AimuxException.fromC in the raw layer. - // Decode failures are local InvalidArgumentError. - return try { - AimuxJson.decodeFromString(GenerateTextResult.serializer(), resultJson) - } catch (e: Exception) { - throw InvalidArgumentError( - "failed to decode GenerateTextResult: ${e.message ?: e::class.simpleName}", - cause = e, - ) - } - } - - // ── generateObject (M12, RFC-0016) ─────────────────────────────────── - - /** - * Generate a structured JSON object from a simple string prompt (M12). - * - * @param prompt Plain text prompt. - * @param options Optional typed [GenerateTextOptions]. - * @return Decoded [GenerateObjectResult]. - */ - fun generateObject(prompt: String, options: GenerateTextOptions? = null): GenerateObjectResult { - val promptJson = AimuxJson.encodeToString(prompt) - val optsJson = options?.let { AimuxJson.encodeToString(GenerateTextOptions.serializer(), it) } - val resultJson = repaired(raw.generateObject(promptJson, optsJson), promptJson, optsJson, options) - return decodeObjectResult(resultJson) - } - - /** - * Generate a structured JSON object from a multi-role message list (M12). - * - * @param messages Conversation as a list of [ModelMessage]s. - * @param options Optional typed [GenerateTextOptions]. - * @return Decoded [GenerateObjectResult]. - */ - fun generateObject(messages: List, options: GenerateTextOptions? = null): GenerateObjectResult { - val promptJson = AimuxJson.encodeToString( - kotlinx.serialization.builtins.ListSerializer(ModelMessage.serializer()), - messages, - ) - val optsJson = options?.let { AimuxJson.encodeToString(GenerateTextOptions.serializer(), it) } - val resultJson = repaired(raw.generateObject(promptJson, optsJson), promptJson, optsJson, options) - return decodeObjectResult(resultJson) - } - - private fun decodeObjectResult(resultJson: String): GenerateObjectResult { - return try { - AimuxJson.decodeFromString(GenerateObjectResult.serializer(), resultJson) - } catch (e: Exception) { - throw InvalidArgumentError( - "failed to decode GenerateObjectResult: ${e.message ?: e::class.simpleName}", - cause = e, - ) - } - } - - // ── consumeStreamText (M11, RFC-0016) ──────────────────────────────── - - /** - * Consume a stream to completion from a simple string prompt and return - * the aggregated result (M11). Synchronous (blocks until finished). - * - * @param prompt Plain text prompt. - * @param options Optional typed [GenerateTextOptions]. - * @return Decoded [GenerateTextResult]. - */ - fun consumeStreamText(prompt: String, options: GenerateTextOptions? = null): GenerateTextResult { - val promptJson = AimuxJson.encodeToString(prompt) - val optsJson = options?.let { AimuxJson.encodeToString(GenerateTextOptions.serializer(), it) } - val resultJson = repaired(raw.consumeStreamText(promptJson, optsJson), promptJson, optsJson, options) - return decodeResult(resultJson) - } - - /** - * Consume a stream to completion from a multi-role message list and - * return the aggregated result (M11). - * - * @param messages Conversation as a list of [ModelMessage]s. - * @param options Optional typed [GenerateTextOptions]. - * @return Decoded [GenerateTextResult]. - */ - fun consumeStreamText(messages: List, options: GenerateTextOptions? = null): GenerateTextResult { - val promptJson = AimuxJson.encodeToString( - kotlinx.serialization.builtins.ListSerializer(ModelMessage.serializer()), - messages, - ) - val optsJson = options?.let { AimuxJson.encodeToString(GenerateTextOptions.serializer(), it) } - val resultJson = repaired(raw.consumeStreamText(promptJson, optsJson), promptJson, optsJson, options) - return decodeResult(resultJson) - } - - // ── streamText ─────────────────────────────────────────────────────── - - /** - * Stream text, delivering each chunk as a typed [StreamPart]. - * - * Blocks the calling thread until the stream completes. - * - * @param prompt Plain text prompt. - * @param options Optional typed [GenerateTextOptions]. - * @param onPart Called for each decoded [StreamPart]. - * @param onDone Called when the stream completes normally. - * @param onError Called (with a message) on a stream error. - */ - fun streamText( - prompt: String, - options: GenerateTextOptions? = null, - onPart: (StreamPart) -> Unit, - onDone: () -> Unit, - onError: (String) -> Unit, - ) { - streamTextParts( - promptJson = AimuxJson.encodeToString(prompt), - options = options, - onPart = onPart, - onDone = onDone, - onError = onError, - ) - } - - /** - * Stream text from a multi-role message list, delivering typed [StreamPart]s. - */ - fun streamText( - messages: List, - options: GenerateTextOptions? = null, - onPart: (StreamPart) -> Unit, - onDone: () -> Unit, - onError: (String) -> Unit, - ) { - streamTextParts( - promptJson = AimuxJson.encodeToString( - kotlinx.serialization.builtins.ListSerializer(ModelMessage.serializer()), - messages, - ), - options = options, - onPart = onPart, - onDone = onDone, - onError = onError, - ) - } - - private fun streamTextParts( - promptJson: String, - options: GenerateTextOptions?, - onPart: (StreamPart) -> Unit, - onDone: () -> Unit, - onError: (String) -> Unit, - ) { - val optsJson = options?.let { AimuxJson.encodeToString(GenerateTextOptions.serializer(), it) } - // Off the callback thread: the native re-entrancy guard is thread-local - // (see offCallbackThread), so a hook calling aimux would fail with 204. - val repair = options?.repairToolCall?.let { offCallbackThread(raw, it) } - raw.streamText( - promptJson = promptJson, - optsJson = optsJson, - onPart = { rawPartJson -> - // Only an invalid ToolCall part is rewritten; tool-input deltas - // pass through untouched and immediately. A repair that fails - // at the boundary (rather than in the user's function, which - // core turns into a ToolCallRepair error) must not kill the - // stream: report it and deliver the unrepaired part. - val partJson = if (repair == null) rawPartJson else try { - repairToolCallStreamPart(rawPartJson, promptJson, optsJson, repair) - } catch (error: Exception) { - onError("failed to repair tool call: ${error.message ?: error::class.simpleName}") - rawPartJson - } - try { - onPart(AimuxJson.decodeFromString(StreamPart.serializer(), partJson)) - } catch (error: Exception) { - onError( - "failed to decode StreamPart: ${error.message ?: error::class.simpleName}", - ) - } - }, - onDone = onDone, - ) - // raw.streamText throws AimuxException on native failure; onError only - // reports local decode failures. - } - - /** - * Stream text as a [Sequence] of typed [StreamPart]s. - */ - fun streamTextSequence( - prompt: String, - options: GenerateTextOptions? = null, - ): Sequence = streamTextSequenceParts( - AimuxJson.encodeToString(prompt), options, - ) - - /** - * Stream text from a multi-role message list as a [Sequence] of typed [StreamPart]s. - */ - fun streamTextSequence( - messages: List, - options: GenerateTextOptions? = null, - ): Sequence = streamTextSequenceParts( - AimuxJson.encodeToString( - kotlinx.serialization.builtins.ListSerializer(ModelMessage.serializer()), - messages, - ), - options, - ) - - private fun streamTextSequenceParts( - promptJson: String, - options: GenerateTextOptions?, - ): Sequence = sequence { - // LinkedBlockingQueue rejects null, so end-of-stream is a sentinel object. - val eos = Any() - val parts = java.util.concurrent.LinkedBlockingQueue() - var streamError: AimuxException? = null - - try { - streamTextParts( - promptJson = promptJson, - options = options, - onPart = { parts.put(it) }, - onDone = { parts.put(eos) }, - onError = { msg -> - streamError = OtherError(msg) - parts.put(eos) - }, - ) - } catch (e: AimuxException) { - streamError = e - parts.put(eos) - } - - while (true) { - val part = parts.take() - if (part === eos) break - yield(part as StreamPart) - } - streamError?.let { throw it } - } - - // ── OpenAI-compatible output (RFC-0026) ───────────────────────────────── - - /** - * Generate text (non-streaming) with OpenAI Chat Completions output. - * - * @param prompt Plain text prompt (encoded as a JSON string on the wire). - * @param options Optional typed [GenerateTextOptions]. - * @return Decoded [ChatCompletion]. - */ - fun generateTextAsOpenAI(prompt: String, options: GenerateTextOptions? = null): ChatCompletion { - val promptJson = AimuxJson.encodeToString(prompt) - val optsJson = options?.let { AimuxJson.encodeToString(GenerateTextOptions.serializer(), it) } - return generateTextAsOpenAIParts(promptJson, optsJson, options) - } - - /** - * Generate text from a multi-role message list with OpenAI Chat Completions - * output. - * - * @param messages Conversation as a list of [ModelMessage]s. - * @param options Optional typed [GenerateTextOptions]. - * @return Decoded [ChatCompletion]. - */ - fun generateTextAsOpenAI( - messages: List, options: GenerateTextOptions? = null - ): ChatCompletion { - val promptJson = AimuxJson.encodeToString( - kotlinx.serialization.builtins.ListSerializer(ModelMessage.serializer()), - messages, - ) - val optsJson = options?.let { AimuxJson.encodeToString(GenerateTextOptions.serializer(), it) } - return generateTextAsOpenAIParts(promptJson, optsJson, options) - } - - /** - * A `ChatCompletion` has no `invalid` marker to repair from, so with a - * [GenerateTextOptions.repairToolCall] the native result is generated, - * repaired, then converted — its tool calls carry the repaired arguments. - */ - private fun generateTextAsOpenAIParts( - promptJson: String, - optsJson: String?, - options: GenerateTextOptions?, - ): ChatCompletion { - if (options?.repairToolCall == null) { - return decodeChatCompletion(raw.generateTextAsOpenAI(promptJson, optsJson)) - } - val resultJson = repaired(raw.generateText(promptJson, optsJson), promptJson, optsJson, options) - return decodeChatCompletion(raw.generateTextResultAsOpenAI(resultJson)) - } - - private fun decodeChatCompletion(resultJson: String): ChatCompletion { - return try { - AimuxJson.decodeFromString(ChatCompletion.serializer(), resultJson) - } catch (e: Exception) { - throw InvalidArgumentError( - "failed to decode ChatCompletion: ${e.message ?: e::class.simpleName}", - cause = e, - ) - } - } - - /** - * Stream text with OpenAI Chat Completions output, yielding typed - * [ChatCompletionChunk]s. Blocks the calling thread until the stream - * completes. - * - * @param prompt Plain text prompt. - * @param options Optional typed [GenerateTextOptions]. - * @param onPart Called for each decoded [ChatCompletionChunk]. - * @param onDone Called when the stream completes normally. - * @param onError Called (with a message) on a stream error. - */ - fun streamTextAsOpenAI( - prompt: String, - options: GenerateTextOptions? = null, - onPart: (ChatCompletionChunk) -> Unit, - onDone: () -> Unit, - onError: (String) -> Unit, - ) { - streamTextAsOpenAIChunks( - promptJson = AimuxJson.encodeToString(prompt), - options = options, - onPart = onPart, - onDone = onDone, - onError = onError, - ) - } - - /** - * Stream text from a multi-role message list with OpenAI Chat Completions - * output, yielding typed [ChatCompletionChunk]s. - */ - fun streamTextAsOpenAI( - messages: List, - options: GenerateTextOptions? = null, - onPart: (ChatCompletionChunk) -> Unit, - onDone: () -> Unit, - onError: (String) -> Unit, - ) { - streamTextAsOpenAIChunks( - promptJson = AimuxJson.encodeToString( - kotlinx.serialization.builtins.ListSerializer(ModelMessage.serializer()), - messages, - ), - options = options, - onPart = onPart, - onDone = onDone, - onError = onError, - ) - } - - private fun streamTextAsOpenAIChunks( - promptJson: String, - options: GenerateTextOptions?, - onPart: (ChatCompletionChunk) -> Unit, - onDone: () -> Unit, - onError: (String) -> Unit, - ) { - val optsJson = options?.let { AimuxJson.encodeToString(GenerateTextOptions.serializer(), it) } - raw.streamTextAsOpenAI( - promptJson = promptJson, - optsJson = optsJson, - onPart = { chunkJson -> - try { - onPart(AimuxJson.decodeFromString(ChatCompletionChunk.serializer(), chunkJson)) - } catch (error: Exception) { - onError("failed to decode ChatCompletionChunk: ${error.message ?: error::class.simpleName}") - } - }, - onDone = onDone, - ) - } - - /** - * Stream text with OpenAI Chat Completions output as a [Sequence] of typed - * [ChatCompletionChunk]s (RFC-0026). - */ - fun streamTextAsOpenAISequence( - prompt: String, - options: GenerateTextOptions? = null, - ): Sequence = streamTextAsOpenAISequenceParts( - AimuxJson.encodeToString(prompt), options, - ) - - /** - * Stream text from a multi-role message list with OpenAI Chat Completions - * output as a [Sequence] of typed [ChatCompletionChunk]s. - */ - fun streamTextAsOpenAISequence( - messages: List, - options: GenerateTextOptions? = null, - ): Sequence = streamTextAsOpenAISequenceParts( - AimuxJson.encodeToString( - kotlinx.serialization.builtins.ListSerializer(ModelMessage.serializer()), - messages, - ), - options, - ) - - private fun streamTextAsOpenAISequenceParts( - promptJson: String, - options: GenerateTextOptions?, - ): Sequence = sequence { - // LinkedBlockingQueue rejects null, so end-of-stream is a sentinel object. - val eos = Any() - val parts = java.util.concurrent.LinkedBlockingQueue() - var streamError: AimuxException? = null - - try { - streamTextAsOpenAIChunks( - promptJson = promptJson, - options = options, - onPart = { parts.put(it) }, - onDone = { parts.put(eos) }, - onError = { msg -> - streamError = OtherError(msg) - parts.put(eos) - }, - ) - } catch (e: AimuxException) { - streamError = e - parts.put(eos) - } - - while (true) { - val part = parts.take() - if (part === eos) break - yield(part as ChatCompletionChunk) - } - streamError?.let { throw it } - } - - // ── Companion: provider constructors that own the handle ───────────── - - companion object { - /** Wrap an existing [Model]; the caller retains ownership of the handle. */ - fun of(model: Model): TypedModel = TypedModel(model, ownsModel = false) - - /** Create an OpenAI-backed [TypedModel] that owns its [Model]. */ - fun openai(apiKey: String, modelId: String): TypedModel = - TypedModel(Model.openai(apiKey, modelId), ownsModel = true) - - /** Create an Anthropic-backed [TypedModel] that owns its [Model]. */ - fun anthropic(apiKey: String, modelId: String): TypedModel = - TypedModel(Model.anthropic(apiKey, modelId), ownsModel = true) - - /** Create an OpenAI-backed [TypedModel] with a custom base URL; owns its [Model]. */ - fun openai(apiKey: String, modelId: String, baseUrl: String): TypedModel = - TypedModel(Model.openai(apiKey, modelId, baseUrl), ownsModel = true) - - /** Create an Anthropic-backed [TypedModel] with a custom base URL; owns its [Model]. */ - fun anthropic(apiKey: String, modelId: String, baseUrl: String): TypedModel = - TypedModel(Model.anthropic(apiKey, modelId, baseUrl), ownsModel = true) - - /** Create a registry-backed [TypedModel] (RFC-0017 phase 4) that owns its [Model]. */ - fun provider(name: String, apiKey: String? = null, modelId: String, configJson: String? = null): TypedModel = - TypedModel(Model.provider(name, apiKey, modelId, configJson), ownsModel = true) - } -} diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt deleted file mode 100644 index ee8e263d5..000000000 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt +++ /dev/null @@ -1,1591 +0,0 @@ -/** - * aimux — typed data classes mirroring the ts-rs output in `bindings/node/src/types` - * (the generated TypeScript types). - * - * The wire JSON is the AI SDK's: camelCase field names, unions tagged with a - * `type` key (a few use `role`, `name` or `sourceType`), optional fields - * absent rather than null. Kotlin property names equal the wire names, so no - * [kotlinx.serialization.SerialName] is needed on fields. The raw JSON boundary - * is handled by [TypedModel] — callers of this layer never parse JSON by hand. - * - * Decode is lenient (unknown keys ignored, every result field has a default) - * so engine additions do not break existing clients. The serialization config - * lives in [AimuxJson]. - */ - -package ai.arcships.aimux - -import kotlinx.serialization.EncodeDefault -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.SerializationException -import kotlinx.serialization.Transient -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.descriptors.buildClassSerialDescriptor -import kotlinx.serialization.descriptors.nullable -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonClassDiscriminator -import kotlinx.serialization.json.JsonDecoder -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonEncoder -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.jsonPrimitive - -// ───────────────────────────────────────────────────────────────────────────── -// Shared Json instance. -// -// - ignoreUnknownKeys : tolerate forward-compatible fields from the engine. -// - explicitNulls=false: omit null fields when encoding (optional fields are -// absent on the wire, never null). -// - encodeDefaults=false: do not encode default values (keeps payloads small). -// -// Unions tagged with `type` use kotlinx's default sealed-class polymorphism -// (`classDiscriminator` stays "type"). -// ───────────────────────────────────────────────────────────────────────────── - -val AimuxJson: Json = Json { - ignoreUnknownKeys = true - explicitNulls = false - encodeDefaults = false -} - -/** - * (De)serializer for a `JsonElement?` that is an optional `unknown` upstream - * (a response `body`, `inputSchemaInput`): a missing field is `null` and an - * explicit JSON `null` is [JsonNull], as the AI SDK's JSON keeps `null` apart - * from `undefined` (Rust `wire::present`). - */ -object JsonValuePresentSerializer : KSerializer { - override val descriptor: SerialDescriptor = JsonElement.serializer().descriptor.nullable - - override fun deserialize(decoder: Decoder): JsonElement? { - val json = decoder as? JsonDecoder - ?: throw SerializationException("a JSON value can only be decoded from JSON") - return json.decodeJsonElement() - } - - override fun serialize(encoder: Encoder, value: JsonElement?) { - val json = encoder as? JsonEncoder - ?: throw SerializationException("a JSON value can only be encoded to JSON") - json.encodeJsonElement(value ?: JsonNull) - } -} - -/** Provider-specific options / metadata: `{ providerName: { key: value } }`. */ -typealias ProviderOptions = Map> - -/** Provider-specific metadata on a result (same shape as [ProviderOptions]). */ -typealias ProviderMetadata = Map> - -// ───────────────────────────────────────────────────────────────────────────── -// Enums (simple string enums on the wire). -// ───────────────────────────────────────────────────────────────────────────── - -@Serializable -enum class Role { - @SerialName("system") SYSTEM, - @SerialName("user") USER, - @SerialName("assistant") ASSISTANT, - @SerialName("tool") TOOL, -} - -@Serializable -enum class FinishReasonUnified { - @SerialName("stop") STOP, - @SerialName("length") LENGTH, - @SerialName("content-filter") CONTENT_FILTER, - @SerialName("tool-calls") TOOL_CALLS, - @SerialName("error") ERROR, - @SerialName("other") OTHER, -} - -@Serializable -enum class ReasoningEffort { - @SerialName("provider-default") PROVIDER_DEFAULT, - @SerialName("none") NONE, - @SerialName("minimal") MINIMAL, - @SerialName("low") LOW, - @SerialName("medium") MEDIUM, - @SerialName("high") HIGH, - @SerialName("xhigh") XHIGH, -} - -// ───────────────────────────────────────────────────────────────────────────── -// Core nested types. -// ───────────────────────────────────────────────────────────────────────────── - -@Serializable -data class InputTokenUsage( - val total: Long? = null, - val noCache: Long? = null, - val cacheRead: Long? = null, - val cacheWrite: Long? = null, -) - -@Serializable -data class OutputTokenUsage( - val total: Long? = null, - val text: Long? = null, - val reasoning: Long? = null, -) - -/** Token usage statistics. Mirrors `Usage.ts`. */ -@Serializable -data class Usage( - val inputTokens: InputTokenUsage = InputTokenUsage(), - val outputTokens: OutputTokenUsage = OutputTokenUsage(), - val raw: JsonObject? = null, -) { - companion object { - /** Convenience for tests/inspection. */ - fun of(input: Long?, output: Long?): Usage = - Usage(inputTokens = InputTokenUsage(input), outputTokens = OutputTokenUsage(output)) - } -} - -/** Input-token breakdown of a [LanguageModelUsage]. Mirrors `InputTokenDetails.ts`. */ -@Serializable -data class InputTokenDetails( - val noCacheTokens: Long? = null, - val cacheReadTokens: Long? = null, - val cacheWriteTokens: Long? = null, -) - -/** Output-token breakdown of a [LanguageModelUsage]. Mirrors `OutputTokenDetails.ts`. */ -@Serializable -data class OutputTokenDetails( - val textTokens: Long? = null, - val reasoningTokens: Long? = null, -) - -/** - * Call-level token usage, the AI SDK's `LanguageModelUsage`: flat camelCase - * counts, an unknown count `null` here and absent in the JSON. Provider - * results keep [Usage]. - * - * Mirrors `LanguageModelUsage.ts`. - */ -@OptIn(ExperimentalSerializationApi::class) -@Serializable -data class LanguageModelUsage( - val inputTokens: Long? = null, - /** Always written, `{}` when every count is unknown, as upstream. */ - @EncodeDefault - val inputTokenDetails: InputTokenDetails = InputTokenDetails(), - val outputTokens: Long? = null, - @EncodeDefault - val outputTokenDetails: OutputTokenDetails = OutputTokenDetails(), - val totalTokens: Long? = null, - val raw: JsonObject? = null, -) - -/** Unified finish reason. Mirrors `FinishReason.ts`: `raw` is absent when the provider sent none. */ -@Serializable -data class FinishReason( - val unified: FinishReasonUnified = FinishReasonUnified.OTHER, - val raw: String? = null, -) - -/** Mirrors `ResponseMetadata.ts`. */ -@Serializable -data class ResponseMetadata( - val id: String? = null, - val timestamp: String? = null, - val modelId: String? = null, -) - -/** Mirrors `ResponseInfo.ts`: [ResponseMetadata] plus the HTTP headers and body. */ -@Serializable -data class ResponseInfo( - val id: String? = null, - val timestamp: String? = null, - val modelId: String? = null, - val headers: Map? = null, - @Serializable(with = JsonValuePresentSerializer::class) - val body: JsonElement? = null, -) - -@Serializable -data class RequestInfo( - @Serializable(with = JsonValuePresentSerializer::class) - val body: JsonElement? = null, -) - -/** - * A tool call requested by the model. Mirrors `ToolCall.ts`. - * - * `input` is the parsed argument value (usually an object). `invalid` is set - * by Core when the call stays invalid after optional repair; `error` is the - * `name`-tagged `AiMuxError` JSON of the lookup, parse, schema, or repair - * failure (`{"name":"AI_NoSuchToolError",...}`). - * - * Its JSON carries `"type": "tool-call"`: a call outside `content` is the AI - * SDK's tagged `TypedToolCall` (ai generate-text/tool-call.ts). - */ -@OptIn(ExperimentalSerializationApi::class) -@Serializable -data class ToolCall( - val toolCallId: String, - val toolName: String, - val input: JsonElement = JsonObject(emptyMap()), - val providerExecuted: Boolean? = null, - val dynamic: Boolean? = null, - val providerMetadata: ProviderMetadata? = null, - val invalid: Boolean? = null, - val error: JsonElement? = null, -) { - /** Always `"tool-call"`. */ - @EncodeDefault - val type: String = "tool-call" - - init { - requireTypeTag("tool-call", type) - } -} - -/** - * Check the `type` tag of a value that always carries one (a standalone tool - * call or source); JSON written before the tag has none, and reads the default. - */ -internal fun requireTypeTag(expected: String, actual: String) { - if (actual != expected) { - throw SerializationException("expected type \"$expected\", got \"$actual\"") - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Tools (input side of GenerateTextOptions). -// -// `Tool` is an internally-tagged union on `type` (`"function" | "provider"`). -// ───────────────────────────────────────────────────────────────────────────── - -@Serializable -data class FunctionToolInputExample(val input: JsonObject) - -/** A user-defined function tool. Mirrors `FunctionTool.ts`; `inputSchema` is a JSON Schema. */ -@Serializable -data class FunctionTool( - val name: String, - val description: String? = null, - val inputSchema: JsonElement, - val strict: Boolean? = null, - val providerOptions: ProviderOptions? = null, - val inputExamples: List? = null, -) - -/** A provider-defined tool (e.g. `anthropic.web_search_20250305`). Mirrors `ProviderTool.ts`. */ -@Serializable -data class ProviderTool( - val id: String, - val name: String, - val args: JsonObject = JsonObject(emptyMap()), - /** Whether a result may arrive in a later response than its call (provider-utils@5.0.55 src/types/tool.ts:316-326). */ - val supportsDeferredResults: Boolean? = null, -) - -/** - * A function tool or a provider tool. Mirrors `Tool.ts`; serialized as - * `{"type":"function", ...}` / `{"type":"provider", ...}`. - */ -@Serializable -sealed interface Tool { - @Serializable - @SerialName("function") - data class Function( - val name: String, - val description: String? = null, - val inputSchema: JsonElement, - val strict: Boolean? = null, - val providerOptions: ProviderOptions? = null, - val inputExamples: List? = null, - ) : Tool { - companion object { - /** Convenience constructor from a [FunctionTool]. */ - fun from(tool: FunctionTool): Function = Function( - name = tool.name, - description = tool.description, - inputSchema = tool.inputSchema, - strict = tool.strict, - providerOptions = tool.providerOptions, - inputExamples = tool.inputExamples, - ) - } - } - - @Serializable - @SerialName("provider") - data class Provider( - val id: String, - val name: String, - val args: JsonObject = JsonObject(emptyMap()), - /** Whether a result may arrive in a later response than its call (provider-utils@5.0.55 src/types/tool.ts:316-326). */ - val supportsDeferredResults: Boolean? = null, - ) : Tool { - companion object { - fun from(tool: ProviderTool): Provider = Provider( - tool.id, tool.name, tool.args, tool.supportsDeferredResults, - ) - } - } -} - -/** - * How the model should choose tools. - * - * Mirrors `ToolChoice.ts`: `"auto" | "none" | "required" | { type: "tool", - * toolName: "..." }`. This is a mixed untagged/tagged shape (bare strings plus - * a tagged object), so a custom serializer handles the two forms. - */ -@Serializable(with = ToolChoiceSerializer::class) -sealed interface ToolChoice { - data object Auto : ToolChoice - data object None : ToolChoice - data object Required : ToolChoice - - data class Tool(val toolName: String) : ToolChoice - - companion object { - val AUTO: ToolChoice = Auto - val NONE: ToolChoice = None - val REQUIRED: ToolChoice = Required - } -} - -object ToolChoiceSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("aimux.ToolChoice") - - override fun deserialize(decoder: Decoder): ToolChoice { - val json = decoder as? JsonDecoder - ?: throw SerializationException("ToolChoice can only be decoded from JSON") - return when (val el = json.decodeJsonElement()) { - is JsonPrimitive -> when (el.content) { - "auto" -> ToolChoice.Auto - "none" -> ToolChoice.None - "required" -> ToolChoice.Required - else -> throw SerializationException("Unknown ToolChoice string: '${el.content}'") - } - is JsonObject -> { - val type = el["type"]?.jsonPrimitive?.content - when (type) { - "tool" -> ToolChoice.Tool(el["toolName"]?.jsonPrimitive?.content ?: "") - else -> throw SerializationException("Unknown ToolChoice object: $el") - } - } - else -> throw SerializationException("Unexpected ToolChoice element: $el") - } - } - - override fun serialize(encoder: Encoder, value: ToolChoice) { - val json = encoder as? JsonEncoder - ?: throw SerializationException("ToolChoice can only be encoded to JSON") - val el: JsonElement = when (value) { - ToolChoice.Auto -> JsonPrimitive("auto") - ToolChoice.None -> JsonPrimitive("none") - ToolChoice.Required -> JsonPrimitive("required") - is ToolChoice.Tool -> JsonObject( - mapOf( - "type" to JsonPrimitive("tool"), - "toolName" to JsonPrimitive(value.toolName), - ) - ) - } - json.encodeJsonElement(el) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// File bytes / file data. -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Bytes in the AI SDK's JSON: a `Uint8Array` is a base64 string there - * (`@ai-sdk/provider-utils` `uint8-utils.ts:12-23`), never an array of numbers. - * Byte values (0–255) are encoded and decoded at the JSON boundary. - */ -object Base64Bytes { - /** Standard, padded base64, as `btoa`. */ - fun encode(bytes: List): String = - java.util.Base64.getEncoder().encodeToString(ByteArray(bytes.size) { bytes[it].toByte() }) - - /** - * Decodes as `convertBase64ToUint8Array` (`uint8-utils.ts:6-10`): `-` and `_` - * read as `+` and `/`, padding optional, ASCII whitespace ignored. - */ - fun decode(text: String): List { - val normalized = buildString { - for (c in text) { - when (c) { - ' ', '\t', '\n', '\u000C', '\r', '=' -> Unit - '-' -> append('+') - '_' -> append('/') - else -> append(c) - } - } - } - val raw = try { - java.util.Base64.getDecoder().decode(normalized) - } catch (e: IllegalArgumentException) { - throw SerializationException("invalid base64 data: ${e.message}") - } - return raw.map { it.toInt() and 0xff } - } - - internal fun decode(el: JsonElement, what: String): List { - val text = (el as? JsonPrimitive)?.takeIf { it.isString }?.content - ?: throw SerializationException("$what must be a base64 string, got: $el") - return decode(text) - } -} - -/** (De)serializer for a byte list field: a base64 string on the wire. */ -object Base64ByteListSerializer : KSerializer> { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("aimux.Base64Bytes") - - override fun deserialize(decoder: Decoder): List { - val json = decoder as? JsonDecoder - ?: throw SerializationException("bytes can only be decoded from JSON") - return Base64Bytes.decode(json.decodeJsonElement(), "bytes") - } - - override fun serialize(encoder: Encoder, value: List) { - val json = encoder as? JsonEncoder - ?: throw SerializationException("bytes can only be encoded to JSON") - json.encodeJsonElement(JsonPrimitive(Base64Bytes.encode(value))) - } -} - -/** - * Raw bytes or a base64 string. Mirrors `FileBytes.ts`: `string`, the AI SDK's - * `Uint8Array | string` in JSON (`shared-v4-file-data.ts:7-10`). [Binary] is - * written as base64; parsing yields [Base64]. Also the shape of every - * `Uint8Array | string` field (audio input, image / video file data). - */ -@Serializable(with = FileBytesSerializer::class) -sealed interface FileBytes { - - /** Raw binary bytes (0–255 ints), written as a base64 string. */ - data class Binary(val data: List = emptyList()) : FileBytes - - /** A base64-encoded string. */ - data class Base64(val data: String = "") : FileBytes -} - -object FileBytesSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("aimux.FileBytes") - - override fun deserialize(decoder: Decoder): FileBytes { - val json = decoder as? JsonDecoder - ?: throw SerializationException("FileBytes can only be decoded from JSON") - val el = json.decodeJsonElement() - val text = (el as? JsonPrimitive)?.takeIf { it.isString }?.content - ?: throw SerializationException("FileBytes must be a base64 string, got: $el") - return FileBytes.Base64(text) - } - - override fun serialize(encoder: Encoder, value: FileBytes) { - val json = encoder as? JsonEncoder - ?: throw SerializationException("FileBytes can only be encoded to JSON") - json.encodeJsonElement( - when (value) { - is FileBytes.Binary -> JsonPrimitive(Base64Bytes.encode(value.data)) - is FileBytes.Base64 -> JsonPrimitive(value.data) - } - ) - } -} - -/** File data, tagged on `type`. Mirrors `FileData.ts`. */ -@Serializable -sealed interface FileData { - - @Serializable - @SerialName("data") - data class Data(val data: FileBytes = FileBytes.Base64("")) : FileData - - @Serializable - @SerialName("url") - data class Url(val url: String = "", val originalUrl: String? = null) : FileData - - @Serializable - @SerialName("reference") - data class Reference(val reference: Map = emptyMap()) : FileData - - @Serializable - @SerialName("text") - data class Text(val text: String = "") : FileData -} - -/** Generated file data, tagged on `type`. Mirrors `GeneratedFileData.ts`. */ -@Serializable -sealed interface GeneratedFileData { - - @Serializable - @SerialName("data") - data class Data(val data: FileBytes = FileBytes.Base64("")) : GeneratedFileData - - @Serializable - @SerialName("url") - data class Url(val url: String = "", val originalUrl: String? = null) : GeneratedFileData -} - -/** - * `FilePart.data` (Rust `FilePartData`, provider-utils - * `types/content-part.ts:75`): `FileData | DataContent | URL | - * ProviderReference`, in JSON a tagged [FileData], a string (base64 or a URL) - * or a bare `{provider: id}` reference. - */ -@Serializable(with = FilePartDataSerializer::class) -sealed interface FilePartData { - /** A tagged [FileData]. */ - data class Tagged(val data: FileData) : FilePartData - - /** A bare string: base64 data or a URL. */ - data class Bare(val value: String) : FilePartData - - /** A bare provider reference, `{provider: id}`. */ - data class Reference(val reference: Map) : FilePartData -} - -private val FILE_DATA_TAGS = setOf("data", "url", "reference", "text") -private val GENERATED_FILE_DATA_TAGS = setOf("data", "url") - -private fun fileDataTag(element: JsonElement): String? = - ((element as? JsonObject)?.get("type") as? JsonPrimitive)?.takeIf { it.isString }?.content - -object FilePartDataSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("aimux.FilePartData") - - override fun deserialize(decoder: Decoder): FilePartData { - val json = decoder as? JsonDecoder - ?: throw SerializationException("FilePartData can only be decoded from JSON") - val el = json.decodeJsonElement() - if (el is JsonPrimitive && el.isString) return FilePartData.Bare(el.content) - if (fileDataTag(el) in FILE_DATA_TAGS) { - return FilePartData.Tagged(json.json.decodeFromJsonElement(FileData.serializer(), el)) - } - if (el is JsonObject) { - return FilePartData.Reference(el.mapValues { (_, id) -> - (id as? JsonPrimitive)?.takeIf { it.isString }?.content - ?: throw SerializationException("a provider reference maps providers to string ids, got: $el") - }) - } - throw SerializationException("file data must be a tagged object, a string or a provider reference, got: $el") - } - - override fun serialize(encoder: Encoder, value: FilePartData) { - val json = encoder as? JsonEncoder - ?: throw SerializationException("FilePartData can only be encoded to JSON") - json.encodeJsonElement( - when (value) { - is FilePartData.Tagged -> json.json.encodeToJsonElement(FileData.serializer(), value.data) - is FilePartData.Bare -> JsonPrimitive(value.value) - is FilePartData.Reference -> JsonObject(value.reference.mapValues { JsonPrimitive(it.value) }) - } - ) - } -} - -/** - * `ReasoningFilePart.data` (Rust `ReasoningFilePartData`, - * `types/content-part.ts:164`): tagged data or a URL ([GeneratedFileData]), or - * a bare string (base64 or a URL). - */ -@Serializable(with = ReasoningFilePartDataSerializer::class) -sealed interface ReasoningFilePartData { - /** Tagged data or a URL. */ - data class Tagged(val data: GeneratedFileData) : ReasoningFilePartData - - /** A bare string: base64 data or a URL. */ - data class Bare(val value: String) : ReasoningFilePartData -} - -object ReasoningFilePartDataSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("aimux.ReasoningFilePartData") - - override fun deserialize(decoder: Decoder): ReasoningFilePartData { - val json = decoder as? JsonDecoder - ?: throw SerializationException("ReasoningFilePartData can only be decoded from JSON") - val el = json.decodeJsonElement() - if (el is JsonPrimitive && el.isString) return ReasoningFilePartData.Bare(el.content) - if (fileDataTag(el) in GENERATED_FILE_DATA_TAGS) { - return ReasoningFilePartData.Tagged(json.json.decodeFromJsonElement(GeneratedFileData.serializer(), el)) - } - throw SerializationException("reasoning file data must be tagged data or a URL, or a string, got: $el") - } - - override fun serialize(encoder: Encoder, value: ReasoningFilePartData) { - val json = encoder as? JsonEncoder - ?: throw SerializationException("ReasoningFilePartData can only be encoded to JSON") - json.encodeJsonElement( - when (value) { - is ReasoningFilePartData.Tagged -> - json.json.encodeToJsonElement(GeneratedFileData.serializer(), value.data) - is ReasoningFilePartData.Bare -> JsonPrimitive(value.value) - } - ) - } -} - -/** - * A generated file (Rust `GeneratedFile`, the AI SDK's `GeneratedFile`, - * `generate-text/generated-file.ts:10-32`): `{"base64", "mediaType", - * "providerMetadata"?}` on the wire. Mirrors `GeneratedFile.ts`. - */ -@Serializable -data class GeneratedFile( - /** The file as a base64 string. */ - val base64: String, - val mediaType: String, - val providerMetadata: ProviderMetadata? = null, -) { - /** The file's bytes (0–255). */ - fun bytes(): List = Base64Bytes.decode(base64) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Tool results inside a prompt (ContentPart `tool-result`). -// ───────────────────────────────────────────────────────────────────────────── - -/** One element of a `content` tool output. Mirrors `ToolResultContent.ts`. */ -@Serializable -sealed interface ToolResultContent { - @Serializable - @SerialName("text") - data class Text(val text: String = "", val providerOptions: ProviderOptions? = null) : ToolResultContent - - @Serializable - @SerialName("file") - data class File( - val data: FileData = FileData.Data(), - val mediaType: String = "", - val filename: String? = null, - val providerOptions: ProviderOptions? = null, - ) : ToolResultContent - - @Serializable - @SerialName("custom") - data class Custom(val providerOptions: ProviderOptions? = null) : ToolResultContent -} - -/** The output of a tool call, tagged on `type`. Mirrors `ToolResultOutput.ts`. */ -@Serializable -sealed interface ToolResultOutput { - @Serializable - @SerialName("text") - data class Text(val value: String, val providerOptions: ProviderOptions? = null) : ToolResultOutput - - @Serializable - @SerialName("json") - data class JsonValue(val value: JsonElement, val providerOptions: ProviderOptions? = null) : ToolResultOutput - - @Serializable - @SerialName("execution-denied") - data class ExecutionDenied(val reason: String? = null, val providerOptions: ProviderOptions? = null) : ToolResultOutput - - @Serializable - @SerialName("error-text") - data class ErrorText(val value: String, val providerOptions: ProviderOptions? = null) : ToolResultOutput - - @Serializable - @SerialName("error-json") - data class ErrorJson(val value: JsonElement, val providerOptions: ProviderOptions? = null) : ToolResultOutput - - @Serializable - @SerialName("content") - data class Content(val value: List) : ToolResultOutput -} - -// ───────────────────────────────────────────────────────────────────────────── -// ContentPart (multi-part message content), tagged on `type`. -// ───────────────────────────────────────────────────────────────────────────── - -/** - * A part of a multi-part message. Mirrors `ContentPart.ts`. - * - * Shared by [ModelMessage] (user-facing) and the provider-facing prompt. A - * tool result carries a [ToolResultOutput] (`output`), not a bare value. - */ -@Serializable -sealed interface ContentPart { - @Serializable - @SerialName("text") - data class Text(val text: String = "", val providerOptions: ProviderOptions? = null) : ContentPart - - @Serializable - @SerialName("custom") - data class Custom(val kind: String, val providerOptions: ProviderOptions? = null) : ContentPart - - /** The AI SDK `ReasoningFilePart` (`types/content-part.ts:146-180`). */ - @Serializable - @SerialName("reasoning-file") - data class ReasoningFile( - val data: ReasoningFilePartData, - val mediaType: String, - val providerOptions: ProviderOptions? = null, - ) : ContentPart { - constructor(data: GeneratedFileData, mediaType: String, providerOptions: ProviderOptions? = null) : - this(ReasoningFilePartData.Tagged(data), mediaType, providerOptions) - } - - @Serializable - @SerialName("tool-approval-request") - data class ToolApprovalRequest( - val approvalId: String, - val toolCallId: String, - val reason: String? = null, - val isAutomatic: Boolean? = null, - val signature: String? = null, - @Serializable(with = JsonValuePresentSerializer::class) - val inputSchemaInput: JsonElement? = null, - ) : ContentPart - - @Serializable - @SerialName("image") - data class Image( - /** Image bytes (0–255), a base64 string on the wire. */ - @Serializable(with = Base64ByteListSerializer::class) - val image: List = emptyList(), - val mediaType: String = "", - val providerOptions: ProviderOptions? = null, - ) : ContentPart - - /** - * The AI SDK `FilePart` (provider-utils `types/content-part.ts:62-102`): - * `data` is a [FilePartData]: tagged [FileData], a bare string (base64 or a - * URL) or a bare provider reference. - */ - @Serializable - @SerialName("file") - data class File( - val data: FilePartData, - val mediaType: String = "", - val filename: String? = null, - val providerOptions: ProviderOptions? = null, - ) : ContentPart { - /** Tagged data, e.g. [FileData.Url]. */ - constructor(data: FileData, mediaType: String, filename: String? = null, providerOptions: ProviderOptions? = null) : - this(FilePartData.Tagged(data), mediaType, filename, providerOptions) - - /** Inline bytes (0–255), written as `{"type":"data","data":""}`. */ - constructor(data: List, mediaType: String, filename: String? = null, providerOptions: ProviderOptions? = null) : - this(FileData.Data(FileBytes.Binary(data)), mediaType, filename, providerOptions) - } - - @Serializable - @SerialName("reasoning") - data class Reasoning( - val text: String = "", - val signature: String? = null, - val providerOptions: ProviderOptions? = null, - ) : ContentPart - - @Serializable - @SerialName("tool-call") - data class ToolCall( - val toolCallId: String = "", - val toolName: String = "", - val input: JsonElement = JsonObject(emptyMap()), - val providerExecuted: Boolean? = null, - val providerOptions: ProviderOptions? = null, - ) : ContentPart - - @Serializable - @SerialName("tool-result") - data class ToolResult( - val toolCallId: String, - val toolName: String, - val output: ToolResultOutput, - val providerOptions: ProviderOptions? = null, - ) : ContentPart -} - -// ───────────────────────────────────────────────────────────────────────────── -// ModelMessage (prompt side). -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Message body: either a simple string or multi-part content. - * - * Mirrors `MessageContent.ts`: `string | Array` (untagged): a JSON - * string decodes to [MessageContent.Text], a JSON array to [MessageContent.Parts]. - */ -@Serializable(with = MessageContentSerializer::class) -sealed interface MessageContent { - - @Serializable - data class Text(val text: String = "") : MessageContent - - @Serializable - data class Parts(val parts: List = emptyList()) : MessageContent -} - -object MessageContentSerializer : KSerializer { - override val descriptor: SerialDescriptor = - buildClassSerialDescriptor("aimux.MessageContent") - - override fun deserialize(decoder: Decoder): MessageContent { - val json = decoder as? JsonDecoder - ?: throw SerializationException("MessageContent can only be decoded from JSON") - val element = json.decodeJsonElement() - val ctx = json.json - return when (element) { - is JsonPrimitive -> MessageContent.Text(element.content) - is JsonArray -> MessageContent.Parts( - element.map { ctx.decodeFromJsonElement(ContentPart.serializer(), it) } - ) - else -> throw SerializationException( - "MessageContent must be a string or array of ContentPart, got: $element" - ) - } - } - - override fun serialize(encoder: Encoder, value: MessageContent) { - val json = encoder as? JsonEncoder - ?: throw SerializationException("MessageContent can only be encoded to JSON") - val ctx = json.json - val element: JsonElement = when (value) { - is MessageContent.Text -> JsonPrimitive(value.text) - is MessageContent.Parts -> JsonArray( - value.parts.map { ctx.encodeToJsonElement(ContentPart.serializer(), it) } - ) - } - json.encodeJsonElement(element) - } -} - -/** - * A single user-facing chat message. Mirrors `ModelMessage.ts`: - * `{ role: Role, content: MessageContent }`. Use [contentString] / - * [contentParts] for ergonomic access, or the companion factories to build one. - */ -@Serializable -data class ModelMessage( - val role: Role, - val content: MessageContent = MessageContent.Text(""), -) { - /** The content as a plain string, if the message was sent with string content. */ - val contentString: String? - get() = (content as? MessageContent.Text)?.text - - /** The content as a list of parts, if the message was sent with multi-part content. */ - val contentParts: List? - get() = (content as? MessageContent.Parts)?.parts - - companion object { - /** Build a message with plain string content (the common case). */ - fun text(role: Role, text: String): ModelMessage = - ModelMessage(role, MessageContent.Text(text)) - - /** Build a message from a list of [ContentPart]s. */ - fun parts(role: Role, parts: List): ModelMessage = - ModelMessage(role, MessageContent.Parts(parts)) - - /** Build a message from a pre-built [MessageContent]. */ - fun of(role: Role, content: MessageContent): ModelMessage = ModelMessage(role, content) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// GenerateTextOptions (input). -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Per-call timeout configuration. Mirrors `TimeoutConfiguration.ts`. All values - * are milliseconds; `null` disables the corresponding limit. A `total` timeout - * also covers retry backoff and the whole streamed response. - */ -@Serializable -data class TimeoutConfiguration( - /** Overall timeout for the entire call (including retries and, for streaming, the whole stream), in milliseconds. */ - val totalMs: Long? = null, - /** Timeout for one model operation attempt, in milliseconds. */ - val stepMs: Long? = null, - /** Timeout waiting for the first stream chunk (streaming only). */ - val firstChunkMs: Long? = null, - /** Maximum idle time between consecutive stream chunks (streaming only). */ - val chunkMs: Long? = null, -) - -/** Response format, tagged on `type`. Mirrors `ResponseFormat.ts`. */ -@Serializable -sealed interface ResponseFormat { - @Serializable - @SerialName("text") - data object Text : ResponseFormat - - /** JSON output; `schema` is a JSON Schema for structured output. */ - @Serializable - @SerialName("json") - data class Json( - val schema: JsonElement? = null, - val name: String? = null, - val description: String? = null, - ) : ResponseFormat -} - -/** - * User-facing options for `generate_text` / `stream_text`. - * - * Mirrors `GenerateTextOptions.ts`. Every field is nullable with a `null` - * default; combined with `explicitNulls=false`, only the fields the caller sets - * are serialized onto the wire. - */ -@Serializable -data class GenerateTextOptions( - val maxOutputTokens: Long? = null, - val temperature: Double? = null, - val stopSequences: List? = null, - val topP: Double? = null, - val topK: Double? = null, - val presencePenalty: Double? = null, - val frequencyPenalty: Double? = null, - /** - * Structured output, the AI SDK's `output`, tagged by `type`: for example - * `{"type": "object", "schema": {...}}`, - * `{"type": "array", "element": {...}, "minItems": 1}`, - * `{"type": "choice", "options": [...]}` or `{"type": "json"}`. Untyped - * ([JsonElement]). The parsed value is [GenerateTextResult.output]. - */ - val output: JsonElement? = null, - val seed: Long? = null, - val tools: List? = null, - val toolChoice: ToolChoice? = null, - val headers: Map? = null, - val providerOptions: ProviderOptions? = null, - val reasoning: ReasoningEffort? = null, - val instructions: String? = null, - /** Per-call retry count (0 = disable retries). */ - val maxRetries: Long? = null, - /** Emit raw provider stream chunks as `raw` stream parts (debugging; OpenAI-compatible family only). */ - val includeRawChunks: Boolean? = null, - /** Per-call timeout configuration (overall / first chunk / inter-chunk idle, in ms). */ - val timeout: TimeoutConfiguration? = null, - /** Session identifier (RFC-0024): groups consecutive calls into a session. */ - val sessionId: String? = null, - /** - * One repair attempt per invalid tool call (RFC-0035), run on the JVM after - * the call returns. Mirrors AI SDK `repairToolCall`. - * - * [Transient]: a function cannot be serialized, and core never sees it — - * [TypedModel] runs it host-side. Honoured by `generateText`, - * `generateObject`, `consumeStreamText`, `streamText` and - * `generateTextAsOpenAI` (which repairs the native result before - * converting it); `streamTextAsOpenAI` ignores it, because the OpenAI - * stream forwards provider argument deltas verbatim. The raw JSON-string - * [Model] ignores it too — it never sees typed options. - * - * If a repair fails at the boundary while streaming (not in the hook — a - * throwing hook is a `ToolCallRepair` error on the call), `onError` is told - * and the unrepaired part is still delivered. The stream waits for the - * hook, without a timeout. - * - * While streaming, the hook runs on a worker thread that is lent the - * stream's read hold on this model, so it may call this same model even - * against a concurrent `close()`. The lend is valid only while the stream - * thread blocks holding that read lock, and only on the thread the hook is - * invoked on: extra threads the hook starts itself take the lock normally - * and would deadlock against a concurrent `close()`. Call the model from - * the thread the hook is invoked on. - */ - @Transient val repairToolCall: RepairToolCall? = null, -) - -// ───────────────────────────────────────────────────────────────────────────── -// Results. -// ───────────────────────────────────────────────────────────────────────────── - -/** - * A URL or document used as a source for the response. Mirrors `Source.ts` - * (tagged on `sourceType`). Its JSON also carries `"type": "source"`, as the - * AI SDK's `Source` (provider language-model-v4-source.ts) does in a result's - * `sources`. - */ -@OptIn(ExperimentalSerializationApi::class) -@Serializable -@JsonClassDiscriminator("sourceType") -sealed interface Source { - val id: String - val providerMetadata: ProviderMetadata? - - /** Always `"source"`. */ - val type: String - - @Serializable - @SerialName("url") - data class Url( - override val id: String, - val url: String, - val title: String? = null, - override val providerMetadata: ProviderMetadata? = null, - ) : Source { - @EncodeDefault - override val type: String = "source" - - init { - requireTypeTag("source", type) - } - } - - @Serializable - @SerialName("document") - data class Document( - override val id: String, - val mediaType: String, - val title: String, - val filename: String? = null, - override val providerMetadata: ProviderMetadata? = null, - ) : Source { - @EncodeDefault - override val type: String = "source" - - init { - requireTypeTag("source", type) - } - } -} - -/** A provider warning, tagged on `type`. Mirrors `Warning.ts`. */ -@Serializable -sealed interface Warning { - @Serializable - @SerialName("unsupported") - data class Unsupported(val feature: String, val details: String? = null) : Warning - - @Serializable - @SerialName("compatibility") - data class Compatibility(val feature: String, val details: String? = null) : Warning - - @Serializable - @SerialName("deprecated") - data class Deprecated(val setting: String, val message: String) : Warning - - @Serializable - @SerialName("other") - data class Other(val message: String) : Warning -} - -/** - * A content item of the provider-level result (`GenerateResult.content`), - * tagged on `type`. Mirrors `GenerateContent`: a tool call's `input` is the raw argument text, and an - * approval request carries just the ids. - */ -@Serializable -sealed interface GenerateContent { - @Serializable - @SerialName("text") - data class Text(val text: String = "", val providerMetadata: ProviderMetadata? = null) : GenerateContent - - @Serializable - @SerialName("tool-call") - data class ToolCall( - val toolCallId: String = "", - val toolName: String = "", - val input: String = "", - val providerExecuted: Boolean? = null, - val dynamic: Boolean? = null, - val providerMetadata: ProviderMetadata? = null, - ) : GenerateContent - - @Serializable - @SerialName("source") - data class Source( - val sourceType: String, - val id: String, - val url: String? = null, - val mediaType: String? = null, - val title: String? = null, - val filename: String? = null, - val providerMetadata: ProviderMetadata? = null, - ) : GenerateContent - - @Serializable - @SerialName("reasoning") - data class Reasoning(val text: String = "", val providerMetadata: ProviderMetadata? = null) : GenerateContent - - @Serializable - @SerialName("file") - data class File( - val data: GeneratedFileData = GeneratedFileData.Data(), - val mediaType: String = "", - val providerMetadata: ProviderMetadata? = null, - ) : GenerateContent - - @Serializable - @SerialName("reasoning-file") - data class ReasoningFile( - val data: GeneratedFileData = GeneratedFileData.Data(), - val mediaType: String = "", - val providerMetadata: ProviderMetadata? = null, - ) : GenerateContent - - @Serializable - @SerialName("custom") - data class Custom(val kind: String, val providerMetadata: ProviderMetadata? = null) : GenerateContent - - @Serializable - @SerialName("tool-approval-request") - data class ToolApprovalRequest( - val approvalId: String, - val toolCallId: String, - val providerMetadata: ProviderMetadata? = null, - ) : GenerateContent - - @Serializable - @SerialName("tool-result") - data class ToolResult( - val toolCallId: String = "", - val toolName: String = "", - val result: JsonElement = JsonObject(emptyMap()), - val isError: Boolean? = null, - val preliminary: Boolean? = null, - val dynamic: Boolean? = null, - val providerMetadata: ProviderMetadata? = null, - ) : GenerateContent -} - -/** Result of `LanguageModel::do_generate`, the provider-level result of one model call. Mirrors `GenerateResult.ts`. */ -@Serializable -data class GenerateResult( - val content: List = emptyList(), - val finishReason: FinishReason = FinishReason(), - val usage: Usage = Usage(), - val warnings: List = emptyList(), - val providerMetadata: ProviderMetadata? = null, - val request: RequestInfo? = null, - val response: ResponseInfo? = null, -) - -/** - * Result of `generate_text` and of a consumed stream (user-facing), the AI - * SDK's `GenerateTextResult`. [steps] holds every step (weak type); the other - * fields are derived from them: the last step's text, finish reason, - * reasoning, request, response and provider metadata, every step's content, - * files, sources, tool calls, tool results and warnings, and the usage summed - * over the steps. [reasoning], [reasoningText] (`null` when there is none), - * [totalUsage] (equal to [usage]), [request], [response] and - * [providerMetadata] are deprecated upstream. [output] is the value parsed by - * the `output` option: `null` when it was not parsed (absent from the JSON), - * [JsonNull] when the parsed value is JSON `null`. - * - * Mirrors `GenerateTextResult.ts`; [response] reads the id, timestamp and - * model id of the step response. `content`, `reasoning`, the tool results and - * the steps are kept as raw [JsonElement]s. - */ -@Serializable -data class GenerateTextResult( - val text: String = "", - val content: List = emptyList(), - val reasoning: List = emptyList(), - val reasoningText: String? = null, - val files: List = emptyList(), - val sources: List = emptyList(), - val toolCalls: List = emptyList(), - val toolResults: List = emptyList(), - val finishReason: FinishReasonUnified = FinishReasonUnified.OTHER, - val rawFinishReason: String? = null, - val usage: LanguageModelUsage = LanguageModelUsage(), - val totalUsage: LanguageModelUsage = LanguageModelUsage(), - val warnings: List = emptyList(), - val request: RequestInfo = RequestInfo(), - val response: ResponseMetadata = ResponseMetadata(), - val responseMessages: List = emptyList(), - val providerMetadata: ProviderMetadata? = null, - val steps: List = emptyList(), - @Serializable(with = JsonValuePresentSerializer::class) - val output: JsonElement? = null, -) - -/** - * Result of `generate_object` (user-facing, M12), the AI SDK's - * `GenerateObjectResult`: the parsed JSON object and the call's reasoning, - * finish reason, usage, warnings, request, response and provider metadata. - * The AI SDK deprecates `generateObject` for `generateText` with an `output`. - * - * Mirrors `GenerateObjectResult.ts`. `object` is an arbitrary JSON value; - * [response] reads the id, timestamp and model id. - */ -@Serializable -data class GenerateObjectResult( - val `object`: JsonElement, - /** The reasoning text, `null` when there is none. */ - val reasoning: String? = null, - val finishReason: FinishReasonUnified = FinishReasonUnified.OTHER, - val usage: LanguageModelUsage = LanguageModelUsage(), - val warnings: List = emptyList(), - val request: RequestInfo = RequestInfo(), - val response: ResponseMetadata = ResponseMetadata(), - val providerMetadata: ProviderMetadata? = null, -) - -// ───────────────────────────────────────────────────────────────────────────── -// StreamPart (the streaming chunk type), tagged on `type`. -// -// Mirrors `TextStreamPart.ts`, the AI SDK's full stream that -// `aimux_stream_text` emits: `start`, then per step `start-step` … -// `finish-step`, then `finish` (or `abort`). An unknown `type` fails decoding -// ([TypedModel] reports it through `onError`). -// ───────────────────────────────────────────────────────────────────────────── - -@Serializable -sealed interface StreamPart { - @Serializable - @SerialName("text-start") - data class TextStart(val id: String = "", val providerMetadata: ProviderMetadata? = null) : StreamPart - - @Serializable - @SerialName("text-delta") - data class TextDelta( - val id: String = "", - /** The delta, under `text` as in the AI SDK's TextStreamPart. */ - val text: String = "", - val providerMetadata: ProviderMetadata? = null, - ) : StreamPart - - @Serializable - @SerialName("text-end") - data class TextEnd(val id: String = "", val providerMetadata: ProviderMetadata? = null) : StreamPart - - /** Opens the stream (the AI SDK's `start` part). */ - @Serializable - @SerialName("start") - class Start : StreamPart { - override fun equals(other: Any?): Boolean = other is Start - override fun hashCode(): Int = 0 - override fun toString(): String = "Start" - } - - /** Opens a step: the step's request and the provider's call warnings. */ - @Serializable - @SerialName("start-step") - data class StartStep( - val request: RequestInfo = RequestInfo(), - val warnings: List = emptyList(), - ) : StreamPart - - /** Closes a step: its response metadata, usage, performance and finish reason. */ - @Serializable - @SerialName("finish-step") - data class FinishStep( - val response: ResponseMetadata = ResponseMetadata(), - val usage: LanguageModelUsage = LanguageModelUsage(), - /** Timing of the step (Rust `StepResultPerformance`). Weak type. */ - val performance: JsonElement? = null, - val finishReason: FinishReasonUnified = FinishReasonUnified.OTHER, - val rawFinishReason: String? = null, - val providerMetadata: ProviderMetadata? = null, - ) : StreamPart - - /** Closes the stream: the last step's finish reason and the usage summed over the steps. */ - @Serializable - @SerialName("finish") - data class Finish( - val finishReason: FinishReasonUnified = FinishReasonUnified.OTHER, - val rawFinishReason: String? = null, - val totalUsage: LanguageModelUsage = LanguageModelUsage(), - ) : StreamPart - - /** Ends a stream whose call was aborted. */ - @Serializable - @SerialName("abort") - data class Abort(val reason: String? = null) : StreamPart - - /** `error` is the `name`-tagged `AiMuxError` JSON. */ - @Serializable - @SerialName("error") - data class Error(val error: JsonElement = JsonObject(emptyMap())) : StreamPart - - @Serializable - @SerialName("tool-input-start") - data class ToolInputStart( - val id: String = "", - val toolName: String = "", - val providerExecuted: Boolean? = null, - val dynamic: Boolean? = null, - val title: String? = null, - val providerMetadata: ProviderMetadata? = null, - ) : StreamPart - - @Serializable - @SerialName("tool-input-delta") - data class ToolInputDelta( - val id: String = "", - val delta: String = "", - val providerMetadata: ProviderMetadata? = null, - ) : StreamPart - - @Serializable - @SerialName("tool-input-end") - data class ToolInputEnd(val id: String = "", val providerMetadata: ProviderMetadata? = null) : StreamPart - - /** - * `invalid` is set by Core when the tool call stays invalid after optional - * repair; `error` is the typed lookup, parse, schema, or repair failure. - */ - @Serializable - @SerialName("tool-call") - data class ToolCall( - val toolCallId: String = "", - val toolName: String = "", - val input: JsonElement = JsonObject(emptyMap()), - val providerExecuted: Boolean? = null, - val dynamic: Boolean? = null, - val providerMetadata: ProviderMetadata? = null, - val invalid: Boolean? = null, - val error: JsonElement? = null, - ) : StreamPart - - /** - * A provider-executed tool's result (Rust `TypedToolResult`), with the - * input of the call it answers (`null` for a deferred result). - */ - @Serializable - @SerialName("tool-result") - data class ToolResult( - val toolCallId: String = "", - val toolName: String = "", - val input: JsonElement? = null, - val output: JsonElement = JsonNull, - val providerExecuted: Boolean? = null, - val providerMetadata: ProviderMetadata? = null, - val dynamic: Boolean? = null, - val preliminary: Boolean? = null, - ) : StreamPart - - /** - * A provider-executed tool's error result, or the error that follows an - * invalid tool call (Rust `TypedToolError`). - */ - @Serializable - @SerialName("tool-error") - data class ToolError( - val toolCallId: String = "", - val toolName: String = "", - val input: JsonElement? = null, - val error: JsonElement = JsonNull, - val providerExecuted: Boolean? = null, - val providerMetadata: ProviderMetadata? = null, - val dynamic: Boolean? = null, - ) : StreamPart - - /** A tool whose execution was denied (Rust `ToolOutputDenied`). */ - @Serializable - @SerialName("tool-output-denied") - data class ToolOutputDenied( - val toolCallId: String = "", - val toolName: String = "", - val providerExecuted: Boolean? = null, - val dynamic: Boolean? = null, - ) : StreamPart - - /** - * A file generated by the model (e.g. an image or document): - * `{"type": "file", "file": GeneratedFile, "providerMetadata"?}` - * (`stream-language-model-call.ts:569-590`). - */ - @Serializable - @SerialName("file") - data class File( - val file: GeneratedFile, - val providerMetadata: ProviderMetadata? = null, - ) : StreamPart - - /** A file generated as part of reasoning, with the same shape as [File]. */ - @Serializable - @SerialName("reasoning-file") - data class ReasoningFile( - val file: GeneratedFile, - val providerMetadata: ProviderMetadata? = null, - ) : StreamPart - - @Serializable - @SerialName("custom") - data class Custom(val kind: String, val providerMetadata: ProviderMetadata? = null) : StreamPart - - @Serializable - @SerialName("tool-approval-request") - data class ToolApprovalRequest( - val approvalId: String, - val toolCall: ai.arcships.aimux.ToolCall, - val reason: String? = null, - val isAutomatic: Boolean? = null, - val signature: String? = null, - ) : StreamPart - - /** The answer to a tool approval request (Rust `ToolApprovalResponseOutput`). */ - @Serializable - @SerialName("tool-approval-response") - data class ToolApprovalResponse( - val approvalId: String, - val toolCall: ai.arcships.aimux.ToolCall, - val approved: Boolean, - val reason: String? = null, - val providerExecuted: Boolean? = null, - ) : StreamPart - - @Serializable - @SerialName("reasoning-start") - data class ReasoningStart(val id: String = "", val providerMetadata: ProviderMetadata? = null) : StreamPart - - @Serializable - @SerialName("reasoning-delta") - data class ReasoningDelta( - val id: String = "", - /** The delta, under `text` as in the AI SDK's TextStreamPart. */ - val text: String = "", - val providerMetadata: ProviderMetadata? = null, - ) : StreamPart - - @Serializable - @SerialName("reasoning-end") - data class ReasoningEnd(val id: String = "", val providerMetadata: ProviderMetadata? = null) : StreamPart - - /** A source cited by the response (flat: `type: "source"` plus the [Source] fields). */ - @Serializable - @SerialName("source") - data class Source( - val sourceType: String, - val id: String, - val url: String? = null, - val mediaType: String? = null, - val title: String? = null, - val filename: String? = null, - val providerMetadata: ProviderMetadata? = null, - ) : StreamPart - - @Serializable - @SerialName("raw") - data class Raw(val rawValue: JsonElement = JsonObject(emptyMap())) : StreamPart -} - -// ───────────────────────────────────────────────────────────────────────────── -// OpenAI Chat Completions output (RFC-0026). -// -// Mirrors `aimux-core::openai_output`. Field names are camelCase in Kotlin and -// mapped to the wire's snake_case via [SerialName]. The `type` field is JSON -// `"type"` (Rust `#[serde(rename = "type")]`) → `toolType`. Arbitrary-JSON -// fields (`logprobs`, `annotations`) are [JsonElement]. -// ───────────────────────────────────────────────────────────────────────────── - -/** A complete Chat Completion response (non-streaming). Mirrors OpenAI `chat.completion`. */ -@Serializable -data class ChatCompletion( - val id: String = "", - val `object`: String = "chat.completion", - val created: Long = 0, - val model: String = "", - val choices: List = emptyList(), - val usage: ChatCompletionUsage = ChatCompletionUsage(), - @SerialName("system_fingerprint") val systemFingerprint: String? = null, -) - -@Serializable -data class ChatCompletionChoice( - val index: Int = 0, - val message: ChatCompletionMessage = ChatCompletionMessage(), - @SerialName("finish_reason") val finishReason: String? = null, - val logprobs: JsonElement? = null, -) - -@Serializable -data class ChatCompletionMessage( - val role: String = "assistant", - val content: String? = null, - @SerialName("reasoning_content") val reasoningContent: String? = null, - @SerialName("tool_calls") val toolCalls: List? = null, - val annotations: List? = null, -) - -/** - * A tool call in a [ChatCompletionMessage]. - * - * Wire: `{"id","type":"function","function":{"name","arguments"}}`. The `type` - * field is JSON `"type"` (Rust `#[serde(rename = "type")]`). - */ -@Serializable -data class ChatCompletionToolCall( - val id: String = "", - @SerialName("type") val toolType: String = "function", - val function: ChatCompletionFunction = ChatCompletionFunction(), -) - -@Serializable -data class ChatCompletionFunction( - val name: String = "", - val arguments: String = "", -) - -/** A single Chat Completion chunk (streaming). Mirrors OpenAI `chat.completion.chunk`. */ -@Serializable -data class ChatCompletionChunk( - val id: String = "", - val `object`: String = "chat.completion.chunk", - val created: Long = 0, - val model: String = "", - val choices: List = emptyList(), - val usage: ChatCompletionUsage? = null, -) - -@Serializable -data class ChatCompletionChunkChoice( - val index: Int = 0, - val delta: ChatCompletionDelta = ChatCompletionDelta(), - @SerialName("finish_reason") val finishReason: String? = null, - val logprobs: JsonElement? = null, -) - -@Serializable -data class ChatCompletionDelta( - val role: String? = null, - val content: String? = null, - @SerialName("reasoning_content") val reasoningContent: String? = null, - @SerialName("tool_calls") val toolCalls: List? = null, -) - -/** - * A tool call delta in a [ChatCompletionChunk]. - * - * Wire: `{"index","id"?,"type":"function"?,"function":{"name"?,"arguments"?}}`. - * The `type` field is JSON `"type"` (Rust `#[serde(rename = "type")]`). - */ -@Serializable -data class ChatCompletionChunkToolCall( - val index: Int = 0, - val id: String? = null, - @SerialName("type") val toolType: String? = null, - val function: ChatCompletionChunkFunction = ChatCompletionChunkFunction(), -) - -@Serializable -data class ChatCompletionChunkFunction( - val name: String? = null, - val arguments: String? = null, -) - -/** Token usage statistics (shared by streaming and non-streaming). */ -@Serializable -data class ChatCompletionUsage( - @SerialName("prompt_tokens") val promptTokens: Int = 0, - @SerialName("completion_tokens") val completionTokens: Int = 0, - @SerialName("total_tokens") val totalTokens: Int = 0, - @SerialName("prompt_tokens_details") val promptTokensDetails: PromptTokensDetails? = null, - @SerialName("completion_tokens_details") val completionTokensDetails: CompletionTokensDetails? = null, -) - -@Serializable -data class PromptTokensDetails( - @SerialName("cached_tokens") val cachedTokens: Int = 0, - @SerialName("cache_write_tokens") val cacheWriteTokens: Int? = null, -) - -@Serializable -data class CompletionTokensDetails( - @SerialName("reasoning_tokens") val reasoningTokens: Int? = null, -) diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/internal/FlowBridge.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/internal/FlowBridge.kt new file mode 100644 index 000000000..9b03897d4 --- /dev/null +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/internal/FlowBridge.kt @@ -0,0 +1,75 @@ +package ai.arcships.aimux.internal + +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +/** Bridges a synchronous native call; its callbacks must finish before [produce] returns. */ +internal object FlowBridge { + /** + * Each collector starts its own call on IO. A full channel blocks the native + * callback without dropping values. [abort] must be bounded and unblock the + * native call; collection retains the callback context until that call returns. + * Payload byte limits remain the responsibility of the native operation. + */ + fun flow( + capacity: Int = 64, + abort: () -> Unit, + produce: ((T) -> Unit) -> Unit, + ): Flow { + require(capacity in 1..256) { "capacity must be between 1 and 256" } + return flow { + coroutineScope { + val channel = Channel(capacity) + val failure = AtomicReference() + val aborted = AtomicBoolean() + fun abortOnce() { + if (aborted.compareAndSet(false, true)) { + try { + abort() + } catch (error: Throwable) { + failure.compareAndSet(null, error) + } + } + } + launch(Dispatchers.IO) { + try { + produce { value -> + if (failure.get() == null) { + try { + runBlocking { channel.send(value) } + } catch (error: Throwable) { + // Never unwind through a native callback. + failure.compareAndSet(null, error) + abortOnce() + } + } + } + } catch (error: Throwable) { + failure.compareAndSet(null, error) + abortOnce() + } finally { + // Closing needs no queue slot and preserves buffered events. + channel.close(failure.get()) + } + } + var drained = false + try { + for (value in channel) emit(value) + drained = true + } finally { + if (!drained) { + channel.cancel() + abortOnce() + } + } + } + } + } +} diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/kotlin/Coroutines.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/kotlin/Coroutines.kt new file mode 100644 index 000000000..452e737df --- /dev/null +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/kotlin/Coroutines.kt @@ -0,0 +1,102 @@ +package ai.arcships.aimux.kotlin + +import ai.arcships.aimux.NativeException +import ai.arcships.aimux.internal.FlowBridge +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.util.concurrent.CompletionException +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executor +import java.util.concurrent.Future +import java.util.concurrent.Phaser +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import java.util.function.Consumer +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** Start and await a Java call on IO. Cancellation signals Java's owned Abort. */ +suspend fun aimuxCall(start: (Executor) -> Future): T = withContext(Dispatchers.IO) { + var future: Future? = null + try { + currentCoroutineContext().ensureActive() + future = start(Dispatchers.IO.asExecutor()) + currentCoroutineContext().ensureActive() + future.awaitAimux() + } catch (error: Throwable) { + throw projectError(error) + } finally { + if (!currentCoroutineContext().isActive && future?.isCancelled != true) future?.cancel(true) + } +} + +/** Await a Java operation; cancelling the coroutine cancels its Future. */ +suspend fun Future.awaitAimux(): T = suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cancel(true) } + Dispatchers.IO.asExecutor().execute { + try { + continuation.resume(get()) + } catch (error: Throwable) { + continuation.resumeWithException(projectError(error)) + } + } +} + +/** + * A cold, bounded stream over Java's Executor/Consumer API. [start] must schedule + * all callback work on the supplied executor and return its Future promptly. + * Collection waits for that work to return, including after cancellation, so + * Java retains callback context through the native return boundary. + */ +fun aimuxFlow( + capacity: Int = 64, + start: (Executor, Consumer) -> Future<*>, +): Flow { + require(capacity in 1..256) { "capacity must be between 1 and 256" } + return flow { + val future = AtomicReference?>() + val cancelled = AtomicBoolean() + val tasks = Phaser(1) + val executor = Executor { task -> + tasks.register() + try { + Dispatchers.IO.asExecutor().execute { + try { task.run() } finally { tasks.arriveAndDeregister() } + } + } catch (error: Throwable) { + tasks.arriveAndDeregister() + throw error + } + } + emitAll(FlowBridge.flow(capacity, abort = { + cancelled.set(true) + future.get()?.cancel(true) + }) { send -> + try { + val operation = start(executor, Consumer(send)) + future.set(operation) + if (cancelled.get()) operation.cancel(true) + operation.get() + } finally { + // A cancelled Future can finish before its native task returns. + tasks.arriveAndAwaitAdvance() + } + }) + }.catch { throw projectError(it) } +} + +// Preserve CancellationException with its Java source error for coroutine cancellation semantics. +internal fun projectError(error: Throwable): Throwable = when (error) { + is ExecutionException, is CompletionException -> error.cause?.let(::projectError) ?: error + is NativeException -> AimuxException.fromJava(error) + else -> error +} diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/kotlin/Errors.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/kotlin/Errors.kt new file mode 100644 index 000000000..83e9c662b --- /dev/null +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/kotlin/Errors.kt @@ -0,0 +1,80 @@ +package ai.arcships.aimux.kotlin + +import ai.arcships.aimux.AimuxException as JavaAimuxException +import ai.arcships.aimux.NativeException +import ai.arcships.aimux.BindingException +import ai.arcships.aimux.ProtocolException +import ai.arcships.aimux.RecordingException + +/** Sealed view of Java's decoded envelope. No JSON decoding occurs in Kotlin. */ +sealed class AimuxException protected constructor(val javaError: NativeException) : + RuntimeException(javaError.message, javaError) { + val code get() = javaError.code + val kind get() = javaError.kind + val errorName get() = javaError.errorName + val retryAfterMs get() = javaError.retryAfterMs + val error get() = javaError.error + val envelope get() = javaError.envelope + + companion object { + @JvmStatic + fun fromJava(error: NativeException): AimuxException = when (error) { + is JavaAimuxException.NoOutputGeneratedError -> NoOutputGeneratedError(error) + is JavaAimuxException.JSONParseError -> JSONParseError(error) + is JavaAimuxException.InvalidResponseDataError -> InvalidResponseDataError(error) + is JavaAimuxException.ToolCallNotFoundForApprovalError -> ToolCallNotFoundForApprovalError(error) + is JavaAimuxException.InvalidArgumentError -> InvalidArgumentError(error) + is JavaAimuxException.InvalidPromptError -> InvalidPromptError(error) + is JavaAimuxException.LoadAPIKeyError -> LoadAPIKeyError(error) + is JavaAimuxException.LoadSettingError -> LoadSettingError(error) + is JavaAimuxException.TokenExpiredError -> TokenExpiredError(error) + is JavaAimuxException.UnsupportedFunctionalityError -> UnsupportedFunctionalityError(error) + is JavaAimuxException.NoSuchModelError -> NoSuchModelError(error) + is JavaAimuxException.NoSuchProviderError -> NoSuchProviderError(error) + is JavaAimuxException.APICallError -> APICallError(error) + is JavaAimuxException.RetryError -> RetryError(error) + is JavaAimuxException.TimeoutError -> TimeoutError(error) + is JavaAimuxException.RequestAbortedError -> RequestAbortedError(error) + is JavaAimuxException.NoSuchToolError -> NoSuchToolError(error) + is JavaAimuxException.InvalidToolInputError -> InvalidToolInputError(error) + is JavaAimuxException.ToolCallRepairError -> ToolCallRepairError(error) + is JavaAimuxException.MissingToolResultsError -> MissingToolResultsError(error) + is JavaAimuxException.ToolChoiceViolationError -> ToolChoiceViolationError(error) + is JavaAimuxException.OtherError -> OtherError(error) + is BindingException -> BindingError(error) + is ProtocolException -> ProtocolError(error) + is RecordingException -> RecordingError(error) + else -> UnknownError(error) + } + } +} + +class NoOutputGeneratedError internal constructor(val details: JavaAimuxException.NoOutputGeneratedError) : AimuxException(details) +class JSONParseError internal constructor(val details: JavaAimuxException.JSONParseError) : AimuxException(details) +class InvalidResponseDataError internal constructor(val details: JavaAimuxException.InvalidResponseDataError) : AimuxException(details) +class ToolCallNotFoundForApprovalError internal constructor(val details: JavaAimuxException.ToolCallNotFoundForApprovalError) : AimuxException(details) +class InvalidArgumentError internal constructor(val details: JavaAimuxException.InvalidArgumentError) : AimuxException(details) +class InvalidPromptError internal constructor(val details: JavaAimuxException.InvalidPromptError) : AimuxException(details) +class LoadAPIKeyError internal constructor(val details: JavaAimuxException.LoadAPIKeyError) : AimuxException(details) +class LoadSettingError internal constructor(val details: JavaAimuxException.LoadSettingError) : AimuxException(details) +class TokenExpiredError internal constructor(val details: JavaAimuxException.TokenExpiredError) : AimuxException(details) +class UnsupportedFunctionalityError internal constructor(val details: JavaAimuxException.UnsupportedFunctionalityError) : AimuxException(details) +class NoSuchModelError internal constructor(val details: JavaAimuxException.NoSuchModelError) : AimuxException(details) +class NoSuchProviderError internal constructor(val details: JavaAimuxException.NoSuchProviderError) : AimuxException(details) +class APICallError internal constructor(val details: JavaAimuxException.APICallError) : AimuxException(details) +class RetryError internal constructor(val details: JavaAimuxException.RetryError) : AimuxException(details) +class TimeoutError internal constructor(val details: JavaAimuxException.TimeoutError) : AimuxException(details) +class RequestAbortedError internal constructor(val details: JavaAimuxException.RequestAbortedError) : AimuxException(details) +class NoSuchToolError internal constructor(val details: JavaAimuxException.NoSuchToolError) : AimuxException(details) +class InvalidToolInputError internal constructor(val details: JavaAimuxException.InvalidToolInputError) : AimuxException(details) +class ToolCallRepairError internal constructor(val details: JavaAimuxException.ToolCallRepairError) : AimuxException(details) +class MissingToolResultsError internal constructor(val details: JavaAimuxException.MissingToolResultsError) : AimuxException(details) +class ToolChoiceViolationError internal constructor(val details: JavaAimuxException.ToolChoiceViolationError) : AimuxException(details) +class OtherError internal constructor(val details: JavaAimuxException.OtherError) : AimuxException(details) + +class BindingError internal constructor(val details: BindingException) : AimuxException(details) +class ProtocolError internal constructor(val details: ProtocolException) : AimuxException(details) +class RecordingError internal constructor(val details: RecordingException) : AimuxException(details) + +/** Preserves a future Java subtype without interpreting its open payload. */ +class UnknownError internal constructor(error: NativeException) : AimuxException(error) diff --git a/bindings/kotlin/src/test/java/ai/arcships/aimux/KotlinInteropFixtures.java b/bindings/kotlin/src/test/java/ai/arcships/aimux/KotlinInteropFixtures.java new file mode 100644 index 000000000..d64bc66da --- /dev/null +++ b/bindings/kotlin/src/test/java/ai/arcships/aimux/KotlinInteropFixtures.java @@ -0,0 +1,24 @@ +package ai.arcships.aimux; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** Test access to Java's decoder; production Kotlin never decodes envelopes. */ +public final class KotlinInteropFixtures { + private KotlinInteropFixtures() {} + + public static NativeException decode(String json) throws Exception { + return AimuxResult.decode(new ObjectMapper().readTree(json)); + } + + public static NativeException unknown(String json) throws Exception { + return new FutureError(new ObjectMapper().readTree(json)); + } + + private static final class FutureError extends NativeException { + FutureError(JsonNode envelope) { super(envelope); } + } + + public static Model acceptModel(Model value) { return value; } + public static ProviderHandle acceptProvider(ProviderHandle value) { return value; } +} diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/DecisionModelTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/DecisionModelTest.kt deleted file mode 100644 index 34fb84a2e..000000000 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/DecisionModelTest.kt +++ /dev/null @@ -1,115 +0,0 @@ -package ai.arcships.aimux - -import com.sun.net.httpserver.HttpServer -import java.net.InetSocketAddress -import java.nio.file.Files -import java.nio.file.Paths -import java.util.concurrent.atomic.AtomicReference -import org.json.JSONObject -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Assertions.* - -class DecisionModelTest { - @Test fun providerDecisionModelOwnsItsHandle() { - Model.createProvider("openai", "test-key").use { provider -> - provider.decisionModel("gpt-6-luna").use { model -> - provider.close() - val description = JSONObject(model.describe()) - assertEquals("v4", description.getString("specificationVersion")) - assertEquals("openai", description.getString("provider")) - assertEquals("gpt-6-luna", description.getString("modelId")) - assertThrows(IllegalStateException::class.java) { provider.decisionModel("gpt-6-luna") } - } - } - } - - @Test fun officialStructuredContractAndDescription() { - val fixture = JSONObject(String(Files.readAllBytes(Paths.get("../../contract-tests/fixtures/decision-native.json")))) - val captured = AtomicReference() - val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) - server.createContext("/v1/systemone") { exchange -> - captured.set(JSONObject(exchange.requestBody.bufferedReader().readText())) - val bytes = fixture.getJSONObject("response").toString().toByteArray() - exchange.responseHeaders.set("Content-Type", "application/json") - exchange.sendResponseHeaders(200, bytes.size.toLong()) - exchange.responseBody.use { it.write(bytes) } - exchange.close() - } - server.start() - try { - DecisionModel.jev("test-key", "jev-latest", "http://127.0.0.1:${server.address.port}/v1/systemone").use { model -> - val description = JSONObject(model.describe()) - assertEquals("jev", description.getString("provider")) - assertEquals(3, description.getJSONArray("supportedQuestionTypes").length()) - assertNull(captured.get()) - val request = fixture.getJSONObject("request") - val result = JSONObject(model.decide(request.toString())) - assertEquals(0.9, result.getJSONObject("answers").getJSONObject("urgent").getDouble("probability")) - assertEquals(2, result.getJSONObject("rounding").getInt("probabilityDecimals")) - assertTrue(JSONObject(mapOf("inputTokens" to 50, "outputTokens" to 10, "totalTokens" to 60)) - .similar(result.getJSONObject("usage"))) - val questions = request.getJSONObject("questions") - assertTrue(questions.getJSONObject("urgent").getJSONObject("criteria") - .similar(captured.get().getJSONObject("questions").getJSONObject("urgent").getJSONObject("criteria"))) - assertTrue(questions.getJSONObject("severity").getJSONArray("criteria") - .similar(captured.get().getJSONObject("questions").getJSONObject("severity").getJSONArray("criteria"))) - assertThrows(IllegalArgumentException::class.java) { model.decide("{") } - model.close() - assertThrows(IllegalStateException::class.java) { model.describe() } - } - } finally { server.stop(0) } - assertThrows(AimuxException::class.java) { DecisionModel.jev("test-key", "jev-latest", probabilitySource = "unknown") } - } - - @Test fun openaiMediaAndTypedValues() { - val text = String(Files.readAllBytes(Paths.get("../../contract-tests/fixtures/decision-openai-full.json"))) - val fixture = JSONObject(text) - val captured = AtomicReference() - val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) - server.createContext("/v1/decisions") { exchange -> - captured.set(JSONObject(exchange.requestBody.bufferedReader().readText())) - val bytes = fixture.getJSONObject("response").toString().toByteArray() - exchange.responseHeaders.set("Content-Type", "application/json") - exchange.sendResponseHeaders(200, bytes.size.toLong()) - exchange.responseBody.use { it.write(bytes) } - exchange.close() - } - server.start() - try { - val config = JSONObject().put("base_url", "http://127.0.0.1:${server.address.port}/v1").toString() - Model.createProvider("openai", "test-key", config).use { provider -> - provider.decisionModel("gpt-6-luna").use { model -> - val result = JSONObject(model.decide(rawObject(text, "options"))) - assertTrue(fixture.getJSONObject("request").similar(captured.get())) - assertEquals("boolean_true", result.getJSONObject("answers").getJSONObject("choice").getString("choice")) - assertEquals(true, result.getJSONObject("providerMetadata").getJSONObject("openai") - .getJSONObject("answers").getJSONObject("choice").get("value")) - assertEquals(0.75, result.getJSONObject("answers").getJSONObject("score").getDouble("score")) - } - } - } finally { server.stop(0) } - } - - /** - * The raw text of the fixture's top-level `key` object. The questions and - * criteria are maps whose order is the request's order, and org.json's - * JSONObject does not keep key order. - */ - private fun rawObject(text: String, key: String): String { - val start = text.indexOf('{', text.indexOf("\"$key\":")) - var depth = 0 - var inString = false - var i = start - while (true) { - val c = text[i] - if (inString) { - if (c == '\\') i++ else if (c == '"') inString = false - } else when (c) { - '"' -> inString = true - '{' -> depth++ - '}' -> if (--depth == 0) return text.substring(start, i + 1) - } - i++ - } - } -} diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ErrorsTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ErrorsTest.kt deleted file mode 100644 index c3a2f0bd9..000000000 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ErrorsTest.kt +++ /dev/null @@ -1,248 +0,0 @@ -package ai.arcships.aimux - -import com.sun.jna.ptr.LongByReference -import com.sun.jna.ptr.PointerByReference -import kotlinx.serialization.json.Json -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatThrownBy -import org.junit.jupiter.api.Test - -/** - * Tests for the error decoders: real `aimux_error_t *` objects obtained - * offline (C ABI codes 200..206 → plain IllegalStateException; core failures - * → [AimuxException] via [expectAimuxError]) and the pure - * code → class mapping ([AimuxException.createByCode]) for shapes that need a - * network to produce. - */ -class ErrorsTest { - - /** Dead handle via raw FFI: code 203 and message → IllegalStateException, then freed. */ - @Test - fun `dead handle is an IllegalStateException, not an AiMuxError`() { - val out = PointerByReference() - val e = FFI.lib.aimux_generate_text(0x7FFF_FFFFL, "\"hi\"", null, out) - assertThat(e).isNotNull - assertThat(out.value).isNull() - assertThat(FFI.lib.aimux_error_code(e)).isEqualTo(203) - assertThat(takeString(FFI.lib.aimux_error_message(e))).isNotEmpty() - assertThatThrownBy { throw expectAimuxError(e!!, "generateText") } - .isInstanceOf(IllegalStateException::class.java) - .isNotInstanceOf(AimuxException::class.java) - .hasMessageContaining("aimux ffi:") - .hasMessageContaining("model") - } - - /** NULL required string (unreachable through the binding) → invariant naming the parameter. */ - @Test - fun `null string argument is an IllegalStateException naming the parameter`() { - val out = LongByReference(0) - val e = FFI.lib.aimux_openai_new(null, "gpt-4o-mini", out) - assertThat(e).isNotNull - assertThat(out.value).isEqualTo(0L) - assertThat(FFI.lib.aimux_error_code(e)).isEqualTo(200) - assertThatThrownBy { throw expectAimuxError(e!!) } - .isInstanceOf(IllegalStateException::class.java) - .isNotInstanceOf(AimuxException::class.java) - .hasMessageContaining("aimux ffi:") - .hasMessageContaining("api_key") - } - - /** A C-ABI-only utility decodes through expectFfiError. */ - @Test - fun `expectFfiError reads the message and frees`() { - val e = FFI.lib.aimux_transcription_input_done(0x7FFF_FFFFL) - assertThat(e).isNotNull - assertThatThrownBy { throw expectFfiError(e!!, "inputDone") } - .isInstanceOf(IllegalStateException::class.java) - .hasMessageStartingWith("inputDone: aimux ffi:") - } - - /** The binding rejects malformed raw JSON before the C call, naming the Kotlin parameter. */ - @Test - fun `requireJson rejects malformed text and passes null, empty and valid JSON`() { - assertThatThrownBy { requireJsonRequired("promptJson", "{not json") } - .isInstanceOf(IllegalArgumentException::class.java) - .hasMessageContaining("promptJson") - // Optional (String?) overload: null / empty are left to the FFI. - val optionalEmpty: String? = "" - requireJson("optsJson", null) - requireJson("configJson", optionalEmpty) - // Required (String) overload: blank is rejected up front. - assertThatThrownBy { requireJsonRequired("promptJson", "") } - .isInstanceOf(IllegalArgumentException::class.java) - .hasMessage("promptJson: invalid JSON: empty") - assertThatThrownBy { requireJsonRequired("promptJson", " ") } - .isInstanceOf(IllegalArgumentException::class.java) - requireJsonRequired("promptJson", "\"hi\"") - requireJson("optsJson", "{\"a\":[1,2]}") - } - - /** Core-originated failure: the NoSuchProvider payload is available under code 10. */ - @Test - fun `expectAimuxError maps the provider id onto NoSuchProviderError`() { - val out = LongByReference(0) - val e = FFI.lib.aimux_provider_new("definitely-not-a-provider", "k", "m", null, out) - assertThat(e).isNotNull - assertThat(out.value).isEqualTo(0L) - assertThat(FFI.lib.aimux_error_code(e)).isEqualTo(AIMUX_E_NO_SUCH_PROVIDER) - val ex = expectAimuxError(e!!, "provider") as NoSuchProviderError - assertThat(ex.providerId).isEqualTo("definitely-not-a-provider") - assertThat(ex.code).isEqualTo(AIMUX_E_NO_SUCH_PROVIDER) - assertThat(ex.message).startsWith("provider: ") - } - - /** A recording call returns the recording code range. */ - @Test - fun `expectRecordingError maps the recording code`() { - // Directory path that cannot be created (a file stands in the way). - val f = java.io.File.createTempFile("aimux-rec", ".tmp").apply { deleteOnExit() } - val e = FFI.lib.aimux_init_recording(java.io.File(f, "sub").path) - assertThat(e).isNotNull - assertThat(FFI.lib.aimux_error_code(e)).isEqualTo(100) - val ex = expectRecordingError(e!!, "initRecording") as RecordingException - assertThat(ex.code).isEqualTo(RecordingErrorCode.INIT) - assertThat(ex.message).startsWith("initRecording: ") - } - - /** Each payload lands on the class its code owns and nowhere else. */ - @Test - fun `createByCode maps per-code payloads onto the owning subclass`() { - val apiEx = AimuxException.createByCode( - AIMUX_E_API_CALL, "API call error: HTTP 429: slow down", 429, 1500, - retryable = true, providerCode = "insufficient_quota", providerMessage = "slow down", - responseBody = "{\"error\":{}}", url = "https://api.example/v1/chat", - requestBodyValues = Json.parseToJsonElement("""{"model":"m"}"""), - responseHeaders = mapOf("retry-after-ms" to "1500"), - data = Json.parseToJsonElement("""{"code":"quota"}"""), - ) as APICallError - assertThat(apiEx.status).isEqualTo(429) - assertThat(apiEx.retryMs).isEqualTo(1500L) - assertThat(apiEx.retryable).isTrue() - assertThat(apiEx.providerCode).isEqualTo("insufficient_quota") - assertThat(apiEx.providerMessage).isEqualTo("slow down") - assertThat(apiEx.responseBody).isEqualTo("{\"error\":{}}") - assertThat(apiEx.url).isEqualTo("https://api.example/v1/chat") - assertThat(apiEx.requestBodyValues.toString()).isEqualTo("""{"model":"m"}""") - assertThat(apiEx.responseHeaders).isEqualTo(mapOf("retry-after-ms" to "1500")) - assertThat(apiEx.data.toString()).isEqualTo("""{"code":"quota"}""") - - val modelEx = AimuxException.createByCode( - AIMUX_E_NO_SUCH_MODEL, "no such model", modelId = "gpt-nope", modelType = "language", - ) as NoSuchModelError - assertThat(modelEx.modelId).isEqualTo("gpt-nope") - assertThat(modelEx.modelType).isEqualTo("language") - - // Absent: null on APICallError, "" on the id classes. - assertThat((AimuxException.createByCode(AIMUX_E_API_CALL, "x") as APICallError).url).isNull() - assertThat((AimuxException.createByCode(AIMUX_E_API_CALL, "x") as APICallError).responseHeaders).isNull() - assertThat((AimuxException.createByCode(AIMUX_E_NO_SUCH_MODEL, "x") as NoSuchModelError).modelId).isEmpty() - assertThat((AimuxException.createByCode(AIMUX_E_NO_SUCH_PROVIDER, "x") as NoSuchProviderError).providerId).isEmpty() - } - - /** Code 14: the retry loop's verdict plus the per-attempt history, oldest first. */ - @Test - fun `createByCode builds RetryError with reason and attempt history`() { - val attempts = listOf( - AimuxException.createByCode(AIMUX_E_API_CALL, "HTTP 500", 500, retryable = true), - AimuxException.createByCode(AIMUX_E_API_CALL, "HTTP 401", 401), - ) - val ex = AimuxException.createByCode( - AIMUX_E_RETRY, "Failed after 2 attempts with non-retryable error: 'HTTP 401'", - retryReason = RetryErrorReason.ERROR_NOT_RETRYABLE, retryErrors = attempts, - ) as RetryError - assertThat(ex.code).isEqualTo(AIMUX_E_RETRY) - assertThat(ex.reason).isEqualTo(RetryErrorReason.ERROR_NOT_RETRYABLE) - assertThat(ex.errors).hasSize(2) - assertThat(ex.lastError.status).isEqualTo(401) - assertThat(ex.status).isEqualTo(-1) - - // Defensive: an empty history collapses to one OtherError carrying the message. - val fallback = AimuxException.createByCode(AIMUX_E_RETRY, "gave up") as RetryError - assertThat(fallback.errors).hasSize(1) - assertThat(fallback.lastError).isInstanceOf(OtherError::class.java) - assertThat(fallback.reason).isEqualTo(RetryErrorReason.MAX_RETRIES_EXCEEDED) - } - - /** The wire names are the core's serde camelCase; unknown text falls back to the common case. */ - @Test - fun `RetryErrorReason maps the wire names`() { - assertThat(RetryErrorReason.fromWire("errorNotRetryable")).isEqualTo(RetryErrorReason.ERROR_NOT_RETRYABLE) - assertThat(RetryErrorReason.fromWire("maxRetriesExceeded")).isEqualTo(RetryErrorReason.MAX_RETRIES_EXCEEDED) - assertThat(RetryErrorReason.fromWire(null)).isEqualTo(RetryErrorReason.MAX_RETRIES_EXCEEDED) - } - - /** 401 / 404 are the same class; only the status distinguishes them. */ - @Test - fun `createByCode maps HTTP statuses onto APICallError`() { - val auth = AimuxException.createByCode(AIMUX_E_API_CALL, "invalid api key", 401) - assertThat(auth).isInstanceOf(APICallError::class.java) - assertThat(auth.status).isEqualTo(401) - assertThat(auth.retryMs).isEqualTo(-1L) - assertThat(AimuxException.createByCode(AIMUX_E_API_CALL, "nope", 404).status).isEqualTo(404) - } - - /** TokenExpired keeps its own class: 401 whose fix is a refresh, not a retry. */ - @Test - fun `createByCode maps TokenExpired to status 401`() { - val ex = AimuxException.createByCode(AIMUX_E_TOKEN_EXPIRED, "expired") - assertThat(ex).isInstanceOf(TokenExpiredError::class.java) - assertThat(ex.status).isEqualTo(401) - } - - /** Same status (-1), opposite verdicts: status cannot stand in for retryable. */ - @Test - fun `createByCode carries the retry verdict that status cannot express`() { - val transport = AimuxException.createByCode(AIMUX_E_API_CALL, "reset", retryable = true) - val noKey = AimuxException.createByCode(AIMUX_E_API_CALL, "no key") - assertThat(transport.status).isEqualTo(noKey.status) - assertThat(transport.retryable).isTrue() - assertThat(noKey.retryable).isFalse() - } - - /** 19 / 20: a missing API key / provider setting carry the consulted environment variable. */ - @Test - fun `createByCode maps the missing key and setting codes`() { - assertThat(AIMUX_E_LOAD_API_KEY).isEqualTo(19) - assertThat(AIMUX_E_LOAD_SETTING).isEqualTo(20) - assertThat(AimuxException.codeName(19)).isEqualTo("LoadApiKey") - assertThat(AimuxException.codeName(20)).isEqualTo("LoadSetting") - val key = AimuxException.createByCode(AIMUX_E_LOAD_API_KEY, "no key", envVar = "OPENAI_API_KEY") - assertThat(key).isInstanceOf(LoadAPIKeyError::class.java) - assertThat(key.code).isEqualTo(19) - assertThat((key as LoadAPIKeyError).envVar).isEqualTo("OPENAI_API_KEY") - val setting = AimuxException.createByCode(AIMUX_E_LOAD_SETTING, "no region", envVar = "AWS_REGION") - assertThat(setting).isInstanceOf(LoadSettingError::class.java) - assertThat(setting.code).isEqualTo(20) - assertThat((setting as LoadSettingError).envVar).isEqualTo("AWS_REGION") - } - - /** A code outside the enum is a header/library mismatch, not an error type. */ - @Test - fun `createByCode rejects codes outside the enum`() { - assertThatThrownBy { AimuxException.createByCode(999, "?") } - .isInstanceOf(IllegalStateException::class.java) - // 18 is NoOutputGenerated; 21 is the first unassigned value. - assertThat(AIMUX_E_NO_OUTPUT_GENERATED).isEqualTo(18) - assertThat(AimuxException.codeName(18)).isEqualTo("NoOutputGenerated") - assertThat(AimuxException.createByCode(18, "?")) - .isInstanceOf(NoOutputGeneratedError::class.java) - assertThatThrownBy { AimuxException.createByCode(21, "?") } - .isInstanceOf(IllegalStateException::class.java) - assertThatThrownBy { AimuxException.createByCode(AIMUX_OK, "?") } - .isInstanceOf(IllegalStateException::class.java) - } - - @Test - fun `RecordingException is not an AimuxException`() { - val rec = RecordingException(RecordingErrorCode.FLUSH_TIMEOUT, "flush") - assertThat(rec).isNotInstanceOf(AimuxException::class.java) - assertThat(rec.code).isEqualTo(RecordingErrorCode.FLUSH_TIMEOUT) - assertThat(rec.message).isEqualTo("flush") - } - - @Test - fun `recording codes outside the Rust enum are rejected`() { - assertThatThrownBy { RecordingErrorCode.fromC(999) } - .isInstanceOf(IllegalStateException::class.java) - } -} diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ModelTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ModelTest.kt deleted file mode 100644 index 76972e957..000000000 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ModelTest.kt +++ /dev/null @@ -1,260 +0,0 @@ -package ai.arcships.aimux - -import com.sun.jna.ptr.PointerByReference -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatCode -import org.assertj.core.api.Assertions.assertThatThrownBy -import org.json.JSONArray -import org.json.JSONObject -import org.junit.jupiter.api.Test -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicReference - -class ModelTest { - - @Test - fun `openai creates model instance`() { - // Even with a fake key, the provider should construct. - Model.openai("sk-test-fake-key", "gpt-4o-mini").use { model -> - assertThat(model).isNotNull - } - } - - @Test - fun `anthropic creates model instance`() { - Model.anthropic("sk-ant-test-fake-key", "claude-3-5-sonnet-20241022").use { model -> - assertThat(model).isNotNull - } - } - - @Test - fun `provider creates model instance from registry`() { - // Registry-backed construction (deepseek is in the provider registry); - // the key is validated on the first API call, not construction. - Model.provider("deepseek", apiKey = "sk-test-fake-key", modelId = "deepseek-chat").use { model -> - assertThat(model).isNotNull - } - } - - @Test - fun `generateText rejects malformed prompt JSON with IllegalArgumentException`() { - Model.openai("sk-test-fake-key", "gpt-4o-mini").use { model -> - // Malformed raw JSON is the caller's mistake, caught before the C - // call and named after the Kotlin parameter — not AiMuxError's - // JSONParseError. - assertThatThrownBy { - model.generateText("{invalid json}") - }.isInstanceOf(IllegalArgumentException::class.java) - .isNotInstanceOf(AimuxException::class.java) - .hasMessageContaining("promptJson") - } - } - - @Test - fun `streamTextSequence surfaces a typed AimuxException on failure`() { - // Unroutable base URL: the 7-arg stream signature is only checked by - // JNA at call time, so actually consume the sequence and assert the - // typed error surfaces. - Model.openai("sk-test-fake-key", "gpt-4o-mini", "http://127.0.0.1:9").use { model -> - assertThatThrownBy { - model.streamTextSequence("\"hello\"").toList() - }.isInstanceOf(AimuxException::class.java) - } - } - - @Test - fun `streamTextSequence returns a sequence`() { - Model.openai("sk-test-fake-key", "gpt-4o-mini").use { model -> - // We don't consume the sequence (would need network), - // but verify it can be created. - val seq = model.streamTextSequence("\"hello\"") - assertThat(seq).isNotNull - } - } - - @Test - fun `unknown provider failure carries the requested provider id`() { - // Core-originated failure: provider_id is available under code 10. - // expectAimuxError reads the getters and frees the error after mapping. - assertThatThrownBy { - Model.provider("definitely-not-a-provider", apiKey = "k", modelId = "m") - }.isInstanceOf(NoSuchProviderError::class.java) - .satisfies({ ex -> - assertThat((ex as NoSuchProviderError).providerId).isEqualTo("definitely-not-a-provider") - }) - } - - @Test - fun `FFI-synthesized failure maps and frees cleanly`() { - // Invalid handle: code 203 maps through expectAimuxError to a plain - // IllegalStateException (binding invariant) and frees the error. - val out = PointerByReference() - val e = FFI.lib.aimux_generate_text(0x7FFF_FFFFL, "\"hi\"", null, out) - assertThat(e).isNotNull - assertThat(out.value).isNull() - assertThat(FFI.lib.aimux_error_code(e)).isEqualTo(203) - assertThatThrownBy { throw expectAimuxError(e!!) } - .isInstanceOf(IllegalStateException::class.java) - .isNotInstanceOf(AimuxException::class.java) - .hasMessageContaining("model") - } - - @Test - fun `model is closeable`() { - val model = Model.openai("sk-test-fake-key", "gpt-4o-mini") - model.close() - // Calling close twice should not crash. - model.close() - } - - // ── concurrency (T6: close vs. in-flight FFI call) ───────────────────── - - /** - * Smoke test for the read/write-lock close-race fix (T6): one thread loops - * `generateText` (each call holds the read lock for the whole FFI call) - * while another calls `close()` (write lock). The write lock must wait for - * the in-flight read to finish, so `close()` never drops a handle out from - * under an active call — i.e. no use-after-free / native crash. After - * `close()` completes, further calls must throw a predictable - * [IllegalStateException]. A use-after-free would crash the JVM (SIGSEGV) - * and fail the whole test process, not just this method. - */ - @Test - fun `concurrent close and generate does not crash`() { - // Reuses the internal MockProviderServer (same test module/package). - val server = MockProviderServer() - server.responseBody = plainOpenAiResponse() - try { - val model = chatCompletionsModel(server.baseUrl) - val iterations = 300 - val unexpected = AtomicReference(null) - val sawClosed = AtomicBoolean(false) - - // Worker: loop generateText. Pre-close calls succeed against the - // mock; post-close calls throw IllegalStateException (closed). Any - // other throwable (incl. a native crash, which surfaces as an Error) - // is recorded and fails the test. - val worker = Thread { - for (i in 0 until iterations) { - try { - model.generateText("\"Hello\"") - } catch (e: IllegalStateException) { - sawClosed.set(true) - } catch (e: AimuxException) { - // A transient transport hiccup during teardown is - // acceptable; keep looping. - } catch (t: Throwable) { - unexpected.set(t) - break - } - } - }.apply { isDaemon = true } - - // Closer: close after a brief delay so it races with in-flight calls. - val closer = Thread { - try { - Thread.sleep(15) - } catch (e: InterruptedException) { - Thread.currentThread().interrupt() - } - model.close() - }.apply { isDaemon = true } - - worker.start() - closer.start() - worker.join(60_000) - closer.join(5_000) - - assertThat(unexpected.get()).`as`("unexpected exception in worker").isNull() - // After close, calls must report closed predictably (close-then-use - // ordering assertion). - assertThatThrownBy { model.generateText("\"Hello\"") } - .isInstanceOf(IllegalStateException::class.java) - .hasMessage("Model is closed") - } finally { - server.stop() - } - } - - @Test - fun `response timestamp is toISOString`() { - // The response timestamp is a Date (provider - // language-model-v4-response-metadata.ts:10), built as - // new Date(created * 1000); its JSON is toISOString(). - val server = MockProviderServer() - server.responseBody = JSONObject(plainOpenAiResponse()).put("created", 1741269019).toString() - try { - chatCompletionsModel(server.baseUrl).use { model -> - val result = JSONObject(model.generateText("\"Hello\"")) - assertThat(result.getJSONObject("response").getString("timestamp")) - .isEqualTo("2025-03-06T13:50:19.000Z") - } - } finally { - server.stop() - } - } - - // ── recording (RFC-0023) ──────────────────────────────────────────────── - - @Test - fun `initRecordingRing with null cap uses library default`() { - // Omitting cap uses the library default capacity (FFI - // aimux_init_recording_ring_default) and must not throw. This reaches - // the FFI, so it requires the native library on java.library.path / - // LD_LIBRARY_PATH (same as the other Model tests in this class). - assertThatCode { initRecordingRing() }.doesNotThrowAnyException() - recordingStop() - } - - @Test - fun `recordingTryFlush succeeds when nothing is recording`() { - recordingStop() - assertThatCode { recordingTryFlush() }.doesNotThrowAnyException() - } - - @Test - fun `initRecording reports INIT for an unwritable dir`() { - // Parent path is a regular file → the dir cannot be created and init - // fails with code INIT; nothing is installed, so a later try_flush is a no-op. - val blocker = java.nio.file.Files.createTempFile("aimux-kt-blocker", "") - try { - recordingStop() - assertThatThrownBy { initRecording(blocker.resolve("sub").toString()) } - .isInstanceOf(RecordingException::class.java) - .isNotInstanceOf(AimuxException::class.java) - .matches { (it as RecordingException).code == RecordingErrorCode.INIT } - assertThatCode { recordingTryFlush() }.doesNotThrowAnyException() - } finally { - recordingStop() - java.nio.file.Files.deleteIfExists(blocker) - } - } - - // ── canned OpenAI responses ──────────────────────────────────────────── - - /** Plain OpenAI chat-completions response (no tool calls). */ - private fun plainOpenAiResponse(): String = - JSONObject().apply { - put("id", "chatcmpl-test") - put("model", "gpt-4o") - put( - "choices", - JSONArray().put( - JSONObject() - .put( - "message", - JSONObject() - .put("role", "assistant") - .put("content", "Rust is a systems programming language."), - ).put("finish_reason", "stop"), - ), - ) - put( - "usage", - JSONObject() - .put("prompt_tokens", 10) - .put("completion_tokens", 8) - .put("total_tokens", 18), - ) - }.toString() -} diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/MultimodalE2ETest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/MultimodalE2ETest.kt deleted file mode 100644 index 34ee699b5..000000000 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/MultimodalE2ETest.kt +++ /dev/null @@ -1,219 +0,0 @@ -package ai.arcships.aimux - -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatThrownBy -import org.json.JSONArray -import org.json.JSONObject -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -// ───────────────────────────────────────────────────────────────────────────── -// Multimodal end-to-end tests for the Kotlin/JVM binding. -// -// Mirrors `bindings/go/multimodal_withbase_test.go`: each modality is exercised -// against a local mock HTTP server (MockProviderServer, defined in -// StructuredE2ETest.kt) that replays a canned provider response. The binding -// transforms that response into the aimux wire-format result, which we parse -// back and assert on. -// -// Google Video uses a multi-step async API (POST predict → poll operation → -// fetch result) that a single-response mock server can't drive, so its test -// checks construction + result parsing only — exactly like the Go test. -// -// No real network access is performed — every request hits 127.0.0.1. -// ───────────────────────────────────────────────────────────────────────────── - -class MultimodalE2ETest { - - private lateinit var server: MockProviderServer - - @BeforeEach - fun setUp() { - server = MockProviderServer() - } - - @AfterEach - fun tearDown() { - server.stop() - } - - // ── Embedding ────────────────────────────────────────────────────────── - - @Test - fun `embed parses embeddings`() { - server.responseBody = - """{"data":[{"embedding":[0.1,0.2,0.3],"index":0}],"model":"text-embedding-3-small","usage":{"prompt_tokens":3,"total_tokens":3}}""" - - EmbeddingModel.openai("sk-test", "text-embedding-3-small", server.baseUrl).use { model -> - val result = JSONObject(model.embed(JSONArray().put("hello").toString())) - - // Wire format: {"embeddings":[[0.1,0.2,0.3]], ...} - val embeddings = result.getJSONArray("embeddings") - assertThat(embeddings.length()).isEqualTo(1) - assertThat(embeddings.getJSONArray(0).length()).isEqualTo(3) - } - } - - /** Closed-guard: a call after close() fails predictably, before reaching the C ABI. */ - @Test - fun `embed after close throws IllegalStateException`() { - val model = EmbeddingModel.openai("sk-test", "text-embedding-3-small", server.baseUrl) - model.close() - assertThatThrownBy { model.embed("[\"hello\"]") } - .isInstanceOf(IllegalStateException::class.java) - .hasMessageContaining("is closed") - } - - // ── Speech (TTS) ─────────────────────────────────────────────────────── - - @Test - fun `speech generate returns binary audio`() { - // OpenAI TTS returns raw audio bytes with content-type audio/mpeg. The - // mock body below is ASCII-safe (a base64-looking string carried as raw - // bytes), so it round-trips through the String-based mock unchanged — - // the same trick the Go test uses (`base64Audio`). The core then wraps - // the raw bytes as AudioData::Binary, written as base64. - server.contentType = "audio/mpeg" - server.responseBody = "SGVsbG8gd29ybGQ=" - - SpeechModel.openai("sk-test", "tts-1", server.baseUrl).use { model -> - val opts = AimuxJson.encodeToString( - SpeechCallOptions.serializer(), - SpeechCallOptions(text = "Hi", voice = "alloy", outputFormat = "mp3"), - ) - - val result = AimuxJson.decodeFromString(SpeechResult.serializer(), model.generate(opts)) - - // SpeechModelV4Result.audio is a Uint8Array upstream - // (speech-model-v4-result.ts:16): a base64 string in the JSON. - assertThat(result.audio).isEqualTo( - AudioData.Base64(java.util.Base64.getEncoder().encodeToString("SGVsbG8gd29ybGQ=".toByteArray())), - ) - } - } - - // ── Image ────────────────────────────────────────────────────────────── - - @Test - fun `image generate parses base64 images`() { - server.responseBody = """{"data":[{"b64_json":"aW1hZ2Ux"}]}""" - - ImageModel.openai("sk-test", "dall-e-3", server.baseUrl).use { model -> - // `n` and `providerOptions` are required on the wire and always encoded. - val opts = AimuxJson.encodeToString( - ImageCallOptions.serializer(), - ImageCallOptions(prompt = "otter", size = Size(1024, 1024), aspectRatio = AspectRatio(16, 9)), - ) - assertThat(JSONObject(opts).getString("size")).isEqualTo("1024x1024") - assertThat(JSONObject(opts).getString("aspectRatio")).isEqualTo("16:9") - assertThat(JSONObject(opts).has("n")).isTrue() - assertThat(JSONObject(opts).has("providerOptions")).isTrue() - - val result = AimuxJson.decodeFromString(ImageResult.serializer(), model.generate(opts)) - - // Wire format: {"images":["aW1hZ2Ux"], ...} (untagged: strings are base64). - assertThat(result.images).isEqualTo(ImageOutputs.Base64(listOf("aW1hZ2Ux"))) - } - } - - // ── Transcription (STT) ──────────────────────────────────────────────── - - @Test - fun `transcription generate parses text`() { - server.responseBody = """{"text":"Hello world"}""" - - TranscriptionModel.openai("sk-test", "whisper-1", server.baseUrl).use { model -> - val result = JSONObject(model.generate("dGVzdA==", "audio/mp3")) - - // Wire format: {"text":"Hello world", ...} - assertThat(result.getString("text")).isEqualTo("Hello world") - } - } - - // ── Reranking ────────────────────────────────────────────────────────── - - @Test - fun `rerank parses ranking`() { - server.responseBody = - """{"results":[{"index":1,"relevance_score":0.95},{"index":0,"relevance_score":0.3}]}""" - - RerankingModel.cohere("sk-test", "rerank-v3.0", server.baseUrl).use { model -> - val opts = AimuxJson.encodeToString( - RerankingCallOptions.serializer(), - RerankingCallOptions( - documents = RerankingDocuments.Text(listOf("doc1", "doc2")), - query = "which?", - topN = 2, - ), - ) - assertThat(JSONObject(opts).getJSONObject("documents").getString("type")).isEqualTo("text") - - val result = AimuxJson.decodeFromString(RerankingResult.serializer(), model.rerank(opts)) - - // Wire format: {"ranking":[{"index":1,"relevanceScore":0.95}, ...], ...} - assertThat(result.ranking).hasSize(2) - assertThat(result.ranking[0]).isEqualTo(RerankingRank(index = 1, relevanceScore = 0.95)) - } - } - - // ── Search ───────────────────────────────────────────────────────────── - - @Test - fun `search parses results`() { - server.responseBody = - """{"results":[{"title":"Rust","url":"https://rust-lang.org","content":"Rust is..."}],"answer":"Rust is a systems language."}""" - - SearchModel.tavily("sk-test", server.baseUrl).use { model -> - val opts = AimuxJson.encodeToString( - SearchCallOptions.serializer(), - SearchCallOptions(query = "What is Rust?", maxResults = 5), - ) - - val result = JSONObject(model.search(opts)) - - // Wire format: {"results":[{"title":"Rust", ...}], "answer":"...", ...} - val results = result.getJSONArray("results") - assertThat(results.length()).isEqualTo(1) - assertThat(results.getJSONObject(0).getString("title")).isEqualTo("Rust") - } - } - - // ── Files ────────────────────────────────────────────────────────────── - - @Test - fun `uploadFile parses provider reference`() { - server.responseBody = - """{"id":"file-abc","object":"file","bytes":1024,"created_at":1234,"filename":"test.pdf","purpose":"assistants"}""" - - Files.openai("sk-test", server.baseUrl).use { model -> - val result = JSONObject(model.uploadFile("dGVzdA==", "application/pdf")) - - // Wire format: {"providerReference":{"openai":"file-abc"}, ...} - assertThat(result.getJSONObject("providerReference").getString("openai")) - .isEqualTo("file-abc") - } - } - - // ── Video (construction + result parsing only) ───────────────────────── - - @Test - fun `video construction and result parsing`() { - // Google Video uses a multi-step async API (POST predict → poll - // operation → fetch result). A single-response mock server can't drive - // the full flow, so — like the Go test — we only verify construction - // (via the WithBase factory) and parsing of a canned VideoResult. - VideoModel.google("sk-test", "veo-3.0", "http://localhost:9999").use { model -> - // Construction succeeded; the native handle is live and will be - // released by `use` on exit. - assertThat(model).isNotNull() - } - - // Wire format: {"videos":[{"type":"url","url":"...","mediaType":"..."}], ...} - val parsed = AimuxJson.decodeFromString( - VideoResult.serializer(), - """{"videos":[{"type":"url","url":"https://example.com/v.mp4","mediaType":"video/mp4"}]}""", - ) - assertThat(parsed.videos).containsExactly(VideoData.Url("https://example.com/v.mp4", "video/mp4")) - } -} diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/StructuredE2ETest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/StructuredE2ETest.kt deleted file mode 100644 index 92e9d0cdd..000000000 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/StructuredE2ETest.kt +++ /dev/null @@ -1,575 +0,0 @@ -package ai.arcships.aimux - -import com.sun.net.httpserver.HttpExchange -import com.sun.net.httpserver.HttpServer -import org.assertj.core.api.Assertions.assertThat -import org.json.JSONArray -import org.json.JSONObject -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import java.net.InetSocketAddress -import java.nio.charset.StandardCharsets - -// ───────────────────────────────────────────────────────────────────────────── -// Structured end-to-end tests for the Kotlin/JVM binding. -// -// These spin up a local mock HTTP server speaking the OpenAI chat-completions -// wire format, point an OpenAI `Model` at it via the `baseUrl` constructor, -// and assert that the binding correctly: -// 1. parses toolCalls out of a provider response, -// 2. forwards multi-role messages, -// 3. forwards `toolChoice`, -// 4. parses tool-call stream parts from an SSE stream. -// -// No real network access is performed — every request hits 127.0.0.1. -// ───────────────────────────────────────────────────────────────────────────── - -/** - * A chat-completions model pointed at [baseUrl]. `Model.openai` speaks the - * Responses API, so these tests reach the OpenAI-compatible chat wire through - * a registry provider instead. - */ -internal fun chatCompletionsModel(baseUrl: String): Model = - Model.provider("deepseek", "sk-test-fake-key", "gpt-4o", """{"baseUrl":"$baseUrl"}""") - -/// A tiny mock HTTP server that records the last request body and replays a -/// preset response. Listens on a random loopback port. -internal class MockProviderServer { - private val server: HttpServer = - HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) - - /** Last received request body (raw UTF-8 string). */ - @Volatile - var lastRequestBody: String = "" - private set - - /** Last received request method (e.g. "POST"). */ - @Volatile - var lastRequestMethod: String = "" - private set - - /** Last received request path, used to verify the selected API. */ - @Volatile - var lastRequestPath: String = "" - private set - - /** Status code to return. */ - var status: Int = 200 - - /** Value of the Content-Type response header. */ - var contentType: String = "application/json" - - /** Body bytes to return (single-response mode; used when the queue is empty). */ - var responseBody: String = "" - - /** Response queue — each request pops the next response (FIFO). */ - private val responseQueue = ArrayDeque() - - /** Set the list of responses to return (FIFO). */ - fun setResponses(vararg responses: String) { - synchronized(responseQueue) { - responseQueue.clear() - responses.forEach { responseQueue.addLast(it) } - } - } - - val port: Int - get() = server.address.port - - val baseUrl: String - get() = "http://127.0.0.1:$port" - - init { - server.createContext("/") { exchange: HttpExchange -> - handle(exchange) - } - server.start() - } - - private fun handle(exchange: HttpExchange) { - // Capture the inbound request body for assertions. - val reqBytes = exchange.requestBody.readBytes() - lastRequestBody = String(reqBytes, StandardCharsets.UTF_8) - lastRequestMethod = exchange.requestMethod - lastRequestPath = exchange.requestURI.path - - // Pop the next queued response if any, otherwise fall back to the - // single-response field (backward compatible with the existing tests). - val resp = synchronized(responseQueue) { - if (responseQueue.isNotEmpty()) responseQueue.removeFirst() else responseBody - } - val respBytes = resp.toByteArray(StandardCharsets.UTF_8) - exchange.responseHeaders.add("Content-Type", contentType) - // SSE streams use chunked transfer encoding (response length 0) so the - // client's streaming reader observes a proper end-of-stream. A fixed - // content length can leave the streaming reader blocked waiting for more - // data. Plain JSON responses use a fixed length. - val responseLength = - if (contentType.contains("text/event-stream")) 0L else respBytes.size.toLong() - exchange.sendResponseHeaders(status, responseLength) - exchange.responseBody.use { - it.write(respBytes) - it.flush() - } - exchange.close() - } - - fun stop() = server.stop(0) -} - -class StructuredE2ETest { - - private lateinit var server: MockProviderServer - - @BeforeEach - fun setUp() { - server = MockProviderServer() - } - - @AfterEach - fun tearDown() { - server.stop() - } - - // ── canned OpenAI responses ─────────────────────────────────────────── - - /** Plain OpenAI text response (no tool calls). */ - private val plainOpenAiResponse: String = - JSONObject().apply { - put("id", "chatcmpl-test") - put("model", "gpt-4o") - put( - "choices", - JSONArray().put( - JSONObject().apply { - put( - "message", - JSONObject().apply { - put("role", "assistant") - put("content", "Rust is a systems programming language.") - }, - ) - put("finish_reason", "stop") - }, - ), - ) - put( - "usage", - JSONObject().apply { - put("prompt_tokens", 10) - put("completion_tokens", 8) - put("total_tokens", 18) - }, - ) - }.toString() - - /** OpenAI response that requests a tool call (get_weather). */ - private val toolCallOpenAiResponse: String = - JSONObject().apply { - put("id", "chatcmpl-tc") - put("model", "gpt-4o") - put( - "choices", - JSONArray().put( - JSONObject().apply { - put( - "message", - JSONObject().apply { - put("role", "assistant") - put("content", JSONObject.NULL) - put( - "tool_calls", - JSONArray().put( - JSONObject().apply { - put("id", "call_abc") - put("type", "function") - put( - "function", - JSONObject().apply { - put("name", "get_weather") - put("arguments", "{\"location\":\"Tokyo\"}") - }, - ) - }, - ), - ) - }, - ) - put("finish_reason", "tool_calls") - }, - ), - ) - put( - "usage", - JSONObject().apply { - put("prompt_tokens", 20) - put("completion_tokens", 10) - put("total_tokens", 30) - }, - ) - }.toString() - - /** A `tools` option describing a single get_weather function tool. */ - private fun weatherToolsOpts(includeToolChoice: String? = null): String = - JSONObject().apply { - put( - "tools", - JSONArray().put( - JSONObject().apply { - put("type", "function") - put("name", "get_weather") - put( - "inputSchema", - JSONObject().apply { - put("type", "object") - put( - "properties", - JSONObject().apply { - put("location", JSONObject().put("type", "string")) - }, - ) - }, - ) - }, - ), - ) - if (includeToolChoice != null) { - put("toolChoice", includeToolChoice) - } - }.toString() - - // ── Tests ───────────────────────────────────────────────────────────── - - @Test - fun `generateText parses tool_calls`() { - server.responseBody = toolCallOpenAiResponse - - chatCompletionsModel(server.baseUrl).use { model -> - val resultJson = model.generateText("\"What is the weather in Tokyo?\"", weatherToolsOpts()) - val result = JSONObject(resultJson) - - // toolCalls[0].toolName == "get_weather" - val toolCalls = result.getJSONArray("toolCalls") - assertThat(toolCalls.length()).isGreaterThan(0) - val firstCall = toolCalls.getJSONObject(0) - assertThat(firstCall.getString("toolName")).isEqualTo("get_weather") - assertThat(firstCall.getString("toolCallId")).isEqualTo("call_abc") - assertThat(firstCall.getJSONObject("input").getString("location")).isEqualTo("Tokyo") - - // content contains a `tool-call` item. - val content = result.getJSONArray("content") - val hasToolCallVariant = (0 until content.length()).any { - content.getJSONObject(it).getString("type") == "tool-call" - } - assertThat(hasToolCallVariant).isTrue() - } - } - - @Test - fun `multi-role messages reach provider`() { - server.responseBody = plainOpenAiResponse - - // [{role:"system",content:"You are a helpful assistant."}, - // {role:"user",content:"What is Rust?"}] - val promptJson = JSONArray().apply { - put(JSONObject().apply { - put("role", "system") - put("content", "You are a helpful assistant.") - }) - put(JSONObject().apply { - put("role", "user") - put("content", "What is Rust?") - }) - }.toString() - - chatCompletionsModel(server.baseUrl).use { model -> - val resultJson = model.generateText(promptJson) - val result = JSONObject(resultJson) - assertThat(result.getString("text")) - .isEqualTo("Rust is a systems programming language.") - - // The provider received both messages (system + user). - val reqBody = JSONObject(server.lastRequestBody) - assertThat(reqBody.getString("model")).isEqualTo("gpt-4o") - val messages = reqBody.getJSONArray("messages") - assertThat(messages.length()).isEqualTo(2) - assertThat(messages.getJSONObject(0).getString("role")).isEqualTo("system") - assertThat(messages.getJSONObject(1).getString("role")).isEqualTo("user") - } - } - - @Test - fun `tool_choice reaches provider`() { - server.responseBody = toolCallOpenAiResponse - - chatCompletionsModel(server.baseUrl).use { model -> - model.generateText("\"What is the weather in Tokyo?\"", weatherToolsOpts(includeToolChoice = "required")) - - val reqBody = JSONObject(server.lastRequestBody) - assertThat(reqBody.getString("tool_choice")).isEqualTo("required") - // tools were forwarded too. - assertThat(reqBody.has("tools")).isTrue() - } - } - - @Test - fun `streamText parses tool-call stream parts`() { - // SSE: tool_calls delta (name) -> arguments delta -> finish_reason. - server.contentType = "text/event-stream" - server.responseBody = buildString { - append("data: ") - append( - JSONObject().apply { - put("id", "1") - put("model", "gpt-4o") - put( - "choices", - JSONArray().put( - JSONObject().apply { - put( - "delta", - JSONObject().apply { - put("role", "assistant") - put( - "tool_calls", - JSONArray().put( - JSONObject().apply { - put("index", 0) - put("id", "call_xyz") - put("type", "function") - put( - "function", - JSONObject().apply { - put("name", "get_weather") - put("arguments", "") - }, - ) - }, - ), - ) - }, - ) - }, - ), - ) - }.toString(), - ) - append("\n\n") - append("data: ") - append( - JSONObject().apply { - put("id", "1") - put("model", "gpt-4o") - put( - "choices", - JSONArray().put( - JSONObject().apply { - put( - "delta", - JSONObject().apply { - put( - "tool_calls", - JSONArray().put( - JSONObject().apply { - put("index", 0) - put( - "function", - JSONObject().apply { - put("arguments", "{\"location\":\"Tokyo\"}") - }, - ) - }, - ), - ) - }, - ) - }, - ), - ) - }.toString(), - ) - append("\n\n") - append("data: ") - append( - JSONObject().apply { - put("id", "1") - put("model", "gpt-4o") - put( - "choices", - JSONArray().put( - JSONObject().apply { - put("delta", JSONObject()) - put("finish_reason", "tool_calls") - }, - ), - ) - put( - "usage", - JSONObject().apply { - put("prompt_tokens", 5) - put("completion_tokens", 2) - put("total_tokens", 7) - }, - ) - }.toString(), - ) - append("\n\n") - append("data: [DONE]\n\n") - } - - chatCompletionsModel(server.baseUrl).use { model -> - // Run the blocking FFI stream call on a separate thread so the JNA - // callbacks (which attach to the calling thread) don't deadlock with - // tokio's block_on. The main thread collects parts via a queue. - val parts = java.util.concurrent.LinkedBlockingQueue() - val streamThread = Thread { - model.streamText( - "\"What is the weather in Tokyo?\"", - weatherToolsOpts(), - onPart = { parts.put(it) }, - onDone = { parts.put("__done__") }, - ) - } - streamThread.isDaemon = true - streamThread.start() - - val collected = mutableListOf() - while (true) { - val part = parts.poll(30, java.util.concurrent.TimeUnit.SECONDS) ?: break - if (part == "__done__") break - collected.add(part) - } - streamThread.join(5000) - - assertThat(collected).isNotEmpty() - // Stream must surface tool-call activity: either a complete tool-call - // part or tool-input-delta deltas (or both). - val hasToolPart = collected.any { - it.contains("\"type\":\"tool-call\"") || it.contains("\"type\":\"tool-input-delta\"") - } - assertThat(hasToolPart) - .`as`("expected a tool-call or tool-input-delta stream part; got: $collected") - .isTrue() - - // The finish part should also be present, carrying the tool-calls - // finish reason. - val hasFinish = collected.any { it.contains("\"type\":\"finish\"") } - assertThat(hasFinish).isTrue() - } - } - - @Test - fun `tool-call full round-trip`() { - // A tool-call round-trip = two generateText calls with a ToolResult - // back-filled between them. The mock server returns a tool-call - // response for the first request, then a plain text response for the - // second (FIFO queue). - val finalTextResponse = JSONObject().apply { - put("id", "chatcmpl-2") - put("model", "gpt-4o") - put( - "choices", - JSONArray().put( - JSONObject().apply { - put( - "message", - JSONObject().apply { - put("role", "assistant") - put("content", "The weather in Tokyo is sunny.") - }, - ) - put("finish_reason", "stop") - }, - ), - ) - put( - "usage", - JSONObject().apply { - put("prompt_tokens", 30) - put("completion_tokens", 8) - put("total_tokens", 38) - }, - ) - }.toString() - - server.setResponses(toolCallOpenAiResponse, finalTextResponse) - - chatCompletionsModel(server.baseUrl).use { model -> - // 1st call: provider asks to call get_weather. - val firstResultJson = - model.generateText("\"What's the weather in Tokyo?\"", weatherToolsOpts()) - val firstResult = JSONObject(firstResultJson) - val toolCalls = firstResult.getJSONArray("toolCalls") - assertThat(toolCalls.length()).isGreaterThan(0) - val firstCall = toolCalls.getJSONObject(0) - assertThat(firstCall.getString("toolName")).isEqualTo("get_weather") - assertThat(firstCall.getString("toolCallId")).isEqualTo("call_abc") - assertThat(firstCall.getJSONObject("input").getString("location")) - .isEqualTo("Tokyo") - - // 2nd call: echo the full conversation back, including the - // assistant's tool call and the tool result we synthesised. - // Input uses the engine's ContentPart variants (tool-call / - // tool-result); the engine converts these to the OpenAI wire - // format on the outbound request. - val messages = JSONArray().apply { - put(JSONObject().apply { - put("role", "user") - put("content", "What's the weather in Tokyo?") - }) - put(JSONObject().apply { - put("role", "assistant") - put( - "content", - JSONArray().put( - JSONObject().apply { - put("type", "tool-call") - put("toolCallId", "call_abc") - put("toolName", "get_weather") - put("input", JSONObject().put("location", "Tokyo")) - }, - ), - ) - }) - put(JSONObject().apply { - put("role", "tool") - put( - "content", - JSONArray().put( - JSONObject().apply { - put("type", "tool-result") - put("toolCallId", "call_abc") - put("toolName", "get_weather") - put( - "output", - JSONObject().put("type", "json").put( - "value", - JSONObject().put("temperature", 22).put("condition", "sunny"), - ), - ) - }, - ), - ) - }) - }.toString() - - val secondResultJson = model.generateText(messages, weatherToolsOpts()) - val secondResult = JSONObject(secondResultJson) - assertThat(secondResult.getString("text")) - .isEqualTo("The weather in Tokyo is sunny.") - - // The provider received 3 messages; the last is the tool result, - // carrying the matching tool_call_id. - val reqBody = JSONObject(server.lastRequestBody) - val reqMessages = reqBody.getJSONArray("messages") - assertThat(reqMessages.length()).isEqualTo(3) - val lastMsg = reqMessages.getJSONObject(2) - assertThat(lastMsg.getString("role")).isEqualTo("tool") - assertThat(lastMsg.getString("tool_call_id")).isEqualTo("call_abc") - val output = JSONObject(lastMsg.getString("content")) - assertThat(output.getInt("temperature")).isEqualTo(22) - assertThat(output.getString("condition")).isEqualTo("sunny") - } - } -} diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TranscriptionSessionTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TranscriptionSessionTest.kt deleted file mode 100644 index cf576d49d..000000000 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TranscriptionSessionTest.kt +++ /dev/null @@ -1,116 +0,0 @@ -package ai.arcships.aimux - -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import java.net.InetAddress -import java.net.ServerSocket -import java.net.Socket - -// ───────────────────────────────────────────────────────────────────────────── -// TranscriptionSession nextPart tests (issue #116). -// -// The realtime transcription session streams over a WebSocket -// (`ws://{base}/realtime?intent=transcription`). We point an OpenAI realtime -// transcription model at a **stalling** TCP server — it accepts connections -// but never completes the WebSocket handshake — so the session's connect -// hangs and no part ever arrives. nextPart(timeoutMs) then hits its wait -// bound and the FFI returns NULL + AIMUX_E_TIMEOUT, which the binding must -// surface as the catchable retryable sentinel -// TranscriptionSession.AimuxTranscriptionTimeoutException — not as the -// generic TimeoutError (AimuxException), which a sentinel-catch could never -// intercept. -// -// Requires the native library (same as the other E2E tests); no real network -// access — everything stays on 127.0.0.1. -// ───────────────────────────────────────────────────────────────────────────── - -class TranscriptionSessionTest { - - /** Accepted-but-stalled sockets; held open so the WS handshake hangs. */ - private val accepted = java.util.concurrent.CopyOnWriteArrayList() - - private lateinit var server: ServerSocket - private lateinit var acceptThread: Thread - - @BeforeEach - fun setUp() { - server = ServerSocket(0, 8, InetAddress.getByName("127.0.0.1")) - acceptThread = Thread { - while (!server.isClosed) { - try { - accepted.add(server.accept()) - } catch (_: java.io.IOException) { - return@Thread - } - } - } - acceptThread.isDaemon = true - acceptThread.start() - } - - @AfterEach - fun tearDown() { - runCatching { server.close() } - accepted.forEach { runCatching { it.close() } } - } - - private val baseUrl: String - get() = "http://127.0.0.1:${server.localPort}" - - @Test - fun `nextPart timeout throws the retryable AimuxTranscriptionTimeoutException and the session stays live`() { - TranscriptionModel.openai("sk-test", "gpt-realtime-whisper", baseUrl).use { model -> - model.startStream().use { session -> - // The WS handshake to the stalling server never completes, so - // no part arrives within the wait bound: the documented - // sentinel must be thrown (state TIMEOUT: nothing to decode). - val ex = catchSentinel { session.nextPart(150) } - assertThat(ex).isNotNull - assertThat(ex).isInstanceOf(RuntimeException::class.java) - - // It must NOT be the generic engine TimeoutError: callers - // catching the sentinel per the KDoc need it to match, and a - // TimeoutError catch-all is not how "session still live, - // call again" is expressed (RFC-0028 AIMUX_E_TIMEOUT). - assertThat(ex).isNotInstanceOf(TimeoutError::class.java) - assertThat(ex).isNotInstanceOf(AimuxException::class.java) - - // Retryable: after a timeout the session is still alive — a - // second pull again yields the sentinel, not a stream-failure - // or invalid-handle error. - val second = catchSentinel { session.nextPart(150) } - assertThat(second).isNotNull - } - } - } - - @Test - fun `nextPart maps non-timeout failures to the typed AimuxException hierarchy`() { - // `whisper-1` is not a realtime model: do_stream rejects it before - // connecting (UnsupportedFunctionality), which surfaces on the first - // nextPart as a typed engine error — never as the timeout sentinel. - TranscriptionModel.openai("sk-test", "whisper-1", baseUrl).use { model -> - model.startStream().use { session -> - val ex = try { - session.nextPart(2_000) - null - } catch (e: AimuxException) { - e - } - assertThat(ex).isInstanceOf(UnsupportedFunctionalityError::class.java) - assertThat(ex!!.code).isEqualTo(AIMUX_E_UNSUPPORTED_FUNCTIONALITY) - } - } - } - - /** Run [block], returning the timeout sentinel if it was thrown. */ - private inline fun catchSentinel(block: () -> Unit): TranscriptionSession.AimuxTranscriptionTimeoutException? = - try { - block() - null - } catch (e: TranscriptionSession.AimuxTranscriptionTimeoutException) { - e - } -} diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt deleted file mode 100644 index b2bdaa62d..000000000 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/TypedModelTest.kt +++ /dev/null @@ -1,731 +0,0 @@ -package ai.arcships.aimux - -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatThrownBy -import org.json.JSONArray -import org.json.JSONObject -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -// ───────────────────────────────────────────────────────────────────────────── -// Tests for the typed [TypedModel] wrapper over the raw JSON-string [Model]. -// -// Like StructuredE2ETest, these spin up the shared [MockProviderServer] -// (OpenAI chat-completions wire format) and assert that the typed wrapper: -// 1. returns a [GenerateTextResult] object (no manual JSON parsing), -// 2. surfaces `.text`, `.toolCalls[0].toolName`, and `.content`, -// 3. forwards typed `tools` / `toolChoice` onto the provider request, -// 4. forwards multi-role [ModelMessage] conversations. -// -// No real network access — every request hits 127.0.0.1. -// ───────────────────────────────────────────────────────────────────────────── - -class TypedModelTest { - - private lateinit var server: MockProviderServer - - @BeforeEach - fun setUp() { - server = MockProviderServer() - } - - @AfterEach - fun tearDown() { - server.stop() - } - - // ── canned OpenAI responses (mirror StructuredE2ETest) ─────────────── - - /** Plain OpenAI text response (no tool calls). */ - private val plainOpenAiResponse: String = - JSONObject().apply { - put("id", "chatcmpl-test") - put("model", "gpt-4o") - put( - "choices", - JSONArray().put( - JSONObject().apply { - put( - "message", - JSONObject().apply { - put("role", "assistant") - put("content", "Rust is a systems programming language.") - }, - ) - put("finish_reason", "stop") - }, - ), - ) - put( - "usage", - JSONObject().apply { - put("prompt_tokens", 10) - put("completion_tokens", 8) - put("total_tokens", 18) - }, - ) - }.toString() - - /** OpenAI response that requests a tool call (get_weather). */ - private val toolCallOpenAiResponse: String = - JSONObject().apply { - put("id", "chatcmpl-tc") - put("model", "gpt-4o") - put( - "choices", - JSONArray().put( - JSONObject().apply { - put( - "message", - JSONObject().apply { - put("role", "assistant") - put("content", JSONObject.NULL) - put( - "tool_calls", - JSONArray().put( - JSONObject().apply { - put("id", "call_abc") - put("type", "function") - put( - "function", - JSONObject().apply { - put("name", "get_weather") - put("arguments", "{\"location\":\"Tokyo\"}") - }, - ) - }, - ), - ) - }, - ) - put("finish_reason", "tool_calls") - }, - ), - ) - put( - "usage", - JSONObject().apply { - put("prompt_tokens", 20) - put("completion_tokens", 10) - put("total_tokens", 30) - }, - ) - }.toString() - - // ── typed builders ────────────────────────────────────────────────── - - /** A JSON Schema for the get_weather tool's `location` argument. */ - private val weatherSchema: JsonObject = JsonObject( - mapOf( - "type" to JsonPrimitive("object"), - "properties" to JsonObject( - mapOf("location" to JsonObject(mapOf("type" to JsonPrimitive("string")))) - ), - ) - ) - - /** A typed function tool definition for `get_weather`. */ - private val weatherTool: Tool = Tool.Function( - name = "get_weather", - inputSchema = weatherSchema, - ) - - // ── Tests ─────────────────────────────────────────────────────────── - - @Test - fun `generateText returns a typed GenerateTextResult with text and raw content`() { - server.responseBody = plainOpenAiResponse - - TypedModel(chatCompletionsModel(server.baseUrl), ownsModel = true).use { model -> - val result = model.generateText("What is Rust?") - - // No manual parsing: the wrapper returned a typed object. - assertThat(result).isInstanceOf(GenerateTextResult::class.java) - - // .text is a plain Kotlin String. - assertThat(result.text).isEqualTo("Rust is a systems programming language.") - assertThat(server.lastRequestPath).isEqualTo("/chat/completions") - - // A plain-text response carries no tool calls. - assertThat(result.toolCalls).isEmpty() - - // .content is accessible as a list (no JSON digging required). - assertThat(result.content).isNotEmpty - // Everything else is derived from the steps. - assertThat(result.steps).hasSize(1) - assertThat(result.finishReason).isEqualTo(FinishReasonUnified.STOP) - } - } - - @Test - fun `generateText parses tool_calls into typed ToolCall objects`() { - server.responseBody = toolCallOpenAiResponse - - val options = GenerateTextOptions( - tools = listOf(weatherTool), - toolChoice = ToolChoice.AUTO, - ) - - TypedModel(chatCompletionsModel(server.baseUrl), ownsModel = true).use { model -> - val result = model.generateText("What is the weather in Tokyo?", options) - - // .toolCalls[0].toolName / .toolCallId / .input — all typed. - assertThat(result.toolCalls).hasSize(1) - val call = result.toolCalls[0] - assertThat(call.toolName).isEqualTo("get_weather") - assertThat(call.toolCallId).isEqualTo("call_abc") - // input is a JsonElement (tool arguments); inspect it directly. - assertThat(call.input.jsonObject["location"]!!.jsonPrimitive.content) - .isEqualTo("Tokyo") - - // .content carries the tool-call item. - assertThat(result.content.any { (it as JsonObject)["type"]?.jsonPrimitive?.content == "tool-call" }).isTrue() - assertThat(result.finishReason).isEqualTo(FinishReasonUnified.TOOL_CALLS) - } - } - - @Test - fun `typed tools and tool_choice reach the provider`() { - server.responseBody = toolCallOpenAiResponse - - val options = GenerateTextOptions( - tools = listOf(weatherTool), - toolChoice = ToolChoice.REQUIRED, - ) - - TypedModel(chatCompletionsModel(server.baseUrl), ownsModel = true).use { model -> - model.generateText("What is the weather in Tokyo?", options) - - // The serialized options crossed the JSON boundary in the engine's - // camelCase shape with only the fields the caller set. - val reqBody = JSONObject(server.lastRequestBody) - assertThat(reqBody.getString("tool_choice")).isEqualTo("required") - assertThat(reqBody.has("tools")).isTrue() - val tools = reqBody.getJSONArray("tools") - assertThat(tools.length()).isEqualTo(1) - val tool = tools.getJSONObject(0) - // The engine forwards the typed tool to the provider in OpenAI's - // wire format: {type:"function", function:{name, parameters}}. - assertThat(tool.getString("type")).isEqualTo("function") - assertThat(tool.getJSONObject("function").getString("name")).isEqualTo("get_weather") - assertThat(tool.getJSONObject("function").getJSONObject("parameters").getString("type")) - .isEqualTo("object") - } - } - - @Test - fun `multi-role ModelMessage list reaches the provider`() { - server.responseBody = plainOpenAiResponse - - val messages = listOf( - ModelMessage.text(Role.SYSTEM, "You are a helpful assistant."), - ModelMessage.text(Role.USER, "What is Rust?"), - ) - - TypedModel(chatCompletionsModel(server.baseUrl), ownsModel = true).use { model -> - val result = model.generateText(messages) - - assertThat(result.text).isEqualTo("Rust is a systems programming language.") - - // The provider received both messages (system + user), in order. - val reqBody = JSONObject(server.lastRequestBody) - assertThat(reqBody.getString("model")).isEqualTo("gpt-4o") - val reqMessages = reqBody.getJSONArray("messages") - assertThat(reqMessages.length()).isEqualTo(2) - assertThat(reqMessages.getJSONObject(0).getString("role")).isEqualTo("system") - assertThat(reqMessages.getJSONObject(0).getString("content")) - .isEqualTo("You are a helpful assistant.") - assertThat(reqMessages.getJSONObject(1).getString("role")).isEqualTo("user") - assertThat(reqMessages.getJSONObject(1).getString("content")).isEqualTo("What is Rust?") - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Round-trip (de)serialization tests for the typed content / file data classes. -// -// These are pure serialization tests — no network, no [MockProviderServer]. Each -// test builds a typed value, serializes it with [AimuxJson], decodes it back, and -// asserts the round-tripped value equals the original. They also pin down the -// wire-format quirks the typed wrappers rely on: -// - [GenerateContent.File] carries no `filename` (unlike [ContentPart.File]), -// - [GenerateContent.ToolResult] keeps the executed-tool `result` shape, while -// [ContentPart.ToolResult] carries a [ToolResultOutput] under `output`, -// - every union is tagged on `type` with the AI SDK's kebab-case names. -// ───────────────────────────────────────────────────────────────────────────── -class TypedModelRoundTripTest { - - @Test - fun `provider tool deferred results round-trip and omit null`() { - for (supported in listOf(null, true, false)) { - val original = ProviderTool( - id = "anthropic.code_execution_20250522", - name = "code_execution", - supportsDeferredResults = supported, - ) - val json = AimuxJson.encodeToString(ProviderTool.serializer(), original) - assertThat(AimuxJson.parseToJsonElement(json).jsonObject.containsKey("supportsDeferredResults")) - .isEqualTo(supported != null) - assertThat(AimuxJson.decodeFromString(ProviderTool.serializer(), json)).isEqualTo(original) - - val tool = Tool.Provider.from(original) - assertThat(tool.supportsDeferredResults).isEqualTo(supported) - val toolJson = AimuxJson.encodeToString(Tool.serializer(), tool) - assertThat(AimuxJson.parseToJsonElement(toolJson).jsonObject.containsKey("supportsDeferredResults")) - .isEqualTo(supported != null) - assertThat(AimuxJson.decodeFromString(Tool.serializer(), toolJson)).isEqualTo(tool) - } - } - - @Test - fun `top-level ToolCall provider metadata round-trips`() { - val original = ToolCall( - toolCallId = "call_1", - toolName = "get_weather", - input = JsonObject(mapOf("city" to JsonPrimitive("Paris"))), - providerMetadata = mapOf("openai" to mapOf("itemId" to JsonPrimitive("item_1"))), - ) - val json = AimuxJson.encodeToString(ToolCall.serializer(), original) - assertThat(json).contains("\"providerMetadata\"") - val decoded = AimuxJson.decodeFromString(ToolCall.serializer(), json) - assertThat(decoded).isEqualTo(original) - } - - // ── GenerateContent (tagged on `type`) ────────────────────────────── - - @Test - fun `GenerateContent Text round-trips`() { - val original = GenerateContent.Text(text = "hello", providerMetadata = null) - val json = AimuxJson.encodeToString(GenerateContent.serializer(), original) - val decoded = AimuxJson.decodeFromString(GenerateContent.serializer(), json) - assertThat(decoded).isEqualTo(original) - } - - @Test - fun `GenerateContent ToolCall round-trips`() { - val original = GenerateContent.ToolCall( - toolCallId = "call_1", - toolName = "get_weather", - input = """{"location":"Tokyo"}""", - providerExecuted = false, - dynamic = true, - providerMetadata = null, - ) - val json = AimuxJson.encodeToString(GenerateContent.serializer(), original) - assertThat(json).contains("\"type\":\"tool-call\"") - val decoded = AimuxJson.decodeFromString(GenerateContent.serializer(), json) - assertThat(decoded).isEqualTo(original) - } - - @Test - fun `GenerateContent Reasoning round-trips`() { - val original = GenerateContent.Reasoning(text = "thinking...", providerMetadata = null) - val json = AimuxJson.encodeToString(GenerateContent.serializer(), original) - val decoded = AimuxJson.decodeFromString(GenerateContent.serializer(), json) - assertThat(decoded).isEqualTo(original) - } - - - - @Test - fun `GenerateContent ToolResult round-trips using result not output`() { - val original = GenerateContent.ToolResult( - toolCallId = "call_1", - toolName = "get_weather", - result = JsonObject(mapOf("temp" to JsonPrimitive("20"))), - isError = true, - preliminary = true, - dynamic = false, - providerMetadata = null, - ) - val json = AimuxJson.encodeToString(GenerateContent.serializer(), original) - // The field is `result`, never `output`. - assertThat(json).contains("\"result\"").doesNotContain("\"output\"") - val decoded = AimuxJson.decodeFromString(GenerateContent.serializer(), json) - assertThat(decoded).isEqualTo(original) - } - - // ── ContentPart (tagged on `type`) ─────────────────────── - - @Test - fun `ContentPart Text round-trips`() { - val original = ContentPart.Text(text = "hi", providerOptions = null) - val json = AimuxJson.encodeToString(ContentPart.serializer(), original) - val decoded = AimuxJson.decodeFromString(ContentPart.serializer(), json) - assertThat(decoded).isEqualTo(original) - assertThat(json).contains("\"text\"") - } - - @Test - fun `ContentPart ToolCall round-trips`() { - val original = ContentPart.ToolCall( - toolCallId = "call_1", - toolName = "get_weather", - input = JsonObject(mapOf("location" to JsonPrimitive("Tokyo"))), - providerExecuted = true, - providerOptions = null, - ) - val json = AimuxJson.encodeToString(ContentPart.serializer(), original) - val decoded = AimuxJson.decodeFromString(ContentPart.serializer(), json) - assertThat(decoded).isEqualTo(original) - assertThat(json).contains("\"type\":\"tool-call\"") - assertThat(json).contains("\"providerExecuted\":true") - } - - @Test - fun `ContentPart ToolResult round-trips with a typed output`() { - val original = ContentPart.ToolResult( - toolCallId = "call_1", - toolName = "get_weather", - output = ToolResultOutput.JsonValue(JsonObject(mapOf("temp" to JsonPrimitive("20")))), - providerOptions = null, - ) - val json = AimuxJson.encodeToString(ContentPart.serializer(), original) - - // `output` is a tagged union; the old result / isError fields are gone. - assertThat(json).contains("\"type\":\"tool-result\"") - assertThat(json).contains("\"output\":{\"type\":\"json\",\"value\":{\"temp\":\"20\"}}") - assertThat(json).doesNotContain("\"result\"").doesNotContain("\"isError\"") - - val decoded = AimuxJson.decodeFromString(ContentPart.serializer(), json) - assertThat(decoded).isEqualTo(original) - } - - // ── FileBytes (a base64 string) / FileData (tagged on `type`) ───────────────── - - @Test - fun `FileBytes Binary is a base64 string`() { - // SharedV4FileDataData.data is Uint8Array | string upstream - // (shared-v4-file-data.ts:7-10): one base64 string in the JSON - // (uint8-utils.ts:12-23), never an array of numbers. - val original = FileBytes.Binary(data = listOf(0, 1, 2, 250, 255)) - val json = AimuxJson.encodeToString(FileBytes.serializer(), original) - assertThat(json).isEqualTo("\"AAEC+v8=\"") - val decoded = AimuxJson.decodeFromString(FileBytes.serializer(), json) - assertThat(decoded).isEqualTo(FileBytes.Base64("AAEC+v8=")) - assertThat(Base64Bytes.decode("AAEC-v8")).isEqualTo(original.data) - assertThatThrownBy { AimuxJson.decodeFromString(FileBytes.serializer(), "[1,2,3,255]") } - .isInstanceOf(kotlinx.serialization.SerializationException::class.java) - } - - @Test - fun `unknown fields keep null apart from missing`() { - // body?: unknown (provider language-model-v4-generate-result.ts:41, :56) - // and inputSchemaInput?: unknown (provider-utils - // types/tool-approval-request.ts:41): the AI SDK's JSON keeps null and - // omits undefined, and a parsed value writes back the same JSON. - for (wire in listOf("{}", "{\"body\":null}", "{\"body\":{\"a\":null}}")) { - val info = AimuxJson.decodeFromString(ResponseInfo.serializer(), wire) - assertThat(AimuxJson.encodeToString(ResponseInfo.serializer(), info)).isEqualTo(wire) - val request = AimuxJson.decodeFromString(RequestInfo.serializer(), wire) - assertThat(AimuxJson.encodeToString(RequestInfo.serializer(), request)).isEqualTo(wire) - } - val approval = "{\"type\":\"tool-approval-request\",\"approvalId\":\"a1\"," + - "\"toolCallId\":\"c1\",\"inputSchemaInput\":null}" - val part = AimuxJson.decodeFromString(ContentPart.serializer(), approval) - assertThat(AimuxJson.encodeToString(ContentPart.serializer(), part)).isEqualTo(approval) - } - - @Test - fun `image and file part bytes are base64 strings`() { - // ImagePart.image / FilePart.data are DataContent (provider-utils - // types/content-part.ts:42, :75): base64 strings in the JSON. - val image = ContentPart.Image(image = listOf(137, 80, 78, 71), mediaType = "image/png") - val json = AimuxJson.encodeToString(ContentPart.serializer(), image) - assertThat(json).isEqualTo("{\"type\":\"image\",\"image\":\"iVBORw==\",\"mediaType\":\"image/png\"}") - assertThat(AimuxJson.decodeFromString(ContentPart.serializer(), json)).isEqualTo(image) - val file = ContentPart.File(data = listOf(0, 1, 2, 250, 255), mediaType = "application/pdf") - assertThat(AimuxJson.encodeToString(ContentPart.serializer(), file)).isEqualTo( - "{\"type\":\"file\",\"data\":{\"type\":\"data\",\"data\":\"AAEC+v8=\"},\"mediaType\":\"application/pdf\"}" - ) - } - - @Test - fun `generated files are base64 and mediaType`() { - // GeneratedFile is {base64, mediaType, providerMetadata?} (ai - // generate-text/generated-file.ts:10-32); a stream file part is - // {type, file, providerMetadata?} (stream-language-model-call.ts:569-590); - // the provider's LanguageModelV4File keeps {type, data, mediaType}. - for (tag in listOf("file", "reasoning-file")) { - val wire = """{"type":"$tag","file":{"base64":"aGk=","mediaType":"text/plain"},""" + - """"providerMetadata":{"google":{"thoughtSignature":"sig"}}}""" - val part = AimuxJson.decodeFromString(StreamPart.serializer(), wire) - assertThat(AimuxJson.encodeToString(StreamPart.serializer(), part)).isEqualTo(wire) - } - val file = AimuxJson.decodeFromString(GeneratedFile.serializer(), """{"base64":"aGk=","mediaType":"text/plain"}""") - assertThat(file.bytes()).isEqualTo(listOf(104, 105)) - - val raw = """{"type":"file","data":{"type":"url","url":"https://example.com/a.png"},"mediaType":"image/png"}""" - val content = AimuxJson.decodeFromString(GenerateContent.serializer(), raw) - assertThat(AimuxJson.encodeToString(GenerateContent.serializer(), content)).isEqualTo(raw) - } - - @Test - fun `text stream deltas are text`() { - // The text stream's text-delta and reasoning-delta carry `text` (ai - // generate-text/stream-text-result.ts:435-440, :466-471); a - // tool-input-delta keeps `delta` (:496-501). - val wires = listOf( - """{"type":"text-delta","id":"t1","text":"Hello"}""", - """{"type":"reasoning-delta","id":"t1","text":"Hmm"}""", - """{"type":"tool-input-delta","id":"t1","delta":"{\"a\""}""", - ) - for (wire in wires) { - val part = AimuxJson.decodeFromString(StreamPart.serializer(), wire) - assertThat(AimuxJson.encodeToString(StreamPart.serializer(), part)).isEqualTo(wire) - } - val text = AimuxJson.decodeFromString(StreamPart.serializer(), wires[0]) as StreamPart.TextDelta - assertThat(text.text).isEqualTo("Hello") - } - - @Test - fun `standalone tool calls and sources are tagged`() { - // A result's toolCalls and sources are the AI SDK's TypedToolCall and - // Source, tagged {type: 'tool-call'} (ai generate-text/tool-call.ts:8-14) - // and {type: 'source', sourceType} (provider language-model-v4-source.ts:8, - // :36); generate-text-result.ts:73, :78. A repair function's call is a - // tagged LanguageModelV4ToolCall (language-model-v4-tool-call.ts:7). - val call = """{"type":"tool-call","toolCallId":"call-1","toolName":"weather","input":{"city":"SG"}}""" - val url = """{"type":"source","sourceType":"url","id":"s1","url":"https://example.com"}""" - val result = AimuxJson.decodeFromString( - GenerateTextResult.serializer(), - """{"text":"","toolCalls":[$call],"sources":[$url],"finishReason":"stop","warnings":[]}""", - ) - val written = AimuxJson.parseToJsonElement(AimuxJson.encodeToString(GenerateTextResult.serializer(), result)) - .jsonObject - assertThat(written["toolCalls"]).isEqualTo(AimuxJson.parseToJsonElement("[$call]")) - assertThat(written["sources"]).isEqualTo(AimuxJson.parseToJsonElement("[$url]")) - val raw = RawToolCall(toolCallId = "c", toolName = "t", input = "{}") - assertThat(AimuxJson.parseToJsonElement(AimuxJson.encodeToString(RawToolCall.serializer(), raw))) - .isEqualTo(AimuxJson.parseToJsonElement("""{"type":"tool-call","toolCallId":"c","toolName":"t","input":"{}"}""")) - - // JSON written before the tag still reads; another tag is rejected. - val untagged = AimuxJson.decodeFromString(ToolCall.serializer(), """{"toolCallId":"c","toolName":"t"}""") - assertThat(untagged.type).isEqualTo("tool-call") - assertThatThrownBy { - AimuxJson.decodeFromString(ToolCall.serializer(), """{"type":"source","toolCallId":"c","toolName":"t"}""") - }.isInstanceOf(SerializationException::class.java) - } - - @Test - fun `a parsed null output stays apart from no output`() { - // aimux writes `output` only when the output was parsed; a parsed JSON - // null is written as null (Rust GenerateTextResult, the public JSON - // keeping null apart from undefined). - val parsedNull = AimuxJson.decodeFromString( - GenerateTextResult.serializer(), """{"text":"null","finishReason":"stop","output":null}""", - ) - assertThat(parsedNull.output).isEqualTo(JsonNull) - assertThat(AimuxJson.encodeToString(GenerateTextResult.serializer(), parsedNull)).contains(""""output":null""") - val notParsed = AimuxJson.decodeFromString( - GenerateTextResult.serializer(), """{"text":"","finishReason":"tool-calls"}""", - ) - assertThat(notParsed.output).isNull() - assertThat(AimuxJson.encodeToString(GenerateTextResult.serializer(), notParsed)).doesNotContain("output") - } - - @Test - fun `namespace maps keep insertion order`() { - // Provider options, provider metadata and provider references are JS - // objects upstream (@ai-sdk/provider shared-v4-provider-options.ts:24, - // shared-v4-provider-metadata.ts:24, shared-v4-provider-reference.ts:20), - // so their keys keep insertion order, through Rust and back (#229 NP2-1). - val metadata = """{"zeta":{"z":1,"a":2},"alpha":{},"mid":{}}""" - val invalid = """{"toolCallId":"call-1","toolName":"weather","input":{"town":"SG"},""" + - """"dynamic":true,"invalid":true,"providerMetadata":$metadata,""" + - """"error":{"name":"AI_InvalidToolInputError","toolName":"weather",""" + - """"toolInput":"{\"town\":\"SG\"}","cause":"missing city"}}""" - val opts = """{"tools":[{"type":"function","name":"weather","inputSchema":{"type":"object"}}]}""" - val out = applyToolCallRepair(invalid, opts, """{"type":"unchanged"}""") - val call = AimuxJson.decodeFromString(ToolCall.serializer(), out) - assertThat(call.providerMetadata!!.keys).containsExactly("zeta", "alpha", "mid") - assertThat(AimuxJson.encodeToString(ToolCall.serializer(), call)).contains(""""providerMetadata":$metadata""") - - val reference = """{"openai":"file-abc123","anthropic":"file-xyz789","google":"files/1"}""" - val upload = AimuxJson.decodeFromString(UploadFileResult.serializer(), """{"providerReference":$reference}""") - assertThat(upload.providerReference.keys).containsExactly("openai", "anthropic", "google") - assertThat(AimuxJson.encodeToString(UploadFileResult.serializer(), upload)) - .startsWith("""{"providerReference":$reference""") - } - - @Test - fun `file parts are the AI SDK file part`() { - // FilePart.data is FileData | DataContent | URL | ProviderReference - // (provider-utils types/content-part.ts:75, file-data.ts:44-48) and - // ReasoningFilePart.data is tagged data or a URL, or a string (:164). - // There is no file-base64 / file-url / file-reference part. - val wires = listOf( - """{"type":"file","data":{"type":"data","data":"aGk="},"mediaType":"text/plain","filename":"a.txt"}""", - """{"type":"file","data":{"type":"url","url":"https://example.com/a.pdf"},"mediaType":"application/pdf"}""", - """{"type":"file","data":{"type":"reference","reference":{"openai":"file-abc"}},"mediaType":"application/pdf"}""", - """{"type":"file","data":{"type":"text","text":"hello"},"mediaType":"text/plain"}""", - """{"type":"file","data":"aGk=","mediaType":"text/plain"}""", - """{"type":"file","data":"https://example.com/a.pdf","mediaType":"application/pdf"}""", - """{"type":"file","data":{"openai":"file-abc"},"mediaType":"application/pdf"}""", - """{"type":"reasoning-file","data":"aGk=","mediaType":"image/png"}""", - """{"type":"reasoning-file","data":{"type":"url","url":"https://example.com/a.png"},"mediaType":"image/png"}""", - ) - for (wire in wires) { - val part = AimuxJson.decodeFromString(ContentPart.serializer(), wire) - assertThat(AimuxJson.encodeToString(ContentPart.serializer(), part)).isEqualTo(wire) - } - assertThat((AimuxJson.decodeFromString(ContentPart.serializer(), wires[6]) as ContentPart.File).data) - .isEqualTo(FilePartData.Reference(mapOf("openai" to "file-abc"))) - for (removed in listOf("file-base64", "file-url", "file-reference")) { - assertThatThrownBy { - AimuxJson.decodeFromString( - ContentPart.serializer(), - """{"type":"$removed","data":"aGk=","url":"https://example.com/a.pdf","mediaType":"application/pdf"}""", - ) - }.isInstanceOf(SerializationException::class.java) - } - val url = ContentPart.File(FileData.Url("https://example.com/a.pdf"), "application/pdf") - assertThat(AimuxJson.encodeToString(ContentPart.serializer(), url)).isEqualTo(wires[1]) - } - - @Test - fun `FileBytes Base64 round-trips`() { - val original = FileBytes.Base64(data = "aGVsbG8=") - val json = AimuxJson.encodeToString(FileBytes.serializer(), original) - val decoded = AimuxJson.decodeFromString(FileBytes.serializer(), json) - assertThat(decoded).isEqualTo(original) - assertThat(json).isEqualTo("\"aGVsbG8=\"") - } - - @Test - fun `FileData Data round-trips`() { - val original = FileData.Data(data = FileBytes.Base64("aGVsbG8=")) - val json = AimuxJson.encodeToString(FileData.serializer(), original) - val decoded = AimuxJson.decodeFromString(FileData.serializer(), json) - assertThat(decoded).isEqualTo(original) - assertThat(json).contains("\"type\":\"data\"") - } - - @Test - fun `FileData Url round-trips`() { - val original = FileData.Url(url = "https://example.com/file") - val json = AimuxJson.encodeToString(FileData.serializer(), original) - val decoded = AimuxJson.decodeFromString(FileData.serializer(), json) - assertThat(decoded).isEqualTo(original) - assertThat(json).contains("\"type\":\"url\"") - } - - // ── GenerateResult (integration: mixed content variants) ─────────── - - @Test - fun `stream parts decode the call-level framing`() { - val decode = { json: String -> AimuxJson.decodeFromString(StreamPart.serializer(), json) } - assertThat(decode("""{"type":"start"}""")).isEqualTo(StreamPart.Start()) - assertThat(decode("""{"type":"start-step","request":{},"warnings":[]}""")) - .isInstanceOf(StreamPart.StartStep::class.java) - assertThat(decode("""{"type":"text-delta","id":"t","text":"Hi"}""")) - .isEqualTo(StreamPart.TextDelta(id = "t", text = "Hi")) - val finish = decode( - """{"type":"finish","finishReason":"tool-calls","rawFinishReason":"tool_use",""" + - """"totalUsage":{"inputTokens":3,"inputTokenDetails":{"cacheReadTokens":1},""" + - """"outputTokens":2,"outputTokenDetails":{},"totalTokens":5}}""", - ) as StreamPart.Finish - assertThat(finish.finishReason).isEqualTo(FinishReasonUnified.TOOL_CALLS) - assertThat(finish.rawFinishReason).isEqualTo("tool_use") - assertThat(finish.totalUsage.totalTokens).isEqualTo(5L) - assertThat(finish.totalUsage.inputTokenDetails.cacheReadTokens).isEqualTo(1L) - // LanguageModelUsage (ai types/usage.ts:10-67): camelCase, an unknown - // count absent. - assertThat(AimuxJson.encodeToString(LanguageModelUsage.serializer(), finish.totalUsage)) - .isEqualTo("""{"inputTokens":3,"inputTokenDetails":{"cacheReadTokens":1},"outputTokens":2,"outputTokenDetails":{},"totalTokens":5}""") - assertThat(decode("""{"type":"abort","reason":"stop"}""")).isEqualTo(StreamPart.Abort("stop")) - // Encoding writes the type tag back. - assertThat(AimuxJson.encodeToString(StreamPart.serializer(), StreamPart.Start())) - .isEqualTo("""{"type":"start"}""") - } - - // The text stream's tool parts are the AI SDK's TypedToolResult, - // TypedToolError, TypedToolOutputDenied and ToolApprovalResponseOutput, - // camelCase (ai generate-text/tool-result.ts:10-42, tool-error.ts:6-36, - // tool-output-denied.ts:7-15, tool-approval-response-output.ts:7-35). - @Test - fun `stream parts decode tool results and tool errors`() { - val decode = { json: String -> AimuxJson.decodeFromString(StreamPart.serializer(), json) } - val result = decode( - """{"type":"tool-result","toolCallId":"srv-1","toolName":"web_search","input":{"q":"rust"},""" + - """"output":[{"url":"https://example.com"}],"providerExecuted":true,"dynamic":true}""", - ) as StreamPart.ToolResult - assertThat(result.toolCallId).isEqualTo("srv-1") - assertThat(result.input.toString()).isEqualTo("""{"q":"rust"}""") - assertThat(result.output.toString()).isEqualTo("""[{"url":"https://example.com"}]""") - assertThat(result.providerExecuted).isTrue() - val deferred = decode( - """{"type":"tool-result","toolCallId":"srv-0","toolName":"code_execution","output":{"stdout":"2"},"providerExecuted":true}""", - ) as StreamPart.ToolResult - assertThat(deferred.input).isNull() - val error = decode( - """{"type":"tool-error","toolCallId":"call-1","toolName":"weather","input":{"city":"Paris"},""" + - """"error":"no such tool","dynamic":true}""", - ) as StreamPart.ToolError - assertThat(error.error.toString()).isEqualTo("\"no such tool\"") - assertThat(error.providerExecuted).isNull() - assertThat(error.dynamic).isTrue() - assertThat(decode("""{"type":"tool-output-denied","toolCallId":"c","toolName":"t"}""")) - .isEqualTo(StreamPart.ToolOutputDenied(toolCallId = "c", toolName = "t")) - val response = decode( - """{"type":"tool-approval-response","approvalId":"a","toolCall":{"type":"tool-call","toolCallId":"c","toolName":"t","input":{}},"approved":false}""", - ) as StreamPart.ToolApprovalResponse - assertThat(response.approved).isFalse() - assertThat(AimuxJson.encodeToString(StreamPart.serializer(), StreamPart.ToolOutputDenied(toolCallId = "c", toolName = "t"))) - .isEqualTo("""{"type":"tool-output-denied","toolCallId":"c","toolName":"t"}""") - } - - @Test - fun `GenerateResult round-trips with mixed content variants`() { - val original = GenerateResult( - content = listOf( - GenerateContent.Text(text = "hello"), - GenerateContent.ToolCall( - toolCallId = "call_1", - toolName = "get_weather", - input = """{"location":"Tokyo"}""", - ), - GenerateContent.ToolResult( - toolCallId = "call_1", - toolName = "get_weather", - result = JsonObject(mapOf("temp" to JsonPrimitive("20"))), - isError = false, - ), - ), - finishReason = FinishReason(unified = FinishReasonUnified.STOP), - usage = Usage.of(input = 10, output = 5), - ) - val json = AimuxJson.encodeToString(GenerateResult.serializer(), original) - val decoded = AimuxJson.decodeFromString(GenerateResult.serializer(), json) - - // The whole structure round-trips intact. - assertThat(decoded).isEqualTo(original) - - // Variant order is preserved. - assertThat(decoded.content.map { it::class.simpleName }) - .containsExactly("Text", "ToolCall", "ToolResult") - - // Spot-check the decoded content variants by type and field. - assertThat(decoded.content[0]).isInstanceOf(GenerateContent.Text::class.java) - assertThat((decoded.content[0] as GenerateContent.Text).text).isEqualTo("hello") - - assertThat(decoded.content[1]).isInstanceOf(GenerateContent.ToolCall::class.java) - assertThat((decoded.content[1] as GenerateContent.ToolCall).toolName).isEqualTo("get_weather") - - assertThat(decoded.content[2]).isInstanceOf(GenerateContent.ToolResult::class.java) - assertThat((decoded.content[2] as GenerateContent.ToolResult).result.toString()) - .isEqualTo("""{"temp":"20"}""") - - // The tags are present on the wire, and there is no `output`. - assertThat(json).contains("\"type\":\"text\"").contains("\"type\":\"tool-call\"") - .contains("\"type\":\"tool-result\"") - assertThat(json).contains("\"result\"").doesNotContain("\"output\"") - } -} diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/internal/FlowBridgeTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/internal/FlowBridgeTest.kt new file mode 100644 index 000000000..9acd351c3 --- /dev/null +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/internal/FlowBridgeTest.kt @@ -0,0 +1,137 @@ +package ai.arcships.aimux.internal + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class FlowBridgeTest { + @Test + fun `normal completion preserves ordering and runs on IO`() = runBlocking { + val collector = Thread.currentThread() + val aborts = AtomicInteger() + val stream = FlowBridge.flow(capacity = 2, abort = { aborts.incrementAndGet() }) { send -> + assertNotSame(collector, Thread.currentThread()) + repeat(100) { send(it) } + } + withTimeout(5000) { + assertEquals((0 until 100).toList(), stream.toList()) + } + assertEquals(0, aborts.get()) + } + + @Test + fun `take one aborts and waits for native return`() = runBlocking { + val aborts = AtomicInteger() + val release = CountDownLatch(1) + val returned = CountDownLatch(1) + val stream = FlowBridge.flow(capacity = 1, abort = { + aborts.incrementAndGet() + release.countDown() + }) { send -> + try { + send(1) + assertTrue(release.await(5, TimeUnit.SECONDS)) + send(2) // Late callbacks after cancellation cannot escape the bridge. + } finally { + returned.countDown() + } + } + withTimeout(5000) { assertEquals(listOf(1), stream.take(1).toList()) } + assertEquals(1, aborts.get()) + assertEquals(0L, returned.count) + } + + @Test + fun `full queue blocks producer and cancellation releases it`() = runBlocking { + val entered = CompletableDeferred() + val full = CompletableDeferred() + val returned = CountDownLatch(1) + val sends = AtomicInteger() + val aborts = AtomicInteger() + val job = launch { + FlowBridge.flow(capacity = 1, abort = { aborts.incrementAndGet() }) { send -> + try { + send(1) + // Wait until the collector holds the first event. + runBlocking { entered.await() } + send(2) + sends.incrementAndGet() + full.complete(Unit) + send(3) + sends.incrementAndGet() + } finally { + returned.countDown() + } + }.collect { + entered.complete(Unit) + awaitCancellation() + } + } + withTimeout(5000) { + full.await() + assertEquals(1, sends.get()) + job.cancelAndJoin() + } + assertEquals(1, aborts.get()) + assertEquals(0L, returned.count) + } + + @Test + fun `producer exception is delivered once after buffered events`() = runBlocking { + val failure = IllegalStateException("native failed") + val errors = mutableListOf() + val aborts = AtomicInteger() + val values = withTimeout(5000) { + FlowBridge.flow(capacity = 1, abort = { + aborts.incrementAndGet() + throw IllegalArgumentException("secondary abort failure") + }) { send -> + send(7) + throw failure + }.catch { errors.add(it) }.toList() + } + assertEquals(listOf(7), values) + assertSame(failure, generateSequence(errors.single()) { it.cause }.last()) + assertEquals(1, aborts.get()) + } + + @Test + fun `collector failure is preserved and aborts once`() = runBlocking { + val failure = IllegalArgumentException("consumer failed") + val aborts = AtomicInteger() + var caught: Throwable? = null + try { + withTimeout(5000) { + FlowBridge.flow(capacity = 1, abort = { aborts.incrementAndGet() }) { send -> + repeat(10) { send(it) } + throw IllegalStateException("later native failure") + }.collect { throw failure } + } + } catch (error: Throwable) { + caught = error + } + assertSame(failure, generateSequence(caught) { it.cause }.lastOrNull()) + assertEquals(1, aborts.get()) + } + + @Test + fun `unbounded and invalid capacities are rejected`() { + for (capacity in listOf(-1, 0, 257, Int.MAX_VALUE)) { + assertThrows(IllegalArgumentException::class.java) { + FlowBridge.flow(capacity, abort = {}) {} + } + } + } +} diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/kotlin/CoroutinesTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/kotlin/CoroutinesTest.kt new file mode 100644 index 000000000..e2d9634c5 --- /dev/null +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/kotlin/CoroutinesTest.kt @@ -0,0 +1,345 @@ +package ai.arcships.aimux.kotlin + +import ai.arcships.aimux.AimuxException as JavaAimuxException +import ai.arcships.aimux.KotlinInteropFixtures +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +class CoroutinesTest { + private fun javaError(name: String, code: Int): JavaAimuxException = + KotlinInteropFixtures.decode( + """{"kind":"aimux","code":$code,"message":"failure","error":{"name":"$name"}}""" + ) as JavaAimuxException + + private class Operation : CompletableFuture() { + val aborts = AtomicInteger() + override fun cancel(mayInterruptIfRunning: Boolean): Boolean { + assertTrue(mayInterruptIfRunning) + aborts.incrementAndGet() + return super.cancel(mayInterruptIfRunning) + } + } + + @Test + fun `successful Flow drains in order without cancellation`() = runBlocking { + val operation = Operation() + val values = aimuxFlow(2) { executor, consumer -> + executor.execute { + repeat(20) { consumer.accept(it) } + operation.complete(Unit) + } + operation + }.toList() + assertEquals((0 until 20).toList(), values) + assertEquals(0, operation.aborts.get()) + } + + @Test + fun `take one cancels Future and releases full callback queue`() = runBlocking { + val operation = Operation() + val exited = CountDownLatch(1) + val values = aimuxFlow(1) { executor, consumer -> + executor.execute { + try { + for (value in 0 until 1000) { + if (operation.isCancelled) break + consumer.accept(value) + } + } finally { + exited.countDown() + } + } + operation + }.take(1).toList() + assertEquals(listOf(0), values) + assertEquals(1, operation.aborts.get()) + assertTrue(exited.await(2, TimeUnit.SECONDS)) + } + + @Test + fun `cancelled Future retains callback lifetime until executor task returns`() = runBlocking { + val cancelled = CompletableDeferred() + val release = CountDownLatch(1) + val operation = object : CompletableFuture() { + override fun cancel(mayInterruptIfRunning: Boolean): Boolean { + val changed = super.cancel(mayInterruptIfRunning) + cancelled.complete(Unit) + return changed + } + } + val collection = async { + aimuxFlow(1) { executor, consumer -> + executor.execute { + consumer.accept(1) + assertTrue(release.await(5, TimeUnit.SECONDS)) + } + operation + }.take(1).toList() + } + try { + withTimeout(2000) { cancelled.await() } + assertTrue(operation.isDone) + assertFalse(collection.isCompleted) + } finally { release.countDown() } + assertEquals(listOf(1), withTimeout(2000) { collection.await() }) + } + + @Test + fun `slow collector imposes a finite producer bound`() = runBlocking { + val operation = Operation() + val entered = CompletableDeferred() + val release = CompletableDeferred() + val produced = AtomicInteger() + val exited = CountDownLatch(1) + val job = launch { + aimuxFlow(2) { executor, consumer -> + executor.execute { + try { + for (value in 0 until 1000) { + if (operation.isCancelled) break + consumer.accept(value) + produced.incrementAndGet() + } + } finally { + exited.countDown() + } + } + operation + }.collect { + entered.complete(Unit) + release.await() + } + } + withTimeout(2000) { entered.await() } + delay(100) + // Two queued values and the collector's value; no extra dispatcher buffer. + assertTrue(produced.get() in 1..3, "produced=${produced.get()}") + job.cancelAndJoin() + assertEquals(1, operation.aborts.get()) + assertTrue(exited.await(2, TimeUnit.SECONDS)) + } + + @Test + fun `explicit cancellation before first event triggers Abort`() = runBlocking { + val operation = Operation() + val started = CompletableDeferred() + val job = launch { + aimuxFlow { _, _ -> + started.complete(Unit) + operation + }.collect() + } + withTimeout(2000) { started.await() } + job.cancelAndJoin() + assertEquals(1, operation.aborts.get()) + } + + @Test + fun `collector exception cancels producer and is preserved`() = runBlocking { + val operation = Operation() + val expected = IllegalStateException("collector") + try { + aimuxFlow { executor, consumer -> + executor.execute { consumer.accept(1) } + operation + }.collect { throw expected } + fail("expected collector exception") + } catch (error: IllegalStateException) { + assertSame(expected, generateSequence(error) { it.cause }.last()) + } + assertEquals(1, operation.aborts.get()) + } + + @Test + fun `Java Future failure follows buffered events as sealed error`() = runBlocking { + val javaError = javaError("AI_APICallError", 11) + val values = mutableListOf() + try { + aimuxFlow(2) { executor, consumer -> + val future = CompletableFuture() + executor.execute { + consumer.accept(1) + consumer.accept(2) + future.completeExceptionally(javaError) + } + future + }.collect { values.add(it) } + fail("expected error") + } catch (error: APICallError) { + assertSame(javaError, error.javaError) + } + assertEquals(listOf(1, 2), values) + } + + @Test + fun `startup exception maps without waiting for an event`() = runBlocking { + val javaError = javaError("AI_InvalidPromptError", 6) + try { + aimuxFlow { _, _ -> throw javaError }.collect() + fail("expected startup failure") + } catch (error: InvalidPromptError) { + assertSame(javaError, error.javaError) + } + } + + @Test + fun `Flow is cold and each collection starts its own operation`() = runBlocking { + val starts = AtomicInteger() + val stream = aimuxFlow { _, consumer -> + consumer.accept(starts.incrementAndGet()) + CompletableFuture.completedFuture(Unit) + } + assertEquals(0, starts.get()) + assertEquals(listOf(1), stream.toList()) + assertEquals(listOf(2), stream.toList()) + } + + @Test + fun `an error value in the event payload is ordinary stream data`() = runBlocking { + val event = javaError("AI_APICallError", 11) + val values = aimuxFlow { _, consumer -> + consumer.accept(event) + CompletableFuture.completedFuture(Unit) + }.toList() + assertSame(event, values.single()) + } + + @Test + fun `await cancellation invokes the Java cancellation hook`() = runBlocking { + val operation = Operation() + val started = CompletableDeferred() + val job = launch { + aimuxCall { + started.complete(Unit) + operation + } + } + withTimeout(2000) { started.await() } + job.cancelAndJoin() + assertEquals(1, operation.aborts.get()) + } + + @Test + fun `cancellation during admission cancels Future once it returns`() = runBlocking { + val operation = Operation() + val entered = CompletableDeferred() + val release = CountDownLatch(1) + val job = launch { + aimuxCall { + entered.complete(Unit) + assertTrue(release.await(2, TimeUnit.SECONDS)) + operation + } + } + withTimeout(2000) { entered.await() } + job.cancel() + release.countDown() + job.join() + assertEquals(1, operation.aborts.get()) + } + + @Test + fun `cancellation during Flow admission cancels the returned Future`() = runBlocking { + val operation = Operation() + val entered = CompletableDeferred() + val release = CountDownLatch(1) + val job = launch { + aimuxFlow { _, _ -> + entered.complete(Unit) + assertTrue(release.await(2, TimeUnit.SECONDS)) + operation + }.collect() + } + withTimeout(2000) { entered.await() } + job.cancel() + release.countDown() + job.join() + assertEquals(1, operation.aborts.get()) + } + + @Test + fun `Java cancellation keeps its source error through call and Flow`() = runBlocking { + val source = javaError("AbortError", 13) + val cancellation = CancellationException("Native stream cancelled").apply { initCause(source) } + val future = CompletableFuture().apply { completeExceptionally(cancellation) } + for (await in listOf Unit>( + { aimuxCall { future } }, + { aimuxFlow { _, _ -> future }.collect() }, + )) { + try { + await() + fail("expected cancellation") + } catch (error: CancellationException) { + assertEquals(cancellation.message, error.message) + assertSame(source, generateSequence(error) { it.cause }.last()) + } + } + } + + @Test + fun `await maps Java failure and unwraps CompletionException`() = runBlocking { + val javaError = javaError("TimeoutError", 12) + val future = CompletableFuture() + future.completeExceptionally(java.util.concurrent.CompletionException(javaError)) + try { + future.awaitAimux() + fail("expected error") + } catch (error: TimeoutError) { + assertSame(javaError, error.javaError) + } + } + + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class, kotlinx.coroutines.DelicateCoroutinesApi::class) + @Test + fun `blocking startup and Future wait leave caller UI dispatcher free`() = runBlocking { + newSingleThreadContext("test-ui").use { ui -> + withContext(ui) { + val caller = Thread.currentThread() + val operation = Operation() + val thread = AtomicReference() + val started = CompletableDeferred() + val call = async { + aimuxCall { + thread.set(Thread.currentThread()) + started.complete(Unit) + Thread.sleep(100) + operation + } + } + withTimeout(2000) { started.await() } + assertNotSame(caller, thread.get()) + assertSame(caller, Thread.currentThread()) + operation.complete(Unit) + withTimeout(2000) { call.await() } + } + } + } + + @Test + fun `invalid bounds reject immediately`() { + for (capacity in listOf(-1, 0, 257, Int.MAX_VALUE)) { + assertThrows(IllegalArgumentException::class.java) { + aimuxFlow(capacity) { _, _ -> CompletableFuture.completedFuture(Unit) } + } + } + } +} diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/kotlin/ErrorsTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/kotlin/ErrorsTest.kt new file mode 100644 index 000000000..67fd07d57 --- /dev/null +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/kotlin/ErrorsTest.kt @@ -0,0 +1,143 @@ +package ai.arcships.aimux.kotlin + +import ai.arcships.aimux.KotlinInteropFixtures +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.math.BigInteger + +class ErrorsTest { + // Compiled without else, so additions must update the exhaustive facade. + private fun classify(error: AimuxException): String = when (error) { + is NoOutputGeneratedError -> "NoOutputGeneratedError" + is JSONParseError -> "JSONParseError" + is InvalidResponseDataError -> "InvalidResponseDataError" + is ToolCallNotFoundForApprovalError -> "ToolCallNotFoundForApprovalError" + is InvalidArgumentError -> "InvalidArgumentError" + is InvalidPromptError -> "InvalidPromptError" + is LoadAPIKeyError -> "LoadAPIKeyError" + is LoadSettingError -> "LoadSettingError" + is TokenExpiredError -> "TokenExpiredError" + is UnsupportedFunctionalityError -> "UnsupportedFunctionalityError" + is NoSuchModelError -> "NoSuchModelError" + is NoSuchProviderError -> "NoSuchProviderError" + is APICallError -> "APICallError" + is RetryError -> "RetryError" + is TimeoutError -> "TimeoutError" + is RequestAbortedError -> "RequestAbortedError" + is NoSuchToolError -> "NoSuchToolError" + is InvalidToolInputError -> "InvalidToolInputError" + is ToolCallRepairError -> "ToolCallRepairError" + is MissingToolResultsError -> "MissingToolResultsError" + is ToolChoiceViolationError -> "ToolChoiceViolationError" + is OtherError -> "OtherError" + is BindingError -> "BindingError" + is ProtocolError -> "ProtocolError" + is RecordingError -> "RecordingError" + is UnknownError -> "UnknownError" + } + + @Test + fun `every Java core subtype maps to an exhaustive sealed variant`() { + val codes = listOf(18, 2, 3, 3, 5, 6, 19, 20, 7, 8, 9, 10, 11, 14, 12, 13, 15, 16, 17, 6, 3, 1) + val variants = listOf( + "NoOutputGeneratedError", + "JSONParseError", + "InvalidResponseDataError", + "ToolCallNotFoundForApprovalError", + "InvalidArgumentError", + "InvalidPromptError", + "LoadAPIKeyError", + "LoadSettingError", + "TokenExpiredError", + "UnsupportedFunctionalityError", + "NoSuchModelError", + "NoSuchProviderError", + "APICallError", + "RetryError", + "TimeoutError", + "RequestAbortedError", + "NoSuchToolError", + "InvalidToolInputError", + "ToolCallRepairError", + "MissingToolResultsError", + "ToolChoiceViolationError", + "OtherError" + ) + for ((name, code) in variants.zip(codes)) { + val wireName = when (name) { + "OtherError" -> "AI_Error" + "TimeoutError" -> "TimeoutError" + "RequestAbortedError" -> "AbortError" + else -> "AI_$name" + } + val java = KotlinInteropFixtures.decode( + """{"kind":"aimux","code":$code,"message":"错误😀","error":{"name":"$wireName"}}""" + ) + val error = AimuxException.fromJava(java) + assertEquals(name, classify(error)) + assertEquals(code, error.code) + assertEquals("错误😀", error.message) + assertSame(java, error.javaError) + assertSame(java, error.cause) + assertEquals(java.error, error.error) + } + } + + @Test + fun `all Java error domains project without another decoder`() { + for ((kind, code, name, variant) in listOf( + listOf("binding", "203", "InvalidHandle", "BindingError"), + listOf("protocol", "300", "InvalidOp", "ProtocolError"), + listOf("recording", "105", "Write", "RecordingError"), + )) { + val java = KotlinInteropFixtures.decode( + """{"kind":"$kind","code":$code,"message":"failed","error":{"name":"$name","extra":17}}""" + ) + val error = AimuxException.fromJava(java) + assertEquals(variant, classify(error)) + assertSame(java, error.javaError) + assertEquals(java.envelope, error.envelope) + } + } + + @Test + fun `open payload and unsigned retry hint survive unchanged`() { + val java = KotlinInteropFixtures.decode("""{ + "kind":"aimux","code":11,"message":"limited","retryAfterMs":18446744073709551615, + "error":{"name":"AI_APICallError","statusCode":429,"isRetryable":true, + "requestBodyValues":{"model":"模型"},"responseHeaders":{"retry-after":"10"}, + "data":{"unknown":17}} + }""") + val error = AimuxException.fromJava(java) as APICallError + assertSame(java, error.details) + assertEquals(BigInteger("18446744073709551615"), error.retryAfterMs) + assertTrue(error.details.isRetryable) + assertEquals(429, error.details.statusCode) + assertEquals(java.error, error.error) + assertEquals(17, error.error.path("data").path("unknown").asInt()) + } + + @Test + fun `future Java subtype retains its complete envelope`() { + val java = KotlinInteropFixtures.unknown("""{ + "kind":"aimux","code":399,"message":"future","retryAfterMs":25, + "error":{"name":"FutureError","extra":{"nested":true}} + }""") + val error = AimuxException.fromJava(java) + assertEquals("UnknownError", classify(error)) + assertSame(java, error.javaError) + assertEquals(java.envelope, error.envelope) + assertEquals(BigInteger.valueOf(25), error.retryAfterMs) + } + + @Test + fun `nested retry payload is delegated without losing unknown fields`() { + val java = KotlinInteropFixtures.decode("""{ + "kind":"aimux","code":14,"message":"failed","error":{"name":"AI_RetryError", + "reason":"errorNotRetryable","errors":[{"name":"AI_APICallError","future":42}]} + }""") + val error = AimuxException.fromJava(java) as RetryError + assertEquals(java.error, error.error) + assertEquals(42, error.error.path("errors")[0].path("future").asInt()) + } +} diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/kotlin/NativeIntegrationTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/kotlin/NativeIntegrationTest.kt new file mode 100644 index 000000000..e3e6a1475 --- /dev/null +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/kotlin/NativeIntegrationTest.kt @@ -0,0 +1,171 @@ +package ai.arcships.aimux.kotlin + +import ai.arcships.aimux.Aimux +import ai.arcships.aimux.AimuxException as JavaAimuxException +import ai.arcships.aimux.KotlinInteropFixtures +import ai.arcships.aimux.StreamFrame +import ai.arcships.aimux.generated.AimuxOps +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.util.concurrent.CompletableFuture + +/** Uses the Rust cdylib through Java, without a fixture native implementation. */ +class NativeIntegrationTest { + private fun client(): Aimux = Aimux.load( + System.getProperty("aimux.library") ?: System.getenv("AIMUX_LIBRARY_PATH") ?: "aimux_ffi" + ) + + private val providerParams = """{"package":"openai","settings":{"apiKey":"test-key"}}""" + + @Test + fun `real manifest loads and Java provider model objects retain identity`() = runBlocking { + val aimux = client() + assertEquals(AimuxOps.ABI_VERSION, aimux.manifest.path("abiVersion").asLong()) + assertEquals(AimuxOps.OPS_VERSION, aimux.manifest.path("opsVersion").asText()) + val provider = aimux.createProvider(providerParams) + try { + val model = provider.languageModel("test-model") + try { + assertSame(provider, KotlinInteropFixtures.acceptProvider( + aimuxCall { CompletableFuture.completedFuture(provider) } + )) + assertSame(model, KotlinInteropFixtures.acceptModel( + aimuxCall { CompletableFuture.completedFuture(model) } + )) + assertEquals("test-model", model.info().path("modelId").asText()) + } finally { model.close() } + } finally { provider.close() } + } + + @Test + fun `Decision remains reachable through Java generic resources`() = runBlocking { + client().createProvider("""{"package":"jev","settings":{"apiKey":"test-key"}}""").use { provider -> + provider.create(AimuxOps.OP_PROVIDER_MODEL, """{"method":"decision","modelId":"test-model"}""").use { decision -> + val capabilities = aimuxCall { executor -> + decision.callAsync(AimuxOps.OP_DECISION_CAPABILITIES, "{}", executor) + } + assertTrue(capabilities.isObject) + assertEquals("test-model", decision.call(AimuxOps.OP_MODEL_INFO, "{}").path("modelId").asText()) + } + } + } + + @Test + fun `Java Abort reaches the real native stream`() = runBlocking { + val aimux = client() + aimux.createProvider(providerParams).use { provider -> + provider.languageModel("test-model").use { model -> + aimux.createAbort().use { abort -> + abort.signal() + val frames = mutableListOf() + try { + withTimeout(5000) { + aimuxFlow { executor, consumer -> + val operation = CompletableFuture() + executor.execute { + try { + model.stream(AimuxOps.OP_TEXT_STREAM, """{"prompt":"hello"}""", abort, consumer) + operation.complete(Unit) + } catch (error: Throwable) { operation.completeExceptionally(error) } + } + operation + }.collect { frames.add(it) } + } + fail("expected native cancellation") + } catch (expected: CancellationException) { + val source = assertInstanceOf( + JavaAimuxException.RequestAbortedError::class.java, + generateSequence(expected) { it.cause }.last(), + ) + assertTrue(frames.single().isEnd) + assertEquals(StreamFrame.Outcome.CANCELLED, frames.single().outcome) + assertEquals(source.envelope, frames.single().error.envelope) + } + } + } + } + } + + @Test + fun `real protocol envelope maps through Java async Future`() = runBlocking { + val aimux = client() + try { + aimuxCall { executor -> aimux.callAsync("missing.kotlin.op", "{}", executor) } + fail("expected invalid operation") + } catch (error: ProtocolError) { + assertEquals(300, error.code) + assertEquals("InvalidOp", error.errorName) + assertEquals(error.javaError.envelope, error.envelope) + } + } + + @Test + fun `real binding envelope preserves an unknown handle`() = runBlocking { + val resource = client().own("18446744073709551615") + try { + try { + aimuxCall { executor -> resource.callAsync(AimuxOps.OP_MODEL_INFO, "{}", executor) } + fail("expected invalid handle") + } catch (error: BindingError) { + assertEquals(203, error.code) + assertEquals("InvalidHandle", error.errorName) + } + } finally { resource.close(); resource.close() } + } + + @Test + fun `real stream rejects invalid controls before any frame`() = runBlocking { + client().createProvider(providerParams).use { provider -> + provider.languageModel("test-model").use { model -> + val values = mutableListOf() + try { + withTimeout(5000) { + aimuxFlow(1) { executor, consumer -> + model.streamAsync("{}", consumer, executor) + }.collect { values.add(it) } + } + fail("expected invalid params") + } catch (error: ProtocolError) { + assertEquals(301, error.code) + } + assertTrue(values.isEmpty()) + } + } + } + + @Test + fun `accepted stream drains terminal envelope and take one returns`() = runBlocking { + client().createProvider(providerParams).use { provider -> + provider.languageModel("test-model").use { model -> + // An empty prompt fails core validation before provider I/O. + val params = """{"prompt":[],"options":{"maxRetries":0}}""" + val values = mutableListOf() + try { + withTimeout(5000) { + aimuxFlow(1) { executor, consumer -> + model.streamAsync(params, consumer, executor) + }.collect { values.add(it) } + } + fail("expected invalid prompt") + } catch (error: InvalidPromptError) { + assertTrue(values.last().isEnd) + assertEquals(StreamFrame.Outcome.ERROR, values.last().outcome) + assertEquals(error.envelope, values.last().error.envelope) + } + val first = withTimeout(5000) { + aimuxFlow(1) { executor, consumer -> + model.streamAsync(params, consumer, executor) + }.take(1).toList() + } + assertEquals(1, first.size) + assertTrue(first.single().isEnd) + } + } + } +} diff --git a/bindings/node/Cargo.toml b/bindings/node/Cargo.toml index 0c06edcbc..e64fe9c47 100644 --- a/bindings/node/Cargo.toml +++ b/bindings/node/Cargo.toml @@ -8,10 +8,12 @@ description = "Node.js binding for aimux (napi-rs, native path — bypasses aimu [lib] crate-type = ["cdylib"] +[features] +contract-tests = [] + [dependencies] aimux-core = { path = "../../aimux-core" } -aimux-providers = { path = "../../aimux-providers" } -aimux-provider-utils = { path = "../../aimux-provider-utils" } +aimux-ops = { path = "../../aimux-ops" } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } tokio = { version = "1", features = ["full"] } diff --git a/bindings/node/README.md b/bindings/node/README.md new file mode 100644 index 000000000..8a1bd2f8b --- /dev/null +++ b/bindings/node/README.md @@ -0,0 +1,82 @@ +# Node ops binding + +The napi adapter awaits `aimux_ops::dispatch` on napi-rs's Tokio runtime. It does +not use the C ABI, create a blocking runtime, retry calls, or accept JavaScript +fetch/model/token/telemetry implementations. + +```ts +import { Client, LanguageModel } from '@arcships/aimux' + +const client = new Client() +const provider = await client.provider({ package: 'openai', settings: { apiKey: '...' } }) +const model = await provider.model('gpt-4o-mini', 'languageModel') +try { + if (!(model instanceof LanguageModel)) throw new Error('Expected Language') + console.log((await model.generate('Hello')).text) + const controller = new AbortController() + for await (const frame of await model.stream('Hello', {}, { signal: controller.signal })) { + // Data errors remain data. Only the explicit end frame describes termination. + if (frame.type === 'event') console.log(frame.event) + else console.log(frame.outcome, frame.error) + } +} finally { + await model.close() + await provider.close() +} +``` + +The raw entrypoint `@arcships/aimux/raw` exposes `Runtime.call(handle, op, +paramsJson, abort?)` and `Runtime.stream(...)`. It transports the same DTOs and +frames as the typed facade. `runtime.abort(signal?)` creates a native Abort. +`runtime.operationPush(handle, bytes)` admits bytes before returning a Promise. +A full input queue rejects immediately with ResourceLimit (305); it never waits +for capacity. Accepted bytes are copied before the caller can mutate the buffer. +Handle IDs and sequence numbers are decimal strings, including beyond 2^53. +Resources belong to their Node Env; they cannot be transferred to workers. + +Streams have a 64-item / 8 MiB data bridge and a 1 MiB serialized frame limit. +They stop pulling upstream when full. The terminal has a separate slot, so +cancellation cannot lose its outcome behind a full queue. `return()`, `throw()`, +`readable(iterator).getReader().cancel()`, object collection, and Env teardown +cancel the underlying work. Explicit cancellation can interrupt pending reads +and capacity waits. A forgotten live iterator can retain its bounded queue; +close it explicitly when it is no longer needed. + +Errors expose the shared `kind`, numeric `code`, `message`, `error` payload and +optional `retryAfterMs`. The AI SDK payload retains `name`, `isRetryable` and +other variant fields. Data error events and end-frame error envelopes stay +plain DTOs. Raw and typed call/next rejections use `AimuxError`. + +`close()` is idempotent. Recorder persistence is explicit: await `flush()` and +inspect its result before closing; finalization does not flush. Dropping a +provider does not revoke a model's strong reference. Dropping an Operation +cancels it; dropping an Abort releases the handle without signalling shared work. + +The binding requires the exact generated ABI version, ops version and manifest +`digest` at load, and exposes the verified manifest and digest. + +Run `npm run test:facade` for the JSON-transport facade tests (Node with TypeScript stripping, +22.18+). These tests use a transport double. Run `npm run test:native` after +building a real napi binary for owner/error/Env integration checks. `npm run test:lifecycle` runs local HTTP timer, slow-consumer, cancellation and +shutdown scenarios; it requires permission to listen on loopback sockets. `npm test` +also replays all 11 JSON scenarios from `AIMUX_CONTRACT_VECTORS/scenarios` +(default: `contract-tests/ops/scenarios` in the repository) through the native call +boundary, real Dispatcher, framing and bounded queue. It builds an isolated addon +with the `contract-tests` feature for the scripted source; release builds do not +expose test operations. Set `AIMUX_BUILD_COMMAND` to a Cargo build wrapper if +needed. The scenario driver does not read expected output. `npm test` requires +the production binary for the other native suites. Rust bridge +unit tests exercise real channel/byte limits and cancellation with synthetic +streams. Build the addon with `npm run build:debug` and the typed facade with +`npm run build:typed`. Run Rust unit tests with +`cargo test --manifest-path bindings/node/Cargo.toml --lib`. + +For C ABI parity, build `aimux-ffi`, set `AIMUX_FFI_LIBRARY` to its absolute library +path, and run `npm run test:c-parity` (Python 3 is required). This compares the +same provider, registry, trace, recorder, session, replay and error vectors. + +Recorder, session storage and inference state are process-wide. Trace middleware +wraps language models using a TraceStore. Credential resolver creation and replay +transport capabilities report UnsupportedFunctionality. Recording schemas +are unchanged; business type schema generation and per-platform installation +verification are separate release work. diff --git a/bindings/node/__test__/abi.parity.ts b/bindings/node/__test__/abi.parity.ts new file mode 100644 index 000000000..dadd8cd30 --- /dev/null +++ b/bindings/node/__test__/abi.parity.ts @@ -0,0 +1,64 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { Runtime, AimuxError } from '../src/native.ts' +import * as ops from '../src/generated/ops.ts' + +test('Node dispatch and the C ABI agree on provider, registry, devtools and errors', async () => { + assert.ok(process.env.AIMUX_FFI_LIBRARY, 'Set AIMUX_FFI_LIBRARY to the built aimux-ffi library') + const recording = { + schema: 2, callId: 'node-c-parity', recordedAt: '2026-10-15T00:00:00Z', + input: { prompt: [{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }], options: {} }, + provider: { providerId: 'openai', provider: 'openai.chat', modelId: 'test-model' }, + exchanges: [], outcome: { status: 'success' }, complete: true, transportClosed: true, + } + const vectors = [ + { op: ops.OP_PROVIDER_CREATE, params: { package: 'openai', settings: { apiKey: 'local-test-key' } }, save: 'provider' }, + { handle: '$provider', op: ops.OP_PROVIDER_MODEL, params: { modelId: 'test-model', method: 'languageModel' }, save: 'model' }, + { handle: '$model', op: ops.OP_MODEL_INFO }, + { handle: '$provider', op: ops.OP_PROVIDER_FILES, save: 'files' }, + { op: ops.OP_REGISTRY_CREATE, params: { namespace: 'test', entries: [{ key: 'main', provider: '$provider' }] }, save: 'registry' }, + { handle: '$registry', op: ops.OP_REGISTRY_PROVIDER, params: { key: 'main' }, save: 'lookup' }, + { op: ops.OP_TRACE_CREATE, params: { capacity: 16 }, save: 'trace' }, + { handle: '$trace', op: ops.OP_TRACE_AGGREGATE, params: { filter: {} } }, + { op: ops.OP_SESSION_STORE_CREATE, params: { infer: false }, save: 'sessions' }, + { handle: '$sessions', op: ops.OP_SESSION_STORE_LIST }, + { op: ops.OP_RECORDER_CREATE, params: { backend: { type: 'ring', capacity: 16 }, capturePolicy: 'full' }, save: 'recorder' }, + { handle: '$recorder', op: ops.OP_RECORDER_EXPORT }, + { op: ops.OP_REPLAY_CREATE, params: { recordingsJsonl: JSON.stringify(recording) }, save: 'replay' }, + { handle: '$replay', op: ops.OP_REPLAY_INSPECT }, + { handle: '$replay', op: ops.OP_REPLAY_DRY_RUN }, + { op: 'unknown.op' }, + { handle: '$provider', op: ops.OP_TEXT_GENERATE }, + { op: ops.OP_PROVIDER_CREATE, params: { package: 'does-not-exist' } }, + { op: ops.OP_REPLAY_CREATE, params: { recordingsJsonl: '' } }, + ] + const python = spawnSync('python3', [fileURLToPath(new URL('./fixtures/ffi.py', import.meta.url)), process.env.AIMUX_FFI_LIBRARY], { input: JSON.stringify(vectors), encoding: 'utf8', timeout: 30_000 }) + assert.equal(python.status, 0, python.stderr) + const runtime = new Runtime() + const handles = new Map() + function resolve(value: unknown): unknown { + if (typeof value === 'string' && value.startsWith('$')) return handles.get(value.slice(1))! + if (Array.isArray(value)) return value.map(resolve) + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolve(item)])) + return value + } + const results = [] + try { + for (const vector of vectors) { + try { + let result = JSON.parse(await runtime.call(resolve(vector.handle ?? '0') as string, vector.op, JSON.stringify(resolve(vector.params ?? {})))) + if (vector.save) { + handles.set(vector.save, typeof result === 'string' ? result : result.handle) + result = typeof result === 'string' ? '$' + vector.save : { ...result, handle: '$' + vector.save } + } + results.push({ result }) + } catch (error) { + assert.ok(error instanceof AimuxError) + results.push({ error: { kind: error.kind, code: error.code, message: error.message, error: error.error, ...(error.retryAfterMs === undefined ? {} : { retryAfterMs: error.retryAfterMs }) } }) + } + } + assert.deepEqual(results, JSON.parse(python.stdout)) + } finally { for (const handle of handles.values()) runtime.drop(handle) } +}) diff --git a/bindings/node/__test__/cassette-exhaustive.test.ts b/bindings/node/__test__/cassette-exhaustive.test.ts deleted file mode 100644 index cac953c59..000000000 --- a/bindings/node/__test__/cassette-exhaustive.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -// cassette-exhaustive.test.ts — Exhaustive cassette replay for Node binding. -// -// Iterates over EVERY chat/completions cassette file across all provider -// directories, mounts each one individually, and verifies the full chain: -// Node.js → napi → Rust engine → single cassette → parse → result - -import test from 'ava' -import { deepseek } from '../src/native.ts' -import { readFileSync, readdirSync, statSync } from 'node:fs' -import { join, dirname } from 'node:path' -import { fileURLToPath } from 'node:url' -import { createServer, type Server } from 'node:http' - -const __dirname = dirname(fileURLToPath(import.meta.url)) -const CASSETTE_BASE = join(__dirname, '..', '..', '..', 'aimux-providers', 'tests', 'cassettes') - -interface Cassette { - provider: string - file: string - reqPath: string - reqBody: any - isStream: boolean - respStatus: number - respHeaders: Record - respBody: string -} - -function loadChatCassettes(): Cassette[] { - const all: Cassette[] = [] - for (const provider of readdirSync(CASSETTE_BASE)) { - const dir = join(CASSETTE_BASE, provider) - try { - const stat = statSync(dir) - if (!stat.isDirectory()) continue - } catch { continue } - - for (const file of readdirSync(dir)) { - if (!file.endsWith('.json')) continue - try { - const raw = JSON.parse(readFileSync(join(dir, file), 'utf-8')) - const reqPath = raw.request?.path || '/' - if (!reqPath.endsWith('/chat/completions')) continue - - let body = raw.request?.body || {} - if (typeof body === 'string') { - try { body = JSON.parse(body) } catch { body = {} } - } - - const headers: Record = {} - for (const [k, v] of Object.entries(raw.response?.headers || {})) { - if (typeof v === 'string') headers[k] = v - } - - all.push({ - provider, - file, - reqPath, - reqBody: body, - isStream: body.stream === true, - respStatus: raw.response?.status || 200, - respHeaders: headers, - respBody: raw.response?.body || '', - }) - } catch {} - } - } - return all -} - -function startSingleCassetteServer(cass: Cassette): Promise<{ server: Server; url: string }> { - return new Promise((resolve) => { - const server = createServer((req, res) => { - let body = '' - req.on('data', (chunk) => (body += chunk)) - req.on('end', () => { - res.writeHead(cass.respStatus, cass.respHeaders) - res.end(cass.respBody) - }) - }) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as any - resolve({ server, url: `http://127.0.0.1:${addr.port}` }) - }) - }) -} - -function getModel(cass: Cassette): string { - return cass.reqBody?.model || 'gpt-4o' -} - -function getBasePath(reqPath: string): string { - if (reqPath.endsWith('/chat/completions')) { - return reqPath.slice(0, -'/chat/completions'.length) - } - return '' -} - -function extractPrompt(cass: Cassette): string { - const msgs = cass.reqBody?.messages - if (Array.isArray(msgs)) { - for (let i = msgs.length - 1; i >= 0; i--) { - if (msgs[i].role === 'user') { - const content = msgs[i].content - if (typeof content === 'string') return content - if (Array.isArray(content)) { - for (const p of content) { - if (p.text) return p.text - } - } - } - } - } - return 'Hello' -} - -const cassettes = loadChatCassettes() - -test(`exhaustive: ${cassettes.length} chat/completions cassettes replayed`, async (t) => { - // 803 sequential replays: ~2.5s idle but >10s (ava's default timeout) on a - // loaded machine, so give the whole run explicit headroom. - t.timeout(120_000) - t.true(cassettes.length > 700, `expected 700+ chat cassettes, got ${cassettes.length}`) - - let passed = 0 - let failed = 0 - const errors: string[] = [] - - for (const cass of cassettes) { - const { server, url } = await startSingleCassetteServer(cass) - const basePath = getBasePath(cass.reqPath) - const baseUrl = basePath ? `${url}${basePath}` : url - const modelId = getModel(cass) - const prompt = extractPrompt(cass) - - try { - const model = await deepseek('test-key', modelId, baseUrl) - if (cass.isStream) { - const gen = await model.streamText(JSON.stringify(prompt)) - let parts = 0 - for await (const _json of gen) { parts++ } - if (parts === 0) throw new Error('no stream parts') - } else { - const resultJson = await model.generateText(JSON.stringify(prompt)) - const result = JSON.parse(resultJson) - if (result.error) throw new Error(result.error) - } - passed++ - } catch (e: any) { - const msg = String(e?.message || e) - // Accept any provider error — these cassettes record error responses - if (msg.includes('404') || msg.includes('400') || msg.includes('401') - || msg.includes('429') || msg.includes('500') - || msg.includes('model not found') || msg.includes('rate limited') - || msg.includes('error decoding') || msg.includes('does not exist') - || msg.includes('invalid type') || msg.includes('missing field')) { - passed++ - } else { - failed++ - if (errors.length < 20) errors.push(`${cass.provider}/${cass.file}: ${msg}`) - } - } finally { - await new Promise((resolve) => server.close(() => resolve())) - } - } - - t.log(`Total: ${cassettes.length}, Passed: ${passed}, Failed: ${failed}`) - if (errors.length > 0) { - for (const e of errors) t.log(` FAIL: ${e}`) - } - - const passRate = passed / cassettes.length - t.true(passRate > 0.9, `pass rate ${(passRate * 100).toFixed(1)}% too low: ${passed}/${cassettes.length}`) -}) diff --git a/bindings/node/__test__/cassette-replay.ts b/bindings/node/__test__/cassette-replay.ts deleted file mode 100644 index 2d4d8059c..000000000 --- a/bindings/node/__test__/cassette-replay.ts +++ /dev/null @@ -1,140 +0,0 @@ -// cassette-replay.ts — Lightweight cassette replay server for Node tests. -// -// Reads real cassette JSON files (from aimux-providers/tests/cassettes/), -// serves them via a local HTTP server with the same matching logic as -// the Rust `common::replay` module: group by (method, path), score by -// model + stream flag, fall back to first cassette. - -import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http' -import { readFileSync, readdirSync } from 'node:fs' -import { join, dirname } from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = dirname(fileURLToPath(import.meta.url)) -const CASSETTE_BASE = join(__dirname, '..', '..', '..', 'aimux-providers', 'tests', 'cassettes') - -interface Cassette { - scenario: string - reqPath: string - reqMethod: string - reqBody: any - respStatus: number - respHeaders: Record - respBody: string -} - -function loadCassettes(provider: string): Cassette[] { - const dir = join(CASSETTE_BASE, provider) - const files = readdirSync(dir).filter((f) => f.endsWith('.json')) - const cassettes: Cassette[] = [] - for (const file of files) { - try { - const raw = JSON.parse(readFileSync(join(dir, file), 'utf-8')) - const body = raw.request?.body - // body can be a JSON object or a string - let reqBody = body - if (typeof body === 'string') { - try { reqBody = JSON.parse(body) } catch { reqBody = null } - } - cassettes.push({ - scenario: raw.scenario || file, - reqPath: raw.request?.path || '/', - reqMethod: raw.request?.method || 'POST', - reqBody: reqBody || {}, - respStatus: raw.response?.status || 200, - respHeaders: raw.response?.headers || {}, - respBody: raw.response?.body || '', - }) - } catch (e) { - // skip malformed cassettes - } - } - return cassettes -} - -// Score a cassette against the incoming request body (same logic as Rust replay) -function score(cassetteBody: any, reqBody: any): number { - if (typeof cassetteBody !== 'object' || cassetteBody === null) return 0 - if (typeof reqBody !== 'object' || reqBody === null) return 0 - let s = 0 - for (const [key, val] of Object.entries(cassetteBody)) { - if (typeof val !== 'string' && typeof val !== 'boolean' && typeof val !== 'number') continue - if (reqBody[key] === val) { - if (key === 'model') s += 100 - else if (key === 'stream') s += 10 - else s += 1 - } - } - return s -} - -function findBestMatch(cassettes: Cassette[], method: string, path: string, reqBody: any): Cassette | null { - // Group by (method, path) - const group = cassettes.filter((c) => c.reqMethod === method && c.reqPath === path) - if (group.length === 0) return null - - // Score and pick best - let best = group[0] - let bestScore = score(best.reqBody, reqBody) - for (let i = 1; i < group.length; i++) { - const s = score(group[i].reqBody, reqBody) - if (s > bestScore) { - bestScore = s - best = group[i] - } - } - return best -} - -export class CassetteServer { - private server: Server - private cassettes: Cassette[] - public url: string - - constructor(provider: string) { - this.cassettes = loadCassettes(provider) - if (this.cassettes.length === 0) { - throw new Error(`No cassettes found for provider: ${provider}`) - } - - this.server = createServer((req, res) => this.handle(req, res)) - this.url = '' // set in start() - } - - private handle(req: IncomingMessage, res: ServerResponse) { - let body = '' - req.on('data', (chunk) => (body += chunk)) - req.on('end', () => { - let reqBody: any = null - try { reqBody = JSON.parse(body) } catch {} - - const match = findBestMatch(this.cassettes, req.method || 'POST', req.url || '/', reqBody) - if (!match) { - res.writeHead(404, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ error: `no cassette for ${req.method} ${req.url}` })) - return - } - - res.writeHead(match.respStatus, match.respHeaders) - res.end(match.respBody) - }) - } - - async start(): Promise { - return new Promise((resolve) => { - this.server.listen(0, '127.0.0.1', () => { - const addr = this.server.address() as any - this.url = `http://127.0.0.1:${addr.port}` - resolve() - }) - }) - } - - async stop(): Promise { - return new Promise((resolve) => this.server.close(() => resolve())) - } - - get count(): number { - return this.cassettes.length - } -} diff --git a/bindings/node/__test__/cassette.test.ts b/bindings/node/__test__/cassette.test.ts deleted file mode 100644 index cf6b4433d..000000000 --- a/bindings/node/__test__/cassette.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -// cassette.test.ts — Real cassette replay tests for Node binding. -// -// These tests load ACTUAL recorded provider responses from -// aimux-providers/tests/cassettes/ and verify the full chain: -// Node.js → napi → Rust engine → cassette replay → parse → typed result -// -// Hard asserts only — no catch-all pass. Providers with non-standard paths -// are excluded (not tested here), not faked. - -import test from 'ava' -import { openai, deepseek, anthropic } from '../src/native.ts' -import { CassetteServer } from './cassette-replay.ts' - -// ── OpenAI ────────────────────────────────────────────────────────────────── - -test('cassette: OpenAI generate', async (t) => { - const srv = new CassetteServer('openai') - await srv.start() - t.true(srv.count > 0, 'should load OpenAI cassettes') - - try { - const model = await openai('test-key', 'gpt-4o', `${srv.url}/v1`) - const resultJson = await model.generateText(JSON.stringify('Hello')) - const result = JSON.parse(resultJson) - - t.false(!!result.error, `unexpected error: ${result.error}`) - t.is(typeof result.text, 'string', 'should have text field') - t.true(result.text.length > 0, 'text should be non-empty') - t.truthy(result.usage, 'should have usage') - t.truthy(result.finishReason, 'should have finishReason') - } finally { - await srv.stop() - } -}) - -test('cassette: OpenAI stream', async (t) => { - const srv = new CassetteServer('openai') - await srv.start() - - try { - const model = await openai('test-key', 'gpt-4o', `${srv.url}/v1`) - const gen = await model.streamText(JSON.stringify('Hello')) - let parts: any[] = [] - for await (const json of gen) { - parts.push(JSON.parse(json)) - } - t.true(parts.length > 0, 'should receive stream parts') - - const types = parts.map((p) => p.type) - t.is(types[0], 'start', 'should open with start') - t.true(types.includes('start-step'), 'should have start-step') - t.true(types.includes('finish'), 'should have finish') - } finally { - await srv.stop() - } -}) - -// ── DeepSeek ──────────────────────────────────────────────────────────────── - -test('cassette: DeepSeek generate', async (t) => { - const srv = new CassetteServer('deepseek') - await srv.start() - t.true(srv.count > 0) - - try { - const model = await deepseek('test-key', 'deepseek-chat', srv.url) - const resultJson = await model.generateText(JSON.stringify('Hello')) - const result = JSON.parse(resultJson) - t.false(!!result.error, `unexpected error: ${result.error}`) - t.is(typeof result.text, 'string', 'should have text') - } finally { - await srv.stop() - } -}) - -test('cassette: DeepSeek step usage.raw carries vendor fields', async (t) => { - // RFC-0016 M10: DeepSeek's vendor-specific usage fields (not part of the - // typed Usage model) survive in the step's usage.raw. The result's `usage` - // is summed over the steps and, as the AI SDK's addLanguageModelUsage, - // carries no raw object. - const srv = new CassetteServer('deepseek') - await srv.start() - t.true(srv.count > 0) - - try { - const model = await deepseek('test-key', 'deepseek-chat', srv.url) - const resultJson = await model.generateText(JSON.stringify('Hello')) - const result = JSON.parse(resultJson) - const raw = result.steps?.[0]?.usage?.raw - t.truthy(raw, 'steps[0].usage.raw should be populated (RFC-0016 M10)') - t.is(typeof raw?.prompt_cache_hit_tokens, 'number') - t.is(typeof raw?.prompt_cache_miss_tokens, 'number') - t.is(result.usage?.raw ?? null, null) - } finally { - await srv.stop() - } -}) - -// ── Anthropic ─────────────────────────────────────────────────────────────── - -test('cassette: Anthropic generate', async (t) => { - const srv = new CassetteServer('anthropic') - await srv.start() - t.true(srv.count > 0) - - try { - const model = await anthropic('test-key', 'claude-sonnet-4-6', `${srv.url}/v1`) - const resultJson = await model.generateText(JSON.stringify('Hello')) - const result = JSON.parse(resultJson) - t.false(!!result.error, `unexpected error: ${result.error}`) - t.is(typeof result.text, 'string', 'should have text') - t.true(result.text.length > 0, 'text should be non-empty') - t.truthy(result.usage, 'should have usage') - } finally { - await srv.stop() - } -}) - -test('cassette: Anthropic stream', async (t) => { - const srv = new CassetteServer('anthropic') - await srv.start() - - try { - const model = await anthropic('test-key', 'claude-3-haiku-20240307', `${srv.url}/v1`) - const gen = await model.streamText(JSON.stringify('Hello')) - let parts: any[] = [] - for await (const json of gen) { - parts.push(JSON.parse(json)) - } - t.true(parts.length > 0, 'should receive stream parts') - } finally { - await srv.stop() - } -}) - -// ── Groq ──────────────────────────────────────────────────────────────────── - -test('cassette: Groq generate', async (t) => { - const srv = new CassetteServer('groq') - await srv.start() - t.true(srv.count > 0) - - try { - const model = await deepseek('test-key', 'llama-3.3-70b-versatile', `${srv.url}/openai/v1`) - const resultJson = await model.generateText(JSON.stringify('Hello')) - const result = JSON.parse(resultJson) - t.false(!!result.error, `unexpected error: ${result.error}`) - t.is(typeof result.text, 'string', 'should have text') - } finally { - await srv.stop() - } -}) - -// ── Mistral ───────────────────────────────────────────────────────────────── - -test('cassette: Mistral generate', async (t) => { - const srv = new CassetteServer('mistral') - await srv.start() - t.true(srv.count > 0) - - try { - const model = await deepseek('test-key', 'ministral-8b-latest', `${srv.url}/v1`) - const resultJson = await model.generateText(JSON.stringify('Hello')) - const result = JSON.parse(resultJson) - t.false(!!result.error, `unexpected error: ${result.error}`) - t.is(typeof result.text, 'string', 'should have text') - } finally { - await srv.stop() - } -}) - -// ── Ollama ────────────────────────────────────────────────────────────────── - -test('cassette: Ollama generate', async (t) => { - const srv = new CassetteServer('ollama') - await srv.start() - t.true(srv.count > 0) - - try { - const model = await deepseek('test-key', 'qwen3:4b', `${srv.url}/v1`) - const resultJson = await model.generateText(JSON.stringify('Hello')) - const result = JSON.parse(resultJson) - t.false(!!result.error, `unexpected error: ${result.error}`) - t.is(typeof result.text, 'string', 'should have text') - } finally { - await srv.stop() - } -}) - -// ── Perplexity ────────────────────────────────────────────────────────────── - -test('cassette: Perplexity generate', async (t) => { - const srv = new CassetteServer('perplexity') - await srv.start() - t.true(srv.count > 0) - - try { - const model = await deepseek('test-key', 'sonar', srv.url) - const resultJson = await model.generateText(JSON.stringify('Hello')) - const result = JSON.parse(resultJson) - t.false(!!result.error, `unexpected error: ${result.error}`) - t.is(typeof result.text, 'string', 'should have text') - } finally { - await srv.stop() - } -}) diff --git a/bindings/node/__test__/contract.native.ts b/bindings/node/__test__/contract.native.ts new file mode 100644 index 000000000..064e921eb --- /dev/null +++ b/bindings/node/__test__/contract.native.ts @@ -0,0 +1,137 @@ +// Runs against a real napi build, separately from the mocked transport facade suite. +import test from 'node:test' +import assert from 'node:assert/strict' +import { Worker } from 'node:worker_threads' +import { Runtime, AimuxError } from '../src/native.ts' +import * as ops from '../src/generated/ops.ts' + +const source = new URL('../src/native.ts', import.meta.url).href + +test('native errors preserve the shared envelope and reject invalid wire JSON', async () => { + const runtime = new Runtime() + await assert.rejects(runtime.call('0', 'unknown.op', '{}'), error => error instanceof AimuxError && error.kind === 'protocol' && error.code === 300 && error.error.name === 'InvalidOp') + await assert.rejects(runtime.call('0', ops.OP_RUNTIME_DESCRIBE, '{'), error => error instanceof AimuxError && error.kind === 'binding' && error.code === 202) + await assert.rejects(runtime.call('9007199254740993', ops.OP_MODEL_INFO, '{}'), error => error instanceof AimuxError && error.code === 203 && !('actual' in error.error)) +}) + +test('runtime instances in one Env share ownership and native AbortSignal is immediate', async () => { + const first = new Runtime() + const second = new Runtime() + const controller = new AbortController() + const abort = first.abort(controller.signal) + await second.call(abort.handle, ops.OP_ABORT_SIGNAL, '{}') + assert.equal(abort.aborted, true) + const preaborted = new AbortController() + preaborted.abort() + assert.equal(first.abort(preaborted.signal).aborted, true) +}) + +test('worker_threads cannot use a foreign realm handle; environment teardown completes', async () => { + const runtime = new Runtime() + const abort = runtime.abort() + const worker = new Worker(` + const { parentPort, workerData } = require('node:worker_threads') + ;(async () => { + const { Runtime } = await import(workerData.source) + const runtime = new Runtime() + try { + await runtime.call(workerData.handle, workerData.op, '{}') + parentPort.postMessage({ unexpectedSuccess: true }) + } catch (error) { + parentPort.postMessage({ kind: error.kind, code: error.code, error: error.error }) + } + })().catch(error => { throw error }) + `, { eval: true, workerData: { source, handle: abort.handle, op: ops.OP_ABORT_SIGNAL }, execArgv: ['--experimental-strip-types'] }) + const exit = new Promise(resolve => worker.once('exit', resolve)) + const message = await new Promise>((resolve, reject) => { worker.once('message', resolve); worker.once('error', reject) }) + assert.equal(message.kind, 'binding') + assert.equal(message.code, 203) + assert.equal('actual' in (message.error as object), false) + const timeout = new Promise((_, reject) => { const timer = setTimeout(() => reject(new Error('Worker teardown hung')), 5000); timer.unref() }) + await Promise.race([exit, timeout]) + assert.equal(abort.aborted, false) +}) + +test('typed lifecycle uses the real provider, registry, files, composite and devtools DTOs', async () => { + const { Client, LanguageModel } = await import('../src/index.ts') + const client = new Client() + const provider = await client.provider({ package: 'openai', settings: { apiKey: 'local-test-key' } }) + const registry = await client.registry({ main: provider }, 'test') + const resolved = await registry.resolve({ namespace: 'test', key: 'main', method: 'languageModel', modelId: 'test-model' }) + assert.ok(resolved instanceof LanguageModel) + const raw = await client.call(resolved.handle, ops.OP_MODEL_INFO) + assert.deepEqual(await resolved.info(), raw) + const files = await provider.files() + assert.equal(files.kind, 'Files') + assert.match(files.handle, /^[1-9][0-9]*$/) + const router = await client.router({ children: [resolved.handle], strategy: { type: 'rule' } }) + assert.equal(router.kind, 'Language') + const trace = await client.trace({ capacity: 16, perScopeCapacity: 8 }) + assert.equal(trace.kind, 'TraceStore') + assert.deepEqual(await trace.aggregate(), await client.call(trace.handle, ops.OP_TRACE_AGGREGATE, { filter: {} })) + const middleware = await client.middleware({ kind: 'trace', traceStore: trace.handle }) + assert.equal(middleware.kind, 'Middleware') + assert.match(middleware.handle, /^[1-9][0-9]*$/) + const wrapped = await resolved.wrap([middleware]) + assert.equal(wrapped.kind, 'Language') + assert.deepEqual(await wrapped.info(), raw) + const recorder = await client.recorder({ backend: { type: 'ring', capacity: 16 }, capturePolicy: 'full' }) + assert.equal(recorder.kind, 'Recorder') + await recorder.attach() + await recorder.flush() + assert.deepEqual(await recorder.export(), { jsonl: '' }) + await recorder.stop() + const sessions = await client.sessionStore({ infer: false }) + assert.equal(sessions.kind, 'SessionStore') + assert.deepEqual(await sessions.list(), []) + assert.deepEqual(await sessions.calls('missing'), []) + // Child resources retain their references after the provider is closed. + await provider.close() + assert.deepEqual(await resolved.info(), raw) + await Promise.all([registry, resolved, files, router, trace, middleware, wrapped, recorder, sessions].map(resource => resource.close())) +}) + +test('replay inspection and planning preserve the canonical recording DTO', async () => { + const { Client } = await import('../src/index.ts') + const client = new Client() + const recording = { + schema: 2, callId: 'node-replay', recordedAt: '2026-10-15T00:00:00Z', + input: { prompt: [{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }], options: {} }, + provider: { providerId: 'openai', provider: 'openai.chat', modelId: 'test-model' }, + exchanges: [], outcome: { status: 'success' }, complete: true, transportClosed: true, + } + const replay = await client.replay({ recordingsJsonl: JSON.stringify(recording) }) + try { + assert.equal(replay.kind, 'ReplaySession') + assert.deepEqual(await replay.inspect(), await client.call(replay.handle, ops.OP_REPLAY_INSPECT)) + assert.deepEqual(await replay.dryRun(), await client.call(replay.handle, ops.OP_REPLAY_DRY_RUN)) + const inspection = await replay.inspect() as { recordings: Array<{ callId: string }> } + assert.equal(inspection.recordings[0].callId, recording.callId) + await assert.rejects(replay.capabilities(), error => error instanceof AimuxError && error.code === 8) + } finally { await replay.close() } +}) + +test('accepted native streams preserve one terminal and support return during next', async () => { + const { Client, LanguageModel } = await import('../src/index.ts') + const client = new Client() + const provider = await client.provider({ package: 'deepseek', settings: { apiKey: 'local-test-key' } }) + const model = await provider.model('test-model', 'languageModel') + assert.ok(model instanceof LanguageModel) + const controller = new AbortController() + controller.abort() + try { + const stream = await model.stream('hello', {}, { signal: controller.signal }) + const first = stream.next() + const terminal = await first + assert.equal(terminal.done, false) + assert.equal(terminal.value.type, 'end') + assert.equal(terminal.value.outcome, 'cancelled') + assert.equal(terminal.value.seq, '0') + assert.equal((await stream.next()).done, true) + await assert.rejects(client.stream(model.handle, ops.OP_TEXT_STREAM, { unexpected: true }), error => error instanceof AimuxError && error.code === 301) + const returned = await model.stream('hello', {}, { signal: controller.signal }) + const pending = returned.next() + await returned.return!() + assert.equal((await pending).done, true) + } finally { await Promise.all([model.close(), provider.close()]) } +}) diff --git a/bindings/node/__test__/decision.test.ts b/bindings/node/__test__/decision.test.ts deleted file mode 100644 index 563431332..000000000 --- a/bindings/node/__test__/decision.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import test from 'ava' -import { createServer } from 'node:http' -import { readFileSync } from 'node:fs' -import { APICallError, InvalidArgumentError, RequestAbortedError, jevDecision, decide, describeDecisionModel, createProvider } from '../src/index.ts' -import type { DecideOptions } from '../src/index.ts' - -const fixture = JSON.parse(readFileSync(new URL('../../../aimux-providers/tests/fixtures/jev_systemone.json', import.meta.url), 'utf8')).response - -test('provider handle creates native OpenAI decisions that answer every question', async t => { - const contract = JSON.parse(readFileSync(new URL('../../../aimux-providers/tests/fixtures/openai_decisions.json', import.meta.url), 'utf8')) - let captured: any - const server = createServer((req, res) => { - t.is(req.url, '/v1/decisions') - t.is(req.headers.authorization, 'Bearer test-key') - let data = '' - req.on('data', chunk => { data += chunk }) - req.on('end', () => { - captured = JSON.parse(data) - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify(contract.response)) - }) - }) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - t.teardown(() => server.close()) - const address = server.address() as { port: number } - const provider = await createProvider('openai', 'test-key', { baseUrl: `http://127.0.0.1:${address.port}/v1` }) - const model = await provider.decisionModel('gpt-6-luna') - t.deepEqual(describeDecisionModel(model), { - specificationVersion: 'v4', - provider: 'openai', - modelId: 'gpt-6-luna', - supportedQuestionTypes: ['choice', 'score', 'boolean'], - }) - const result = await decide(model, contract.options) - t.deepEqual(captured, contract.request) - t.deepEqual(Object.keys(result.answers), ['urgent', 'team', 'severity']) - t.deepEqual(result.answers.urgent, { type: 'boolean', probability: 0.925 }) -}) -const options: DecideOptions = { - state: { message: 'Billed twice' }, - questions: { - is_urgent: { type: 'boolean', instructions: 'The message conveys urgency or time-sensitivity' }, - department: { type: 'choice', instructions: 'Which team should handle this', criteria: { billing: null, technical: null, sales: null } }, - frustration: { type: 'score', instructions: 'How frustrated the customer appears', criteria: ['Calm, just stating facts', 'Frustrated but civil', 'Very angry, strong language'] }, - }, - maxRetries: 0, -} - -test('missing or null usage counts preserve valid decision answers', async t => { - let usage: Record = {} - const server = createServer((req, res) => { - req.resume() - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ ...fixture, usage })) - }) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - t.teardown(() => server.close()) - const address = server.address() as { port: number } - const model = await jevDecision('test-key', 'jev-latest', `http://127.0.0.1:${address.port}/v1/systemone`) - const usages: Record[] = [{}, { input_tokens: null, output_tokens: null }, { input_tokens: 12 }, { output_tokens: 7 }] - for (const counts of usages) { - usage = counts - const result = await decide(model, options) - t.is(Object.keys(result.answers).length, 3) - // Unknown counts are absent, as the AI SDK's `undefined` is in JSON. - t.is(result.usage.inputTokens, counts.input_tokens ?? undefined) - t.is(result.usage.outputTokens, counts.output_tokens ?? undefined) - t.false('totalTokens' in result.usage) - t.false(Object.values(result.usage).includes(null as never)) - t.deepEqual((result.response.body as any).usage, counts) - } -}) - -test('typed decide maps all question types and preserves response metadata', async t => { - let body: any - let authorization: string | undefined - let idempotency: string | undefined - const server = createServer((req, res) => { - authorization = req.headers.authorization - idempotency = req.headers['idempotency-key'] as string - let data = '' - req.on('data', chunk => { data += chunk }) - req.on('end', () => { - body = JSON.parse(data) - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify(fixture)) - }) - }) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - t.teardown(() => server.close()) - const address = server.address() as { port: number } - const model = await jevDecision('test-key', 'jev-latest', `http://127.0.0.1:${address.port}/v1/systemone`) - const result = await decide(model, { ...options, headers: { 'Idempotency-Key': 'test-decision-001' } }) - t.is(authorization, 'Bearer test-key') - t.is(idempotency, 'test-decision-001') - t.deepEqual(body.state, options.state) - t.is(body.questions.is_urgent.type, 'noul') - t.deepEqual(body.questions.department.criteria, { billing: null, technical: null, sales: null }) - t.deepEqual(body.questions.frustration.criteria, ['Calm, just stating facts', 'Frustrated but civil', 'Very angry, strong language']) - t.deepEqual(result.answers.is_urgent, { type: 'boolean', probability: 1.0 }) - t.like(result.answers.department, { type: 'choice', choice: 'technical' }) - t.deepEqual(result.answers.frustration, { type: 'score', score: 1.0, probabilities: { 0: 0, 1: 1.0, 2: 0 } }) - t.deepEqual(result.usage, { inputTokens: 392, outputTokens: 65, totalTokens: 457 }) - t.deepEqual(result.response.body, fixture) - t.is((result.providerMetadata?.jev as any).probabilitySource, 'native') -}) - -test('decision abort uses the canonical JavaScript error class', async t => { - const model = await jevDecision('test-key', 'jev-latest', 'http://127.0.0.1:1/v1/systemone') - const controller = new AbortController() - controller.abort() - await t.throwsAsync(decide(model, options, controller.signal), { instanceOf: RequestAbortedError }) -}) - -test('decision abort cancels an in-flight HTTP request', async t => { - const controller = new AbortController() - const server = createServer((req, _res) => { req.resume(); controller.abort() }) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - t.teardown(() => { server.closeAllConnections(); server.close() }) - const address = server.address() as { port: number } - const model = await jevDecision('test-key', 'jev-latest', `http://127.0.0.1:${address.port}/v1/systemone`) - await t.throwsAsync(decide(model, options, controller.signal), { instanceOf: RequestAbortedError }) -}) - -test('invalid provider answer rejects with HTTP context', async t => { - const server = createServer((req, res) => { - req.resume() - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ ...fixture, answers: {} })) - }) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - t.teardown(() => server.close()) - const address = server.address() as { port: number } - const model = await jevDecision('test-key', 'jev-latest', `http://127.0.0.1:${address.port}/v1/systemone`) - await t.throwsAsync(decide(model, options), { instanceOf: APICallError }) -}) - -test('self-hosted probability provenance survives the Node boundary', async t => { - const server = createServer((req, res) => { - req.resume() - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify(fixture)) - }) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - t.teardown(() => server.close()) - const address = server.address() as { port: number } - for (const source of ['native', 'logit_scoring', 'model_estimate'] as const) { - const model = await jevDecision('test-key', 'jev-latest', `http://127.0.0.1:${address.port}/v1/systemone`, source) - const result = await decide(model, options) - t.is((result.providerMetadata?.jev as any).probabilitySource, source) - } -}) - -test('unknown probability source is rejected at model construction', async t => { - // Exercise the raw JSON boundary as well as the typed wrapper contract. - const { jevDecision: rawJevDecision } = await import('../src/native.ts') - await t.throwsAsync(rawJevDecision('test-key', 'jev-latest', undefined, 'unknown'), { instanceOf: InvalidArgumentError }) -}) - -test('official structured fields and the model description preserve the native contract', async t => { - const fixture = JSON.parse(readFileSync(new URL('../../../contract-tests/fixtures/decision-native.json', import.meta.url), 'utf8')) - const requests: any[] = [] - const server = createServer((req, res) => { - let data = '' - req.on('data', chunk => { data += chunk }) - req.on('end', () => { - requests.push(JSON.parse(data)) - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify(fixture.response)) - }) - }) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - t.teardown(() => server.close()) - const address = server.address() as { port: number } - const model = await jevDecision('test-key', 'jev-latest', `http://127.0.0.1:${address.port}/v1/systemone`) - t.is(describeDecisionModel(model).provider, 'jev') - t.is(requests.length, 0) - const result = await decide(model, fixture.request) - t.is(requests.length, 1) - t.deepEqual(requests[0].questions.urgent.criteria, fixture.request.questions.urgent.criteria) - t.deepEqual(requests[0].questions.department.instructions, fixture.request.questions.department.instructions) - t.deepEqual(requests[0].questions.severity.criteria, fixture.request.questions.severity.criteria) - t.deepEqual(result.answers.severity, { type: 'score', score: 0.25, probabilities: { 0: 0.75, 1: 0.25 } }) - t.deepEqual(result.rounding, { probabilityDecimals: 2, scoreDecimals: 2 }) -}) - -test('OpenAI images, typed choice values and score descriptions cross the typed boundary', async t => { - const contract = JSON.parse(readFileSync(new URL('../../../contract-tests/fixtures/decision-openai-full.json', import.meta.url), 'utf8')) - let captured: unknown - const server = createServer((req, res) => { - let data = '' - req.on('data', chunk => { data += chunk }) - req.on('end', () => { - captured = JSON.parse(data) - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify(contract.response)) - }) - }) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - t.teardown(() => server.close()) - const address = server.address() as { port: number } - const provider = await createProvider('openai', 'test-key', { baseUrl: `http://127.0.0.1:${address.port}/v1` }) - const model = await provider.decisionModel('gpt-6-luna') - const request: DecideOptions = contract.options - const result = await decide(model, request) - t.deepEqual(captured, contract.request) - t.deepEqual(result.answers.choice, { type: 'choice', choice: 'boolean_true', probabilities: { boolean_true: 0.75, text_true: 0.25 } }) - t.is((result.providerMetadata?.openai as any).answers.choice.value, true) - t.deepEqual(result.answers.score, { type: 'score', score: 0.75, probabilities: { 0: 0.25, 1: 0.75 } }) -}) - -test('local runtime factory preserves provider identity with no API key', async t => { - const contract = JSON.parse(readFileSync(new URL('../../../aimux-providers/tests/fixtures/runtime_decisions.json', import.meta.url), 'utf8')) - const server = createServer((req, res) => { - t.is(req.url, '/v1/systemone') - req.resume() - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify(contract.response)) - }) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - t.teardown(() => server.close()) - const address = server.address() as { port: number } - const provider = await createProvider('ollama', undefined, { baseUrl: `http://127.0.0.1:${address.port}/v1` }) - const model = await provider.decisionModel('served-decision') - t.is(describeDecisionModel(model).provider, 'ollama') - const result = await decide(model, contract.options) - t.is((result.providerMetadata?.ollama as any).probabilitySource, 'native') - t.is(result.response.modelId, 'served-decision') -}) diff --git a/bindings/node/__test__/e2e.test.ts b/bindings/node/__test__/e2e.test.ts deleted file mode 100644 index 343a02432..000000000 --- a/bindings/node/__test__/e2e.test.ts +++ /dev/null @@ -1,568 +0,0 @@ -// e2e.test.ts — End-to-end provider tests with mock HTTP server. -// -// These tests verify the FULL chain: Node.js → napi-rs → Rust engine → -// HTTP mock server → response parsing → typed result. -// -// The mock responses are taken from real provider API responses -// (same data shape as the Rust e2e_test.rs wiremock mocks). - -import test from 'ava' -import { createServer, type Server } from 'node:http' -import { deepseek, anthropic, google } from '../src/native.ts' - -// ── Mock server helpers ───────────────────────────────────────────────────── - -function startMockServer(handler: (req: any, res: any) => void): Promise<{ server: Server; url: string }> { - return new Promise((resolve) => { - const server = createServer(handler) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as any - resolve({ server, url: `http://127.0.0.1:${addr.port}` }) - }) - }) -} - -function closeServer(server: Server): Promise { - return new Promise((resolve) => server.close(() => resolve())) -} - -// ── OpenAI mock responses (from real API shape) ───────────────────────────── - -const OPENAI_CHAT_RESPONSE = JSON.stringify({ - id: 'chatcmpl-test', - model: 'gpt-4o', - choices: [{ - message: { role: 'assistant', content: 'Rust is a systems programming language.' }, - finish_reason: 'stop', - }], - usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 }, -}) - -const OPENAI_STREAM_BODY = [ - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"content":"Hello"}}]}\n\n', - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"content":" world"}}]}\n\n', - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}\n\n', - 'data: [DONE]\n\n', -].join('') - -// ── Anthropic mock responses ──────────────────────────────────────────────── - -const ANTHROPIC_MESSAGE_RESPONSE = JSON.stringify({ - id: 'msg_test', - type: 'message', - role: 'assistant', - model: 'claude-3-5-sonnet-20241022', - content: [{ type: 'text', text: 'Hello from Claude!' }], - stop_reason: 'end_turn', - usage: { input_tokens: 10, output_tokens: 5 }, -}) - -const ANTHROPIC_STREAM_BODY = [ - 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1","model":"claude-3-5-sonnet-20241022","usage":{"input_tokens":10,"output_tokens":0}}}\n\n', - 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n', - 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}\n\n', - 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" from Claude"}}\n\n', - 'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n', - 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}\n\n', - 'event: message_stop\ndata: {"type":"message_stop"}\n\n', -].join('') - -// ── Tests ─────────────────────────────────────────────────────────────────── - -test('e2e: OpenAI generateText via mock server', async (t) => { - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(OPENAI_CHAT_RESPONSE) - }) - - try { - const model = await deepseek('test-key', 'gpt-4o', url) - const resultJson = await model.generateText(JSON.stringify('What is Rust?')) - const r = JSON.parse(resultJson) - - t.is(r.text, 'Rust is a systems programming language.') - t.truthy(r.usage) - t.truthy(r.finishReason) - } finally { - await closeServer(server) - } -}) - -test('e2e: response timestamp is toISOString', async (t) => { - // The response timestamp is a Date (provider - // language-model-v4-response-metadata.ts:10), built as - // new Date(created * 1000); its JSON is toISOString(). - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ ...JSON.parse(OPENAI_CHAT_RESPONSE), created: 1741269019 })) - }) - - try { - const model = await deepseek('test-key', 'gpt-4o', url) - const r = JSON.parse(await model.generateText(JSON.stringify('What is Rust?'))) - t.is(r.response.timestamp, new Date(1741269019 * 1000).toISOString()) - t.is(r.response.timestamp, '2025-03-06T13:50:19.000Z') - } finally { - await closeServer(server) - } -}) - -test('e2e: OpenAI streamText via mock server', async (t) => { - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'text/event-stream' }) - res.end(OPENAI_STREAM_BODY) - }) - - try { - const model = await deepseek('test-key', 'gpt-4o', url) - const parts: any[] = [] - - for await (const json of await model.streamText(JSON.stringify('Say hello'))) { - parts.push(JSON.parse(json)) - } - - t.true(parts.length > 0) - - const text = parts - .filter((p) => p.type === 'text-delta') - .map((p) => p.text) - .join('') - - t.is(text, 'Hello world') - } finally { - await closeServer(server) - } -}) - -test('e2e: Anthropic generateText via mock server', async (t) => { - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(ANTHROPIC_MESSAGE_RESPONSE) - }) - - try { - const model = await anthropic('test-key', 'claude-3-5-sonnet-20241022', url) - const resultJson = await model.generateText(JSON.stringify('Hello')) - const r = JSON.parse(resultJson) - - t.is(r.text, 'Hello from Claude!') - t.truthy(r.usage) - } finally { - await closeServer(server) - } -}) - -test('e2e: Anthropic streamText via mock server', async (t) => { - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'text/event-stream' }) - res.end(ANTHROPIC_STREAM_BODY) - }) - - try { - const model = await anthropic('test-key', 'claude-3-5-sonnet-20241022', url) - const parts: any[] = [] - for await (const json of await model.streamText(JSON.stringify('Hello'))) { - parts.push(JSON.parse(json)) - } - - t.true(parts.length > 0) - - const text = parts - .filter((p) => p.type === 'text-delta') - .map((p) => p.text) - .join('') - - t.is(text, 'Hello from Claude') - } finally { - await closeServer(server) - } -}) - -test('e2e: OpenAI generateText with options (maxOutputTokens, temperature)', async (t) => { - let receivedBody: any = null - const { server, url } = await startMockServer((req, res) => { - let body = '' - req.on('data', (chunk: Buffer) => (body += chunk)) - req.on('end', () => { - receivedBody = JSON.parse(body) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(OPENAI_CHAT_RESPONSE) - }) - }) - - try { - const model = await deepseek('test-key', 'gpt-4o', url) - const opts = JSON.stringify({ maxOutputTokens: 100, temperature: 0.5 }) - await model.generateText(JSON.stringify('Hello'), opts) - - // Verify the options were passed through to the HTTP request - t.is(receivedBody.max_tokens, 100) - t.is(receivedBody.temperature, 0.5) - } finally { - await closeServer(server) - } -}) - -test('e2e: image bytes are a base64 string in the prompt JSON', async (t) => { - // ImagePart.image is DataContent upstream (provider-utils - // types/content-part.ts:42): a base64 string in the JSON, never an array. - let receivedBody: any = null - const { server, url } = await startMockServer((req, res) => { - let body = '' - req.on('data', (chunk: Buffer) => (body += chunk)) - req.on('end', () => { - receivedBody = JSON.parse(body) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(ANTHROPIC_MESSAGE_RESPONSE) - }) - }) - - try { - const model = await anthropic('test-key', 'claude-3-5-sonnet-20241022', url) - const image = Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64') - const prompt = [{ role: 'user', content: [{ type: 'image', image, mediaType: 'image/png' }] }] - await model.generateText(JSON.stringify(prompt)) - - t.deepEqual(receivedBody.messages[0].content[0].source, { - type: 'base64', - media_type: 'image/png', - data: 'iVBORw==', - }) - const err = await t.throwsAsync( - model.generateText( - JSON.stringify([{ role: 'user', content: [{ type: 'image', image: [1, 2], mediaType: 'image/png' }] }]), - ), - ) - t.truthy(err) - } finally { - await closeServer(server) - } -}) - -test('e2e: file parts take the AI SDK file part data', async (t) => { - // FilePart.data is FileData | DataContent | URL | ProviderReference upstream - // (provider-utils types/content-part.ts:75); a data: URL's media type wins - // (ai prompt/file-part-data.ts:31-41) and a bare reference is a reference - // (:133-138). There is no file-base64 / file-url / file-reference part. - let receivedBody: any = null - const { server, url } = await startMockServer((req, res) => { - let body = '' - req.on('data', (chunk: Buffer) => (body += chunk)) - req.on('end', () => { - receivedBody = JSON.parse(body) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(ANTHROPIC_MESSAGE_RESPONSE) - }) - }) - - try { - const model = await anthropic('test-key', 'claude-3-5-sonnet-20241022', url) - const prompt = [{ - role: 'user', - content: [ - { type: 'file', data: 'data:image/png;base64,iVBORw==', mediaType: 'image' }, - { type: 'file', data: { type: 'reference', reference: { anthropic: 'file-abc' } }, mediaType: 'application/pdf' }, - { type: 'file', data: { anthropic: 'file-def' }, mediaType: 'application/pdf' }, - ], - }] - await model.generateText(JSON.stringify(prompt)) - - const content = receivedBody.messages[0].content - t.deepEqual(content[0].source, { type: 'base64', media_type: 'image/png', data: 'iVBORw==' }) - t.deepEqual(content[1], { type: 'document', source: { type: 'file', file_id: 'file-abc' } }) - t.deepEqual(content[2], { type: 'document', source: { type: 'file', file_id: 'file-def' } }) - - for (const removed of ['file-base64', 'file-url', 'file-reference']) { - const part = { type: removed, data: 'aGk=', url: 'https://example.com/a.pdf', mediaType: 'application/pdf' } - await t.throwsAsync(model.generateText(JSON.stringify([{ role: 'user', content: [part] }]))) - } - } finally { - await closeServer(server) - } -}) - -test('e2e: generated files are base64 and mediaType', async (t) => { - // A generated file is GeneratedFile upstream, whose JSON is - // { base64, mediaType } (ai generate-text/generated-file.ts:10-32); the - // content part wraps it as { type: 'file', file } - // (generate-text/convert-language-model-content.ts:55-70). - const { server, url } = await startMockServer((req, res) => { - req.resume() - req.on('end', () => { - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ - candidates: [{ - content: { role: 'model', parts: [{ text: 'Here:' }, { inlineData: { mimeType: 'image/png', data: 'iVBORw==' } }] }, - finishReason: 'STOP', - }], - usageMetadata: { promptTokenCount: 3, candidatesTokenCount: 2, totalTokenCount: 5 }, - })) - }) - }) - - try { - const model = await google('test-key', 'gemini-2.0-flash', url) - const r = JSON.parse(await model.generateText(JSON.stringify('draw'))) - t.deepEqual(r.files, [{ base64: 'iVBORw==', mediaType: 'image/png' }]) - t.deepEqual(r.content[1], { type: 'file', file: { base64: 'iVBORw==', mediaType: 'image/png' } }) - } finally { - await closeServer(server) - } -}) - -// ── Tool-call parsing ─────────────────────────────────────────────────────── - -const OPENAI_TOOL_CALL_RESPONSE = JSON.stringify({ - id: 'chatcmpl-tc', - model: 'gpt-4o', - choices: [{ - message: { - role: 'assistant', - content: null, - tool_calls: [{ - id: 'call_abc', - type: 'function', - function: { name: 'get_weather', arguments: '{"location":"Tokyo"}' }, - }], - }, - finish_reason: 'tool_calls', - }], - usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 }, -}) - -test('e2e: OpenAI generateText parses toolCalls (structured content)', async (t) => { - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(OPENAI_TOOL_CALL_RESPONSE) - }) - - try { - const model = await deepseek('test-key', 'gpt-4o', url) - const opts = JSON.stringify({ - tools: [{ - type: 'function', - name: 'get_weather', - description: 'Get weather for a location', - inputSchema: { - type: 'object', - properties: { location: { type: 'string' } }, - required: ['location'], - }, - }], - }) - const r = JSON.parse(await model.generateText(JSON.stringify("What's the weather in Tokyo?"), opts)) - - // Convenience field: toolCalls extracted. Each is the AI SDK's tagged - // TypedToolCall (ai generate-text/tool-call.ts:8-14; #229 NP2-7). - t.is(r.toolCalls.length, 1) - t.is(r.toolCalls[0].type, 'tool-call') - t.is(Object.keys(r.toolCalls[0])[0], 'type') - t.is(r.toolCalls[0].toolName, 'get_weather') - t.is(r.toolCalls[0].toolCallId, 'call_abc') - t.deepEqual(r.toolCalls[0].input, { location: 'Tokyo' }) - - // Structured content: the final step's content holds the parsed tool call - t.true(Array.isArray(r.content)) - const tc = r.content.find((c: any) => c.type === 'tool-call') - t.truthy(tc, 'content contains a tool-call part') - t.is(tc.toolName, 'get_weather') - t.is(tc.toolCallId, 'call_abc') - t.deepEqual(tc.input, { location: 'Tokyo' }) - } finally { - await closeServer(server) - } -}) - -// ── Multi-role messages ──────────────────────────────────────────────────── - -test('e2e: OpenAI generateText with multi-role messages (system + user)', async (t) => { - let receivedBody: any = null - const { server, url } = await startMockServer((req, res) => { - let body = '' - req.on('data', (chunk: Buffer) => (body += chunk)) - req.on('end', () => { - receivedBody = JSON.parse(body) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(OPENAI_CHAT_RESPONSE) - }) - }) - - try { - const model = await deepseek('test-key', 'gpt-4o', url) - const prompt = JSON.stringify([ - { role: 'system', content: 'You are a helpful assistant.' }, - { role: 'user', content: 'What is Rust?' }, - ]) - const r = JSON.parse(await model.generateText(prompt)) - - // The full multi-role message sequence reaches the provider - t.true(Array.isArray(receivedBody.messages)) - t.is(receivedBody.messages.length, 2) - t.is(receivedBody.messages[0].role, 'system') - t.is(receivedBody.messages[0].content, 'You are a helpful assistant.') - t.is(receivedBody.messages[1].role, 'user') - t.is(receivedBody.messages[1].content, 'What is Rust?') - t.truthy(r.text) - } finally { - await closeServer(server) - } -}) - -// ── ToolChoice ────────────────────────────────────────────────────────────── - -test('e2e: OpenAI generateText with toolChoice: required', async (t) => { - let receivedBody: any = null - const { server, url } = await startMockServer((req, res) => { - let body = '' - req.on('data', (chunk: Buffer) => (body += chunk)) - req.on('end', () => { - receivedBody = JSON.parse(body) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(OPENAI_TOOL_CALL_RESPONSE) - }) - }) - - try { - const model = await deepseek('test-key', 'gpt-4o', url) - const opts = JSON.stringify({ - tools: [{ - type: 'function', - name: 'get_weather', - inputSchema: { type: 'object', properties: { location: { type: 'string' } } }, - }], - toolChoice: 'required', - }) - await model.generateText(JSON.stringify('Hello'), opts) - - // toolChoice reaches the provider request body as "required" - t.is(receivedBody.tool_choice, 'required') - } finally { - await closeServer(server) - } -}) - -// ── Streaming tool calls ──────────────────────────────────────────────────── - -const OPENAI_STREAM_TOOL_BODY = [ - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_xyz","type":"function","function":{"name":"get_weather","arguments":""}}]}}]}\n\n', - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"location\\":\\"Tokyo\\"}"}}]}}]}\n\n', - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}\n\n', - 'data: [DONE]\n\n', -].join('') - -test('e2e: OpenAI streamText parses tool-call stream parts', async (t) => { - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'text/event-stream' }) - res.end(OPENAI_STREAM_TOOL_BODY) - }) - - try { - const model = await deepseek('test-key', 'gpt-4o', url) - const opts = JSON.stringify({ - tools: [{ - type: 'function', - name: 'get_weather', - inputSchema: { type: 'object', properties: { location: { type: 'string' } } }, - }], - }) - const parts: any[] = [] - for await (const json of await model.streamText(JSON.stringify("What's the weather?"), opts)) { - parts.push(JSON.parse(json)) - } - - // The stream must contain a tool-call or tool-input part (not just text-delta/finish) - const hasToolPart = parts.some((p) => - ['tool-call', 'tool-input-delta', 'tool-input-start'].includes(p.type), - ) - t.true(hasToolPart, 'stream contained a tool-related StreamPart') - - // A complete tool-call part should carry the parsed tool name + input - const toolCall = parts.find((p) => p.type === 'tool-call') - if (toolCall) { - t.is(toolCall.toolName, 'get_weather') - } - } finally { - await closeServer(server) - } -}) - -// ── Tool-call full round-trip ─────────────────────────────────────────────── - -test('e2e: OpenAI tool-call full round-trip (tool-call → tool-result → final text)', async (t) => { - // Two calls: the mock returns tool_calls on the first, final text on the second. - let callCount = 0 - let secondRequestBody: any = null - const { server, url } = await startMockServer((req, res) => { - let body = '' - req.on('data', (chunk: Buffer) => (body += chunk)) - req.on('end', () => { - callCount++ - if (callCount === 2) secondRequestBody = JSON.parse(body) - // First call → tool_calls; second call → final text. - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(callCount === 1 ? OPENAI_TOOL_CALL_RESPONSE : OPENAI_CHAT_RESPONSE) - }) - }) - - try { - const model = await deepseek('test-key', 'gpt-4o', url) - const opts = JSON.stringify({ - tools: [{ - type: 'function', - name: 'get_weather', - description: 'Get weather for a location', - inputSchema: { - type: 'object', - properties: { location: { type: 'string' } }, - required: ['location'], - }, - }], - }) - - // Step 1: first call — model requests a tool call. - const r1 = JSON.parse(await model.generateText(JSON.stringify("What's the weather in Tokyo?"), opts)) - t.is(r1.toolCalls[0].toolName, 'get_weather') - t.is(r1.toolCalls[0].toolCallId, 'call_abc') - - // Step 2: user "executes" the tool, then builds the full conversation: - // user → assistant(tool_call) → tool(result) - // Input uses the engine's ContentPart variants (tool-call / tool-result); - // the engine converts these to the OpenAI wire format on the outbound request. - const messages = [ - { role: 'user', content: "What's the weather in Tokyo?" }, - { - role: 'assistant', - content: [{ - type: 'tool-call', - toolCallId: 'call_abc', - toolName: 'get_weather', - input: { location: 'Tokyo' }, - }], - }, - { - role: 'tool', - content: [{ - type: 'tool-result', - toolCallId: 'call_abc', - toolName: 'get_weather', - output: { type: 'json', value: { temperature: 22, condition: 'sunny' } }, - }], - }, - ] - - // Step 3: second call — model returns final text after the tool result. - const r2 = JSON.parse(await model.generateText(JSON.stringify(messages), opts)) - t.is(r2.text, 'Rust is a systems programming language.') - - // Step 4: verify the second request carried the full tool round-trip. - t.true(Array.isArray(secondRequestBody.messages)) - t.is(secondRequestBody.messages.length, 3) - t.is(secondRequestBody.messages[2].role, 'tool') - t.is(secondRequestBody.messages[2].tool_call_id, 'call_abc') - t.true(Array.isArray(secondRequestBody.messages[1].tool_calls)) - t.is(secondRequestBody.messages[1].tool_calls[0].id, 'call_abc') - t.is(secondRequestBody.messages[1].tool_calls[0].function.name, 'get_weather') - } finally { - await closeServer(server) - } -}) diff --git a/bindings/node/__test__/error.test.ts b/bindings/node/__test__/error.test.ts deleted file mode 100644 index a8ab022e6..000000000 --- a/bindings/node/__test__/error.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -// Error contract tests: AiMuxError failures are typed `AimuxError` subclasses; -// recorder failures are the independent `RecordingError` hierarchy; binding -// failures are napi-rs's own plain `Error` (napi `code`). -// All tests are hermetic — no network beyond 127.0.0.1. - -import test from 'ava' -import { createServer, type Server } from 'node:http' -import { - openai, - provider, - mockReplay, - streamText, - AimuxError, - APICallError, - RetryError, - InvalidArgumentError, - LoadAPIKeyError, - LoadSettingError, - NoSuchProviderError, - RecordingError, - initRecording, - initRecordingRing, - recordingStop, - recordingTryFlush, -} from '../src/index.ts' -import * as native from '../src/native.ts' -import { mkdtempSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -function startMockServer( - handler: (req: any, res: any) => void, -): Promise<{ server: Server; url: string }> { - return new Promise((resolve) => { - const server = createServer(handler) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as any - resolve({ server, url: `http://127.0.0.1:${addr.port}` }) - }) - }) -} - -test('sync native failure throws a typed AimuxError subclass', (t) => { - const err = t.throws(() => mockReplay('')) as AimuxError - t.true(err instanceof AimuxError) - t.true(err instanceof InvalidArgumentError) - t.is(err.name, 'InvalidArgumentError') - t.false('code' in err) - t.false('status' in err) -}) - -test('async native failure throws a typed AimuxError subclass', async (t) => { - const err = (await t.throwsAsync(() => - provider('definitely-not-a-provider', null, 'some-model'), - )) as AimuxError - t.true(err instanceof AimuxError) - t.true(err instanceof NoSuchProviderError) - t.is(err.constructor, NoSuchProviderError) - t.false('code' in err) - t.false('status' in err) -}) - -test('stream error path yields a typed AimuxError', async (t) => { - // Unreachable port: connection is refused immediately; no retries. - const model = await openai('test-key', 'gpt-4o', { - baseUrl: 'http://127.0.0.1:1', - }) - const err = (await t.throwsAsync(async () => { - for await (const _part of streamText(model, 'hello', { maxRetries: 0 })) { - // never yields - } - })) as AimuxError - t.true(err instanceof APICallError) - t.false('code' in err) - // No HTTP response arrived, so no sentinel properties are invented. - t.false('status' in err) - t.false('retryMs' in err) -}) - -test('instanceof narrows to the specific subclass only', async (t) => { - const err = (await t.throwsAsync(() => - provider('definitely-not-a-provider', null, 'some-model'), - )) as AimuxError - t.true(err instanceof NoSuchProviderError) - t.false(err instanceof APICallError) - t.false(err instanceof InvalidArgumentError) -}) - -test('payload fields live on the subclass the core fills them for', async (t) => { - const err = (await t.throwsAsync(() => - provider('definitely-not-a-provider', null, 'some-model'), - )) as NoSuchProviderError - t.true(err instanceof NoSuchProviderError) - - // The registry payload is a typed field; there is no JSON to dig it out of. - t.is(err.providerId, 'definitely-not-a-provider') - - // ApiCall-only fields are absent here — the core fills them for no other - // variant, so they must not exist as always-empty properties. - const apiCallOnly = ['status', 'retryMs', 'providerCode', 'responseBody', 'url', 'requestBodyValues', 'responseHeaders', 'data'] - for (const key of apiCallOnly) { - t.false(key in err, `${key} must not exist on ${err.name}`) - } - t.false('retryable' in err) -}) - -test('APICallError carries every field the response produced', async (t) => { - const body = JSON.stringify({ - error: { message: 'slow down', type: 'rate_limit_exceeded' }, - }) - const { server, url } = await startMockServer((_req, res) => { - res.writeHead(429, { - 'content-type': 'application/json', - 'x-request-id': 'req_abc123', - 'retry-after-ms': '1500', - }) - res.end(body) - }) - try { - const model = await native.openai('test-key', 'gpt-4o', { baseUrl: url }) - const err = (await t.throwsAsync(() => - model.generateText(JSON.stringify('hi'), JSON.stringify({ maxRetries: 0 })), - )) as APICallError - t.true(err instanceof APICallError) - t.true(err instanceof native.APICallError) - t.is(err.constructor, APICallError) - - t.is(err.status, 429) - t.is(err.retryMs, 1500) - t.true(err.retryable) - t.is(err.providerCode, 'rate_limit_exceeded') - t.is(err.responseBody, body) - // Request context is carried on the error itself. - t.true(err.url?.startsWith(url), `url should start with ${url}: ${err.url}`) - t.is((err.requestBodyValues as { model?: string }).model, 'gpt-4o') - // Sanitized response headers, as sent (retryMs above is derived from them). - t.is(err.responseHeaders?.['retry-after-ms'], '1500') - // The provider's text on its own; `message` is the composed form. - t.is(err.providerMessage, 'slow down') - t.true(err.message.includes('slow down')) - t.not(err.message, err.providerMessage) - } finally { - await new Promise((resolve) => server.close(() => resolve())) - } -}) - -test('exhausted retries throw RetryError with the per-attempt history', async (t) => { - const body = JSON.stringify({ - error: { message: 'slow down', type: 'rate_limit_exceeded' }, - }) - const { server, url } = await startMockServer((_req, res) => { - // retry-after-ms 0 keeps the retry loop instant. - res.writeHead(429, { 'content-type': 'application/json', 'retry-after-ms': '0' }) - res.end(body) - }) - try { - const model = await native.openai('test-key', 'gpt-4o', { baseUrl: url }) - const err = (await t.throwsAsync(() => - model.generateText(JSON.stringify('hi'), JSON.stringify({ maxRetries: 1 })), - )) as RetryError - t.true(err instanceof RetryError) - t.true(err instanceof AimuxError) - t.false(err instanceof APICallError) - t.is(err.reason, 'maxRetriesExceeded') - - // Complete per-attempt history, oldest first, each a full typed error. - t.is(err.errors.length, 2) - for (const attempt of err.errors) { - t.true(attempt instanceof APICallError) - t.is((attempt as APICallError).status, 429) - t.is((attempt as APICallError).providerCode, 'rate_limit_exceeded') - } - t.is(err.lastError, err.errors[1]) - t.regex(err.message, /Failed after 2 attempts/) - // Retry exhaustion is not itself an API exchange: no ApiCall-only fields. - t.false('status' in err) - t.false('retryable' in err) - } finally { - await new Promise((resolve) => server.close(() => resolve())) - } -}) - -test('recorder init failures are RecordingError; flush is a no-op when none is installed', (t) => { - // Nothing recording: nothing to flush is a success. - recordingStop() - t.notThrows(() => recordingTryFlush()) - - // A regular file in the parent position makes recorder initialization fail - // before a recorder is installed. This is Init, not a later WriterGone. - const dir = mkdtempSync(join(tmpdir(), 'aimux-node-rec-')) - const blocker = join(dir, 'occupied') - writeFileSync(blocker, 'x') - try { - const err = t.throws(() => initRecording(join(blocker, 'sub'))) - t.true(err instanceof RecordingError) - t.is(err.code, 'Init') - // A recorder failure is not an AiMuxError failure: separate type. - t.false(err instanceof AimuxError) - // Nothing was installed: the checked flush still succeeds. - t.notThrows(() => recordingTryFlush()) - } finally { - recordingStop() - } -}) - -test('typed initRecordingRing keeps core errors in the AimuxError hierarchy', (t) => { - const err = t.throws(() => initRecordingRing(0)) - t.true(err instanceof InvalidArgumentError) - t.false(err instanceof RecordingError) -}) - -test('malformed prompt JSON is a plain napi InvalidArg error, not a core error', async (t) => { - const model = await openai('test-key', 'gpt-4o', { baseUrl: 'http://127.0.0.1:1' }) - // The typed generateText() always stringifies; hit the raw JSON-text path. - const err = (await t.throwsAsync(() => model.generateText('{not json'))) as Error & { - code?: string - } - t.is(err.constructor, Error) - t.is(err.code, 'InvalidArg') - t.regex(err.message, /prompt_json/) - t.false(err instanceof AimuxError) - t.false(err instanceof RecordingError) -}) - -test('sync wire-JSON failure is napi InvalidArg; business validation stays core-owned', (t) => { - const err = t.throws(() => mockReplay('{not json')) as Error & { code?: string } - t.is(err.code, 'InvalidArg') - t.regex(err.message, /recordings_jsonl/) - t.false(err instanceof AimuxError) - // Well-formed JSON that fails the schema is what the core would reject. - t.true(t.throws(() => mockReplay('{}')) instanceof InvalidArgumentError) -}) - -// ── LoadAPIKeyError / LoadSettingError ─────────────────────────────────────── - -test.serial('a missing API key is a LoadAPIKeyError at call time carrying the environment variable', async (t) => { - const saved = process.env.GROQ_API_KEY - delete process.env.GROQ_API_KEY - try { - const model = await provider('groq', null, 'some-model') - const err = (await t.throwsAsync(() => - model.generateText(JSON.stringify('hi'), JSON.stringify({ maxRetries: 0 })), - )) as LoadAPIKeyError - t.true(err instanceof LoadAPIKeyError) - t.true(err instanceof AimuxError) - t.false(err instanceof InvalidArgumentError) - t.is(err.constructor, LoadAPIKeyError) - t.is(err.name, 'LoadAPIKeyError') - t.is(err.envVar, 'GROQ_API_KEY') - t.is(err.description, 'Groq') - t.regex(err.message, /GROQ_API_KEY/) - // Pre-HTTP: none of the ApiCall-only fields exist. - t.false('status' in err) - t.false('retryable' in err) - } finally { - if (saved !== undefined) process.env.GROQ_API_KEY = saved - } -}) - -test.serial('an explicit empty API key is not a LoadAPIKeyError', async (t) => { - // The AI SDK sends an explicit empty key verbatim; only `null` consults the env. - const model = await provider('groq', '', 'some-model') - t.truthy(model) -}) - -test.serial('a missing provider setting is a LoadSettingError at call time', async (t) => { - const saved = process.env.AZURE_RESOURCE_NAME - delete process.env.AZURE_RESOURCE_NAME - try { - const model = await native.azure('test-key', '', 'some-deployment') - const err = (await t.throwsAsync(() => - model.generateText(JSON.stringify('hi'), JSON.stringify({ maxRetries: 0 })), - )) as LoadSettingError - t.true(err instanceof LoadSettingError) - t.true(err instanceof AimuxError) - t.is(err.constructor, LoadSettingError) - t.is(err.envVar, 'AZURE_RESOURCE_NAME') - t.is(err.settingName, 'resourceName') - t.false('status' in err) - } finally { - if (saved !== undefined) process.env.AZURE_RESOURCE_NAME = saved - } -}) diff --git a/bindings/node/__test__/event_loop.spec.ts b/bindings/node/__test__/event_loop.spec.ts new file mode 100644 index 000000000..b966d7e61 --- /dev/null +++ b/bindings/node/__test__/event_loop.spec.ts @@ -0,0 +1,296 @@ +// Run with npm run test:lifecycle; requires a real native addon. +import test from 'node:test' +import assert from 'node:assert/strict' +import { createServer, type ServerResponse } from 'node:http' +import { once } from 'node:events' +import { performance } from 'node:perf_hooks' +import { setTimeout as delay } from 'node:timers/promises' +import { Worker } from 'node:worker_threads' +import { spawn } from 'node:child_process' +import * as native from '../src/native.ts' +import * as ops from '../src/generated/ops.ts' + +// This adapter is also serialized into the worker and shutdown child process. +function createAdapter(binding: typeof native, ops: typeof import('../src/generated/ops.ts')) { + const runtime = new binding.Runtime() + const params = JSON.stringify({ prompt: 'hello', options: { maxRetries: 0 } }) + return { + async model(url: string) { + const provider = JSON.parse(await runtime.call('0', ops.OP_PROVIDER_CREATE, JSON.stringify({ + package: 'deepseek', settings: { apiKey: 'local-test-key', baseUrl: url }, + }))) + const model = JSON.parse(await runtime.call(provider.handle, ops.OP_PROVIDER_MODEL, JSON.stringify({ + modelId: 'test-model', method: 'languageModel', + }))) + runtime.drop(provider.handle) + return model.handle as string + }, + async call(handle: string) { + return JSON.parse(await runtime.call(handle, ops.OP_TEXT_GENERATE, params)) + }, + stream(handle: string, signal?: AbortSignal) { + return runtime.stream(handle, ops.OP_TEXT_STREAM, params, signal ? runtime.abort(signal) : undefined) + }, + part(value: string) { + const frame = JSON.parse(value) + if (frame.type === 'event') return frame.event as { type: string; text?: string } + if (frame.outcome === 'error') throw new Error(JSON.stringify(frame.error)) + return { type: 'end' } + }, + } +} +const api = createAdapter(native, ops) +const nativeUrl = new URL('../src/native.ts', import.meta.url).href +const opsUrl = new URL('../src/generated/ops.ts', import.meta.url).href +const LIMIT_MS = 4_000 + +async function bounded(promise: PromiseLike, label: string, ms = LIMIT_MS): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} exceeded ${ms}ms`)), ms) + }), + ]) + } finally { + clearTimeout(timer) + } +} + +function latch() { + let resolve!: () => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + +async function fixture(mode: 'call' | 'finite' | 'flood' | 'idle') { + const started = latch() + const disconnected = latch() + let requests = 0 + let sent = 0 + let closed = false + const producers = new Set>() + const server = createServer((req, res) => { + requests++ + req.resume() + res.on('close', () => { + closed = true + disconnected.resolve() + }) + const producer = produce(res).catch((error) => { + if (!res.destroyed) res.destroy(error) + }) + producers.add(producer) + void producer.finally(() => producers.delete(producer)) + }) + async function produce(res: ServerResponse) { + if (mode === 'call') { + started.resolve() + await delay(600) + if (res.destroyed) return + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ + id: 'local', model: 'test-model', + choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + })) + return + } + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.flushHeaders() + started.resolve() + if (mode === 'idle') return // Remains open until the native client cancels. + const count = mode === 'finite' ? 12 : 4096 + for (let i = 0; i < count && !res.destroyed; i++) { + const content = mode === 'finite' ? `${i},` : 'x'.repeat(32 * 1024) + const chunk = `data: ${JSON.stringify({ + id: 'local', model: 'test-model', choices: [{ delta: { content } }], + })}\n\n` + sent++ + if (!res.write(chunk)) { + // `close` must also release a producer waiting on socket backpressure. + await new Promise((resolve) => { + const done = () => { + res.off('drain', done) + res.off('close', done) + resolve() + } + res.once('drain', done) + res.once('close', done) + }) + } + if (mode === 'finite') await delay(2) + } + if (!res.destroyed) res.end('data: {"id":"local","model":"test-model","choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n') + } + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Expected TCP address') + return { + url: `http://127.0.0.1:${address.port}`, + started: started.promise, + disconnected: disconnected.promise, + requests: () => requests, + sent: () => sent, + closed: () => closed, + async close() { + const closed = new Promise((resolve) => server.close(() => resolve())) + server.closeAllConnections() + await bounded(closed, 'fixture close') + await bounded(Promise.all(producers), 'fixture producers') + }, + } +} + +function worker(body: string, data: unknown) { + return new Worker(` + const { parentPort, workerData } = require('node:worker_threads'); + (async () => { + const api = (${createAdapter.toString()})(await import(${JSON.stringify(nativeUrl)}), await import(${JSON.stringify(opsUrl)})); + ${body} + })().catch(error => { throw error; }); + `, { eval: true, workerData: data, execArgv: ['--experimental-strip-types'] }) +} + +// Serial execution keeps CPU contention between these timing scenarios out of the measurements. +test('event loop: timers advance with bounded drift during a native call', async (t) => { + const f = await fixture('call') + t.after(() => f.close()) + const model = await api.model(f.url) + const gaps: number[] = [] + let previous = performance.now() + const timer = setInterval(() => { + const now = performance.now() + gaps.push(now - previous) + previous = now + }, 10) + try { + const result = await bounded(api.call(model), 'generate call') + gaps.push(performance.now() - previous) + assert.equal(result.text, 'ok') + assert.ok(gaps.length >= 15, `Only ${gaps.length} timer samples during the delayed call`) + assert.ok(Math.max(...gaps) < 250, `Maximum timer gap: ${Math.max(...gaps)}ms`) + } finally { + clearInterval(timer) + } +}) + +test('stream lifecycle: slow consumer preserves every text delta and terminates', async (t) => { + const f = await fixture('finite') + t.after(() => f.close()) + const controller = new AbortController() + t.after(() => controller.abort()) + const stream = await api.stream(await api.model(f.url), controller.signal) + let text = '' + await bounded((async () => { + for await (const value of stream) { + const part = api.part(value) + if (part.type === 'text-delta') text += part.text + await delay(15) + } + })(), 'slow stream') + assert.equal(text, Array.from({ length: 12 }, (_, i) => `${i},`).join('')) + assert.ok((await bounded(stream.next(), 'next after completion')).done) +}) + +test('stream lifecycle: AbortSignal settles a pending next and closes transport', async (t) => { + const f = await fixture('idle') + t.after(() => f.close()) + const controller = new AbortController() + t.after(() => controller.abort()) + const stream = await api.stream(await api.model(f.url), controller.signal) + // Drain any metadata events until the iterator actually waits for provider data. + const draining = (async () => { + try { + for await (const value of stream) { + const part = api.part(value) + if (part.type === 'error') return 'error' + } + return 'done' + } catch (error) { + if (!(error instanceof Error) || !/abort|cancel/i.test(`${error.name} ${error.message}`)) throw error + return 'cancelled' + } + })() + void draining.catch(() => {}) // Observe failures even if request startup times out. + await bounded(f.started, 'stream request') + controller.abort() + assert.ok(['error', 'done', 'cancelled'].includes(await bounded(draining, 'aborted next'))) + await bounded(f.disconnected, 'aborted transport') +}) + +for (const action of ['abort', 'return'] as const) { + test(`stream lifecycle: ${action} releases an unconsumed full queue`, async (t) => { + const f = await fixture('flood') + t.after(() => f.close()) + const controller = new AbortController() + t.after(() => controller.abort()) + const stream = await api.stream(await api.model(f.url), controller.signal) + await bounded(f.started, 'unconsumed request') + await delay(250) // Let the unconsumed bounded bridge fill. + assert.ok(f.sent() > 64, `Fixture sent only ${f.sent()} chunks`) + assert.equal(f.closed(), false, 'The producer must still be active before cancellation') + if (action === 'abort') controller.abort() + else { + assert.equal(typeof stream.return, 'function') + assert.ok((await bounded(stream.return(undefined), 'unconsumed return')).done) + } + await bounded(f.disconnected, `${action} closes unconsumed transport`) + }) +} + +test('worker realms: a live foreign model is rejected before provider I/O', async (t) => { + const f = await fixture('call') + t.after(() => f.close()) + const model = await api.model(f.url) + const w = worker(` + parentPort.once('message', async foreign => { + try { + await api.call(foreign); + parentPort.postMessage({ accepted: true }); + } catch (error) { + parentPort.postMessage({ accepted: false, code: error.code, message: error.message }); + } finally { parentPort.close(); } + }); + parentPort.postMessage('ready'); + `, null) + t.after(() => w.terminate()) + await bounded(once(w, 'message'), 'worker ready') + const reply = once(w, 'message') + w.postMessage(model) + const [result] = await bounded(reply, 'foreign handle rejection') + assert.equal(result.accepted, false) + assert.equal(result.code, 203) + assert.equal(f.requests(), 0) +}) + +for (const mode of ['idle', 'flood'] as const) { + test(`environment shutdown: outstanding ${mode} stream does not hang cleanup`, async (t) => { + const f = await fixture(mode) + t.after(() => f.close()) + // Use a process boundary: a native cleanup deadlock can also hang + // Worker.terminate(), whereas the parent can kill this disposable process. + const source = ` + const api = (${createAdapter.toString()})(await import(${JSON.stringify(nativeUrl)}), await import(${JSON.stringify(opsUrl)})); + const model = await api.model(${JSON.stringify(f.url)}); + globalThis.liveStream = await api.stream(model); + // Allow transport startup and queue saturation, then let Node exit naturally. + await new Promise(resolve => setTimeout(resolve, 250)); + ` + const child = spawn(process.execPath, [ + '--experimental-strip-types', '--input-type=module', '--eval', source, + ], { stdio: ['ignore', 'ignore', 'pipe'] }) + t.after(() => { child.kill('SIGKILL') }) + let stderr = '' + child.stderr.on('data', (chunk) => { stderr += chunk.toString() }) + const exited = once(child, 'exit') + void exited.catch(() => {}) // Request startup is checked before process exit. + await bounded(f.started, 'child provider request') + const [code, signal] = await bounded(exited, 'natural environment shutdown', 6_000) + assert.equal(code, 0, stderr) + assert.equal(signal, null) + await bounded(f.disconnected, 'shutdown transport cleanup') + }) +} diff --git a/bindings/node/__test__/facade.test.ts b/bindings/node/__test__/facade.test.ts new file mode 100644 index 000000000..f569899d8 --- /dev/null +++ b/bindings/node/__test__/facade.test.ts @@ -0,0 +1,178 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import * as ops from '../src/generated/ops.ts' + +// Install the test transport before loading either public entrypoint. +const require = createRequire(import.meta.url) +const loader = require.resolve('../index.js') +require.cache[loader] = { id: loader, filename: loader, loaded: true, exports: require('./fixtures/native.cjs') } as never +const api = await import('../src/index.ts') +const rawApi = await import('../src/native.ts') +const { Client, LanguageModel, EmbeddingModel, AimuxError, readable } = api + +test('raw and typed preserve the same DTO, null, nested provider options and 64-bit IDs', async () => { + const client = new Client() + const provider = await client.provider({ package: 'openai', settings: { apiKey: 'test' } }) + const model = await provider.model('test-model') + assert.ok(model instanceof LanguageModel) + assert.ok(BigInt(model.handle) > BigInt(Number.MAX_SAFE_INTEGER)) + const params = { prompt: '你好 🚀', options: { providerOptions: { openai: { nested: [null, { enabled: true }] } }, toolChoice: 'required' as const } } + const raw = JSON.parse(await client.raw.call(model.handle, ops.OP_TEXT_GENERATE, JSON.stringify(params))) + const typed = await (model as InstanceType).generate(params.prompt, params.options) + assert.deepEqual(typed, raw) + const embedding = await provider.model('embed', 'embeddingModel') + assert.ok(embedding instanceof EmbeddingModel) + await (embedding as InstanceType).embedMany({ values: ['one', 'two', '三'] }) + await Promise.all([model.close(), embedding.close(), provider.close()]) +}) + +test('error properties and prototype match for both layers, including zero retry hint', async () => { + const client = new Client() + for (const call of [() => client.raw.call('0', 'fixture.error', '{}'), () => client.call('0', 'fixture.error')]) { + await assert.rejects(call, (error: unknown) => { + assert.ok(error instanceof AimuxError) + assert.equal(error.kind, 'aimux') + assert.equal(error.code, 1) + assert.equal(error.retryAfterMs, 0) + assert.deepEqual(error.error, { name: 'AI_APICallError', statusCode: 429, isRetryable: true, responseHeaders: { 'retry-after': '0' }, data: { unicode: '你好' } }) + assert.equal('retryable' in error, false) + assert.equal('type' in error, false) + return true + }) + } +}) + +test('events retain recoverable error data and a unique explicit terminal', async () => { + const client = new Client() + const model = await (await client.provider({ package: 'openai' })).model('test') + const frames = [] + for await (const frame of await client.stream(model.handle, ops.OP_TEXT_STREAM)) frames.push(frame) + assert.equal(frames.length, 2) + assert.equal(frames[0].type, 'event') + assert.equal(frames[0].seq, '9007199254740993') + assert.deepEqual(frames[1], { type: 'end', seq: '9007199254740994', outcome: 'success' }) +}) + +test('pre-acceptance rejection occurs before an iterator exists', async () => { + const client = new Client() + await assert.rejects(client.stream('0', ops.OP_TEXT_STREAM, { reject: true }), (error: unknown) => error instanceof AimuxError && error.code === 301) +}) + +test('return cancels while next is pending', async () => { + const client = new Client() + const abort = client.abort() + const iterator = await client.stream('0', ops.OP_TEXT_STREAM, { pending: true }, { abort }) + const pending = iterator.next() + await iterator.return!() + assert.equal(abort.aborted, true) + assert.deepEqual(await pending, { done: true, value: undefined }) + assert.equal((await iterator.next()).done, true) +}) + +test('reader.cancel cancels while a read is pending without prefetch', async () => { + const client = new Client() + const abort = client.abort() + const iterator = await client.stream('0', ops.OP_TEXT_STREAM, { pending: true }, { abort }) + const reader = readable(iterator).getReader() + const pending = reader.read() + await new Promise(resolve => setImmediate(resolve)) + await reader.cancel() + assert.equal(abort.aborted, true) + assert.equal((await pending).done, true) +}) + +test('throw preserves the original consumer error and aborts', async () => { + const client = new Client() + const abort = client.abort() + const iterator = await client.stream('0', ops.OP_TEXT_STREAM, {}, { abort }) + const error = new Error('consumer failed') + await assert.rejects(iterator.throw!(error), caught => caught === error) + assert.equal(abort.aborted, true) +}) + +test('AbortSignal is wired before invocation, including pre-aborted signals', async () => { + const client = new Client() + const controller = new AbortController() + controller.abort() + await assert.rejects(client.call('0', ops.OP_RUNTIME_DESCRIBE, {}, { signal: controller.signal }), error => error instanceof AimuxError && error.kind === 'aimux') + const second = new AbortController() + const abort = client.abort(second.signal) + second.abort() + assert.equal(abort.aborted, true) +}) + +test('clients in one environment share handles and Abort objects', async () => { + const first = new Client() + const second = new Client() + const provider = await first.provider({ package: 'openai' }) + const registry = await second.registry({ shared: provider }) + assert.equal((await registry.provider('shared')).kind, 'Provider') + await second.call('0', ops.OP_RUNTIME_DESCRIBE, {}, { abort: first.abort() }) + await Promise.all([registry.close(), provider.close()]) +}) + +test('close is idempotent and retires the handle before awaiting native drop', async () => { + const client = new Client() + const provider = await client.provider({ package: 'openai' }) + const close = provider.close() + assert.throws(() => provider.handle, error => error instanceof AimuxError && error.code === 203) + await Promise.all([close, provider.close()]) +}) + +test('devtools, registry and binary operation use the same raw dispatch', async () => { + const client = new Client() + const provider = await client.provider({ package: 'openai' }) + const registry = await client.registry({ main: provider }) + assert.equal((await registry.provider('main')).kind, 'Provider') + const trace = await client.trace({ capacity: 64 }) + assert.deepEqual(await trace.sessionChain('s'), { op: ops.OP_TRACE_SESSION_CHAIN, params: { sessionId: 's' } }) + const recorder = await client.recorder({ kind: 'ring' }) + assert.deepEqual(await recorder.flush(), { op: ops.OP_RECORDER_FLUSH, params: {} }) + const transcription = await provider.model('asr', 'transcriptionModel') as InstanceType + const operation = await transcription.start({}) + await operation.push(new Uint8Array([0, 255, 128])) + const events = operation.events() + assert.equal((await events.next()).done, true) + await events.return!() +}) + +test('manifest handshake pins every generated value and reports both sides', () => { + const compatible = { abiVersion: ops.ABI_VERSION, opsVersion: ops.OPS_VERSION, digest: ops.MANIFEST_DIGEST } as rawApi.Manifest + rawApi.verifyManifest(compatible) + for (const [field, received] of [['abiVersion', 0], ['opsVersion', '1.1'], ['digest', 'outdated'], ['digest', undefined]] as const) { + assert.throws(() => rawApi.verifyManifest({ ...compatible, [field]: received }), (error: unknown) => { + assert.ok(error instanceof AimuxError) + assert.equal(error.code, 302) + assert.ok(error.message.includes(String(compatible[field]))) + assert.ok(error.message.includes(String(received))) + return true + }) + } +}) + +test('typed serialization failures stay in the binding error domain', async () => { + const client = new Client() + const cyclic: Record = {} + cyclic.self = cyclic + await assert.rejects(client.call('0', ops.OP_RUNTIME_CONFIGURE, cyclic), error => error instanceof AimuxError && error.kind === 'binding' && error.code === 202) +}) + +test('raw and typed expose matching error/cancelled terminal frames as DTOs', async () => { + const client = new Client() + for (const outcome of ['error', 'cancelled']) { + const envelope = { kind: 'aimux', code: 1, message: 'failed', error: { name: 'AI_APICallError', isRetryable: false } } + const values = [{ type: 'end', seq: '1', outcome, ...(outcome === 'error' ? { error: envelope } : {}) }] + const raw = [] + for await (const frame of await client.raw.stream('0', ops.OP_TEXT_STREAM, JSON.stringify({ values }))) raw.push(JSON.parse(frame)) + const typed = [] + for await (const frame of await client.stream('0', ops.OP_TEXT_STREAM, { values })) typed.push(frame) + assert.deepEqual(typed, raw) + assert.deepEqual(typed, values) + } +}) + +test('host callbacks are rejected instead of silently disappearing from settings', async () => { + const client = new Client() + await assert.rejects(client.provider({ package: 'openai', settings: { fetch: async () => {} } }), error => error instanceof AimuxError && error.kind === 'binding' && error.code === 202) +}) diff --git a/bindings/node/__test__/fixtures/ffi.py b/bindings/node/__test__/fixtures/ffi.py new file mode 100644 index 000000000..8597cd5cf --- /dev/null +++ b/bindings/node/__test__/fixtures/ffi.py @@ -0,0 +1,56 @@ +"""Execute JSON vectors through the installed C ABI for addon parity tests.""" +import ctypes +import json +import sys + +lib = ctypes.CDLL(sys.argv[1]) +lib.aimux_call.argtypes = [ctypes.c_uint64, ctypes.c_char_p, ctypes.c_char_p, + ctypes.c_uint64, ctypes.POINTER(ctypes.c_void_p)] +lib.aimux_call.restype = ctypes.c_void_p +lib.aimux_error_json.argtypes = [ctypes.c_void_p] +lib.aimux_error_json.restype = ctypes.c_void_p +lib.aimux_error_free.argtypes = [ctypes.c_void_p] +lib.aimux_free_string.argtypes = [ctypes.c_void_p] +lib.aimux_drop.argtypes = [ctypes.c_uint64] +handles = {} + + +def resolve(value): + if isinstance(value, str) and value.startswith('$'): + return handles[value[1:]] + if isinstance(value, list): + return [resolve(item) for item in value] + if isinstance(value, dict): + return {key: resolve(item) for key, item in value.items()} + return value + + +results = [] +try: + for vector in json.load(sys.stdin): + output = ctypes.c_void_p() + error = lib.aimux_call(int(resolve(vector.get('handle', '0'))), + vector['op'].encode(), + json.dumps(resolve(vector.get('params', {}))).encode(), + 0, ctypes.byref(output)) + if error: + data = lib.aimux_error_json(error) + try: + results.append({'error': json.loads(ctypes.string_at(data))}) + finally: + lib.aimux_free_string(data) + lib.aimux_error_free(error) + else: + try: + result = json.loads(ctypes.string_at(output)) + finally: + lib.aimux_free_string(output) + if 'save' in vector: + handles[vector['save']] = result if isinstance(result, str) else result['handle'] + result = ('$' + vector['save'] if isinstance(result, str) + else dict(result, handle='$' + vector['save'])) + results.append({'result': result}) +finally: + for handle in handles.values(): + lib.aimux_drop(int(handle)) +print(json.dumps(results)) diff --git a/bindings/node/__test__/fixtures/native.cjs b/bindings/node/__test__/fixtures/native.cjs new file mode 100644 index 000000000..1dc9fa87b --- /dev/null +++ b/bindings/node/__test__/fixtures/native.cjs @@ -0,0 +1,82 @@ +// Test double for the JSON transport only; this does not validate aimux-ops. +const ops = require('../../src/generated/ops.ts') +let nextHandle = 9007199254740993n +const handles = new Map() +const envelope = (kind, code, name, message, fields = {}) => Object.assign(new Error(message), { + kind, code, error: { name, ...fields }, +}) +class Runtime { + constructor() { this.owner = 1 } + manifestJson() { return JSON.stringify({ abiVersion: ops.ABI_VERSION, opsVersion: ops.OPS_VERSION, digest: ops.MANIFEST_DIGEST, manifestRevision: 'fixture', ops: [], handleKinds: [], errorCodes: [] }) } + manifestDigest() { return ops.MANIFEST_DIGEST } + create(kind) { + const handle = String(nextHandle++) + handles.set(handle, { owner: this.owner, kind }) + return { handle, kind } + } + check(id) { + if (id === '0') return + if (handles.get(id)?.owner !== this.owner) throw envelope('binding', 203, 'InvalidHandle', 'Invalid handle', { expected: [] }) + } + dropHandle(id) { if (handles.get(id)?.owner === this.owner) handles.delete(id) } + async call(id, op, json, abort) { + const params = JSON.parse(json) + if (op === ops.OP_HANDLE_DROP) { + if (handles.get(id)?.owner === this.owner) handles.delete(id) + return '{}' + } + this.check(id) + if (abort) this.check(abort.handle) + if (abort?.aborted) throw envelope('aimux', 18, 'AI_AbortError', 'Aborted') + switch (op) { + case ops.OP_PROVIDER_CREATE: case ops.OP_PROVIDER_DEFAULT: case ops.OP_PROVIDER_CUSTOM: case ops.OP_REGISTRY_PROVIDER: return JSON.stringify(this.create('Provider')) + case ops.OP_REGISTRY_CREATE: for (const id of params.entries) this.check(id.provider); return JSON.stringify(this.create('Registry')) + case ops.OP_PROVIDER_MODEL: return JSON.stringify(this.create(({ embeddingModel: 'Embedding', imageModel: 'Image', speechModel: 'Speech', transcriptionModel: 'Transcription', rerankingModel: 'Reranking', videoModel: 'Video', searchModel: 'Search', decision: 'Decision' })[params.method] || 'Language')) + case ops.OP_TRACE_CREATE: return JSON.stringify(this.create('TraceStore').handle) + case ops.OP_RECORDER_CREATE: return JSON.stringify({ handle: this.create('Recorder').handle }) + case ops.OP_SESSION_STORE_CREATE: return JSON.stringify({ handle: this.create('SessionStore').handle }) + case ops.OP_REPLAY_CREATE: return JSON.stringify({ handle: this.create('ReplaySession').handle }) + case ops.OP_MIDDLEWARE_CREATE: return JSON.stringify(this.create('Middleware')) + case ops.OP_TRANSCRIPTION_START: return JSON.stringify(this.create('Operation').handle) + case ops.OP_TEXT_GENERATE: return JSON.stringify({ text: params.prompt, nested: params.options, raw: null }) + case ops.OP_OPERATION_NEXT: return JSON.stringify({ type: 'ended' }) + case 'fixture.error': throw Object.assign(envelope('aimux', 1, 'AI_APICallError', 'rate limit', { statusCode: 429, isRetryable: true, responseHeaders: { 'retry-after': '0' }, data: { unicode: '你好' } }), { retryAfterMs: 0 }) + default: return JSON.stringify({ op, params }) + } + } + async stream(id, op, json, abort) { + this.check(id) + const params = JSON.parse(json) + if (params.reject) throw envelope('protocol', 301, 'InvalidParams', 'Rejected before acceptance') + let index = 0 + let closed = false + let resolvePending + const values = params.values || [ + { type: 'event', seq: '9007199254740993', event: { type: 'error', error: { name: 'AI_APICallError' } } }, + { type: 'end', seq: '9007199254740994', outcome: 'success' }, + ] + const iterator = { + next: async () => { + if (closed) return { done: true, value: undefined } + if (params.pending) return new Promise(resolve => { resolvePending = resolve }) + if (index === values.length) return { done: true, value: undefined } + return { done: false, value: JSON.stringify(values[index++]) } + }, + return: async () => { closed = true; if (abort) abort.signal(); resolvePending?.({ done: true, value: undefined }); return { done: true, value: undefined } }, + throw: async error => { await iterator.return(); throw error }, + [Symbol.asyncIterator]() { return this }, + } + return { [Symbol.asyncIterator]() { return iterator } } + } + async operationPush(id, data) { this.check(id); this.lastPush = new Uint8Array(data) } +} +class Abort { + constructor(runtime, signal) { + this.handle = runtime.create('Abort').handle + this.aborted = signal?.aborted || false + signal?.addEventListener('abort', () => this.signal(), { once: true }) + } + signal() { if (this.handle !== "0") this.aborted = true } + close() { this.handle = "0" } +} +module.exports = { Runtime, Abort } diff --git a/bindings/node/__test__/index.test.ts b/bindings/node/__test__/index.test.ts deleted file mode 100644 index fbb4a7d97..000000000 --- a/bindings/node/__test__/index.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import test from 'ava' -import { - openai, - anthropic, - deepseek, - provider, - initRecordingRing, - recordingStop, -} from '../src/native.ts' -import type { VideoCallOptions, VideoPollOptions } from '../src/index.ts' - -// These tests verify the native module loads and the API surface works. -// They do NOT make real API calls — they test error handling for invalid keys. - -test('native module loads and exports functions', (t) => { - t.is(typeof openai, 'function') - t.is(typeof anthropic, 'function') - t.is(typeof deepseek, 'function') - t.is(typeof provider, 'function') -}) - -test('video poll options are exported from the public type barrel', (t) => { - const poll: VideoPollOptions = { intervalMs: 1_000, timeoutMs: 120_000 } - const options: Pick = { poll } - t.deepEqual(options.poll, poll) -}) - -test('openai() creates a model instance with valid key format', async (t) => { - // Even with a fake key, the provider should construct (the key is only - // validated on the first API call, not at construction time). - const model = await openai('sk-test-fake-key', 'gpt-4o-mini') - t.truthy(model) - t.is(typeof model.generateText, 'function') - t.is(typeof model.streamText, 'function') -}) - -test('provider() creates a registry model instance (RFC-0017 phase 4)', async (t) => { - const model = await provider('groq', 'sk-test-fake-key', 'llama-3.3-70b') - t.truthy(model) - t.is(typeof model.generateText, 'function') - t.is(typeof model.streamText, 'function') -}) - -test.serial('provider() with undefined apiKey reads the registry env var', async (t) => { - // The key is resolved at call time: with the env var unset the call must - // fail with a clear error (missing env key), proving the env-var path is wired. - const saved = process.env.GROQ_API_KEY - delete process.env.GROQ_API_KEY - try { - const model = await provider('groq', undefined, 'm') - await t.throwsAsync( - () => model.generateText(JSON.stringify('hi'), JSON.stringify({ maxRetries: 0 })), - { message: /api key/i }, - ) - } finally { - if (saved !== undefined) process.env.GROQ_API_KEY = saved - } -}) - -test('provider() rejects unknown provider names with the available list', async (t) => { - await t.throwsAsync( - () => provider('no-such-provider', 'k', 'm'), - { message: /no-such-provider/ }, - ) -}) - -test('anthropic() creates a model instance', async (t) => { - const model = await anthropic('sk-ant-test-fake-key', 'claude-3-5-sonnet-20241022') - t.truthy(model) - t.is(typeof model.generateText, 'function') -}) - -test('deepseek() creates a model instance', async (t) => { - const model = await deepseek('sk-test-fake-key', 'deepseek-chat') - t.truthy(model) - t.is(typeof model.streamText, 'function') -}) - -test('generateText rejects on invalid prompt JSON', async (t) => { - const model = await openai('sk-test-fake-key', 'gpt-4o-mini') - // A wire-JSON text that does not parse is the binding's own failure, not - // the engine's: a plain napi Error with code 'InvalidArg' naming the - // argument — never an engine InvalidPromptError. - await t.throwsAsync( - () => model.generateText('{invalid json}'), - { code: 'InvalidArg', message: /^prompt_json: invalid JSON/ }, - ) -}) - -test('streamText returns an async generator', async (t) => { - const model = await openai('sk-test-fake-key', 'gpt-4o-mini') - const gen = await model.streamText('"hello"') - // It should have Symbol.asyncIterator - t.is(typeof gen[Symbol.asyncIterator], 'function') -}) - -// Omitting cap uses the library default capacity (FFI -// aimux_init_recording_ring_default) and must not throw. Requires the native -// addon to be rebuilt (napi build) with the optional-cap signature. -test('initRecordingRing with no cap uses library default', (t) => { - t.notThrows(() => initRecordingRing()) - recordingStop() -}) diff --git a/bindings/node/__test__/multimodal_e2e.test.ts b/bindings/node/__test__/multimodal_e2e.test.ts deleted file mode 100644 index e40fb1d43..000000000 --- a/bindings/node/__test__/multimodal_e2e.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -// multimodal_e2e.test.ts — End-to-end multimodal provider tests with mock HTTP server. -// -// These tests verify the FULL chain for the 8 non-chat modalities: -// Node.js → napi-rs → Rust engine → HTTP mock server → response parsing → typed result. -// -// The mock responses mirror the Go binding's `multimodal_withbase_test.go` -// (same provider wire shapes) and the existing `e2e.test.ts` mock-server pattern. -// -// Each modality constructs a model via its `XxxWithBase(apiKey, modelId, mockUrl)` -// factory (all factories accept an optional `baseUrl`), calls the method, and -// asserts fields on the JSON-serialized result. - -import test from 'ava' -import { createServer, type Server } from 'node:http' -import { - openaiEmbedding, - openaiSpeech, - openaiImage, - openaiTranscription, - cohereReranking, - tavilySearch, - openaiFiles, - googleVideo, -} from '../src/native.ts' - -// ── Mock server helpers (same pattern as e2e.test.ts) ─────────────────────── - -function startMockServer(handler: (req: any, res: any) => void): Promise<{ server: Server; url: string }> { - return new Promise((resolve) => { - const server = createServer(handler) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as any - resolve({ server, url: `http://127.0.0.1:${addr.port}` }) - }) - }) -} - -function closeServer(server: Server): Promise { - return new Promise((resolve) => server.close(() => resolve())) -} - -// ── Mock responses (verified wire shapes from Go multimodal_withbase_test.go) ─ - -const EMBEDDING_RESPONSE = JSON.stringify({ - data: [{ embedding: [0.1, 0.2, 0.3], index: 0 }], - model: 'text-embedding-3-small', - usage: { prompt_tokens: 3, total_tokens: 3 }, -}) - -const IMAGE_RESPONSE = JSON.stringify({ - data: [{ b64_json: 'aW1hZ2Ux' }], -}) - -const TRANSCRIPTION_RESPONSE = JSON.stringify({ - text: 'Hello world', -}) - -const RERANKING_RESPONSE = JSON.stringify({ - results: [ - { index: 1, relevance_score: 0.95 }, - { index: 0, relevance_score: 0.3 }, - ], -}) - -const SEARCH_RESPONSE = JSON.stringify({ - results: [{ title: 'Rust', url: 'https://rust-lang.org', content: 'Rust is...' }], - answer: 'Rust is a systems language.', -}) - -const FILES_RESPONSE = JSON.stringify({ - id: 'file-abc', - object: 'file', - bytes: 1024, - created_at: 1234, - filename: 'test.pdf', - purpose: 'assistants', -}) - -// Google Video uses a multi-step async API (POST predict → poll operation → -// fetch result). A single-response mock can't cover the full flow, so the video -// test only exercises construction + result-JSON parsing (see test below). -const VIDEO_RESULT_JSON = JSON.stringify({ - videos: [{ type: 'url', url: 'https://example.com/v.mp4', mediaType: 'video/mp4' }], -}) - -// ── Tests ─────────────────────────────────────────────────────────────────── - -test('e2e: OpenAI embedding via mock server', async (t) => { - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(EMBEDDING_RESPONSE) - }) - - try { - const embedder = await openaiEmbedding('test-key', 'text-embedding-3-small', url) - const r = JSON.parse(await embedder.embed(JSON.stringify(['hello']))) - - t.is(r.embeddings.length, 1) - t.is(r.embeddings[0].length, 3, 'first embedding has 3 dimensions') - } finally { - await closeServer(server) - } -}) - -test('e2e: OpenAI speech (TTS) via mock server returns binary audio', async (t) => { - // The speech provider returns AudioData::Binary with the raw response bytes. - // The mock serves raw bytes with an audio content-type (not JSON). The JSON - // carries them as a base64 string, as the AI SDK's JSON does for a - // `Uint8Array` (SpeechModelV4Result.audio: string | Uint8Array). - const audioBytes = Buffer.from('fake-mp3-audio-data') - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'audio/mpeg' }) - res.end(audioBytes) - }) - - try { - const speaker = await openaiSpeech('test-key', 'tts-1', url) - const r = JSON.parse( - await speaker.generate(JSON.stringify({ text: 'Hi', voice: 'alloy', outputFormat: 'mp3' })), - ) - - t.is(r.audio, audioBytes.toString('base64'), 'binary audio is a base64 string') - } finally { - await closeServer(server) - } -}) - -test('e2e: OpenAI image generation via mock server', async (t) => { - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(IMAGE_RESPONSE) - }) - - try { - const imager = await openaiImage('test-key', 'dall-e-3', url) - // ImageCallOptions has non-Option required fields `n` and `providerOptions`, - // so both must be present in the opts JSON for serde deserialization. - const r = JSON.parse( - await imager.generate(JSON.stringify({ prompt: 'otter', n: 1, providerOptions: {} })), - ) - - t.is(r.images.length, 1) - t.is(r.images[0], 'aW1hZ2Ux') - } finally { - await closeServer(server) - } -}) - -test('e2e: OpenAI transcription (STT) via mock server', async (t) => { - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(TRANSCRIPTION_RESPONSE) - }) - - try { - const transcriber = await openaiTranscription('test-key', 'whisper-1', url) - const r = JSON.parse(await transcriber.generate('dGVzdA==', 'audio/mp3')) - - t.is(r.text, 'Hello world') - } finally { - await closeServer(server) - } -}) - -test('e2e: Cohere reranking via mock server', async (t) => { - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(RERANKING_RESPONSE) - }) - - try { - const reranker = await cohereReranking('test-key', 'rerank-v3.0', url) - // `docs_json` deserializes into the `type`-tagged `RerankingDocuments` - // enum, so it must be `{"type":"text","values":[...]}` (not a bare array). - const r = JSON.parse( - await reranker.rerank('which?', JSON.stringify({ type: 'text', values: ['doc1', 'doc2'] })), - ) - - t.is(r.ranking.length, 2) - t.is(r.ranking[0].relevanceScore, 0.95) - } finally { - await closeServer(server) - } -}) - -test('e2e: Tavily search via mock server', async (t) => { - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(SEARCH_RESPONSE) - }) - - try { - const searcher = await tavilySearch('test-key', url) - const r = JSON.parse(await searcher.search('What is Rust?')) - - t.is(r.results.length, 1) - t.is(r.results[0].title, 'Rust') - t.is(r.answer, 'Rust is a systems language.') - } finally { - await closeServer(server) - } -}) - -test('e2e: OpenAI files upload via mock server', async (t) => { - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(FILES_RESPONSE) - }) - - try { - const files = await openaiFiles('test-key', url) - const r = JSON.parse(await files.uploadFile('dGVzdA==', 'application/pdf')) - - t.is(r.providerReference.openai, 'file-abc') - } finally { - await closeServer(server) - } -}) - -test('e2e: Google video construction + result parsing', async (t) => { - // Google Video uses a multi-step async API (POST predict → poll → fetch). - // A single-response mock server can't cover the full flow, so (matching the - // Go test) we only verify construction succeeds and the result JSON parses. - // No HTTP call is made, so the base URL is intentionally unreachable. - const video = await googleVideo('test-key', 'veo-3.0', 'http://localhost:9999') - t.truthy(video, 'googleVideo factory constructs without throwing') - - const r = JSON.parse(VIDEO_RESULT_JSON) - t.is(r.videos[0].url, 'https://example.com/v.mp4') - t.is(r.videos[0].mediaType, 'video/mp4') -}) diff --git a/bindings/node/__test__/native_scenarios.rs b/bindings/node/__test__/native_scenarios.rs new file mode 100644 index 000000000..023329ea0 --- /dev/null +++ b/bindings/node/__test__/native_scenarios.rs @@ -0,0 +1,378 @@ +//! Shared test source for the Node and Python native contract builds. +//! No provider/network double replaces aimux-ops. Only the scripted source is +//! injected at its public Dispatcher seam, as in the ops crate's native vectors. +//! Kept out of release builds by the contract-tests feature. +use std::collections::VecDeque; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; +use std::time::{Duration, SystemTime}; + +use aimux_core::error::ApiCallError; +use aimux_core::{AbortSignal, AiMuxError}; +use aimux_ops::dispatch::{Ctx, Dispatcher, EventStream, Handler, Target, serialize_events}; +use aimux_ops::envelope::{OpsError, ProtocolError}; +use aimux_ops::handle::{HandleId, HandleRegistry, Owner}; +use aimux_ops::limits::{QueueEvent, QueueLimits, bounded_queue, check_wire_item}; +use aimux_ops::op::{Cancellation, OpDescriptor, OpMode, RequestBytePolicy, Scope}; +use futures::{Stream, StreamExt}; +use serde::{Serialize, Serializer}; +use serde_json::Value; +use tokio::sync::Notify; + +#[derive(serde::Deserialize)] +#[serde(tag = "action", rename_all = "camelCase", deny_unknown_fields)] +enum SourceStep { + Event { event: Value }, + RepeatEvent { count: usize, event: Value }, + Wait { gate: String }, + TerminalError { kind: String, message: String }, + SerializationFailure { message: String }, + Eof, +} + +#[derive(serde::Deserialize)] +#[serde(tag = "action", rename_all = "camelCase", deny_unknown_fields)] +enum Control { + Read { count: usize }, + Drain, + DrainNative, + Cancel, + CloseReader, + StartProducer, + WaitForQueueFull, + WaitForSourceGate { gate: String }, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Observation { + accepted: bool, + preflight_error: Option, + frames: Vec, + consumer_frames: Vec, + abort_observed: Option, + queue_full_observed: bool, + consumer_closed: bool, + source_started: bool, + source_events_polled: usize, +} + +static SOURCE: Mutex> = Mutex::new(None); +static DESCRIPTOR: OpDescriptor = OpDescriptor { + name: "test.scenario", + since_ops_version: "1.0", + mode: OpMode::Stream, + allowed_handle_kinds: &[], + request_type: "Value", + result_type: "()", + event_type: Some("Value"), + scope: Scope::Current, + terminal_type: Some("StreamFrame"), + runtime_handle_fields: &[], + required_capabilities: &[], + cancellation: Cancellation::Abort, + side_effects: false, + result_ownership: "owned", + request_byte_policy: RequestBytePolicy::Json, + retry_owner: "none", + source_symbols: &[], +}; +fn accept(_: Ctx, _: Target, params: Value) -> Result { + if params + .get("fault") + .is_some_and(|fault| fault["stage"] == "preflight") + { + return Err(ProtocolError::InvalidParams("mock invalid params".into()).into()); + } + Ok(SOURCE + .lock() + .unwrap() + .take() + .expect("installed native source")) +} +#[derive(Default)] +struct Stats { + started: AtomicBool, + polled: AtomicUsize, + gate: Mutex>, + full: AtomicBool, + changed: Notify, +} +struct Script { + steps: VecDeque, + stats: Arc, +} +enum Event { + Value(Value), + Broken(String), +} +impl Serialize for Event { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Value(value) => value.serialize(serializer), + Self::Broken(message) => Err(serde::ser::Error::custom(message)), + } + } +} +impl Stream for Script { + type Item = Result; + fn poll_next(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + this.stats.started.store(true, Ordering::SeqCst); + let Some(step) = this.steps.pop_front() else { + return Poll::Ready(None); + }; + let event = + match step { + SourceStep::Event { event } => Event::Value(event), + SourceStep::RepeatEvent { count, event } if count > 0 => { + if count > 1 { + this.steps.push_front(SourceStep::RepeatEvent { + count: count - 1, + event: event.clone(), + }); + } + Event::Value(event) + } + SourceStep::Wait { gate } => { + *this.stats.gate.lock().unwrap() = Some(gate.clone()); + this.steps.push_front(SourceStep::Wait { gate }); + this.stats.changed.notify_waiters(); + return Poll::Pending; + } + SourceStep::TerminalError { kind, message } => { + assert_eq!(kind, "apiCall"); + return Poll::Ready(Some(Err(AiMuxError::ApiCall(Box::new( + ApiCallError::new(message, "https://mock.invalid", serde_json::json!({})), + )) + .into()))); + } + SourceStep::SerializationFailure { message } => { + return Poll::Ready(Some(Ok(Event::Broken(message)))); + } + SourceStep::Eof => return Poll::Ready(None), + SourceStep::RepeatEvent { .. } => panic!("empty repeat"), + }; + this.stats.polled.fetch_add(1, Ordering::SeqCst); + Poll::Ready(Some(Ok(event))) + } +} + +async fn barrier(stats: &Stats, predicate: impl Fn() -> bool) { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let changed = stats.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + if predicate() { + break; + } + changed.await; + } + }) + .await + .expect("native instrumentation barrier stalled"); +} + +// The host supplies only inputs. Output comes from the real Dispatcher, +// serialize_events, FrameStream and bounded_queue implementations. Both native +// adapters call this through their ordinary call boundary in a test-only build. +// Serializing calls also protects the one-shot handler's SOURCE installation. +pub(crate) async fn replay(input: Value, owner: Owner) -> Value { + static RUN: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + let _run = RUN.lock().await; + serde_json::to_value(observe(input, owner).await).unwrap() +} + +async fn observe(mut input: Value, owner: Owner) -> Observation { + assert!( + input.as_object_mut().unwrap().remove("expected").is_none(), + "driver cannot inspect expected output" + ); + let stats = Arc::new(Stats::default()); + let steps = serde_json::from_value::>(input["script"].clone()).unwrap(); + *SOURCE.lock().unwrap() = Some(serialize_events(Script { + steps: steps.into(), + stats: stats.clone(), + })); + let abort = AbortSignal::new(); + let ctx = Ctx { + owner, + abort: Some(abort.clone()), + now: SystemTime::now, + }; + let registry = HandleRegistry::default(); + let table = [(&DESCRIPTOR, Handler::Stream(accept))]; + let dispatcher = Dispatcher::new(®istry, &table); + let result = dispatcher.stream(ctx, HandleId(0), DESCRIPTOR.name, input.clone()); + let mut observed = Observation { + accepted: result.is_ok(), + preflight_error: None, + frames: Vec::new(), + consumer_frames: Vec::new(), + abort_observed: Some(false), + queue_full_observed: false, + consumer_closed: false, + source_started: false, + source_events_polled: 0, + }; + let mut frames = match result { + Ok(frames) => frames, + Err(error) => { + observed.preflight_error = + Some(serde_json::to_value(error.envelope(SystemTime::UNIX_EPOCH)).unwrap()); + SOURCE.lock().unwrap().take(); + return observed; + } + }; + let limits = &input["queueLimits"]; + let limits = QueueLimits::output() + .lowered( + limits["items"].as_u64().unwrap() as usize, + limits["bytes"].as_u64().unwrap() as usize, + limits["maxEventBytes"].as_u64().unwrap() as usize, + ) + .unwrap(); + let (sender, mut receiver) = bounded_queue::, Value>(limits); + let native_frames = Arc::new(Mutex::new(Vec::new())); + let log = native_frames.clone(); + let producer_stats = stats.clone(); + let producer_abort = abort.clone(); + let producer = tokio::spawn(async move { + while let Some(frame) = frames.next().await { + let terminal = matches!(frame, aimux_ops::StreamFrame::End { .. }); + let value = serde_json::to_value(&frame).unwrap(); + log.lock().unwrap().push(value.clone()); + if terminal { + sender.finish(value); + continue; + } + let size = check_wire_item(&frame, limits.max_item_bytes()).unwrap(); + let permit = sender.reserve(size, Some(&producer_abort)).await.unwrap(); + permit + .send(serde_json::to_vec(&frame).unwrap().into_boxed_slice()) + .unwrap(); + if sender.usage().0 == limits.max_items() { + producer_stats.full.store(true, Ordering::SeqCst); + producer_stats.changed.notify_waiters(); + } + } + }); + let controls = serde_json::from_value::>(input["controls"].clone()).unwrap(); + for control in controls { + match control { + Control::Read { count } => { + for _ in 0..count { + observed.consumer_frames.push( + receive(&mut receiver) + .await + .expect("expected consumer frame"), + ); + } + } + Control::Drain => { + while let Some(frame) = receive(&mut receiver).await { + observed.consumer_frames.push(frame); + } + } + Control::WaitForSourceGate { gate } => { + barrier(&stats, || { + stats.gate.lock().unwrap().as_ref() == Some(&gate) + }) + .await + } + Control::WaitForQueueFull => { + barrier(&stats, || stats.full.load(Ordering::SeqCst)).await; + observed.queue_full_observed = true; + } + Control::Cancel => abort.abort(), + Control::CloseReader => { + observed.consumer_closed = true; + receiver.close(); + abort.abort(); + } + Control::DrainNative | Control::StartProducer => {} + } + } + tokio::time::timeout(Duration::from_secs(3), producer) + .await + .unwrap() + .unwrap(); + observed.frames = native_frames.lock().unwrap().clone(); + observed.abort_observed = Some(abort.is_aborted()); + observed.source_started = stats.started.load(Ordering::SeqCst); + observed.source_events_polled = stats.polled.load(Ordering::SeqCst); + observed +} +async fn receive( + receiver: &mut aimux_ops::limits::QueueReceiver, Value>, +) -> Option { + match receiver.recv().await? { + QueueEvent::Data(data) => Some(serde_json::from_slice(&data).unwrap()), + QueueEvent::Terminal(frame) => Some(frame), + } +} + +// A connecting provider never polls audio, so one admitted chunk deterministically +// fills the lowered input queue. This is a real Operation, not a push double. +struct ConnectingModel; +impl aimux_core::transcription_model::TranscriptionModel for ConnectingModel { + fn provider(&self) -> &str { + "contract" + } + fn model_id(&self) -> &str { + "connecting" + } + fn do_generate<'a, 'b, 'c>( + &'a self, + _: &'b aimux_core::transcription_model::TranscriptionCallOptions, + ) -> futures::future::BoxFuture< + 'c, + Result, + > + where + 'a: 'c, + 'b: 'c, + Self: 'c, + { + Box::pin(async { unreachable!("only streaming is used") }) + } + fn do_stream( + &self, + _: aimux_core::transcription_model::TranscriptionStreamOptions, + ) -> Option< + futures::future::BoxFuture< + '_, + Result, + >, + > { + Some(Box::pin(std::future::pending())) + } +} + +pub(crate) async fn operation(owner: Owner) -> HandleId { + let operation = aimux_ops::operation::Operation::start( + Arc::new(ConnectingModel), + aimux_ops::operation::OperationOptions { + limits: aimux_ops::operation::OperationLimits { + input_items: 1, + ..Default::default() + }, + ..Default::default() + }, + None, + ) + .await + .unwrap_or_else(|_| panic!("accept connecting operation")); + HandleRegistry::global() + .insert(owner, aimux_ops::handle::Entry::Operation(operation)) + .unwrap() +} + +pub(crate) fn released(owner: Owner, id: HandleId) -> bool { + HandleRegistry::global() + .get(owner, id, &[aimux_ops::handle::HandleKind::Operation]) + .is_err() +} diff --git a/bindings/node/__test__/provider_config.test.ts b/bindings/node/__test__/provider_config.test.ts deleted file mode 100644 index b75095620..000000000 --- a/bindings/node/__test__/provider_config.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -// provider_config.test.ts — Node e2e tests for the provider factory config -// (headers) and the call-level maxRetries, plus the removed bodyOverrides and -// the provider-level maxRetries, which are rejected instead of ignored. -// -// These verify the full chain: JS → napi → Rust → HTTP mock. - -import test from 'ava' -import { createServer, type Server } from 'node:http' -import { - anthropic, - anthropicAws, - azure, - bedrock, - cohere, - createProvider, - deepseek, - google, - mistral, - openai, - provider, - vertex, - xai, - InvalidArgumentError, -} from '../src/native.ts' - -function startMockServer(handler: (req: any, res: any) => void): Promise<{ server: Server; url: string }> { - return new Promise((resolve) => { - const server = createServer(handler) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as any - resolve({ server, url: `http://127.0.0.1:${addr.port}` }) - }) - }) -} - -function closeServer(server: Server): Promise { - return new Promise((resolve) => server.close(() => resolve())) -} - -function readBody(req: any): Promise { - return new Promise((resolve) => { - let body = '' - req.on('data', (chunk: string) => { body += chunk }) - req.on('end', () => resolve(JSON.parse(body))) - }) -} - -const RESPONSES_RESPONSE = JSON.stringify({ - id: 'resp-test', - model: 'm', - created_at: 1, - status: 'completed', - output: [{ - type: 'message', - id: 'msg-test', - role: 'assistant', - status: 'completed', - content: [{ type: 'output_text', text: 'Done.', annotations: [] }], - }], - usage: { input_tokens: 10, output_tokens: 1, total_tokens: 11 }, -}) - -// ── removed: bodyOverrides ─────────────────────────────────────────────────── - -test('per-call body_overrides is rejected, not ignored', async (t) => { - let requests = 0 - const { server, url } = await startMockServer(async (req, res) => { - requests += 1 - await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(RESPONSES_RESPONSE) - }) - - try { - const model = await openai('test-key', 'm', url) - const opts = JSON.stringify({ - temperature: 0.9, - body_overrides: { temperature: 0.1, enable_thinking: false }, - }) - const err = await t.throwsAsync(() => model.generateText(JSON.stringify('Hello'), opts)) - t.true(err instanceof InvalidArgumentError) - t.regex(err!.message, /unknown field `body_overrides`/) - t.is(requests, 0, 'the call fails before any request') - } finally { - await closeServer(server) - } -}) - -test('factory config bodyOverrides is rejected, not ignored', async (t) => { - const err = await t.throwsAsync(() => - openai('test-key', 'm', { - baseUrl: 'http://127.0.0.1:1', - bodyOverrides: '{"X-Relay-Tag":"my-team"}', - }), - ) - t.true(err instanceof Error) - t.regex(err!.message, /body_overrides/) -}) - -test('factory config maxRetries is rejected, not ignored', async (t) => { - const err = await t.throwsAsync(() => - openai('test-key', 'm', { baseUrl: 'http://127.0.0.1:1', maxRetries: 0 }), - ) - t.true(err instanceof Error) - t.regex(err!.message, /max_retries/) -}) - -// Every native constructor takes the same config and rejects the two removed -// keys; none of them drops a key silently. -const NATIVE_CONSTRUCTORS: Array<[string, (config: object) => Promise]> = [ - ['openai', (c) => openai('k', 'm', c)], - ['anthropic', (c) => anthropic('k', 'm', c)], - ['google', (c) => google('k', 'm', c)], - ['cohere', (c) => cohere('k', 'm', c)], - ['mistral', (c) => mistral('k', 'm', c)], - ['xai', (c) => xai('k', 'm', c)], - ['deepseek', (c) => deepseek('k', 'm', c)], - ['bedrock', (c) => bedrock('AKIA', 'secret', 'us-east-1', 'm', c)], - ['vertex', (c) => vertex('token', 'proj', 'us-central1', 'm', c)], - ['anthropicAws', (c) => anthropicAws('k', 'us-east-1', 'm', c)], - ['azure', (c) => azure('k', 'res', 'dep', null, c)], - ['provider', (c) => provider('groq', 'k', 'm', c)], - ['createProvider', (c) => createProvider('groq', 'k', c)], -] - -for (const [name, construct] of NATIVE_CONSTRUCTORS) { - test(`${name}: maxRetries / bodyOverrides in the config are InvalidArgumentError`, async (t) => { - const maxRetries = await t.throwsAsync(() => construct({ maxRetries: 1 })) - t.true(maxRetries instanceof InvalidArgumentError) - t.regex(maxRetries!.message, /max_retries/) - const overrides = await t.throwsAsync(() => construct({ bodyOverrides: '{"a":1}' })) - t.true(overrides instanceof InvalidArgumentError) - t.regex(overrides!.message, /body_overrides/) - }) - - test(`${name}: a plain config is accepted`, async (t) => { - await t.notThrowsAsync(() => construct({ baseUrl: 'http://127.0.0.1:1' })) - }) -} - -// ── params: preset template parameters ─────────────────────────────────────── - -test('params fills a preset template parameter', async (t) => { - const saved = process.env.CLOUDFLARE_ACCOUNT_ID - delete process.env.CLOUDFLARE_ACCOUNT_ID - try { - // Without the parameter the preset cannot build its base URL (reported at call time). - const unfilled = await provider('cloudflare_workers_ai', 'k', 'm') - const missing = await t.throwsAsync(() => - unfilled.generateText(JSON.stringify('hi'), JSON.stringify({ maxRetries: 0 })), - ) - t.true(missing instanceof InvalidArgumentError) - t.regex(missing!.message, /account_id/) - - await t.notThrowsAsync(() => - provider('cloudflare_workers_ai', 'k', 'm', { params: { account_id: 'acct123' } }), - ) - await t.notThrowsAsync(() => - createProvider('cloudflare_workers_ai', 'k', { params: { account_id: 'acct123' } }), - ) - } finally { - if (saved !== undefined) process.env.CLOUDFLARE_ACCOUNT_ID = saved - } -}) - -test('params the preset does not declare are rejected', async (t) => { - const err = await t.throwsAsync(() => - provider('abacus', 'k', 'm', { params: { account_id: 'acct123' } }), - ) - t.true(err instanceof InvalidArgumentError) - t.regex(err!.message, /no template parameter `account_id`/) -}) - -test('params values that could change the URL are rejected', async (t) => { - const err = await t.throwsAsync(() => - provider('cloudflare_workers_ai', 'k', 'm', { params: { account_id: 'a/b' } }), - ) - t.true(err instanceof InvalidArgumentError) -}) - -test('factory config headers are sent on every request', async (t) => { - let headers: any = null - const { server, url } = await startMockServer(async (req, res) => { - headers = req.headers - // consume body - await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(RESPONSES_RESPONSE) - }) - - try { - const model = await openai('test-key', 'm', { - baseUrl: url, - headers: JSON.stringify({ 'X-Custom-Header': 'custom-value' }), - }) - await model.generateText(JSON.stringify('Hello')) - - t.is(headers['x-custom-header'], 'custom-value') - } finally { - await closeServer(server) - } -}) - -// ── backward compatibility ─────────────────────────────────────────────────── - -test('factory accepts bare string baseUrl (backward compatible)', async (t) => { - const { server, url } = await startMockServer(async (req, res) => { - t.is(req.url, '/responses') - await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(RESPONSES_RESPONSE) - }) - - try { - // 3rd param is a plain string (old API) - const model = await openai('test-key', 'm', url) - const result = JSON.parse(await model.generateText(JSON.stringify('Hello'))) - t.is(result.text, 'Done.') - } finally { - await closeServer(server) - } -}) - -// ── maxRetries ──────────────────────────────────────────────────────────────── - -test('maxRetries: 0 disables retries (single request on 500)', async (t) => { - let requestCount = 0 - const { server, url } = await startMockServer(async (req, res) => { - requestCount++ - await readBody(req) - res.writeHead(500, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ error: { message: 'Internal server error', type: 'server_error' } })) - }) - - try { - const model = await openai('test-key', 'm', url) - const opts = JSON.stringify({ maxRetries: 0 }) - - await t.throwsAsync( - async () => model.generateText(JSON.stringify('Hello'), opts), - { message: /500|Internal|server_error/i }, - ) - - // With retries disabled, exactly 1 request should have been made. - t.is(requestCount, 1, 'should make exactly 1 request when maxRetries=0') - } finally { - await closeServer(server) - } -}) diff --git a/bindings/node/__test__/reasoning_replay.test.ts b/bindings/node/__test__/reasoning_replay.test.ts deleted file mode 100644 index 67a530763..000000000 --- a/bindings/node/__test__/reasoning_replay.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -// reasoning_replay.test.ts — End-to-end regression tests for the two issues -// reported against aimux 0.1.1 when driving OpenAI-compatible thinking models -// (e.g. DeepSeek `deepseek-v4-flash`) in multi-turn tool-call conversations. -// -// Issue 1: a `tool`-role message carrying `ContentPart[]` with a `tool-result` -// part (the AI SDK `ToolResultPart` shape, with a structured `output`) was -// rejected by `ModelPrompt` deserialization ("data did not match any variant -// of untagged enum ModelPrompt"). -// -// Issue 2: thinking models require prior assistant `reasoning_content` to be -// replayed on later turns. The OpenAI message converter now lifts -// assistant reasoning parts to a top-level `reasoning_content` string. -// -// These tests use a mock HTTP server (no real API) and assert the outbound -// request body shape that the Rust engine produces. - -import test from 'ava' -import { createServer, type Server } from 'node:http' -import { deepseek } from '../src/native.ts' - -function startMockServer(handler: (req: any, res: any) => void): Promise<{ server: Server; url: string }> { - return new Promise((resolve) => { - const server = createServer(handler) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as any - resolve({ server, url: `http://127.0.0.1:${addr.port}` }) - }) - }) -} - -function closeServer(server: Server): Promise { - return new Promise((resolve) => server.close(() => resolve())) -} - -/// Read the full request body as a parsed JSON object. -function readBody(req: any): Promise { - return new Promise((resolve) => { - let body = '' - req.on('data', (chunk: string) => { body += chunk }) - req.on('end', () => resolve(JSON.parse(body))) - }) -} - -const CHAT_RESPONSE = JSON.stringify({ - id: 'chatcmpl-test', - model: 'deepseek-v4-flash', - choices: [{ - message: { role: 'assistant', content: 'Done.' }, - finish_reason: 'stop', - }], - usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 }, -}) - -// ── Issue 1: tool role ContentPart[] with a structured `output` ───────────── - -test('issue 1: tool-result with a structured `output` is accepted and carries tool_call_id', async (t) => { - let requestBody: any = null - const { server, url } = await startMockServer(async (req, res) => { - requestBody = await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(CHAT_RESPONSE) - }) - - try { - const model = await deepseek('test-key', 'deepseek-v4-flash', url) - - // A tool message whose ContentPart carries the AI SDK `ToolResultPart` shape. - const messages = [ - { role: 'user', content: 'write a file' }, - { - role: 'assistant', - content: [{ - type: 'tool-call', - toolCallId: 'tc1', - toolName: 'write_file', - input: { path: '/tmp/test.txt' }, - }], - }, - { - role: 'tool', - content: [{ - type: 'tool-result', - toolCallId: 'tc1', - toolName: 'write_file', - output: { type: 'text', value: 'Successfully wrote to /tmp/test.txt' }, - }], - }, - ] - - // Before the fix this rejected with "invalid prompt" / ModelPrompt error. - const resultJson = await model.generateText(JSON.stringify(messages)) - const r = JSON.parse(resultJson) - t.is(r.text, 'Done.') - - // The outbound request must carry the tool_call_id (the core of issue 1). - const toolMsg = requestBody.messages[2] - t.is(toolMsg.role, 'tool') - t.is(toolMsg.tool_call_id, 'tc1') - t.is(toolMsg.content, 'Successfully wrote to /tmp/test.txt') - } finally { - await closeServer(server) - } -}) - -// ── Issue 2: reasoning_content replay on the request side ─────────────────── - -test('issue 2: assistant reasoning + tool-call lifts reasoning to reasoning_content', async (t) => { - let requestBody: any = null - const { server, url } = await startMockServer(async (req, res) => { - requestBody = await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(CHAT_RESPONSE) - }) - - try { - const model = await deepseek('test-key', 'deepseek-v4-flash', url) - - // Reproduces the user's table row: assistant - // [{reasoning},{tool-call}] + tool result. The first turn produced a - // reasoning_content + tool_call; the second turn must replay both. - // A tool message carries tool_result parts only (AI SDK `ToolModelMessage`). - const messages = [ - { role: 'user', content: 'inspect the repo' }, - { - role: 'assistant', - content: [ - { type: 'reasoning', text: 'I need to inspect files before answering.' }, - { type: 'tool-call', toolCallId: 'call_1', toolName: 'read_file', input: { path: 'README.md' } }, - ], - }, - { - role: 'tool', - content: [{ - type: 'tool-result', - toolCallId: 'call_1', - toolName: 'read_file', - output: { type: 'text', value: 'contents of README.md' }, - }], - }, - ] - - const resultJson = await model.generateText(JSON.stringify(messages)) - const r = JSON.parse(resultJson) - t.is(r.text, 'Done.') - - // The assistant message must carry reasoning_content (the DeepSeek V4 - // thinking-mode requirement) alongside tool_calls. - const assistant = requestBody.messages[1] - t.is(assistant.role, 'assistant') - t.is(assistant.reasoning_content, 'I need to inspect files before answering.') - t.true(Array.isArray(assistant.tool_calls)) - t.is(assistant.tool_calls[0].id, 'call_1') - t.is(assistant.tool_calls[0].function.name, 'read_file') - } finally { - await closeServer(server) - } -}) - -test('issue 2: assistant reasoning + text (no tool calls) emits reasoning_content', async (t) => { - let requestBody: any = null - const { server, url } = await startMockServer(async (req, res) => { - requestBody = await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(CHAT_RESPONSE) - }) - - try { - const model = await deepseek('test-key', 'deepseek-v4-flash', url) - - const messages = [ - { role: 'user', content: 'What is 2+2?' }, - { - role: 'assistant', - content: [ - { type: 'reasoning', text: '2 plus 2 equals 4.' }, - { type: 'text', text: '4' }, - ], - }, - { role: 'user', content: 'thanks' }, - ] - - await model.generateText(JSON.stringify(messages)) - - const assistant = requestBody.messages[1] - t.is(assistant.role, 'assistant') - t.is(assistant.content, '4') - t.is(assistant.reasoning_content, '2 plus 2 equals 4.') - t.falsy(assistant.tool_calls, 'no tool_calls key when there are none') - } finally { - await closeServer(server) - } -}) - -test('issue 2: assistant without reasoning omits reasoning_content', async (t) => { - let requestBody: any = null - const { server, url } = await startMockServer(async (req, res) => { - requestBody = await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(CHAT_RESPONSE) - }) - - try { - const model = await deepseek('test-key', 'deepseek-v4-flash', url) - - // The tool call has its result: a prompt that leaves a tool call without - // one is a MissingToolResultsError (AI SDK `convertToLanguageModelPrompt`). - const messages = [ - { role: 'user', content: 'hi' }, - { - role: 'assistant', - content: [{ - type: 'tool-call', - toolCallId: 'call_1', - toolName: 'write_file', - input: { path: '/tmp/x' }, - }], - }, - { - role: 'tool', - content: [{ - type: 'tool-result', - toolCallId: 'call_1', - toolName: 'write_file', - output: { type: 'text', value: 'Successfully wrote to /tmp/x' }, - }], - }, - ] - - await model.generateText(JSON.stringify(messages)) - - const assistant = requestBody.messages[1] - t.is(assistant.role, 'assistant') - t.falsy(assistant.reasoning_content, 'reasoning_content must be omitted when no reasoning part') - } finally { - await closeServer(server) - } -}) diff --git a/bindings/node/__test__/roundtrip-fields.test.ts b/bindings/node/__test__/roundtrip-fields.test.ts deleted file mode 100644 index 48defe6cb..000000000 --- a/bindings/node/__test__/roundtrip-fields.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -// roundtrip-fields.test.ts — reasoning signature & toolName pass-through (#135). -// -// Proves the language-side pipe for the Round 4 fixes, through the FULL -// chain (Node.js → napi-rs → Rust engine → HTTP mock): -// - anthropic: with thinking enabled, a prompt reasoning part's -// `signature` is echoed as a thinking block on the wire (input -// round-trip), and a thinking-block response surfaces `signature` on -// the result (response visibility, #131). -// - google: a tool-result's `toolName` reaches the request as -// functionResponse.name (#127). - -import test from 'ava' -import { createServer, type Server } from 'node:http' -import { anthropic, applyToolCallRepair, google, toolCallRepairContext } from '../src/native.ts' - -function startMockServer(handler: (req: any, res: any) => void): Promise<{ server: Server; url: string }> { - return new Promise((resolve) => { - const server = createServer(handler) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as any - resolve({ server, url: `http://127.0.0.1:${addr.port}` }) - }) - }) -} - -function closeServer(server: Server): Promise { - return new Promise((resolve) => server.close(() => resolve())) -} - -const ANTHROPIC_THINKING_RESPONSE = JSON.stringify({ - id: 'msg_rt', - type: 'message', - role: 'assistant', - model: 'claude-sonnet-4-20250514', - content: [ - { type: 'thinking', thinking: 'pondering deeply', signature: 'sig-resp-1' }, - { type: 'text', text: 'The answer.' }, - ], - stop_reason: 'end_turn', - usage: { input_tokens: 10, output_tokens: 5 }, -}) - -const GEMINI_RESPONSE = JSON.stringify({ - candidates: [ - { - content: { parts: [{ text: 'ok' }], role: 'model' }, - finishReason: 'STOP', - }, - ], - usageMetadata: { promptTokenCount: 3, candidatesTokenCount: 1, totalTokenCount: 4 }, -}) - -test('roundtrip: anthropic echoes prompt reasoning signature and surfaces response signature', async (t) => { - let receivedBody: any = null - const { server, url } = await startMockServer((req, res) => { - let body = '' - req.on('data', (chunk: Buffer) => (body += chunk)) - req.on('end', () => { - receivedBody = JSON.parse(body) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(ANTHROPIC_THINKING_RESPONSE) - }) - }) - - try { - const model = await anthropic('test-key', 'claude-sonnet-4-20250514', url) - const prompt = [ - { role: 'user', content: [{ type: 'text', text: 'hi' }] }, - { - role: 'assistant', - content: [ - { type: 'reasoning', text: 'pondering', providerOptions: { anthropic: { signature: 'sig-prompt-1' } } }, - { type: 'text', text: 'answer' }, - ], - }, - { role: 'user', content: [{ type: 'text', text: 'go on' }] }, - ] - const opts = JSON.stringify({ - providerOptions: { - anthropic: { thinking: { type: 'enabled', budgetTokens: 1024 } }, - }, - }) - const resultJson = await model.generateText(JSON.stringify(prompt), opts) - - // Input round-trip: the prompt reasoning part is echoed as a thinking - // block (with its signature) on the wire. - const blocks = (receivedBody.messages ?? []).flatMap((m: any) => m.content ?? []) - const thinking = blocks.find((b: any) => b.type === 'thinking') - t.truthy(thinking, `expected a thinking block on the wire, got ${JSON.stringify(blocks)}`) - t.is(thinking.signature, 'sig-prompt-1') - t.is(thinking.thinking, 'pondering') - - // Response visibility: the result carries the response signature so - // extended-thinking multi-turn can echo it back. - t.true( - resultJson.includes('"signature":"sig-resp-1"'), - `expected the result JSON to carry the response signature, got ${resultJson}` - ) - } finally { - await closeServer(server) - } -}) - -test('roundtrip: google toolName reaches functionResponse.name', async (t) => { - let receivedBody: any = null - const { server, url } = await startMockServer((req, res) => { - let body = '' - req.on('data', (chunk: Buffer) => (body += chunk)) - req.on('end', () => { - receivedBody = JSON.parse(body) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(GEMINI_RESPONSE) - }) - }) - - try { - const model = await google('test-key', 'gemini-2.0-flash', url) - const prompt = [ - { role: 'user', content: [{ type: 'text', text: 'weather?' }] }, - { - role: 'assistant', - content: [ - { type: 'tool-call', toolCallId: 'call-1', toolName: 'weather', input: { location: 'SF' } }, - ], - }, - { - role: 'tool', - content: [{ type: 'tool-result', toolCallId: 'call-1', toolName: 'weather', output: { type: 'json', value: { temp: 70 } } }], - }, - ] - await model.generateText(JSON.stringify(prompt)) - - const parts = (receivedBody.contents ?? []).flatMap((c: any) => c.parts ?? []) - const fr = parts.map((p: any) => p.functionResponse).find(Boolean) - t.truthy(fr, `expected a functionResponse part, got ${JSON.stringify(parts)}`) - // `name` carries the real tool name (#127), not the opaque call id. - t.is(fr.name, 'weather') - t.is(fr.id, 'call-1') - } finally { - await closeServer(server) - } -}) - -// Provider options, provider metadata and provider references are JS objects -// upstream (@ai-sdk/provider shared-v4-provider-options.ts:24, -// shared-v4-provider-metadata.ts:24, shared-v4-provider-reference.ts:20), so -// their keys keep insertion order, through Rust and back (#229 NP2-1). -test('roundtrip: provider namespaces keep insertion order through the native layer', (t) => { - const providerMetadata = { zeta: { z: 1, a: 2 }, alpha: {}, mid: {} } - const invalid = { - toolCallId: 'call-1', - toolName: 'weather', - input: { town: 'SG' }, - dynamic: true, - invalid: true, - providerMetadata, - error: { name: 'AI_InvalidToolInputError', toolName: 'weather', toolInput: '{"town":"SG"}', cause: 'missing city' }, - } - const opts = { tools: [{ type: 'function', name: 'weather', inputSchema: { type: 'object' } }] } - const out = applyToolCallRepair(JSON.stringify(invalid), JSON.stringify(opts), '{"type":"unchanged"}') - const call = JSON.parse(out) - t.deepEqual(Object.keys(call.providerMetadata), ['zeta', 'alpha', 'mid']) - t.is(JSON.stringify(call.providerMetadata), JSON.stringify(providerMetadata)) -}) - -// A standalone tool call is the AI SDK's tagged TypedToolCall -// (ai generate-text/tool-call.ts:8-14), and a repair function's call a tagged -// LanguageModelV4ToolCall (provider language-model-v4-tool-call.ts:7) (#229 NP2-7). -test('roundtrip: repair functions write standalone tool calls tagged', (t) => { - const invalid = { - toolCallId: 'call-1', - toolName: 'weather', - input: { town: 'SG' }, - dynamic: true, - invalid: true, - error: { name: 'AI_InvalidToolInputError', toolName: 'weather', toolInput: '{"town":"SG"}', cause: 'missing city' }, - } - const opts = JSON.stringify({ tools: [{ type: 'function', name: 'weather', inputSchema: { type: 'object' } }] }) - const context = JSON.parse(toolCallRepairContext(JSON.stringify(invalid), '{"prompt":"weather?"}', opts)) - t.is(context.toolCall.type, 'tool-call') - const reply = JSON.stringify({ type: 'repaired', toolCall: { ...context.toolCall, input: '{"city":"SG"}' } }) - const repaired = JSON.parse(applyToolCallRepair(JSON.stringify(invalid), opts, reply)) - t.deepEqual(Object.keys(repaired).slice(0, 2), ['type', 'toolCallId']) - t.is(repaired.type, 'tool-call') - t.deepEqual(repaired.input, { city: 'SG' }) -}) diff --git a/bindings/node/__test__/session.test.ts b/bindings/node/__test__/session.test.ts deleted file mode 100644 index b01304eab..000000000 --- a/bindings/node/__test__/session.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -// session.test.ts — RFC-0024 session grouping through the Node binding. -// -// Verifies the full downstream path: `sessionId` in typed options reaches the -// core, calls are grouped in the registered SessionStore, and the query API -// (`getSessionCalls` / `getSessions`) returns typed results. Uses a mock HTTP -// server — no real API calls. The optional inferer is exercised as well. - -import test from 'ava' -import { createServer, type Server } from 'node:http' - -import { - deepseek, - generateText, - initSessionStore, - initSessionInfer, - getSessionCalls, - getSessions, - type SessionCall, - type SessionView, -} from '../src/index.ts' - -const MOCK_RESPONSE = JSON.stringify({ - id: 'chatcmpl-sess', - model: 'gpt-4o', - choices: [ - { - message: { role: 'assistant', content: 'ok' }, - finish_reason: 'stop', - }, - ], - usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 }, -}) - -function startMockServer(): Promise<{ server: Server; url: string }> { - return new Promise((resolve) => { - const server = createServer((_req, res) => { - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(MOCK_RESPONSE) - }) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as any - resolve({ server, url: `http://127.0.0.1:${addr.port}` }) - }) - }) -} - -// NOTE: both tests mutate the process-wide session store, so they must run -// serially (ava's default is concurrent). - -test.serial('sessionId groups calls and query API returns typed results', async (t) => { - const { server, url } = await startMockServer() - t.teardown(() => server.close()) - - initSessionStore() - initSessionInfer(false) // explicit ids only - - const model = await deepseek('sk-test-fake-key', 'gpt-4o-mini', { baseUrl: url }) - - // Two calls in the same session. - await generateText(model, 'first', { sessionId: 'sess-1' }) - await generateText(model, 'second', { sessionId: 'sess-1' }) - - const calls: SessionCall[] = getSessionCalls('sess-1') - t.is(calls.length, 2) - t.is(calls[0].step, 0) - t.is(calls[1].step, 1) - t.truthy(calls[0].callId) - t.truthy(calls[0].recordedAt) - t.not(calls[0].callId, calls[1].callId) - - // A call without a sessionId (inference off) is not grouped. - await generateText(model, 'third') - const sessions: SessionView[] = getSessions() - t.is(sessions.length, 1) - t.is(sessions[0].sessionId, 'sess-1') - t.is(sessions[0].source, 'Explicit') - - // Unknown session → empty. - t.deepEqual(getSessionCalls('nope'), []) - - // Separate explicit session. - await generateText(model, 'other', { sessionId: 'sess-2' }) - t.is(getSessions().length, 2) - t.is(getSessionCalls('sess-2').length, 1) -}) - -test.serial('opt-in inferer groups prefix continuations into auto sessions', async (t) => { - const { server, url } = await startMockServer() - t.teardown(() => server.close()) - - initSessionStore() - initSessionInfer(true) - - const model = await deepseek('sk-test-fake-key', 'gpt-4o-mini', { baseUrl: url }) - - await generateText(model, 'u1') - await generateText(model, [{ role: 'user', content: 'u1' }, { role: 'assistant', content: 'a1' }, { role: 'user', content: 'u2' }]) - - const sessions = getSessions() - const autos = sessions.filter((s) => s.sessionId.startsWith('auto-')) - t.is(autos.length, 1, 'prefix continuation stays in one auto session') - t.is(autos[0].source, 'Inferred') - t.is(autos[0].calls.length, 2) -}) diff --git a/bindings/node/__test__/shared.native.ts b/bindings/node/__test__/shared.native.ts new file mode 100644 index 000000000..a2bd5af32 --- /dev/null +++ b/bindings/node/__test__/shared.native.ts @@ -0,0 +1,99 @@ +// The fixture addon contains the production adapter and real aimux-ops. Only +// the scripted EventStream and its instrumentation are enabled by a feature. +import test from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { spawnSync } from 'node:child_process' +import { readdirSync, readFileSync, mkdtempSync, copyFileSync, rmSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { basename, join, resolve } from 'node:path' +import { tmpdir } from 'node:os' +import * as ops from '../src/generated/ops.ts' + +const binding = fileURLToPath(new URL('..', import.meta.url)) +const root = process.env.AIMUX_CONTRACT_VECTORS ?? fileURLToPath(new URL('../../../contract-tests/ops', import.meta.url)) +const vectors = basename(resolve(root)) === 'scenarios' ? root : join(root, 'scenarios') +const paths = readdirSync(vectors).filter(path => path.endsWith('.json')).sort() +assert.equal(paths.length, 11, 'all shared stream scenarios must be replayed') + +// Honor the build wrapper used by local/offline validation. No napi codegen is +// run here, so the production loader and generated declarations stay untouched. +const target = ({ darwin: { arm64: 'aarch64-apple-darwin', x64: 'x86_64-apple-darwin' }, + linux: { x64: 'x86_64-unknown-linux-gnu', arm64: 'aarch64-unknown-linux-gnu' }, + win32: { x64: 'x86_64-pc-windows-msvc', arm64: 'aarch64-pc-windows-msvc' } } as const)[process.platform]?.[process.arch] +assert.ok(target, `unsupported test host: ${process.platform}/${process.arch}`) +let library = process.env.AIMUX_NODE_CONTRACT_LIBRARY +if (!library) { + const built = spawnSync(process.env.AIMUX_BUILD_COMMAND ?? 'cargo', [ + 'build', '--manifest-path', join(binding, 'Cargo.toml'), '--features', 'contract-tests', + '--target', target, '--message-format=json', + ], { encoding: 'utf8', timeout: 1_200_000, maxBuffer: 16 * 1024 * 1024 }) + const artifacts = built.stdout.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line)) + const diagnostics = artifacts.filter(item => item.reason === 'compiler-message').map(item => item.message.rendered).join('') + assert.equal(built.status, 0, `${built.error ?? ''}\n${built.stderr}\n${diagnostics}`) + const libraries = artifacts.filter(item => item.reason === 'compiler-artifact' && item.target.name === 'aimux_node') + .flatMap(item => item.filenames).filter((path: string) => /\.(dylib|so|dll)$/.test(path)) + assert.equal(libraries.length, 1, 'expected the native contract addon') + library = libraries[0] +} +const folder = mkdtempSync(join(tmpdir(), 'aimux-node-vectors-')) +const addon = resolve(folder, 'contract.node') +copyFileSync(library!, addon) +const { Runtime } = createRequire(import.meta.url)(addon) +const runtime = new Runtime() +// Windows keeps a loaded addon's file locked until the process ends. +process.on('exit', () => { + try { + rmSync(folder, { recursive: true, force: true }) + } catch (error) { + if (process.platform !== 'win32') throw error + } +}) + +function normalizeError(error: Record): object { + assert.equal(typeof error.message, 'string') + assert.equal(typeof error.error.name, 'string') + return { kind: error.kind, code: error.code, error: { name: error.error.name } } +} +function normalize(observation: Record, expected: Record): object { + const result = structuredClone(observation) + if (expected.abortObserved === null) result.abortObserved = null + if (result.preflightError) result.preflightError = normalizeError(result.preflightError) + for (const key of ['frames', 'consumerFrames']) { + for (const frame of result[key]) { + if (frame.type === 'end' && frame.error) { + if (expected[key].find((value: any) => value.type === 'end')?.error) frame.error = normalizeError(frame.error) + else delete frame.error // Cancellation detail is optional in the vectors. + } + } + } + return result +} + +for (const path of paths) { + test(`shared native vector: ${path}`, async () => { + const { expected, ...input } = JSON.parse(readFileSync(join(vectors, path), 'utf8')) + const result = JSON.parse(await runtime.call('0', 'test.scenario', JSON.stringify(input))) + assert.deepEqual(normalize(result, expected), expected) + }) +} + +test('binary push rejects a full queue without waiting for capacity', async () => { + const handle = JSON.parse(await runtime.call('0', 'test.operation', '{}')) + const bytes = new Uint8Array([1, 2]) + await runtime.operationPush(handle, bytes) + const pushed = runtime.operationPush(handle, new Uint8Array([3, 4])) + let timer: ReturnType + try { + await assert.rejects(Promise.race([pushed, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('push waited for capacity')), 1000) + })]), (error: any) => error.kind === 'protocol' && error.code === 305) + } finally { + clearTimeout(timer!) + await runtime.call(handle, ops.OP_HANDLE_DROP, '{}') + } +}) + +test('owner teardown releases operations while its runtime can still run tasks', async () => { + assert.deepEqual(JSON.parse(await runtime.call('0', 'test.teardown', '{}')), { released: true, runtimeAlive: true }) +}) diff --git a/bindings/node/__test__/trace.test.ts b/bindings/node/__test__/trace.test.ts deleted file mode 100644 index 154cdda8c..000000000 --- a/bindings/node/__test__/trace.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -// trace.test.ts — RFC-0015 cache probing through the Node binding. -// -// Verifies the full downstream path: `trace()` wraps a model, probed calls -// record fingerprints/verdicts, and the query API returns typed results. -// Uses a mock HTTP server — no real API calls. - -import test from 'ava' -import { createServer, type Server } from 'node:http' - -import { - deepseek, - generateText, - type GenerateTextResult, -} from '../src/index.ts' - -const MOCK_RESPONSE = JSON.stringify({ - id: 'chatcmpl-trace', - model: 'gpt-4o-mini', - choices: [ - { - message: { role: 'assistant', content: 'ok' }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 200, - completion_tokens: 5, - total_tokens: 205, - // DeepSeek reports cache hits in its own fields (@ai-sdk/deepseek reads - // prompt_cache_hit_tokens, not prompt_tokens_details.cached_tokens). - prompt_cache_hit_tokens: 128, - prompt_cache_miss_tokens: 72, - }, -}) - -function startMockServer(): Promise<{ server: Server; url: string }> { - return new Promise((resolve) => { - const server = createServer((_req, res) => { - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(MOCK_RESPONSE) - }) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as any - resolve({ server, url: `http://127.0.0.1:${addr.port}` }) - }) - }) -} - -test('trace() records calls and query API returns typed results', async (t) => { - const { server, url } = await startMockServer() - t.teardown(() => server.close()) - - const raw = await deepseek('sk-test-fake-key', 'gpt-4o-mini', { baseUrl: url }) - const traced = raw.traceAudited(true) // strict mode - - // > 4 KiB user message so block-aligned prefixes actually match. - const big = 'x'.repeat(5000) - await generateText(traced, [{ role: 'user', content: big }], { sessionId: 'sess-1' }) - await generateText( - traced, - [ - { role: 'user', content: big }, - { role: 'assistant', content: 'a1' }, - { role: 'user', content: 'u2' }, - ], - { sessionId: 'sess-1' }, - ) - - // Untraced models reject the query API. - t.throws(() => (raw as any).traceAggregate(), { message: /not traced/ }) - - const statsJson = traced.traceAggregate() - const stats = JSON.parse(statsJson) as any[] - t.is(stats.length, 1) - t.is(stats[0].provider, 'deepseek.chat') - t.is(stats[0].requests, 2) - t.truthy(stats[0].verdictCounts) - - const chain = JSON.parse(traced.traceSessionChain('sess-1')) as any - t.is(chain.recordIds.length, 2) - t.truthy(chain.prefixStability) - - const jsonl = traced.traceExportJsonl() - const lines = jsonl.trim().split('\n') - t.is(lines.length, 2, 'one TraceRecord per line') - const first = JSON.parse(lines[0]) - t.truthy(first.fingerprint.bodyHash) - t.is(first.sessionId, 'sess-1') - - traced.traceClear() - t.is(traced.traceExportJsonl().trim(), '') -}) - -test('default streams carry no raw parts (RFC-0016 M2 contract)', async (t) => { - // The RFC-0015 stream meta part was removed in favor of the M2 contract: - // raw parts appear only when includeRawChunks is set. Probing itself is - // unaffected (TraceLayer records internally; request bodies remain - // available on the non-streaming path — RFC-0023 runtime recording is the - // planned (draft) mechanism for future streaming-side access). - const { server, url } = await startMockServer() - t.teardown(() => server.close()) - - const model = await deepseek('sk-test-fake-key', 'gpt-4o-mini', { baseUrl: url }) - const gen = await model.streamText(JSON.stringify('hello'), undefined) - const parts: string[] = [] - for await (const json of gen) { - parts.push(json) - } - - t.truthy(parts.length >= 1) - t.is(JSON.parse(parts[0]).type, 'start', `part[0] must be start: ${parts[0]}`) - t.is(JSON.parse(parts[1]).type, 'start-step', `part[1] must be start-step: ${parts[1]}`) - t.true( - parts.every((p) => JSON.parse(p).type !== 'raw'), - 'no raw parts by default', - ) -}) - -test('non-traced model still generates normally', async (t) => { - const { server, url } = await startMockServer() - t.teardown(() => server.close()) - - const model = await deepseek('sk-test-fake-key', 'gpt-4o-mini', { baseUrl: url }) - const result: GenerateTextResult = await generateText(model, 'hello') - t.truthy(result.text) - t.is(result.usage.inputTokens, 200) -}) diff --git a/bindings/node/__test__/transcription-session.test.ts b/bindings/node/__test__/transcription-session.test.ts deleted file mode 100644 index 9462b96fd..000000000 --- a/bindings/node/__test__/transcription-session.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -// transcription-session.test.ts — RFC-0028 session behavior through the Node binding. -// -// Node twin of bindings/python/tests/test_transcription_session.py (issue #118 -// requires one binding-behavior group per language). Same three cases against -// a fake realtime WebSocket server: -// -// - `nextPart(timeoutMs)` rejects with a `TimeoutError`, and the session stays -// live. -// - A server-sent realtime `error` event propagates verbatim as an `Err` part -// (the provider's own message, decidable retry verdict), then the channel -// ends normally. -// - A connect failure rejects through the session channel on the first -// `nextPart` (the session API never silently hangs), and the following -// `nextPart` resolves `null` (channel closed). -// -// No `ws` dependency exists in this package, so the fake server hand-rolls the -// HTTP Upgrade handshake (SHA-1 accept via node:crypto) and the server→client -// text-frame codec (<126-byte payloads), mirroring the python side's raw-socket -// fake. Client frames are masked and simply drained. - -import test from 'ava' -import { createHash } from 'node:crypto' -import { createServer, type Server } from 'node:http' -import type { Duplex } from 'node:stream' - -import { - openaiTranscription, - startTranscriptionSession, - AimuxError, - APICallError, - TimeoutError, -} from '../src/native.ts' - -const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' - -// ── Minimal server-side WebSocket codec (frames < 126 bytes) ──────────────── - -function textFrame(payload: string): Buffer { - const data = Buffer.from(payload, 'utf8') - if (data.length >= 126) throw new Error('extend the codec for long payloads') - return Buffer.concat([Buffer.from([0x81, data.length]), data]) -} - -interface ScriptStep { - /** Milliseconds after the handshake completes. */ - delayMs: number - /** Text-frame payload (a realtime event JSON). */ - json: string -} - -interface FakeRealtimeServer { - server: Server - url: string -} - -/** - * Fake realtime WS server playing `script` (relative to handshake completion). - * After the script it holds the socket open — the client drives the teardown, - * so a silent gap is never an accidental disconnect. - */ -function startFakeRealtimeServer(script: ScriptStep[]): Promise { - return new Promise((resolve) => { - const sockets = new Set() - const server = createServer() - server.on('upgrade', (req, socket) => { - const key = req.headers['sec-websocket-key'] - if (typeof key !== 'string') { - socket.destroy() - return - } - const accept = createHash('sha1').update(key + WS_GUID).digest('base64') - socket.write( - 'HTTP/1.1 101 Switching Protocols\r\n' + - 'Upgrade: websocket\r\n' + - 'Connection: Upgrade\r\n' + - `Sec-WebSocket-Accept: ${accept}\r\n` + - '\r\n', - ) - sockets.add(socket) - // Drain client frames (masked; content irrelevant to the script). - socket.on('data', () => {}) - socket.on('close', () => sockets.delete(socket)) - socket.on('error', () => sockets.delete(socket)) - for (const step of script) { - setTimeout(() => { - if (sockets.has(socket)) socket.write(textFrame(step.json)) - }, step.delayMs) - } - }) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as { port: number } - resolve({ server, url: `http://127.0.0.1:${addr.port}` }) - }) - ;(server as unknown as { __sockets: Set }).__sockets = sockets - }) -} - -function stopFakeRealtimeServer(server: Server): Promise { - const sockets = (server as unknown as { __sockets: Set }).__sockets - for (const socket of sockets) socket.destroy() - return new Promise((resolve) => server.close(() => resolve())) -} - -// ── Realtime event fixtures (same shapes as the python twin) ──────────────── - -const SESSION_CREATED = '{"type":"session.created"}' -const ERROR_EVENT = JSON.stringify({ - type: 'error', - error: { message: 'insufficient quota for realtime transcription' }, -}) -const DELTA_EVENT = JSON.stringify({ - type: 'conversation.item.input_audio_transcription.delta', - item_id: 'i', - delta: 'he', -}) - -// Serial: the timing-sensitive scripts must not race each other. - -test.serial('nextPart timeout preserves the live session', async (t) => { - // session.created at 50ms (no part mapping), delta at 900ms: the 250ms - // nextPart window must time out in between and the session must still - // deliver the later delta. - const { server, url } = await startFakeRealtimeServer([ - { delayMs: 50, json: SESSION_CREATED }, - { delayMs: 900, json: DELTA_EVENT }, - ]) - t.teardown(() => stopFakeRealtimeServer(server)) - - const model = await openaiTranscription('k', 'gpt-realtime-whisper', url) - const session = await startTranscriptionSession(model, null) - try { - const first = JSON.parse((await session.nextPart(3000)) as string) - t.is(first.type, 'stream-start') - - const err = (await t.throwsAsync(session.nextPart(250))) as AimuxError - // Timeout is a spent budget, not a provider-call transport failure. - t.true(err instanceof TimeoutError) - t.regex(err.message, /no transcription part within timeout/) - t.false('retryable' in err) - - // The session stayed live across the timeout. - const late = JSON.parse((await session.nextPart(5000)) as string) - t.is(late.type, 'transcript-delta') - } finally { - session.close() - } -}) - -test.serial('server error event rejects with a typed APICallError', async (t) => { - // Contract (issue #145, option A): in-stream errors REJECT from nextPart - // with the typed hierarchy — matching the FFI session (Err items) and the - // other six bindings. - const { server, url } = await startFakeRealtimeServer([ - { delayMs: 50, json: ERROR_EVENT }, - ]) - t.teardown(() => stopFakeRealtimeServer(server)) - - const model = await openaiTranscription('k', 'gpt-realtime-whisper', url) - const session = await startTranscriptionSession(model, null) - try { - const first = JSON.parse((await session.nextPart(5000)) as string) - t.is(first.type, 'stream-start') - - const err = (await t.throwsAsync(session.nextPart(5000))) as AimuxError - t.true(err instanceof APICallError) - // The provider's own message, verbatim (the error adds its prefix). - t.regex(err.message, /insufficient quota for realtime transcription$/) - // The retry verdict stays decidable on the typed error. - t.is(err.retryable, false) - - // The error terminated the stream: the channel ends normally. - t.is(await session.nextPart(3000), null) - } finally { - session.close() - } -}) - -test.serial('connect failure surfaces through the channel', async (t) => { - // Port 1 on loopback: connection refused immediately. - const model = await openaiTranscription('k', 'gpt-realtime-whisper', 'http://127.0.0.1:1') - const session = await startTranscriptionSession(model, null) - try { - const err = (await t.throwsAsync(session.nextPart(5000))) as AimuxError - t.true(err instanceof APICallError) - t.regex(err.message, /websocket connect failed/) - // The session terminated with the connect error (channel closed). - t.is(await session.nextPart(3000), null) - } finally { - session.close() - } -}) diff --git a/bindings/node/__test__/wrapper.test.ts b/bindings/node/__test__/wrapper.test.ts deleted file mode 100644 index 88eabd193..000000000 --- a/bindings/node/__test__/wrapper.test.ts +++ /dev/null @@ -1,384 +0,0 @@ -// wrapper.test.ts — typed wrapper layer over the raw napi binding. -// -// Verifies that `generateText` / `streamText` (from src/index.ts) erase the -// JSON-string boundary: inputs are typed objects and outputs are parsed into -// typed objects (`.text`, `.toolCalls`, `.content`, stream `text-delta` / -// `tool-call`). Uses the same mock-HTTP-server pattern as e2e.test.ts — no real -// API calls. - -import test from 'ava' -import { createServer, type Server } from 'node:http' - -// The wrapper under test. It re-exports the raw `deepseek`/`anthropic` factories -// and the ts-rs types, so a consumer needs only this one import. -import { deepseek, anthropic, generateText, streamText } from '../src/index.ts' -import type { - GenerateTextResult, - TextStreamPart, - ModelMessage, - Tool, - ToolChoice, -} from '../src/index.ts' - -// ── Mock server helpers (same shape as e2e.test.ts) ──────────────────────── - -function startMockServer( - handler: (req: any, res: any) => void, -): Promise<{ server: Server; url: string }> { - return new Promise((resolve) => { - const server = createServer(handler) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as any - resolve({ server, url: `http://127.0.0.1:${addr.port}` }) - }) - }) -} - -function closeServer(server: Server): Promise { - return new Promise((resolve) => server.close(() => resolve())) -} - -/** Capture the request body, then respond with `response` and `status`. */ -function capturingHandler( - response: string, - contentType = 'application/json', - status = 200, -) { - let _body: string | null = null - const handler = (req: any, res: any) => { - let body = '' - req.on('data', (chunk: Buffer) => (body += chunk)) - req.on('end', () => { - _body = body - res.writeHead(status, { 'content-type': contentType }) - res.end(response) - }) - } - return { - handler, - get body() { - if (_body === null) throw new Error('request not received yet') - return _body - }, - } -} - -// ── Mock responses (same wire shapes as e2e.test.ts) ─────────────────────── - -const OPENAI_CHAT_RESPONSE = JSON.stringify({ - id: 'chatcmpl-test', - model: 'gpt-4o', - choices: [ - { - message: { role: 'assistant', content: 'Rust is a systems programming language.' }, - finish_reason: 'stop', - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 }, -}) - -const OPENAI_STREAM_BODY = [ - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"content":"Hello"}}]}\n\n', - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"content":" world"}}]}\n\n', - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}\n\n', - 'data: [DONE]\n\n', -].join('') - -const ANTHROPIC_MESSAGE_RESPONSE = JSON.stringify({ - id: 'msg_test', - type: 'message', - role: 'assistant', - model: 'claude-3-5-sonnet-20241022', - content: [{ type: 'text', text: 'Hello from Claude!' }], - stop_reason: 'end_turn', - usage: { input_tokens: 10, output_tokens: 5 }, -}) - -const OPENAI_TOOL_CALL_RESPONSE = JSON.stringify({ - id: 'chatcmpl-tc', - model: 'gpt-4o', - choices: [ - { - message: { - role: 'assistant', - content: null, - tool_calls: [ - { - id: 'call_abc', - type: 'function', - function: { name: 'get_weather', arguments: '{"location":"Tokyo"}' }, - }, - ], - }, - finish_reason: 'tool_calls', - }, - ], - usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 }, -}) - -const OPENAI_STREAM_TOOL_BODY = [ - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_xyz","type":"function","function":{"name":"get_weather","arguments":""}}]}}]}\n\n', - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"location\\":\\"Tokyo\\"}"}}]}}]}\n\n', - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}\n\n', - 'data: [DONE]\n\n', -].join('') - -// A typed tool definition + toolChoice, reused across tests. -const weatherTool: Tool = { - type: 'function', - name: 'get_weather', - description: 'Get weather for a location', - inputSchema: { - type: 'object', - properties: { location: { type: 'string' } }, - required: ['location'], - }, -} - -// ── Tests ─────────────────────────────────────────────────────────────────── - -test('wrapper: generateText returns a typed object (.text / .usage / .finishReason / .content)', async (t) => { - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(OPENAI_CHAT_RESPONSE) - }) - - try { - const model = await deepseek('test-key', 'gpt-4o', url) - // Typed input (string prompt) + typed output — no manual JSON.stringify/parse. - const result: GenerateTextResult = await generateText(model, 'What is Rust?') - - // Top-level convenience fields are typed and present. - t.is(result.text, 'Rust is a systems programming language.') - t.is(result.usage.inputTokens, 10) - t.is(result.usage.outputTokens, 8) - t.is(result.finishReason, 'stop') - t.true(Array.isArray(result.warnings)) - - // .content is the final step's typed content; .steps holds every step. - t.is(result.steps.length, 1) - // A step and the usage are the AI SDK's JSON, camelCase - // (ai generate-text/step-result.ts:138-278, types/usage.ts:10-67). - const [step] = result.steps - t.is(step.stepNumber, 0) - t.is(step.model.modelId, 'gpt-4o') - t.is(step.response.modelId, result.response.modelId) - t.is(typeof step.performance.responseTimeMs, 'number') - t.deepEqual(step.usage.outputTokenDetails, result.usage.outputTokenDetails) - t.false('input_tokens' in result.usage) - t.true(Array.isArray(result.content), 'content is a typed array') - const textPart = result.content.find((c) => c.type === 'text') - t.truthy(textPart, 'content contains a text content part') - } finally { - await closeServer(server) - } -}) - -test('wrapper: generateText parses toolCalls + content tool-call', async (t) => { - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(OPENAI_TOOL_CALL_RESPONSE) - }) - - try { - const model = await deepseek('test-key', 'gpt-4o', url) - // Typed options object: tools pass through to the provider. - const result = await generateText(model, "What's the weather in Tokyo?", { - tools: [weatherTool], - }) - - // Convenience field: toolCalls extracted and typed. - t.is(result.toolCalls.length, 1) - t.is(result.toolCalls[0].toolName, 'get_weather') - t.is(result.toolCalls[0].toolCallId, 'call_abc') - t.deepEqual(result.toolCalls[0].input, { location: 'Tokyo' }) - - // Structured content: content carries the parsed tool-call part. - const tc = result.content.find((c) => c.type === 'tool-call') - t.truthy(tc, 'content contains a tool-call content part') - if (tc && tc.type === 'tool-call') { - t.is(tc.toolName, 'get_weather') - t.is(tc.toolCallId, 'call_abc') - t.deepEqual(tc.input, { location: 'Tokyo' }) - } - } finally { - await closeServer(server) - } -}) - -test('wrapper: generateText passes toolChoice through to the provider', async (t) => { - const cap = capturingHandler(OPENAI_TOOL_CALL_RESPONSE) - const { server, url } = await startMockServer(cap.handler) - - try { - const model = await deepseek('test-key', 'gpt-4o', url) - const toolChoice: ToolChoice = 'required' - await generateText(model, 'Hello', { - tools: [weatherTool], - toolChoice, - }) - - const received = JSON.parse(cap.body) - t.is(received.tool_choice, 'required') - } finally { - await closeServer(server) - } -}) - -test('wrapper: generateText accepts typed multi-role messages', async (t) => { - const cap = capturingHandler(OPENAI_CHAT_RESPONSE) - const { server, url } = await startMockServer(cap.handler) - - try { - const model = await deepseek('test-key', 'gpt-4o', url) - // Typed ModelMessage[] prompt — no manual JSON.stringify. - const messages: ModelMessage[] = [ - { role: 'system', content: 'You are a helpful assistant.' }, - { role: 'user', content: 'What is Rust?' }, - ] - const result = await generateText(model, messages) - - // The full multi-role sequence reaches the provider request body. - const received = JSON.parse(cap.body) - t.true(Array.isArray(received.messages)) - t.is(received.messages.length, 2) - t.is(received.messages[0].role, 'system') - t.is(received.messages[0].content, 'You are a helpful assistant.') - t.is(received.messages[1].role, 'user') - t.is(received.messages[1].content, 'What is Rust?') - - t.truthy(result.text) - } finally { - await closeServer(server) - } -}) - -test('wrapper: generateText works across providers (Anthropic mock)', async (t) => { - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(ANTHROPIC_MESSAGE_RESPONSE) - }) - - try { - const model = await anthropic('test-key', 'claude-3-5-sonnet-20241022', url) - const result = await generateText(model, 'Hello') - t.is(result.text, 'Hello from Claude!') - t.truthy(result.usage) - } finally { - await closeServer(server) - } -}) - -test('wrapper: streamText yields typed text-delta parts', async (t) => { - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'text/event-stream' }) - res.end(OPENAI_STREAM_BODY) - }) - - try { - const model = await deepseek('test-key', 'gpt-4o', url) - const parts: TextStreamPart[] = [] - // streamText is an async generator of typed TextStreamParts — no JSON.parse. - for await (const part of streamText(model, 'Say hello')) { - parts.push(part) - } - - t.true(parts.length > 0) - - // Narrow the tagged union by its `type` tag (typed, not `any`). - const deltas = parts - .filter((p): p is Extract => p.type === 'text-delta') - .map((p) => p.text) - t.is(deltas.join(''), 'Hello world') - // The text stream's delta field is `text` (ai stream-text-result.ts:435-440). - const first = parts.find((p) => p.type === 'text-delta') - t.deepEqual(Object.keys(first ?? {}).sort(), ['id', 'text', 'type']) - } finally { - await closeServer(server) - } -}) - -test('wrapper: streamText emits raw parts when includeRawChunks set', async (t) => { - // RFC-0016 M2: includeRawChunks surfaces one raw part per JSON SSE event - // (parsed payload, before the parsed parts; [DONE] excluded). - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'text/event-stream' }) - res.end(OPENAI_STREAM_BODY) - }) - - try { - const model = await deepseek('test-key', 'gpt-4o', url) - const parts: TextStreamPart[] = [] - for await (const part of streamText(model, 'Say hello', { - includeRawChunks: true, - })) { - parts.push(part) - } - - const rawParts = parts.filter( - (p): p is Extract => p.type === 'raw', - ) - // OPENAI_STREAM_BODY has 3 JSON events (2 content + 1 usage chunk); - // the [DONE] sentinel emits no Raw. - t.is(rawParts.length, 3) - const firstRaw = rawParts[0].rawValue as any - t.is(firstRaw.choices[0].delta.content, 'Hello') - - // The raw part for the first event precedes its text-delta. - const firstRawIdx = parts.indexOf(rawParts[0]) - const firstTextIdx = parts.findIndex((p) => p.type === 'text-delta') - t.true(firstRawIdx < firstTextIdx, 'raw must precede the parsed parts') - } finally { - await closeServer(server) - } -}) - -test('wrapper: no raw parts by default', async (t) => { - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'text/event-stream' }) - res.end(OPENAI_STREAM_BODY) - }) - - try { - const model = await deepseek('test-key', 'gpt-4o', url) - const parts: TextStreamPart[] = [] - for await (const part of streamText(model, 'Say hello')) { - parts.push(part) - } - t.true(parts.every((p) => p.type !== 'raw'), 'no raw parts expected by default') - } finally { - await closeServer(server) - } -}) - -test('wrapper: streamText yields typed tool-call parts', async (t) => { - const { server, url } = await startMockServer((req, res) => { - res.writeHead(200, { 'content-type': 'text/event-stream' }) - res.end(OPENAI_STREAM_TOOL_BODY) - }) - - try { - const model = await deepseek('test-key', 'gpt-4o', url) - const parts: TextStreamPart[] = [] - for await (const part of streamText(model, "What's the weather?", { - tools: [weatherTool], - })) { - parts.push(part) - } - - // The stream must contain a tool-related part. - t.true( - parts.some((p) => ['tool-call', 'tool-input-start', 'tool-input-delta'].includes(p.type)), - 'stream contained a tool-related TextStreamPart', - ) - - // A complete tool-call part carries the parsed tool name + input (typed). - const toolCall = parts.find((p): p is Extract => p.type === 'tool-call') - if (toolCall) { - t.is(toolCall.toolName, 'get_weather') - t.deepEqual(toolCall.input, { location: 'Tokyo' }) - } - } finally { - await closeServer(server) - } -}) diff --git a/bindings/node/ava.config.js b/bindings/node/ava.config.js deleted file mode 100644 index 0c9ad9f23..000000000 --- a/bindings/node/ava.config.js +++ /dev/null @@ -1,7 +0,0 @@ -export default { - workerThreads: false, - extensions: { - ts: 'module', - }, - nodeArguments: ['--experimental-strip-types'], -} diff --git a/bindings/node/bench/README.md b/bindings/node/bench/README.md new file mode 100644 index 000000000..a8c314cfb --- /dev/null +++ b/bindings/node/bench/README.md @@ -0,0 +1,3 @@ +# Historical benchmarks + +The one-shot API benchmarks were retired by the ops binding. Their source remains in [git history](https://github.com/arcships/aimux/tree/96dba455d0bec2be322c361ff49334c0e88bcf32/bindings/node/bench). diff --git a/bindings/node/bench/bench-constrained.ts b/bindings/node/bench/bench-constrained.ts deleted file mode 100644 index 234e2bf8c..000000000 --- a/bindings/node/bench/bench-constrained.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * aimux vs AISDK — CPU 受限 + 内存对比 - * - * 真实部署场景:容器化,CPU 受限。 - * Rust 无 GC 的优势在此体现: - * - Node GC 停顿在高频请求下累积 - * - Node 内存随并发增长(对象分配 + GC 压力) - * - Rust 零分配路径 + 无 GC 停顿 - * - * 用 taskset 限制到 1-2 核后跑此脚本: - * taskset -c 0-1 npx tsx bench/bench-constrained.ts - * - * 或脚本内用 OS 调度限制(需要 root,这里靠外部 taskset)。 - */ - -import { startMockServer } from './mock-server.ts' -import { nativeBinaryPath } from './native.ts' -import { createRequire } from 'node:module' -const require = createRequire(import.meta.url) -const napi = require(nativeBinaryPath()) as { - openai: (apiKey: string, modelId: string, baseUrl?: string) => Promise<{ - generateText: (prompt: string, opts?: string) => Promise - }> -} - -import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from 'node:http' - -function startBigMockServer(responseSize: number) { - return new Promise<{ uri: string; close: () => Promise }>((resolve) => { - const chunk = 'x'.repeat(1000) - const body = JSON.stringify({ - id: 'chatcmpl-mock', object: 'chat.completion', created: 1700000000, model: 'gpt-4o', - choices: [{ index: 0, message: { role: 'assistant', content: chunk.repeat(responseSize / 1000) }, finish_reason: 'stop' }], - usage: { prompt_tokens: 5000, completion_tokens: responseSize, total_tokens: responseSize + 5000 }, - }) - const server = createHttpServer((req: IncomingMessage, res: ServerResponse) => { - req.on('data', () => {}) - req.on('end', () => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(body) }) - }) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() - const port = typeof addr === 'object' && addr ? addr.port : 0 - resolve({ uri: `http://127.0.0.1:${port}`, close: () => new Promise((r) => server.close(() => r())) }) - }) - }) -} - -function makeContext(approxBytes: number): string { - const turn = 'Explain Rust ownership in detail. ' + 'word '.repeat(50) - const turns: string[] = []; let total = 0, i = 0 - while (total < approxBytes) { turns.push(`Message ${i}: ${turn}`); total += turn.length + 20; i++ } - return turns.join('\n') -} - -const CTX_200K = makeContext(200_000) - -// ── 内存采样 ────────────────────────────────────────────────────────────── -function rssMB(): number { - const m = process.memoryUsage() - return Math.round(m.rss / 1024 / 1024) -} - -// ── bench ──────────────────────────────────────────────────────────────── -let aisdkModel: { doGenerate: (opts: unknown) => Promise } | null = null -async function initAisdk(uri: string) { - const { createOpenAI } = await import('@ai-sdk/openai') - const openai = createOpenAI({ apiKey: 'test-key', baseURL: `${uri}/v1` }) - aisdkModel = openai.chat('gpt-4o') as never -} - -function pct(a: number[], p: number) { return [...a].sort((x, y) => x - y)[Math.ceil(p / 100 * a.length) - 1] } -function mean(a: number[]) { return a.reduce((s, v) => s + v, 0) / a.length } - -async function main() { - const server = await startBigMockServer(50_000) // 50KB 响应 - const uri = server.uri - await initAisdk(uri) - const model = await napi.openai('test-key', 'gpt-4o', `${uri}/v1`) - - const prompt = CTX_200K // 200KB 上文 - const aisdkOpts = { prompt: [{ role: 'user', content: [{ type: 'text', text: prompt }] }], mode: { type: 'regular' } } - const promptJson = JSON.stringify(prompt) - - // warmup - for (let i = 0; i < 10; i++) { await model.generateText(promptJson) } - for (let i = 0; i < 10; i++) { await aisdkModel!.doGenerate(aisdkOpts) } - if (global.gc) { global.gc() } // 强制 GC 获得干净基线 - - console.log(`\n═══ CPU 受限 + 内存对比 (200KB 上文, 50KB 响应) ═══`) - console.log(` 提示:建议用 taskset -c 0-1 npx tsx bench/bench-constrained.ts 限制 2 核\n`) - console.log(` CPU 核数: ${require('node:os').cpus().length}`) - console.log(` 初始 RSS: ${rssMB()} MB\n`) - - // ===== 持续压测:连续 2000 请求,测 GC 停顿 + 内存增长 ===== - console.log('── 持续压测 (2000 请求) ──\n') - - for (const [name, fn] of [ - ['aimux', async () => { await model.generateText(promptJson) }], - ['AISDK', async () => { await aisdkModel!.doGenerate(aisdkOpts) }], - ] as const) { - if (global.gc) global.gc() - const startRSS = rssMB() - const latencies: number[] = [] - const rssSamples: number[] = [] - - const start = process.hrtime.bigint() - for (let i = 0; i < 2000; i++) { - const s = process.hrtime.bigint() - await fn() - const ms = Number(process.hrtime.bigint() - s) / 1e6 - latencies.push(ms) - if (i % 200 === 0) rssSamples.push(rssMB()) - } - const totalMs = Number(process.hrtime.bigint() - start) / 1e6 - - const endRSS = rssMB() - const m = mean(latencies) - const p99 = pct(latencies, 99) - // GC 停顿指标:P99 - P50 的差值(P99 飙高通常是 GC 停顿) - const p50 = pct(latencies, 50) - const tailJitter = p99 - p50 - - console.log(` ${name}:`) - console.log(` 总耗时: ${totalMs.toFixed(0)}ms (${(2000 / totalMs * 1000).toFixed(0)} rps)`) - console.log(` 延迟: mean=${m.toFixed(2)}ms P50=${p50.toFixed(2)} P95=${pct(latencies, 95).toFixed(2)} P99=${p99.toFixed(2)}`) - console.log(` 尾部抖动: ${tailJitter.toFixed(2)}ms (P99-P50, GC 停顿指标)`) - console.log(` 内存: ${startRSS}MB → ${endRSS}MB (增长 ${endRSS - startRSS}MB)`) - console.log(` RSS 采样: ${rssSamples.join(' → ')} MB`) - console.log() - } - - // ===== 并发对比(带内存) ===== - console.log('── 并发对比 (200KB 上文, 总 500 请求) ──\n') - - for (const concurrency of [1, 20, 50]) { - const total = 500 - const batches = Math.ceil(total / concurrency) - - // aimux - if (global.gc) global.gc() - const aimuxStartRSS = rssMB() - const aimuxStart = process.hrtime.bigint() - for (let b = 0; b < batches; b++) { - const batch = Math.min(concurrency, total - b * concurrency) - await Promise.all(Array.from({ length: batch }, () => model.generateText(promptJson))) - } - const aimuxMs = Number(process.hrtime.bigint() - aimuxStart) / 1e6 - const aimuxRSS = rssMB() - - // AISDK - if (global.gc) global.gc() - const aisdkStartRSS = rssMB() - const aisdkStart = process.hrtime.bigint() - for (let b = 0; b < batches; b++) { - const batch = Math.min(concurrency, total - b * concurrency) - await Promise.all(Array.from({ length: batch }, () => aisdkModel!.doGenerate(aisdkOpts))) - } - const aisdkMs = Number(process.hrtime.bigint() - aisdkStart) / 1e6 - const aisdkRSS = rssMB() - - console.log(` 并发=${String(concurrency).padStart(2)}:`) - console.log(` aimux: ${aimuxMs.toFixed(0)}ms (${(total / aimuxMs * 1000).toFixed(0)} rps) RSS ${aimuxStartRSS}→${aimuxRSS}MB (+${aimuxRSS - aimuxStartRSS})`) - console.log(` AISDK: ${aisdkMs.toFixed(0)}ms (${(total / aisdkMs * 1000).toFixed(0)} rps) RSS ${aisdkStartRSS}→${aisdkRSS}MB (+${aisdkRSS - aisdkStartRSS})`) - console.log(` 差值: ${aimuxMs < aisdkMs ? 'aimux 快' : 'AISDK 快'} ${(aisdkMs / aimuxMs).toFixed(1)}x | 内存差 ${aimuxRSS - aisdkRSS}MB`) - console.log() - } - - await server.close() -} - -main().catch(console.error) diff --git a/bindings/node/bench/bench-head-to-head.ts b/bindings/node/bench/bench-head-to-head.ts deleted file mode 100644 index 5b9c48602..000000000 --- a/bindings/node/bench/bench-head-to-head.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * aimux vs AISDK — 直接对比,不设 B0 基线。 - * - * 同一进程、同一 mock server、同一 prompt, - * 只看两个 SDK 的端到端延迟差异。 - */ - -import { startMockServer } from './mock-server.ts' -import { nativeBinaryPath } from './native.ts' -import { createRequire } from 'node:module' -const require = createRequire(import.meta.url) -const napi = require(nativeBinaryPath()) as { - openai: (apiKey: string, modelId: string, baseUrl?: string) => Promise<{ - generateText: (prompt: string, opts?: string) => Promise - }> -} - -async function main() { - const server = await startMockServer() - const uri = server.uri - const N = 300 - const WARMUP = 30 - const prompt = 'Explain Rust ownership in one sentence.' - - // ── AISDK init ── - const { createOpenAI } = await import('@ai-sdk/openai') - const openai = createOpenAI({ apiKey: 'test-key', baseURL: `${uri}/v1` }) - const aisdkModel = openai.chat('gpt-4o') as { doGenerate: (opts: unknown) => Promise } - - // ── aimux init ── - const aimuxModel = await napi.openai('test-key', 'gpt-4o', `${uri}/v1`) - - // ── warmup ── - for (let i = 0; i < WARMUP; i++) { - await aimuxModel.generateText(JSON.stringify(prompt)) - } - const aisdkOpts = { prompt: [{ role: 'user', content: [{ type: 'text', text: prompt }] }], mode: { type: 'regular' } } - for (let i = 0; i < WARMUP; i++) { - await aisdkModel.doGenerate(aisdkOpts) - } - - // ── aimux bench ── - const aimuxT: number[] = [] - for (let i = 0; i < N; i++) { - const s = process.hrtime.bigint() - await aimuxModel.generateText(JSON.stringify(prompt)) - aimuxT.push(Number(process.hrtime.bigint() - s) / 1e6) - } - - // ── AISDK bench ── - const aisdkT: number[] = [] - for (let i = 0; i < N; i++) { - const s = process.hrtime.bigint() - await aisdkModel.doGenerate(aisdkOpts) - aisdkT.push(Number(process.hrtime.bigint() - s) / 1e6) - } - - // ── stats ── - const pct = (a: number[], p: number) => [...a].sort((x, y) => x - y)[Math.ceil(p / 100 * a.length) - 1] - const mean = (a: number[]) => a.reduce((s, v) => s + v, 0) / a.length - - const aimuxMean = mean(aimuxT) - const aisdkMean = mean(aisdkT) - - console.log() - console.log(` N=${N}, warmup=${WARMUP}, mock=${uri}`) - console.log() - console.log(` aimux: mean=${aimuxMean.toFixed(3)}ms P50=${pct(aimuxT, 50).toFixed(3)} P95=${pct(aimuxT, 95).toFixed(3)} P99=${pct(aimuxT, 99).toFixed(3)}`) - console.log(` AISDK: mean=${aisdkMean.toFixed(3)}ms P50=${pct(aisdkT, 50).toFixed(3)} P95=${pct(aisdkT, 95).toFixed(3)} P99=${pct(aisdkT, 99).toFixed(3)}`) - console.log() - console.log(` aimux 比 AISDK 快 ${(aisdkMean / aimuxMean).toFixed(1)}x`) - console.log(` 每次请求节省 ${(aisdkMean - aimuxMean).toFixed(3)}ms`) - console.log() - - await server.close() -} - -main().catch(console.error) diff --git a/bindings/node/bench/bench-mem-compare.ts b/bindings/node/bench/bench-mem-compare.ts deleted file mode 100644 index 54ac39273..000000000 --- a/bindings/node/bench/bench-mem-compare.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { createRequire } from 'node:module' -const require = createRequire(import.meta.url) - -async function main() { - const { createOpenAI } = await import('@ai-sdk/openai') - - const uri = 'http://127.0.0.1:36131' - const openai = createOpenAI({ apiKey: 'test-key', baseURL: uri + '/v1' }) - const model = openai.chat('gpt-4o') - - const ctx = 'x '.repeat(100000) // 200KB - const opts = { prompt: [{role:'user',content:[{type:'text',text:ctx}]}], mode: {type:'regular'} } - - function rss() { - return Math.round(process.memoryUsage().rss/1024/1024) - } - - console.log('初始 RSS:', rss(), 'MB') - - // warmup - for (let i=0;i<10;i++) await (model as any).doGenerate(opts) - - const start = rss() - for (let i=0;i<2000;i++) await (model as any).doGenerate(opts) - const end = rss() - console.log('AISDK @ai-sdk/openai 2000次大prompt:', start, 'MB ->', end, 'MB (+' + (end-start) + 'MB)') - - // 也测一下 openai npm 包(和 Python 用的同一个 SDK) - const { default: OpenAI } = require('openai') - const client = new OpenAI({ apiKey: 'test-key', baseURL: uri + '/v1' }) - - for (let i=0;i<10;i++) await client.chat.completions.create({ model:'gpt-4o', messages:[{role:'user',content:ctx}], max_tokens:5 }) - - const start2 = rss() - for (let i=0;i<2000;i++) await client.chat.completions.create({ model:'gpt-4o', messages:[{role:'user',content:ctx}], max_tokens:5 }) - const end2 = rss() - console.log('openai npm SDK 2000次大prompt:', start2, 'MB ->', end2, 'MB (+' + (end2-start2) + 'MB)') -} - -main().catch(console.error) diff --git a/bindings/node/bench/bench-node-vs-python.ts b/bindings/node/bench/bench-node-vs-python.ts deleted file mode 100644 index 3c93e3382..000000000 --- a/bindings/node/bench/bench-node-vs-python.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * 同一个 Rust 核心,Node napi vs Python PyO3 的 FFI 开销对比 - * - * 两者都打同一个 Node mock server,同一个 prompt, - * 只测 FFI 边界 + Rust 核心,排除 HTTP 差异。 - */ - -import { startMockServer } from './mock-server.ts' -import { nativeBinaryPath } from './native.ts' -import { createRequire } from 'node:module' -const require = createRequire(import.meta.url) -const napi = require(nativeBinaryPath()) as { - openai: (apiKey: string, modelId: string, baseUrl?: string) => Promise<{ - generateText: (prompt: string, opts?: string) => Promise - }> -} - -async function main() { - const server = await startMockServer() - const uri = server.uri - const N = 300 - const WARMUP = 30 - const prompt = 'Explain Rust ownership in one sentence.' - - const model = await napi.openai('test-key', 'gpt-4o', `${uri}/v1`) - const promptJson = JSON.stringify(prompt) - - // warmup - for (let i = 0; i < WARMUP; i++) await model.generateText(promptJson) - - // bench - const samples: number[] = [] - for (let i = 0; i < N; i++) { - const s = process.hrtime.bigint() - await model.generateText(promptJson) - samples.push(Number(process.hrtime.bigint() - s) / 1e6) - } - - const sorted = [...samples].sort((a, b) => a - b) - const mean = samples.reduce((s, v) => s + v, 0) / samples.length - const pct = (p: number) => sorted[Math.ceil(p / 100 * sorted.length) - 1] - - console.log(`\n Node napi aimux (N=${N})`) - console.log(` mean=${mean.toFixed(3)}ms P50=${pct(50).toFixed(3)} P95=${pct(95).toFixed(3)} P99=${pct(99).toFixed(3)}`) - console.log(` (Python bench 报告 mean=0.084ms,对比这个数字)\n`) - - await server.close() -} - -main().catch(console.error) diff --git a/bindings/node/bench/bench-nonstream.ts b/bindings/node/bench/bench-nonstream.ts deleted file mode 100644 index 6ff1a580e..000000000 --- a/bindings/node/bench/bench-nonstream.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * 维度一·速度:非流式单请求对比 - * - * 三路测量(同一进程、同一 mock server): - * B0 — undici 直调 mock(无 SDK 基线) - * aimux — napi → Rust → reqwest → mock - * aisdk — @ai-sdk/openai → undici → mock - * - * 每路跑 N 次取 P50/P95/P99。 - * - * 注意:aimux 用 raw napi API(直接 JSON.stringify/parse), - * 绕过 ts wrapper 层,测的是 Rust 核心 + FFI 的真实开销。 - */ - -import { startMockServer } from './mock-server.ts' -import { nativeBinaryPath } from './native.ts' -import { createRequire } from 'node:module' -const require = createRequire(import.meta.url) -// raw napi .node — 直接加载原生二进制,绕过 ts wrapper 的循环 re-export -const napi = require(nativeBinaryPath()) as { openai: (apiKey: string, modelId: string, baseUrl?: string) => Promise<{ generateText: (prompt: string, opts?: string) => Promise }> } - -// ── B0: undici 直调 ────────────────────────────────────────────────────── -async function benchB0(uri: string): Promise { - const start = process.hrtime.bigint() - const resp = await fetch(`${uri}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-key' }, - body: JSON.stringify({ - model: 'gpt-4o', - messages: [{ role: 'user', content: 'Explain Rust ownership in one sentence.' }], - max_tokens: 50, - }), - }) - await resp.text() - const end = process.hrtime.bigint() - return Number(end - start) / 1e6 // ms -} - -// ── aimux (napi raw API) ─────────────────────────────────────────────────── -// raw napi: openai(apiKey, modelId, baseUrl) → Model -// model.generateText(promptJson, optsJson) → resultJson -async function benchAimux(uri: string): Promise { - const model = await napi.openai('test-key', 'gpt-4o', `${uri}/v1`) - const prompt = JSON.stringify('Explain Rust ownership in one sentence.') - const start = process.hrtime.bigint() - const result = await model.generateText(prompt) - const end = process.hrtime.bigint() - // 消费 result(确保不被优化掉) - if (!result) throw new Error('empty') - return Number(end - start) / 1e6 -} - -// ── AISDK ───────────────────────────────────────────────────────────────── -// 只用 @ai-sdk/openai 的 createOpenAI + model.doGenerate(LanguageModelV1 接口), -// 不依赖 ai 包的 generateText(那层包含 agent loop 等额外逻辑)。 -// 这样和 aimux 的 generateText 对等——都是 provider 接入层。 -let aisdkModel: { doGenerate: (opts: unknown) => Promise } | null = null - -async function initAisdk(uri: string) { - const { createOpenAI } = await import('@ai-sdk/openai') - const openai = createOpenAI({ apiKey: 'test-key', baseURL: `${uri}/v1` }) - // 用 .chat() 走 chat completions 接口(默认 .responses() 走 Responses API) - aisdkModel = openai.chat('gpt-4o') as never -} - -async function benchAisdk(): Promise { - if (!aisdkModel) throw new Error('aisdk not init') - const opts = { - prompt: [{ role: 'user', content: [{ type: 'text', text: 'Explain Rust ownership in one sentence.' }] }], - mode: { type: 'regular' }, - } - const start = process.hrtime.bigint() - await aisdkModel.doGenerate(opts) - const end = process.hrtime.bigint() - return Number(end - start) / 1e6 -} - -// ── 统计工具 ─────────────────────────────────────────────────────────────── -function percentile(sorted: number[], p: number): number { - const idx = Math.ceil((p / 100) * sorted.length) - 1 - return sorted[Math.max(0, idx)] -} - -function stats(samples: number[]) { - const sorted = [...samples].sort((a, b) => a - b) - const sum = sorted.reduce((s, v) => s + v, 0) - return { - n: sorted.length, - mean: sum / sorted.length, - p50: percentile(sorted, 50), - p95: percentile(sorted, 95), - p99: percentile(sorted, 99), - min: sorted[0], - max: sorted[sorted.length - 1], - } -} - -// ── 主流程 ───────────────────────────────────────────────────────────────── -async function main() { - const server = await startMockServer() - const uri = server.uri - const N = 200 - const WARMUP = 20 - - // 初始化 AISDK(createOpenAI 是同步的,但 import 是异步的) - await initAisdk(uri) - - console.log(`\n╔══════════════════════════════════════════════════════════╗`) - console.log(`║ 非流式单请求对比 (N=${N}, warmup=${WARMUP}) ║`) - console.log(`║ mock: ${uri.padEnd(48)}║`) - console.log(`╚══════════════════════════════════════════════════════════╝\n`) - - const results: Record> = {} - - const benches: [string, () => Promise][] = [ - ['B0 undici', () => benchB0(uri)], - ['aimux', () => benchAimux(uri)], - ['AISDK', () => benchAisdk()], - ] - - for (const [name, fn] of benches) { - process.stdout.write(` ${name}: warmup... `) - // 预热 - for (let i = 0; i < WARMUP; i++) { - await fn() - } - process.stdout.write(`running ${N}... `) - - // 计时 - const samples: number[] = [] - for (let i = 0; i < N; i++) { - samples.push(await fn()) - } - results[name] = stats(samples) - process.stdout.write(`done (mean=${results[name].mean.toFixed(2)}ms)\n`) - } - - // 打印结果 - const fmt = (v: number) => v.toFixed(3).padStart(8) - console.log('\n┌──────────────┬──────────┬──────────┬──────────┬──────────┬──────────┐') - console.log('│ SDK │ mean │ P50 │ P95 │ P99 │ min │') - console.log('├──────────────┼──────────┼──────────┼──────────┼──────────┼──────────┤') - for (const [name, s] of Object.entries(results)) { - console.log( - `│ ${name.padEnd(12)} │ ${fmt(s.mean)} │ ${fmt(s.p50)} │ ${fmt(s.p95)} │ ${fmt(s.p99)} │ ${fmt(s.min)} │` - ) - } - console.log('└──────────────┴──────────┴──────────┴──────────┴──────────┴──────────┘') - - // 差值分析 - const b0 = results['B0 undici'].mean - const aimux = results['aimux'].mean - const aisdk = results['AISDK'].mean - console.log('\n── 差值分析 (ms) ──────────────────────────') - console.log(` B0 (无 SDK) = ${b0.toFixed(3)}`) - console.log(` aimux 开销 = ${(aimux - b0).toFixed(3)} (含 napi FFI)`) - console.log(` AISDK 开销 = ${(aisdk - b0).toFixed(3)}`) - console.log(` aimux vs AISDK = ${aimux > aisdk ? '+' : ''}${(aimux - aisdk).toFixed(3)} (${aimux < aisdk ? 'aimux 快' : 'AISDK 快'})`) - console.log() - - await server.close() -} - -main().catch(console.error) diff --git a/bindings/node/bench/bench-openai-official.ts b/bindings/node/bench/bench-openai-official.ts deleted file mode 100644 index a4af55025..000000000 --- a/bindings/node/bench/bench-openai-official.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * aimux vs OpenAI 官方 Node SDK — 对等对比 - * - * 两者抽象层次一致:都是 HTTP + JSON,不做编排/schema 验证/中间件。 - * 之前对比的 @ai-sdk/openai(Vercel AI SDK)多了 zod 验证/中间件/telemetry, - * 导致 11x 的优势有水分。这里补一个干净的对比。 - */ - -import { createRequire } from 'node:module' -import { nativeBinaryPath } from './native.ts' -const require = createRequire(import.meta.url) -const napi = require(nativeBinaryPath()) as { - openai: (apiKey: string, modelId: string, baseUrl?: string) => Promise<{ - generateText: (prompt: string, opts?: string) => Promise - }> -} - -import { startMockServer } from './mock-server.ts' - -async function main() { - const server = await startMockServer() - const uri = server.uri - const N = 300 - const WARMUP = 30 - const prompt = 'Explain Rust ownership in one sentence.' - - // OpenAI 官方 Node SDK - const openaiModule = await import('openai') - const OpenAI = openaiModule.default ?? (openaiModule as { OpenAI?: unknown }).OpenAI - const client = new OpenAI({ apiKey: 'test-key', baseURL: `${uri}/v1` }) - - // aimux - const model = await napi.openai('test-key', 'gpt-4o', `${uri}/v1`) - const promptJson = JSON.stringify(prompt) - - // warmup - for (let i = 0; i < WARMUP; i++) await model.generateText(promptJson) - for (let i = 0; i < WARMUP; i++) await client.chat.completions.create({ - model: 'gpt-4o', messages: [{ role: 'user', content: prompt }], max_tokens: 50, - }) - - function rss() { return Math.round(process.memoryUsage().rss / 1024 / 1024) } - - const pct = (a: number[], p: number) => [...a].sort((x, y) => x - y)[Math.ceil(p / 100 * a.length) - 1] - const mean = (a: number[]) => a.reduce((s, v) => s + v, 0) / a.length - - console.log(`\n Node.js: aimux vs OpenAI 官方 SDK (N=${N}, warmup=${WARMUP})`) - console.log(` mock: ${uri}\n`) - - // aimux - const rss1Start = rss() - const aimuxT: number[] = [] - for (let i = 0; i < N; i++) { - const s = process.hrtime.bigint() - await model.generateText(promptJson) - aimuxT.push(Number(process.hrtime.bigint() - s) / 1e6) - } - const rss1End = rss() - - // OpenAI SDK - const rss2Start = rss() - const openaiT: number[] = [] - for (let i = 0; i < N; i++) { - const s = process.hrtime.bigint() - await client.chat.completions.create({ - model: 'gpt-4o', messages: [{ role: 'user', content: prompt }], max_tokens: 50, - }) - openaiT.push(Number(process.hrtime.bigint() - s) / 1e6) - } - const rss2End = rss() - - console.log(' SDK mean P50 P95 P99 RSS') - console.log(' ' + '─'.repeat(60)) - const aimuxLabel = 'aimux (Rust)' - const openaiLabel = 'OpenAI Node SDK' - const rss1Str = rss1Start + '->' + rss1End + 'MB' - const rss2Str = rss2Start + '->' + rss2End + 'MB' - console.log(' ' + aimuxLabel.padEnd(20) + ' ' + mean(aimuxT).toFixed(3).padStart(8) + ' ' + pct(aimuxT,50).toFixed(3).padStart(8) + ' ' + pct(aimuxT,95).toFixed(3).padStart(8) + ' ' + pct(aimuxT,99).toFixed(3).padStart(8) + ' ' + rss1Str.padStart(10)) - console.log(' ' + openaiLabel.padEnd(20) + ' ' + mean(openaiT).toFixed(3).padStart(8) + ' ' + pct(openaiT,50).toFixed(3).padStart(8) + ' ' + pct(openaiT,95).toFixed(3).padStart(8) + ' ' + pct(openaiT,99).toFixed(3).padStart(8) + ' ' + rss2Str.padStart(10)) - - const am = mean(aimuxT) - const om = mean(openaiT) - console.log(`\n aimux vs OpenAI SDK = ${(om/am).toFixed(1)}x (aimux 快)`) - console.log(` RSS 差值: aimux +${rss1End-rss1Start}MB | OpenAI +${rss2End-rss2Start}MB\n`) - - await server.close() -} - -main().catch(console.error) diff --git a/bindings/node/bench/bench-real.ts b/bindings/node/bench/bench-real.ts deleted file mode 100644 index 377109439..000000000 --- a/bindings/node/bench/bench-real.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * aimux vs AISDK — 大 payload + 高并发 - * - * 真实 LLM 场景: - * - 上文 200K-500K token(~1-2MB JSON) - * - 输出 10K-100K token - * - 高并发(多请求同时跑) - * - * 测试: - * 1. 大 payload(500K 上文)— 测序列化/解析优势 - * 2. 并发吞吐(N=10/50/100)— 测连接池/TLS 复用优势 - * 3. CPU 限制下跑(taskset 1 核)— 测 Rust 无 GC 的优势 - */ - -import { startMockServer } from './mock-server.ts' -import { nativeBinaryPath } from './native.ts' -import { createRequire } from 'node:module' -const require = createRequire(import.meta.url) -const napi = require(nativeBinaryPath()) as { - openai: (apiKey: string, modelId: string, baseUrl?: string) => Promise<{ - generateText: (prompt: string, opts?: string) => Promise - }> -} - -// ── 生成大 payload ──────────────────────────────────────────────────────── -function makeContext(approxBytes: number): string { - // 模拟真实对话上文:多轮 messages - const turn = 'Explain Rust ownership in detail. ' + 'word '.repeat(50) - const turns: string[] = [] - let total = 0 - let i = 0 - while (total < approxBytes) { - turns.push(`Message ${i}: ${turn}`) - total += turn.length + 20 - i++ - } - return turns.join('\n') -} - -const CTX_500K = makeContext(500_000) // ~500KB 上文 ≈ 125K token -const CTX_1M = makeContext(1_000_000) // ~1MB 上文 ≈ 250K token - -// ── mock server 返回大响应 ──────────────────────────────────────────────── -import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from 'node:http' - -function startBigMockServer(responseSize: number) { - return new Promise<{ uri: string; close: () => Promise }>((resolve) => { - // 生成大响应(模拟 10K-100K token 的输出) - const chunk = 'x'.repeat(1000) - const body = JSON.stringify({ - id: 'chatcmpl-mock', - object: 'chat.completion', - created: 1700000000, - model: 'gpt-4o', - choices: [{ - index: 0, - message: { role: 'assistant', content: chunk.repeat(responseSize / 1000) }, - finish_reason: 'stop', - }], - usage: { prompt_tokens: 5000, completion_tokens: responseSize, total_tokens: responseSize + 5000 }, - }) - const server = createHttpServer((req: IncomingMessage, res: ServerResponse) => { - req.on('data', () => {}) - req.on('end', () => { - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(body) - }) - }) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() - const port = typeof addr === 'object' && addr ? addr.port : 0 - resolve({ - uri: `http://127.0.0.1:${port}`, - close: () => new Promise((r) => server.close(() => r())), - }) - }) - }) -} - -// ── bench 函数 ──────────────────────────────────────────────────────────── -async function benchAimux(uri: string, prompt: string): Promise { - const model = await napi.openai('test-key', 'gpt-4o', `${uri}/v1`) - const promptJson = JSON.stringify(prompt) - const s = process.hrtime.bigint() - await model.generateText(promptJson) - return Number(process.hrtime.bigint() - s) / 1e6 -} - -let aisdkModel: { doGenerate: (opts: unknown) => Promise } | null = null -async function initAisdk(uri: string) { - const { createOpenAI } = await import('@ai-sdk/openai') - const openai = createOpenAI({ apiKey: 'test-key', baseURL: `${uri}/v1` }) - aisdkModel = openai.chat('gpt-4o') as never -} -async function benchAisdk(prompt: string): Promise { - if (!aisdkModel) throw new Error('not init') - const opts = { prompt: [{ role: 'user', content: [{ type: 'text', text: prompt }] }], mode: { type: 'regular' } } - const s = process.hrtime.bigint() - await aisdkModel.doGenerate(opts) - return Number(process.hrtime.bigint() - s) / 1e6 -} - -function pct(a: number[], p: number) { return [...a].sort((x, y) => x - y)[Math.ceil(p / 100 * a.length) - 1] } -function mean(a: number[]) { return a.reduce((s, v) => s + v, 0) / a.length } - -// ── 主流程 ──────────────────────────────────────────────────────────────── -async function main() { - // ===== 测试 1: 大 payload 单请求 ===== - console.log('\n═══ 测试 1: 大 payload 单请求 (N=50) ═══\n') - - for (const [label, ctx] of [['500KB (~125K token)', CTX_500K], ['1MB (~250K token)', CTX_1M]] as const) { - const server = await startBigMockServer(50_000) // 50KB 响应 - const uri = server.uri - await initAisdk(uri) - const model = await napi.openai('test-key', 'gpt-4o', `${uri}/v1`) - - // warmup - for (let i = 0; i < 5; i++) { await model.generateText(JSON.stringify(ctx)) } - const aisdkOpts = { prompt: [{ role: 'user', content: [{ type: 'text', text: ctx }] }], mode: { type: 'regular' } } - for (let i = 0; i < 5; i++) { await aisdkModel!.doGenerate(aisdkOpts) } - - const N = 50 - const aimuxT: number[] = [] - const aisdkT: number[] = [] - for (let i = 0; i < N; i++) { - const s = process.hrtime.bigint() - await model.generateText(JSON.stringify(ctx)) - aimuxT.push(Number(process.hrtime.bigint() - s) / 1e6) - } - for (let i = 0; i < N; i++) { - const s = process.hrtime.bigint() - await aisdkModel!.doGenerate(aisdkOpts) - aisdkT.push(Number(process.hrtime.bigint() - s) / 1e6) - } - - const am = mean(aimuxT), sm = mean(aisdkT) - console.log(` ${label}:`) - console.log(` aimux: ${am.toFixed(1)}ms P50=${pct(aimuxT, 50).toFixed(1)} P95=${pct(aimuxT, 95).toFixed(1)}`) - console.log(` AISDK: ${sm.toFixed(1)}ms P50=${pct(aisdkT, 50).toFixed(1)} P95=${pct(aisdkT, 95).toFixed(1)}`) - console.log(` 差值: ${am < sm ? 'aimux 快' : 'AISDK 快'} ${(sm / am).toFixed(1)}x (省 ${(sm - am).toFixed(1)}ms)`) - console.log() - await server.close() - } - - // ===== 测试 2: 并发吞吐 ===== - console.log('═══ 测试 2: 并发吞吐 ═══\n') - - const server = await startBigMockServer(10_000) // 10KB 响应 - const uri = server.uri - await initAisdk(uri) - const model = await napi.openai('test-key', 'gpt-4o', `${uri}/v1`) - const prompt = makeContext(50_000) // 50KB 上文 - const aisdkOpts = { prompt: [{ role: 'user', content: [{ type: 'text', text: prompt }] }], mode: { type: 'regular' } } - - for (const concurrency of [1, 10, 50, 100]) { - // warmup - await Promise.all(Array.from({ length: Math.min(concurrency, 5) }, () => model.generateText(JSON.stringify(prompt)))) - - const total = 100 // 总请求数 - const batches = Math.ceil(total / concurrency) - - // aimux - const aimuxStart = process.hrtime.bigint() - for (let b = 0; b < batches; b++) { - const batch = Math.min(concurrency, total - b * concurrency) - await Promise.all(Array.from({ length: batch }, () => model.generateText(JSON.stringify(prompt)))) - } - const aimuxMs = Number(process.hrtime.bigint() - aimuxStart) / 1e6 - - // AISDK - await Promise.all(Array.from({ length: Math.min(concurrency, 5) }, () => aisdkModel!.doGenerate(aisdkOpts))) - const aisdkStart = process.hrtime.bigint() - for (let b = 0; b < batches; b++) { - const batch = Math.min(concurrency, total - b * concurrency) - await Promise.all(Array.from({ length: batch }, () => aisdkModel!.doGenerate(aisdkOpts))) - } - const aisdkMs = Number(process.hrtime.bigint() - aisdkStart) / 1e6 - - const aimuxRps = (total / aimuxMs * 1000).toFixed(0) - const aisdkRps = (total / aisdkMs * 1000).toFixed(0) - console.log(` 并发=${String(concurrency).padStart(3)} aimux ${aimuxMs.toFixed(0)}ms (${aimuxRps} rps) | AISDK ${aisdkMs.toFixed(0)}ms (${aisdkRps} rps) | ${(aisdkMs / aimuxMs).toFixed(1)}x`) - } - console.log() - await server.close() -} - -main().catch(console.error) diff --git a/bindings/node/bench/bench-serialize.ts b/bindings/node/bench/bench-serialize.ts deleted file mode 100644 index 89a835045..000000000 --- a/bindings/node/bench/bench-serialize.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * 序列化瓶颈分析 — 拆解 FFI 边界各环节耗时 - */ - -import { createRequire } from 'node:module' -import { nativeBinaryPath } from './native.ts' -const require = createRequire(import.meta.url) -const napi = require(nativeBinaryPath()) as { - openai: (apiKey: string, modelId: string, baseUrl?: string) => Promise<{ - generateText: (prompt: string, opts?: string) => Promise - }> -} - -import { createServer, type ServerResponse } from 'node:http' - -async function main() { - // 生成不同大小的 payload - const sizes = [ - ['1KB', 1_000], - ['10KB', 10_000], - ['100KB', 100_000], - ['500KB', 500_000], - ['1MB', 1_000_000], - ] as const - - // mock server - const server = createServer((req, res: ServerResponse) => { - req.on('data', () => {}) - req.on('end', () => { - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ - id: 'mock', object: 'chat.completion', model: 'gpt-4o', - choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], - usage: { prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 }, - })) - }) - }) - - const { uri, close } = await new Promise<{ uri: string; close: () => Promise }>(resolve => { - server.listen(0, '127.0.0.1', () => { - const port = (server.address() as { port: number }).port - resolve({ uri: `http://127.0.0.1:${port}`, close: () => new Promise(r => server.close(() => r())) }) - }) - }) - - const model = await napi.openai('test-key', 'gpt-4o', `${uri}/v1`) - - console.log('\n payload | JS string | JS parse | napi total | FFI 边界 | Rust+HTTP') - console.log(' ---------|------------|------------|------------|------------|----------') - - for (const [label, size] of sizes) { - // 生成 payload - const chunk = 'x'.repeat(100) - const prompt = Array.from({ length: Math.ceil(size / 110) }, (_, i) => `Message ${i}: ${chunk}`).join('\n') - - // warmup - for (let i = 0; i < 5; i++) { - await model.generateText(JSON.stringify(prompt)) - } - - const N = 30 - - // 1. 纯 JS JSON.stringify - const sT: number[] = [] - for (let i = 0; i < N; i++) { - const s = process.hrtime.bigint() - JSON.stringify(prompt) - sT.push(Number(process.hrtime.bigint() - s) / 1e6) - } - - // 2. 纯 JS JSON.parse(stringify 的结果) - const json = JSON.stringify(prompt) - const pT: number[] = [] - for (let i = 0; i < N; i++) { - const s = process.hrtime.bigint() - JSON.parse(json) - pT.push(Number(process.hrtime.bigint() - s) / 1e6) - } - - // 3. napi 完整调用(stringify → Rust serde → HTTP → Rust serde → parse) - const nT: number[] = [] - for (let i = 0; i < N; i++) { - const s = process.hrtime.bigint() - await model.generateText(JSON.stringify(prompt)) - nT.push(Number(process.hrtime.bigint() - s) / 1e6) - } - - const mean = (a: number[]) => a.reduce((s, v) => s + v, 0) / a.length - const sMean = mean(sT) - const pMean = mean(pT) - const nMean = mean(nT) - const ffi = nMean - sMean - pMean // 减去 JS 侧序列化,剩余是 Rust serde + HTTP - - const fmt = (v: number) => v.toFixed(3).padStart(8) - console.log(` ${label.padEnd(8)} | ${fmt(sMean)} | ${fmt(pMean)} | ${fmt(nMean)} | ${fmt(sMean + pMean)} | ${fmt(ffi)}`) - } - console.log() - console.log(' FFI 边界 = JS stringify + JS parse(JSON string 桥的开销)') - console.log(' Rust+HTTP = napi total - FFI 边界(Rust serde + reqwest HTTP)') - console.log() - - await close() -} - -main().catch(console.error) diff --git a/bindings/node/bench/bench-struct.ts b/bindings/node/bench/bench-struct.ts deleted file mode 100644 index 917adb01b..000000000 --- a/bindings/node/bench/bench-struct.ts +++ /dev/null @@ -1,206 +0,0 @@ -/** - * 维度二·结构化开销:payload 四档曲线 - * - * 测 SDK 自身的协议转换 / 序列化 / 解析开销。 - * 用差值法:SDK 总延迟 − B0 纯网络延迟 = 结构化开销。 - * - * 四档 payload: - * 小 — 1 轮对话,短响应 100 token - * 中 — 10 轮对话,中等响应 500 token - * 大 — 长 prompt 4K token,长响应 2K token - * 工具 — 5 个工具 schema + tool_call 响应 - * - * 每档每路跑 N 次,对比 aimux 和 AISDK 的斜率。 - */ - -import { startMockServer } from './mock-server.ts' -import { nativeBinaryPath } from './native.ts' -import { createRequire } from 'node:module' -const require = cR() -function cR() { return createRequire(import.meta.url) } - -// raw napi .node — 直接加载原生二进制 -const napi = require(nativeBinaryPath()) as { - openai: (apiKey: string, modelId: string, baseUrl?: string) => Promise<{ - generateText: (prompt: string, opts?: string) => Promise - }> -} - -// ── B0: undici 直调 ────────────────────────────────────────────────────── -async function benchB0(uri: string, body: object): Promise { - const start = process.hrtime.bigint() - const resp = await fetch(`${uri}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-key' }, - body: JSON.stringify(body), - }) - await resp.text() - const end = process.hrtime.bigint() - return Number(end - start) / 1e6 -} - -// ── aimux (napi raw API) ─────────────────────────────────────────────────── -async function benchAimux(uri: string, prompt: string): Promise { - const model = await napi.openai('test-key', 'gpt-4o', `${uri}/v1`) - const promptJson = JSON.stringify(prompt) - const start = process.hrtime.bigint() - await model.generateText(promptJson) - const end = process.hrtime.bigint() - return Number(end - start) / 1e6 -} - -// ── AISDK ───────────────────────────────────────────────────────────────── -let aisdkModel: { doGenerate: (opts: unknown) => Promise } | null = null - -async function initAisdk(uri: string) { - const { createOpenAI } = await import('@ai-sdk/openai') - const openai = createOpenAI({ apiKey: 'test-key', baseURL: `${uri}/v1` }) - aisdkModel = openai.chat('gpt-4o') as never -} - -async function benchAisdk(prompt: unknown): Promise { - if (!aisdkModel) throw new Error('aisdk not init') - const opts = { - prompt: Array.isArray(prompt) ? prompt : [{ role: 'user', content: [{ type: 'text', text: prompt }] }], - mode: { type: 'regular' }, - } - const start = process.hrtime.bigint() - await aisdkModel.doGenerate(opts) - const end = process.hrtime.bigint() - return Number(end - start) / 1e6 -} - -// ── 统计工具 ─────────────────────────────────────────────────────────────── -function percentile(sorted: number[], p: number): number { - const idx = Math.ceil((p / 100) * sorted.length) - 1 - return sorted[Math.max(0, idx)] -} - -function stats(samples: number[]) { - const sorted = [...samples].sort((a, b) => a - b) - const sum = sorted.reduce((s, v) => s + v, 0) - return { mean: sum / sorted.length, p50: percentile(sorted, 50), p95: percentile(sorted, 95) } -} - -// ── Payload 四档 ─────────────────────────────────────────────────────────── -const SHORT_TEXT = 'Rust is a systems programming language.' -const LONG_TEXT = Array.from({ length: 4000 }, (_, i) => `word${i}`).join(' ') - -interface PayloadDef { - name: string - // B0 用的 request body - b0Body: object - // aimux 用的 prompt (string) - aimuxPrompt: string - // AISDK 用的 prompt - aisdkPrompt: unknown -} - -const payloads: PayloadDef[] = [ - { - name: '小 (1轮/短)', - b0Body: { model: 'gpt-4o', messages: [{ role: 'user', content: SHORT_TEXT }], max_tokens: 50 }, - aimuxPrompt: SHORT_TEXT, - aisdkPrompt: SHORT_TEXT, - }, - { - name: '中 (10轮)', - b0Body: { - model: 'gpt-4o', - messages: Array.from({ length: 10 }, (_, i) => ({ - role: i % 2 === 0 ? 'user' : 'assistant', - content: `Message ${i}: ${SHORT_TEXT}`, - })), - max_tokens: 50, - }, - aimuxPrompt: JSON.stringify(Array.from({ length: 10 }, (_, i) => ({ - role: i % 2 === 0 ? 'user' : 'assistant', - content: `Message ${i}: ${SHORT_TEXT}`, - }))), - aisdkPrompt: Array.from({ length: 10 }, (_, i) => ({ - role: i % 2 === 0 ? 'user' : 'assistant', - content: [{ type: 'text', text: `Message ${i}: ${SHORT_TEXT}` }], - })), - }, - { - name: '大 (4K/长)', - b0Body: { model: 'gpt-4o', messages: [{ role: 'user', content: LONG_TEXT }], max_tokens: 50 }, - aimuxPrompt: LONG_TEXT, - aisdkPrompt: LONG_TEXT, - }, - { - name: '工具 (5 tools)', - b0Body: { - model: 'gpt-4o', - messages: [{ role: 'user', content: 'What is the weather?' }], - tools: Array.from({ length: 5 }, (_, i) => ({ - type: 'function', - function: { - name: `get_weather_${i}`, - description: `Get weather for region ${i}`, - parameters: { type: 'object', properties: { city: { type: 'string', description: 'City name' } }, required: ['city'] }, - }, - })), - tool_choice: 'auto', - max_tokens: 50, - }, - aimuxPrompt: 'What is the weather?', - aisdkPrompt: 'What is the weather?', - }, -] - -// ── 主流程 ───────────────────────────────────────────────────────────────── -async function main() { - const server = await startMockServer() - const uri = server.uri - const N = 100 - const WARMUP = 10 - - await initAisdk(uri) - - console.log(`\n╔══════════════════════════════════════════════════════════╗`) - console.log(`║ 结构化开销 — payload 四档 (N=${N}, warmup=${WARMUP}) ║`) - console.log(`╚══════════════════════════════════════════════════════════╝\n`) - - const fmt = (v: number) => v.toFixed(3).padStart(8) - console.log('┌──────────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┐') - console.log('│ payload │ B0 mean │ aimux m │ AISDK m │ aimux开 │ AISDK开 │ 差值 │') - console.log('├──────────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤') - - for (const p of payloads) { - // 预热 - for (let i = 0; i < WARMUP; i++) { - await benchB0(uri, p.b0Body) - await benchAimux(uri, p.aimuxPrompt) - await benchAisdk(p.aisdkPrompt) - } - - const b0: number[] = [] - const aimux: number[] = [] - const aisdk: number[] = [] - - for (let i = 0; i < N; i++) { - b0.push(await benchB0(uri, p.b0Body)) - aimux.push(await benchAimux(uri, p.aimuxPrompt)) - aisdk.push(await benchAisdk(p.aisdkPrompt)) - } - - const b0s = stats(b0) - const aimuxs = stats(aimux) - const aisdks = stats(aisdk) - const aimuxOverhead = aimuxs.mean - b0s.mean - const aisdkOverhead = aisdks.mean - b0s.mean - const diff = aimuxs.mean - aisdks.mean - - console.log( - `│ ${p.name.padEnd(12)} │ ${fmt(b0s.mean)} │ ${fmt(aimuxs.mean)} │ ${fmt(aisdks.mean)} │ ${fmt(aimuxOverhead)} │ ${fmt(aisdkOverhead)} │ ${fmt(diff)} │` - ) - } - console.log('└──────────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┘') - console.log('\n 注:aimux 开销 = aimux - B0,AISDK 开销 = AISDK - B0,差值 = aimux - AISDK') - console.log(' 负值 = 该 SDK 比 B0 快(Rust HTTP 优势大于 SDK 开销)\n') - - await server.close() -} - -main().catch(console.error) diff --git a/bindings/node/bench/mock-server.ts b/bindings/node/bench/mock-server.ts deleted file mode 100644 index 3ca6d32ab..000000000 --- a/bindings/node/bench/mock-server.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * 本地 mock server — 返回固定 JSON / SSE 响应。 - * - * 抹平网络 RTT 和 LLM 生成时长,剩下的差值就是两个 SDK 自身的 - * 协议转换 / 序列化 / 流解析开销。 - * - * 用法:先 start(),拿到 uri 后传给 bench 脚本,跑完 stop()。 - */ - -import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' -import { randomBytes } from 'node:crypto' - -const MOCK_RESPONSE = JSON.stringify({ - id: 'chatcmpl-mock', - object: 'chat.completion', - created: 1700000000, - model: 'gpt-4o', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Rust is a systems programming language focused on safety, speed, and concurrency.' }, - finish_reason: 'stop', - }, - ], - usage: { prompt_tokens: 5, completion_tokens: 15, total_tokens: 20 }, -}) - -/** 固定的 SSE 流(3 个 data chunk + done) */ -const SSE_CHUNKS = [ - 'data: {"id":"chatcmpl-mock","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":"Rust"},"finish_reason":null}]}\n\n', - 'data: {"id":"chatcmpl-mock","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":" is a systems"},"finish_reason":null}]}\n\n', - 'data: {"id":"chatcmpl-mock","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":" programming language."},"finish_reason":null}]}\n\n', - 'data: {"id":"chatcmpl-mock","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":8,"total_tokens":13}}\n\n', - 'data: [DONE]\n\n', -].join('') - -export interface MockServer { - uri: string - close(): Promise -} - -/** 启动一个本地 mock server,返回其 uri。 */ -export async function startMockServer(): Promise { - return new Promise((resolve) => { - const server = createServer((req: IncomingMessage, res: ServerResponse) => { - // 收集 request body(虽然不用,但要消费掉) - req.on('data', () => {}) - req.on('end', () => { - if (req.url?.includes('/stream')) { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }) - res.end(SSE_CHUNKS) - } else { - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(MOCK_RESPONSE) - } - }) - }) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() - const port = typeof addr === 'object' && addr ? addr.port : 0 - resolve({ - uri: `http://127.0.0.1:${port}`, - close: () => new Promise((r) => server.close(() => r())), - }) - }) - }) -} - -/** 生成一个随机 payload(模拟大 prompt)的简单文本。 */ -export function makePayload(approxTokens: number): string { - const words = ['Rust', 'safety', 'speed', 'concurrency', 'ownership', 'borrow', 'lifetime', 'trait', 'async', 'await'] - const lines: string[] = [] - for (let i = 0; i < approxTokens; i++) { - lines.push(`${words[i % words.length]} ${i}`) - } - return lines.join(' ') -} diff --git a/bindings/node/bench/native.ts b/bindings/node/bench/native.ts deleted file mode 100644 index a333b52bf..000000000 --- a/bindings/node/bench/native.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Path of the napi binary for the current platform, relative to this bench -// directory. Follows napi-rs artifact naming: -// aimux.-[-].node (e.g. aimux.linux-x64-gnu.node, -// aimux.darwin-arm64.node, aimux.win32-x64-msvc.node). -export function nativeBinaryPath(): string { - const abi = - process.platform === 'linux' ? '-gnu' : process.platform === 'win32' ? '-msvc' : '' - return `../aimux.${process.platform}-${process.arch}${abi}.node` -} diff --git a/bindings/node/bench/package.json b/bindings/node/bench/package.json deleted file mode 100644 index ab198966c..000000000 --- a/bindings/node/bench/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "aimux-bench", - "private": true, - "type": "module", - "scripts": { - "bench": "tsx bench-nonstream.ts" - }, - "dependencies": { - "@ai-sdk/openai": "^2", - "ai": "^5", - "openai": "^6" - }, - "devDependencies": { - "tsx": "^4.19.0" - } -} diff --git a/bindings/node/bench/run-mock-big.ts b/bindings/node/bench/run-mock-big.ts deleted file mode 100644 index 89f793588..000000000 --- a/bindings/node/bench/run-mock-big.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { startMockServer } from './mock-server.ts' -import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' - -// 和 Node bench-constrained 一样的大响应 mock (50KB) -const chunk = 'x'.repeat(1000) -const body = JSON.stringify({ - id: 'chatcmpl-mock', object: 'chat.completion', created: 1700000000, model: 'gpt-4o', - choices: [{ index: 0, message: { role: 'assistant', content: chunk.repeat(50) }, finish_reason: 'stop' }], - usage: { prompt_tokens: 5000, completion_tokens: 50000, total_tokens: 55000 }, -}) - -async function main() { - const server = createServer((req: IncomingMessage, res: ServerResponse) => { - req.on('data', () => {}) - req.on('end', () => { - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(body) - }) - }) - server.listen(0, '127.0.0.1', () => { - const port = (server.address() as { port: number }).port - console.log('MOCK_URI=http://127.0.0.1:' + port) - }) - setInterval(() => {}, 1000) -} - -main().catch(console.error) diff --git a/bindings/node/bench/run-mock.ts b/bindings/node/bench/run-mock.ts deleted file mode 100644 index 1f3dbd21f..000000000 --- a/bindings/node/bench/run-mock.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { startMockServer } from './mock-server.ts' - -async function main() { - const server = await startMockServer() - console.log('MOCK_URI=' + server.uri) - // keep running - setInterval(() => {}, 1000) -} - -main().catch(console.error) diff --git a/bindings/node/examples/generate-text.ts b/bindings/node/examples/generate-text.ts index a35b95938..bdb864f2f 100644 --- a/bindings/node/examples/generate-text.ts +++ b/bindings/node/examples/generate-text.ts @@ -1,22 +1,13 @@ -// Example: generate text with OpenAI -// -// Run: OPENAI_API_KEY=sk-... node --experimental-strip-types examples/generate-text.ts - -import { openai, generateText } from '../src/index.ts' - -async function main() { - const apiKey = process.env.OPENAI_API_KEY - if (!apiKey) { - console.error('Please set OPENAI_API_KEY') - process.exit(1) - } - - const model = await openai(apiKey, 'gpt-4o-mini') - - const result = await generateText(model, 'Explain Rust ownership in one sentence.') - console.log('Text:', result.text) - console.log('Usage:', result.usage) - console.log('Finish reason:', result.finishReason) +import { Client, LanguageModel } from '../src/index.ts' + +const client = new Client() +const provider = await client.provider({ package: 'openai', settings: { apiKey: process.env.OPENAI_API_KEY } }) +const model = await provider.model('gpt-4o-mini', 'languageModel') +try { + if (!(model instanceof LanguageModel)) throw new Error('Expected a language model') + const result = await model.generate('Explain what an async iterator does.') + console.log(result.text) +} finally { + await model.close() + await provider.close() } - -main().catch(console.error) diff --git a/bindings/node/examples/stream-text.ts b/bindings/node/examples/stream-text.ts index 15eebd6e1..8969a54df 100644 --- a/bindings/node/examples/stream-text.ts +++ b/bindings/node/examples/stream-text.ts @@ -1,27 +1,15 @@ -// Example: stream text with OpenAI -// -// Run: OPENAI_API_KEY=sk-... node --experimental-strip-types examples/stream-text.ts - -import { openai, streamText } from '../src/index.ts' - -async function main() { - const apiKey = process.env.OPENAI_API_KEY - if (!apiKey) { - console.error('Please set OPENAI_API_KEY') - process.exit(1) - } - - const model = await openai(apiKey, 'gpt-4o-mini') - - console.log('Streaming:\n') - for await (const part of streamText(model, 'Write a haiku about Rust.')) { - // TextStreamPart is a tagged union — check the type field - if (part.type === 'text-delta') { - process.stdout.write(part.text) - } else if (part.type === 'finish') { - console.log(`\n\n[done: ${part.finishReason}]`) - } +import { Client, LanguageModel } from '../src/index.ts' + +const client = new Client() +const provider = await client.provider({ package: 'openai', settings: { apiKey: process.env.OPENAI_API_KEY } }) +const model = await provider.model('gpt-4o-mini', 'languageModel') +try { + if (!(model instanceof LanguageModel)) throw new Error('Expected a language model') + for await (const frame of await model.stream('Write a short poem.')) { + if (frame.type === 'event' && frame.event.type === 'text-delta') process.stdout.write(frame.event.text) + if (frame.type === 'end' && frame.error) console.error(frame.error) } +} finally { + await model.close() + await provider.close() } - -main().catch(console.error) diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index 77b861dce..a2ccc406c 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -1,9 +1,6 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ -/** Fallible native return — unwraps to `T` at runtime; core failures reject/throw the registered `AimuxError` subclass, while binding faults remain napi-rs plain `Error`s. */ -export type AimuxResult = T -/** Stream yield — a StreamPart JSON string at runtime; failures reject `next()` with a typed error. */ -export type StreamItem = string +export type WireResult = T declare global { interface __NapiRsAsyncGenerator { next(...[value]: [] | [TNext]): globalThis.Promise> @@ -13,583 +10,35 @@ declare global { } } -/** - * A bridge from a JS `AbortSignal` to the core's runtime cancellation. - * - * napi's own `AbortSignal` is not `Send` (it holds `Rc`), so it cannot be a - * parameter of an async napi method. This class is created synchronously - * (registering the abort callback on the JS thread) and then passed to - * `generateText` / `streamText` — it only holds an `Arc` and is `Send`. - * - * ```ts - * const bridge = signal ? new AbortBridge(signal) : undefined - * await model.generateText(prompt, options, bridge) - * ``` - */ -export declare class AbortBridge { - constructor(signal: AbortSignal) - /** Returns `true` once the underlying JS signal has been aborted. */ - aborted(): boolean -} - -export declare class DecisionModel { - /** - * The DecisionModelV4 properties as JSON: `specificationVersion`, - * `provider`, `modelId`, `supportedQuestionTypes`. - */ - describe(): AimuxResult - /** `opts_json` is `DecideOptions` JSON; resolves to `DecideResult` JSON. */ - decide(optsJson: string, bridge?: AbortBridge | undefined | null): Promise -} - -export declare class EmbeddingModel { - /** - * Generate embeddings. `values_json` is a JSON array of strings. - * Returns JSON-serialized EmbeddingResult. - */ - embed(valuesJson: string, optsJson?: string | undefined | null): Promise -} - -export declare class Files { - /** - * Upload a file. `data_base64` is base64-encoded file content, - * `media_type` is e.g. "application/pdf", `opts_json` is optional - * (may contain filename, provider_options). - * Returns JSON-serialized UploadFileResult (contains provider file ID). - */ - uploadFile(dataBase64: string, mediaType: string, optsJson?: string | undefined | null): Promise -} - -export declare class ImageModel { - /** - * Generate images. `opts_json` is JSON-serialized ImageCallOptions. - * `bridge` — optional `AbortBridge`; aborting the wrapped signal cancels - * the call. Returns JSON-serialized ImageResult (images as base64 in JSON). - */ - generate(optsJson: string, bridge?: AbortBridge | undefined | null): Promise -} - -export declare class Model { - /** - * Wrap this model in a cache-probe layer (RFC-0015). The returned - * model records fingerprints/verdicts on every call and exposes the - * `traceAggregate` / `traceSessionChain` / `traceExportJsonl` / - * `traceClear` queries. - */ - trace(): Model - /** - * Wrap this model in a cache-probe layer with the built-in rules - * auditor (RFC-0015 §4). `strict` = strict mode (self-hosted single - * instance); `false` = shared mode (safe default). - */ - traceAudited(strict: boolean): Model - /** - * Aggregated probe statistics (RFC-0015 §5.3), filtered by a JSON - * `TraceFilter` (optional). Returns a JSON `TraceStats[]` string. - */ - traceAggregate(filterJson?: string | undefined | null): AimuxResult - /** - * One session's chain view (RFC-0015 §5.3). Returns a JSON - * `SessionChainView` string or an error when the session is unknown. - */ - traceSessionChain(sessionId: string): AimuxResult - /** - * One session's per-step cache-hit trajectory (RFC-0024 §4.3). Returns - * a JSON array of `SessionStepStat` (empty when the session is unknown - * or the model is not traced). - */ - traceSessionTrajectory(sessionId: string): AimuxResult - /** Export all probe records as JSONL (one `TraceRecord` per line). */ - traceExportJsonl(): AimuxResult - /** Clear all probe records of this traced model. */ - traceClear(): void - /** - * Generate text (non-streaming). - * - * `prompt` — a JSON string: bare prompt (`"text"` or `[{...}]`) or `{"prompt": ...}`. - * `options` — optional JSON-serialized `GenerateTextOptions`. - * `bridge` — optional `AbortBridge` (wrap a JS `AbortSignal` in one); - * aborting the signal cancels the call. - * Returns a JSON-serialized `GenerateTextResult`. - */ - generateText(prompt: string, options?: string | undefined | null, bridge?: AbortBridge | undefined | null): Promise - /** - * Generate a structured JSON object from the model (M12, RFC-0016). - * - * Same signature as [`Model::generate_text`]; returns a JSON-serialized - * `GenerateObjectResult` (core's deprecated `generate_object` wrapper). - * Pass `output: { "type": "object", "schema": ... }` - * via `options` to describe the value; the model's text must parse and - * match the schema. - */ - generateObject(prompt: string, options?: string | undefined | null, bridge?: AbortBridge | undefined | null): Promise - /** - * Consume a stream to completion and return the aggregated result - * (M11, RFC-0016). - * - * Drives `streamText` to completion and returns a JSON-serialized - * `GenerateTextResult`, the same result `generateText` returns. - */ - consumeStreamText(prompt: string, options?: string | undefined | null, bridge?: AbortBridge | undefined | null): Promise - /** - * Stream text from the model. - * - * Returns an `AsyncGenerator` yielding `TextStreamPart` JSON - * strings. Use `for await (const part of model.streamText(...))` to - * consume. - */ - streamText(prompt: string, options?: string | undefined | null, bridge?: AbortBridge | undefined | null): Promise> - /** - * Generate text and return an OpenAI Chat Completion (non-streaming). - * - * Returns a JSON-serialized `ChatCompletion`. Works with any provider. - */ - generateTextAsOpenai(prompt: string, options?: string | undefined | null, bridge?: AbortBridge | undefined | null): Promise - /** - * Convert a serialized `GenerateTextResult` into a serialized - * `ChatCompletion` — the conversion half of `generateTextAsOpenai`. - * - * For repairing on the host (RFC-0035): patch the native result, then - * convert it, so the completion's tool calls are the repaired ones. This - * model only supplies the fallback model id. - */ - generateTextResultAsOpenai(resultJson: string): AimuxResult - /** - * Stream text as OpenAI Chat Completion chunks. - * - * Returns an `AsyncGenerator` yielding `ChatCompletionChunk` JSON - * strings. Works with any provider. - */ - streamTextAsOpenai(prompt: string, options?: string | undefined | null, bridge?: AbortBridge | undefined | null): Promise> -} - -/** - * A provider handle — created by `createProvider`, supports `listModels()` - * (runtime discovery) and `model()` (build a model from a discovered id). - * - * ```ts - * const p = await createProvider('deepseek', apiKey) - * const models = await p.listModels() - * const model = await p.model(models[0].id) - * const result = await generateText(model, 'Hello') - * ``` - */ -export declare class ProviderHandle { - /** Build a native decision model with this provider's existing config. */ - decisionModel(modelId: string): Promise> - /** - * List models available on this provider (runtime discovery via the - * provider's `/models` endpoint). Returns a JSON array of sparse - * `RuntimeModel` (id / owned_by / created) — **no community enrichment**. - * To supplement with model specs (context length, capabilities, reasoning), - * call `getModelSpecs` separately and merge in the host (RFC-0027). - */ - listModels(): Promise> - /** - * Build a language model from a discovered model id. Returns a `Model` - * usable with `generateText` / `streamText`. - */ - model(modelId: string): Promise> -} - -export declare class RerankingModel { - /** - * Rerank documents. `query` is the search query, `docs_json` is a JSON - * array of documents, `opts_json` is optional JSON options. - * `bridge` — optional `AbortBridge`; aborting the wrapped signal cancels - * the call. Returns JSON-serialized RerankingResult. - */ - rerank(query: string, docsJson: string, optsJson?: string | undefined | null, bridge?: AbortBridge | undefined | null): Promise -} - -export declare class SearchModel { - /** - * Search. `query` is the search query, `opts_json` is optional JSON options. - * `bridge` — optional `AbortBridge`; aborting the wrapped signal cancels - * the call. Returns JSON-serialized SearchResult. - */ - search(query: string, optsJson?: string | undefined | null, bridge?: AbortBridge | undefined | null): Promise -} - -export declare class SpeechModel { - /** - * Generate speech audio. `opts_json` is JSON-serialized SpeechCallOptions. - * `bridge` — optional `AbortBridge`; aborting the wrapped signal cancels - * the call. Returns JSON-serialized SpeechResult (audio as base64 in JSON). - */ - generate(optsJson: string, bridge?: AbortBridge | undefined | null): Promise +/** An Abort handle; dropping it releases the handle without signalling shared work. */ +export declare class Abort { + constructor(runtime: Runtime, signal?: AbortSignal) + get handle(): string + signal(): void + close(): void + get aborted(): boolean } /** - * AsyncGenerator that yields StreamPart JSON strings. - * - * The stream is started eagerly in `stream_text()` — a tokio task drives - * the Rust Stream and sends each chunk through a channel. `next()` simply - * receives from the channel. - * * This type implements JavaScript's async iterable protocol. * It can be used with `for await...of` loops. * * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_async_iterator_and_async_iterable_protocols */ -export declare class StreamTextGenerator { +export declare class FrameIterator { - [globalThis.Symbol.asyncIterator](): globalThis.__NapiRsAsyncGenerator + [globalThis.Symbol.asyncIterator](): globalThis.__NapiRsAsyncGenerator, void, undefined> } -export declare class TranscriptionModel { - /** - * Transcribe audio. `audio_base64` is base64-encoded audio data. - * `media_type` is e.g. "audio/mp3". `opts_json` is optional JSON options. - * `bridge` — optional `AbortBridge`; aborting the wrapped signal cancels - * the call. Returns JSON-serialized TranscriptionResult. - */ - generate(audioBase64: string, mediaType: string, optsJson?: string | undefined | null, bridge?: AbortBridge | undefined | null): Promise +/** All Runtime instances in one napi Env share one unforgeable Owner::Node. */ +export declare class Runtime { + constructor() + /** Nonblocking release for explicit disposal and JS finalization. */ + dropHandle(id: string): WireResult + manifestJson(): WireResult + manifestDigest(): string + call(id: string, op: string, paramsJson: string, abort?: Abort | undefined | null): Promise + /** Copy the caller's buffer on the JS thread before returning the Promise. */ + operationPush(id: string, data: Uint8Array): Promise + stream(id: string, op: string, paramsJson: string, abort?: Abort | undefined | null): Promise } - -/** - * A live streaming-transcription session (RFC-0028). Push audio chunks with - * `pushAudio`, mark end-of-audio with `inputDone`, then pull transcription - * parts with `nextPart`. Mirrors the C-ABI session shape, built natively on - * core channels. - */ -export declare class TranscriptionSession { - /** - * Push one binary audio chunk. Awaits while the internal channel is - * full (backpressure). - */ - pushAudio(data: Buffer): Promise> - /** Signal end-of-audio (idempotent). */ - inputDone(): AimuxResult - /** - * Pull the next transcription part (JSON string). Resolves `null` when - * the stream ended normally. Rejects on error — including a timeout - * (no part within `timeoutMs`; the session stays live, call again). - * `timeoutMs`: >0 wait at most; 0 immediate poll; negative OR omitted - * = wait indefinitely. - */ - nextPart(timeoutMs?: number | undefined | null): Promise> - /** - * Terminate the session (aborts the driver). The object becomes inert; - * further `pushAudio`/`nextPart` fail. Call this promptly — GC teardown - * drops the channels (which eventually tears the driver down) but never - * fires the abort token. - */ - close(): void -} - -export declare class VideoModel { - /** - * Generate video. `opts_json` is JSON-serialized VideoCallOptions. - * `bridge` — optional `AbortBridge`; aborting the wrapped signal cancels - * the call. Returns JSON-serialized VideoResult (typically contains a URL). - */ - generate(optsJson: string, bridge?: AbortBridge | undefined | null): Promise -} - -/** - * Register the canonical JS error constructors for this Node environment. - * - * Keeping the constructors in the environment, rather than a process-global, - * preserves class identity and keeps Worker environments isolated. - */ -export declare function __registerErrorClasses(constructors: object): void - -/** Create an Anthropic model instance. */ -export declare function anthropic(apiKey: string, modelId: string, config?: string | ProviderConfig | undefined | null): Promise> - -/** Create an Anthropic-on-AWS language model instance (API key + region). */ -export declare function anthropicAws(apiKey: string, region: string, modelId: string, config?: string | ProviderConfig | undefined | null): Promise> - -/** - * Resolve one invalid tool call against a host's repair reply. - * - * `optsJson` is the same string the call was generated with; the tool set - * comes from it. `replyJson` is `{"type":"repaired","toolCall":{…}}`, - * `{"type":"unchanged"}`, or `{"type":"failed","message":"…"}`. Returns the - * resulting `ToolCall` JSON, tagged `"type": "tool-call"` — valid, or - * invalid carrying a nested `ToolCallRepairError`. - * - * Options carrying no tools throw `InvalidArgumentError`: such a call is not - * repairable, and `toolCallRepairContext` already said so by returning - * `"null"`. - */ -export declare function applyToolCallRepair(toolCallJson: string, optsJson: string | undefined | null, replyJson: string): AimuxResult - -/** - * Apply a repair reply to a serialized `GenerateTextResult`: the call is - * repaired in the result's steps (content, approval requests and response - * messages) and the derived `toolCalls`, `content` and `responseMessages` - * are written from them. `optsJson` is the same string the call was - * generated with. - * - * The OpenAI-shaped result has no equivalent: it carries no `invalid` / - * `error`, so repair is driven from the native result, which - * `Model.generateTextResultAsOpenai` then converts. - */ -export declare function applyToolCallRepairToResult(resultJson: string, optsJson: string | undefined | null, toolCallId: string, replyJson: string): AimuxResult - -/** - * Create an Azure OpenAI language model instance (API key + resource name). - * - * The deployment name is passed as `model_id`; `api_version` is optional. - */ -export declare function azure(apiKey: string, resourceName: string, deployment: string, apiVersion?: string | undefined | null, config?: string | ProviderConfig | undefined | null): Promise> - -/** Create a Bedrock language model instance (AWS SigV4 credentials). */ -export declare function bedrock(accessKeyId: string, secretAccessKey: string, region: string, modelId: string, config?: string | ProviderConfig | undefined | null): Promise> - -/** Create a Cohere language model instance. */ -export declare function cohere(apiKey: string, modelId: string, config?: string | ProviderConfig | undefined | null): Promise> - -/** Create a Cohere embedding model instance. */ -export declare function cohereEmbedding(apiKey: string, modelId: string, baseUrl?: string | undefined | null): Promise> - -/** Create a Cohere reranking model instance. */ -export declare function cohereReranking(apiKey: string, modelId: string, baseUrl?: string | undefined | null): Promise> - -/** - * Create a **provider handle** (RFC-0027) for a registry-backed provider. - * - * Unlike `provider()` (which binds to a single model_id), this returns a - * `ProviderHandle` that supports `listModels()` and `model()`. - * - * `api_key` may be empty/null to read the provider's env var from the - * registry entry. - */ -export declare function createProvider(name: string, apiKey?: string | undefined | null, config?: ProviderConfig | undefined | null): Promise> - -/** Create a DeepSeek model instance (registry-backed since RFC-0017 phase 4). */ -export declare function deepseek(apiKey: string, modelId: string, config?: string | ProviderConfig | undefined | null): Promise> - -/** - * Fetch the community model catalogue (anya2a). Returns a JSON-serialized - * `Catalogue` (provider → model_id → ModelSpec). Thin fetch — no caching. - * `source_url` may be null for the default endpoint. - */ -export declare function getModelSpecs(sourceUrl?: string | undefined | null): Promise> - -/** - * Create a Google Gemini language model instance. - * - * Google speaks the native `generateContent` protocol (not OpenAI-compatible), - * so it is **not** registry-backed — `provider("google", ...)` fails with - * `NoSuchProvider`. This factory is the only entry point. - */ -export declare function google(apiKey: string, modelId: string, config?: string | ProviderConfig | undefined | null): Promise> - -/** Create a Google embedding model instance. */ -export declare function googleEmbedding(apiKey: string, modelId: string, baseUrl?: string | undefined | null): Promise> - -/** Create a Google image model instance. */ -export declare function googleImage(apiKey: string, modelId: string, baseUrl?: string | undefined | null): Promise> - -/** Create a Google video model instance. */ -export declare function googleVideo(apiKey: string, modelId: string, baseUrl?: string | undefined | null): Promise> - -/** - * 初始化全局日志(RFC-0014)。幂等:多次调用无副作用;宿主已自建 - * subscriber 时 no-op。级别:off|error|warn|info|debug|trace(空串回退 - * warn)。`AIMUX_LOG` / `AIMUX_LOG_LEVEL` 环境变量优先级更高。 - * 日志输出到 stderr。 - */ -export declare function initLogging(level: string): void - -/** - * Replace the default transport with one routed by this proxy configuration - * (M6, RFC-0016). Requests started afterwards use it; calling it again - * replaces the previous configuration. - * - * `configJson` shape: `{ "http_url": "...", "https_url": "...", "all_url": - * "...", "no_proxy": "..." }` (all fields optional; omitting all is equivalent - * to relying on the `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` - * env vars). - * - * Throws on invalid JSON. - */ -export declare function initProxy(configJson: string): AimuxResult - -/** - * 启动录制(RFC-0023 P1/P2):把完整 `Recording` 写 JSONL 到 `{dir}/recordings.jsonl` - * (目录自动创建)。录制 **opt-in**;再次调用(不同 dir)替换 recorder。 - * Throws `RecordingError` (`code` = `Init` / `OpenFile` / `Spawn`) when the - * recorder cannot be set up; the previous recorder, if any, stays in place. - */ -export declare function initRecording(dir: string): AimuxResult - -/** - * 启动内存有界录制(RFC-0023 P6):`RingRecorder`,FIFO 淘汰,丢弃计数可查。 - * - * `cap` 可省略:省略时使用库默认容量(等价于 FFI `aimux_init_recording_ring_default()`; - * 本绑定直接依赖 aimux-core 而非 aimux-ffi,故调用等价 core API `RingRecorder::default()`)。 - * 显式传 `cap == 0` 抛错(与其余语言一致),`cap > 0` 启动指定容量的有界 ring。 - */ -export declare function initRecordingRing(cap?: number | undefined | null): AimuxResult - -/** - * Enable/disable the global session inferer (RFC-0024, opt-in, off by - * default). Explicit `sessionId` values always win regardless of this. - */ -export declare function initSessionInfer(enabled: boolean): void - -/** - * Register the global session store (RFC-0024). Replaces any previous one. - * Until called, calls are not grouped and the session query functions return - * empty results. - */ -export declare function initSessionStore(): void - -/** Create a Jev model; endpoint overrides the complete POST URL. */ -export declare function jevDecision(apiKey: string, modelId: string, endpoint?: string | undefined | null, probabilitySource?: string | undefined | null): Promise> - -/** Query: all known sessions (RFC-0024), as a JSON-serialized `SessionView[]`. */ -export declare function listSessions(): AimuxResult - -/** Create a Mistral language model instance. */ -export declare function mistral(apiKey: string, modelId: string, config?: string | ProviderConfig | undefined | null): Promise> - -export declare function moa(references: Array, aggregator: Model, configJson?: string | undefined | null): AimuxResult - -/** - * 从录制 JSONL 创建 mock 回放模型(RFC-0023 P3):按输入匹配录制响应, - * **不发真实 API**。返回的 `Model` 可用于 `generateText` / `streamText`。 - */ -export declare function mockReplay(recordingsJsonl: string): AimuxResult - -/** Create an OpenAI model instance. */ -export declare function openai(apiKey: string, modelId: string, config?: string | ProviderConfig | undefined | null): Promise> - -/** Create an OpenAI embedding model instance. */ -export declare function openaiEmbedding(apiKey: string, modelId: string, baseUrl?: string | undefined | null): Promise> - -/** Create OpenAI files manager. */ -export declare function openaiFiles(apiKey: string, baseUrl?: string | undefined | null): Promise> - -/** Create an OpenAI image model instance. */ -export declare function openaiImage(apiKey: string, modelId: string, baseUrl?: string | undefined | null): Promise> - -/** Create an OpenAI speech (TTS) model instance. */ -export declare function openaiSpeech(apiKey: string, modelId: string, baseUrl?: string | undefined | null): Promise> - -/** Create an OpenAI transcription model instance. */ -export declare function openaiTranscription(apiKey: string, modelId: string, baseUrl?: string | undefined | null): Promise> - -/** - * Create a language model from the built-in registry by provider name - * (RFC-0017 phase 4). `api_key` may be empty/null to read the provider's env - * var from the registry entry. - */ -export declare function provider(name: string, apiKey: string | undefined | null, modelId: string, config?: ProviderConfig | undefined | null): Promise> - -/** - * Optional provider configuration accepted by factory functions. - * - * Passed as the 3rd argument to `openai()` / `anthropic()` / `deepseek()`. - * For backward compatibility a bare string is still accepted (treated as - * `baseUrl`). See RFC-0017. - */ -export interface ProviderConfig { - /** Base URL for API calls (e.g. a relay/proxy endpoint). */ - baseUrl?: string - /** - * Extra HTTP headers merged into every request, as a JSON object string - * (e.g. `'{"X-Custom":"value"}'`). - */ - headers?: string - /** OpenAI organization ID (sent via `OpenAI-Organization` header). */ - organization?: string - /** OpenAI project ID (sent via `OpenAI-Project` header). */ - project?: string - /** - * Values of a preset's template parameters, named as the registry - * declares them (e.g. `{ account_id: '…' }`), for providers created by - * name (`provider()` / `createProvider()`). Only parameters the preset - * declares are accepted. - */ - params?: Record - /** - * Rejected with `InvalidArgumentError`: retry is a per-call setting - * (`maxRetries` in the call options). The field exists only so passing it - * is reported instead of ignored. - */ - maxRetries?: number - /** - * Rejected with `InvalidArgumentError`: request-body overrides were - * removed. The field exists only so passing it is reported instead of - * ignored. - */ - bodyOverrides?: string -} - -/** 刷盘全局 recorder(阻塞至 JSONL 落盘;ring 模式为 no-op)。 */ -export declare function recordingFlush(): void - -/** 停止录制:全局 recorder = None(新调用不再录制)。 */ -export declare function recordingStop(): void - -/** - * Flush the global recorder and **report write failures**: throws - * `RecordingError` (`code` = `WriterGone` / `FlushTimeout` / `Write`) when the - * data could not be confirmed on disk. `RecordingError` is its own class, - * not an `AimuxError`. Resolves normally when nothing is recording. The - * legacy `recordingFlush` stays and never reports. - */ -export declare function recordingTryFlush(): AimuxResult - -/** - * Register external OpenAI-compatible providers from a JSON config string - * (RFC-0020). Entries override same-named built-ins or add new ones. - * - * `configJson` shape: `{ "providers": [ { "name": "...", "base_url": "...", ... } ] }`. - * Malformed JSON text throws a plain `Error` (napi `InvalidArg`); a - * well-formed document the registry rejects (bad base_url scheme, empty - * name, unsupported protocol, wrong shape) throws `InvalidArgumentError`. - */ -export declare function registerProviders(configJson: string): AimuxResult - -export declare function router(models: Array, configJson?: string | undefined | null): AimuxResult - -/** - * Query: all calls of a session (RFC-0024), as a JSON-serialized - * `SessionCall[]` (ordered by step). Empty array if the session is unknown - * or no store is registered. - */ -export declare function sessionCalls(sessionId: string): AimuxResult - -/** - * Start a streaming transcription session. `opts_json` (optional, camelCase - * as in the C ABI, unknown keys rejected): `{ "inputAudioFormat": - * {"formatType","rate"}, "providerOptions", "headers", "includeRawChunks", - * "timeout" }`. `bridge` — optional AbortSignal; firing it aborts the - * session. The returned parts are JSON-serialized `TranscriptionStreamPart`s. - */ -export declare function startTranscriptionSession(model: TranscriptionModel, optsJson?: string | undefined | null, bridge?: AbortBridge | undefined | null): Promise> - -/** Create a Tavily search model instance. */ -export declare function tavilySearch(apiKey: string, baseUrl?: string | undefined | null): Promise> - -/** - * Build the repair argument for one invalid tool call. - * - * `toolCallJson` is a `GenerateTextResult.toolCalls` entry with - * `invalid: true`. `prompt` and `optsJson` are **the same two strings the - * call was generated with** (`generateText` / `streamText`); messages, - * instructions, and the tool set are derived from them here, so no caller - * repeats that derivation. - * - * Returns `{toolCall, error, inputSchema, tools, messages, instructions}` - * — the AI SDK `repairToolCall` argument, with `toolCall.input` the - * provider's raw argument text — or the JSON literal `"null"` when the - * options carried no tools: as in the AI SDK, a call made without a tool set - * is never repaired, and the caller skips it. `"null"` is a success. - * - * Pure and synchronous: no model, no network. Throws `InvalidArgumentError` - * when the call is not an invalid one. - */ -export declare function toolCallRepairContext(toolCallJson: string, prompt: string, optsJson?: string | undefined | null): AimuxResult - -/** Create a Vertex AI language model instance (GCP bearer token). */ -export declare function vertex(accessToken: string, project: string, location: string, modelId: string, config?: string | ProviderConfig | undefined | null): Promise> - -/** Create an xAI language model instance. */ -export declare function xai(apiKey: string, modelId: string, config?: string | ProviderConfig | undefined | null): Promise> diff --git a/bindings/node/index.js b/bindings/node/index.js index 6a77583a2..40d82e61f 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -700,63 +700,6 @@ if (!nativeBinding) { } module.exports = nativeBinding -module.exports.AbortBridge = nativeBinding.AbortBridge -module.exports.DecisionModel = nativeBinding.DecisionModel -module.exports.EmbeddingModel = nativeBinding.EmbeddingModel -module.exports.Files = nativeBinding.Files -module.exports.ImageModel = nativeBinding.ImageModel -module.exports.Model = nativeBinding.Model -module.exports.ProviderHandle = nativeBinding.ProviderHandle -module.exports.RerankingModel = nativeBinding.RerankingModel -module.exports.SearchModel = nativeBinding.SearchModel -module.exports.SpeechModel = nativeBinding.SpeechModel -module.exports.StreamTextGenerator = nativeBinding.StreamTextGenerator -module.exports.TranscriptionModel = nativeBinding.TranscriptionModel -module.exports.TranscriptionSession = nativeBinding.TranscriptionSession -module.exports.VideoModel = nativeBinding.VideoModel -module.exports.__registerErrorClasses = nativeBinding.__registerErrorClasses -module.exports.anthropic = nativeBinding.anthropic -module.exports.anthropicAws = nativeBinding.anthropicAws -module.exports.applyToolCallRepair = nativeBinding.applyToolCallRepair -module.exports.applyToolCallRepairToResult = nativeBinding.applyToolCallRepairToResult -module.exports.azure = nativeBinding.azure -module.exports.bedrock = nativeBinding.bedrock -module.exports.cohere = nativeBinding.cohere -module.exports.cohereEmbedding = nativeBinding.cohereEmbedding -module.exports.cohereReranking = nativeBinding.cohereReranking -module.exports.createProvider = nativeBinding.createProvider -module.exports.deepseek = nativeBinding.deepseek -module.exports.getModelSpecs = nativeBinding.getModelSpecs -module.exports.google = nativeBinding.google -module.exports.googleEmbedding = nativeBinding.googleEmbedding -module.exports.googleImage = nativeBinding.googleImage -module.exports.googleVideo = nativeBinding.googleVideo -module.exports.initLogging = nativeBinding.initLogging -module.exports.initProxy = nativeBinding.initProxy -module.exports.initRecording = nativeBinding.initRecording -module.exports.initRecordingRing = nativeBinding.initRecordingRing -module.exports.initSessionInfer = nativeBinding.initSessionInfer -module.exports.initSessionStore = nativeBinding.initSessionStore -module.exports.jevDecision = nativeBinding.jevDecision -module.exports.listSessions = nativeBinding.listSessions -module.exports.mistral = nativeBinding.mistral -module.exports.moa = nativeBinding.moa -module.exports.mockReplay = nativeBinding.mockReplay -module.exports.openai = nativeBinding.openai -module.exports.openaiEmbedding = nativeBinding.openaiEmbedding -module.exports.openaiFiles = nativeBinding.openaiFiles -module.exports.openaiImage = nativeBinding.openaiImage -module.exports.openaiSpeech = nativeBinding.openaiSpeech -module.exports.openaiTranscription = nativeBinding.openaiTranscription -module.exports.provider = nativeBinding.provider -module.exports.recordingFlush = nativeBinding.recordingFlush -module.exports.recordingStop = nativeBinding.recordingStop -module.exports.recordingTryFlush = nativeBinding.recordingTryFlush -module.exports.registerProviders = nativeBinding.registerProviders -module.exports.router = nativeBinding.router -module.exports.sessionCalls = nativeBinding.sessionCalls -module.exports.startTranscriptionSession = nativeBinding.startTranscriptionSession -module.exports.tavilySearch = nativeBinding.tavilySearch -module.exports.toolCallRepairContext = nativeBinding.toolCallRepairContext -module.exports.vertex = nativeBinding.vertex -module.exports.xai = nativeBinding.xai +module.exports.Abort = nativeBinding.Abort +module.exports.FrameIterator = nativeBinding.FrameIterator +module.exports.Runtime = nativeBinding.Runtime diff --git a/bindings/node/package-lock.json b/bindings/node/package-lock.json index 4996fa5ce..34b477be7 100644 --- a/bindings/node/package-lock.json +++ b/bindings/node/package-lock.json @@ -10,7 +10,6 @@ "license": "MIT", "devDependencies": { "@napi-rs/cli": "3.8.2", - "ava": "^6.0.0", "typescript": "^5.7.0" }, "engines": { @@ -398,84 +397,6 @@ } } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmmirror.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@mapbox/node-pre-gyp": { - "version": "2.0.3", - "resolved": "https://registry.npmmirror.com/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz", - "integrity": "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "consola": "^3.2.3", - "detect-libc": "^2.0.0", - "https-proxy-agent": "^7.0.5", - "node-fetch": "^2.6.7", - "nopt": "^8.0.0", - "semver": "^7.5.3", - "tar": "^7.4.0" - }, - "bin": { - "node-pre-gyp": "bin/node-pre-gyp" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@napi-rs/cli": { "version": "3.8.2", "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-3.8.2.tgz", @@ -1620,44 +1541,6 @@ "node": ">= 12.22.0" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@octokit/auth-token": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/@octokit/auth-token/-/auth-token-6.0.0.tgz", @@ -1825,53 +1708,6 @@ "@octokit/openapi-types": "^27.0.0" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.4.0", - "resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", - "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@sindresorhus/merge-streams": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", - "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -1883,2020 +1719,227 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", "dev": true, - "license": "MIT" + "license": "Apache-2.0" }, - "node_modules/@vercel/nft": { - "version": "0.29.4", - "resolved": "https://registry.npmmirror.com/@vercel/nft/-/nft-0.29.4.tgz", - "integrity": "sha512-6lLqMNX3TuycBPABycx7A9F1bHQR7kiQln6abjFbPrf5C/05qHM9M5E4PeTE59c7z8g6vHnx1Ioihb2AQl7BTA==", + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", "dev": true, - "license": "MIT", - "dependencies": { - "@mapbox/node-pre-gyp": "^2.0.0", - "@rollup/pluginutils": "^5.1.3", - "acorn": "^8.6.0", - "acorn-import-attributes": "^1.9.5", - "async-sema": "^3.1.1", - "bindings": "^1.4.0", - "estree-walker": "2.0.2", - "glob": "^10.4.5", - "graceful-fs": "^4.2.9", - "node-gyp-build": "^4.2.2", - "picomatch": "^4.0.2", - "resolve-from": "^5.0.0" - }, - "bin": { - "nft": "out/cli.js" - }, - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmmirror.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^8" + "node": ">= 12" } }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmmirror.com/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "node_modules/clipanion": { + "version": "4.0.0-rc.4", + "resolved": "https://registry.npmmirror.com/clipanion/-/clipanion-4.0.0-rc.4.tgz", + "integrity": "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==", "dev": true, "license": "MIT", + "workspaces": [ + "website" + ], "dependencies": { - "acorn": "^8.11.0" + "typanion": "^3.8.0" }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" + "peerDependencies": { + "typanion": "*" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmmirror.com/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } + "license": "MIT" }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/array-find-index": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/array-find-index/-/array-find-index-1.0.2.tgz", - "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/arrgv": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/arrgv/-/arrgv-1.0.2.tgz", - "integrity": "sha512-a4eg4yhp7mmruZDQFqVMlxNRFGi/i1r87pt8SDHy0/I8PqSXoUTlWZRdAZo0VXgvEARcujbtTk8kiZRi1uDGRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/arrify": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/arrify/-/arrify-3.0.0.tgz", - "integrity": "sha512-tLkvA81vQG/XqE2mjDkGQHoOINtMHtysSnemrmoGe6PydDPMRbVugqyk4A6V/WDWEfm3l+0d8anA9r8cv/5Jaw==", - "dev": true, - "license": "MIT", + "ms": "^2.1.3" + }, "engines": { - "node": ">=12" + "node": ">=6.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/async-sema": { - "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/async-sema/-/async-sema-3.1.1.tgz", - "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==", - "dev": true, - "license": "MIT" - }, - "node_modules/ava": { - "version": "6.4.1", - "resolved": "https://registry.npmmirror.com/ava/-/ava-6.4.1.tgz", - "integrity": "sha512-vxmPbi1gZx9zhAjHBgw81w/iEDKcrokeRk/fqDTyA2DQygZ0o+dUGRHFOtX8RA5N0heGJTTsIk7+xYxitDb61Q==", + "node_modules/emnapi": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmmirror.com/emnapi/-/emnapi-2.0.0-alpha.3.tgz", + "integrity": "sha512-K9bc9Xx4OwSfhJpdSOpcfIKzn7/6emuubaIorf6I5e7WBAM79665rf6iHr9y50NL4qYMUP/AheTpD1Z4yU1EBw==", "dev": true, "license": "MIT", - "dependencies": { - "@vercel/nft": "^0.29.4", - "acorn": "^8.15.0", - "acorn-walk": "^8.3.4", - "ansi-styles": "^6.2.1", - "arrgv": "^1.0.2", - "arrify": "^3.0.0", - "callsites": "^4.2.0", - "cbor": "^10.0.9", - "chalk": "^5.4.1", - "chunkd": "^2.0.1", - "ci-info": "^4.3.0", - "ci-parallel-vars": "^1.0.1", - "cli-truncate": "^4.0.0", - "code-excerpt": "^4.0.0", - "common-path-prefix": "^3.0.0", - "concordance": "^5.0.4", - "currently-unhandled": "^0.4.1", - "debug": "^4.4.1", - "emittery": "^1.2.0", - "figures": "^6.1.0", - "globby": "^14.1.0", - "ignore-by-default": "^2.1.0", - "indent-string": "^5.0.0", - "is-plain-object": "^5.0.0", - "is-promise": "^4.0.0", - "matcher": "^5.0.0", - "memoize": "^10.1.0", - "ms": "^2.1.3", - "p-map": "^7.0.3", - "package-config": "^5.0.0", - "picomatch": "^4.0.2", - "plur": "^5.1.0", - "pretty-ms": "^9.2.0", - "resolve-cwd": "^3.0.0", - "stack-utils": "^2.0.6", - "strip-ansi": "^7.1.0", - "supertap": "^3.0.1", - "temp-dir": "^3.0.0", - "write-file-atomic": "^6.0.0", - "yargs": "^17.7.2" - }, - "bin": { - "ava": "entrypoints/cli.mjs" - }, - "engines": { - "node": "^18.18 || ^20.8 || ^22 || ^23 || >=24" - }, "peerDependencies": { - "@ava/typescript": "*" + "node-addon-api": ">= 6.1.0" }, "peerDependenciesMeta": { - "@ava/typescript": { + "node-addon-api": { "optional": true } } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/before-after-hook": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/before-after-hook/-/before-after-hook-4.0.0.tgz", - "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "node_modules/es-toolkit": { + "version": "1.50.0", + "resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", "dev": true, "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } + "workspaces": [ + "docs", + "benchmarks", + "tests/types" + ] }, - "node_modules/blueimp-md5": { - "version": "2.19.0", - "resolved": "https://registry.npmmirror.com/blueimp-md5/-/blueimp-md5-2.19.0.tgz", - "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==", + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", "dev": true, "license": "MIT" }, - "node_modules/brace-expansion": { - "version": "2.1.3", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.3.tgz", - "integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==", + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "fast-string-truncated-width": "^3.0.2" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", "dev": true, "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/callsites": { - "version": "4.2.0", - "resolved": "https://registry.npmmirror.com/callsites/-/callsites-4.2.0.tgz", - "integrity": "sha512-kfzR4zzQtAE9PC7CzZsjl3aBNbXWuXiSeOCdLcPpBfGW8YuCqQHcRPFDbr/BPVmd3EEPVpuFzLyuT/cUhPr4OQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "fast-string-width": "^3.0.2" } }, - "node_modules/cbor": { - "version": "10.0.12", - "resolved": "https://registry.npmmirror.com/cbor/-/cbor-10.0.12.tgz", - "integrity": "sha512-exQDevYd7ZQLP4moMQcZkKCVZsXLAtUSflObr3xTh4xzFIv/xBCdvCd6L259kQOUP2kcTC0jvC6PpZIf/WmRXA==", + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "dev": true, "license": "MIT", "dependencies": { - "nofilter": "^3.0.2" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">=20" - } - }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "node": ">=0.10.0" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/chardet": { - "version": "2.2.0", - "resolved": "https://registry.npmmirror.com/chardet/-/chardet-2.2.0.tgz", - "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", - "dev": true, - "license": "MIT" - }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/chunkd": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/chunkd/-/chunkd-2.0.1.tgz", - "integrity": "sha512-7d58XsFmOq0j6el67Ug9mHf9ELUXsQXYJBkyxhH/k+6Ke0qXRnv0kbemx+Twc6fRJ07C49lcbdgm9FL1Ei/6SQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/ci-info": { - "version": "4.4.0", - "resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ci-parallel-vars": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/ci-parallel-vars/-/ci-parallel-vars-1.0.1.tgz", - "integrity": "sha512-uvzpYrpmidaoxvIQHM+rKSrigjOe9feHYbw4uOI2gdfe1C3xIlxO+kVXq83WQWNniTf8bAxVpy+cQeFQsMERKg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/clipanion": { - "version": "4.0.0-rc.4", - "resolved": "https://registry.npmmirror.com/clipanion/-/clipanion-4.0.0-rc.4.tgz", - "integrity": "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==", - "dev": true, - "license": "MIT", - "workspaces": [ - "website" - ], - "dependencies": { - "typanion": "^3.8.0" - }, - "peerDependencies": { - "typanion": "*" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/code-excerpt": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/code-excerpt/-/code-excerpt-4.0.0.tgz", - "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", - "dev": true, - "license": "MIT", - "dependencies": { - "convert-to-spaces": "^2.0.1" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmmirror.com/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/common-path-prefix": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/common-path-prefix/-/common-path-prefix-3.0.0.tgz", - "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", - "dev": true, - "license": "ISC" - }, - "node_modules/concordance": { - "version": "5.0.4", - "resolved": "https://registry.npmmirror.com/concordance/-/concordance-5.0.4.tgz", - "integrity": "sha512-OAcsnTEYu1ARJqWVGwf4zh4JDfHZEaSNlNccFmt8YjB2l/n19/PF2viLINHc57vO4FKIAFl2FWASIGZZWZ2Kxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "date-time": "^3.1.0", - "esutils": "^2.0.3", - "fast-diff": "^1.2.0", - "js-string-escape": "^1.0.1", - "lodash": "^4.17.15", - "md5-hex": "^3.0.1", - "semver": "^7.3.2", - "well-known-symbols": "^2.0.0" - }, - "engines": { - "node": ">=10.18.0 <11 || >=12.14.0 <13 || >=14" - } - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmmirror.com/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, - "node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/convert-to-spaces": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", - "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/currently-unhandled": { - "version": "0.4.1", - "resolved": "https://registry.npmmirror.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz", - "integrity": "sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-find-index": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/date-time": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/date-time/-/date-time-3.1.0.tgz", - "integrity": "sha512-uqCUKXE5q1PNBXjPqvwhwJf9SwMoAHBgWJ6DcrnS5o+W2JOiIILl0JEdVD8SGujrNS02GGxgwAg2PN2zONgtjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "time-zone": "^1.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/emittery": { - "version": "1.2.1", - "resolved": "https://registry.npmmirror.com/emittery/-/emittery-1.2.1.tgz", - "integrity": "sha512-sFz64DCRjirhwHLxofFqxYQm6DCp6o0Ix7jwKQvuCHPn4GMRZNuBZyLPu9Ccmk/QSCAMZt6FOUqA8JZCQvA9fw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, - "node_modules/emnapi": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmmirror.com/emnapi/-/emnapi-2.0.0-alpha.3.tgz", - "integrity": "sha512-K9bc9Xx4OwSfhJpdSOpcfIKzn7/6emuubaIorf6I5e7WBAM79665rf6iHr9y50NL4qYMUP/AheTpD1Z4yU1EBw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "node-addon-api": ">= 6.1.0" - }, - "peerDependenciesMeta": { - "node-addon-api": { - "optional": true - } - } - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-toolkit": { - "version": "1.50.0", - "resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.50.0.tgz", - "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", - "dev": true, - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks", - "tests/types" - ] - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmmirror.com/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-string-truncated-width": { - "version": "3.0.3", - "resolved": "https://registry.npmmirror.com/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", - "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-string-width": { - "version": "3.0.2", - "resolved": "https://registry.npmmirror.com/fast-string-width/-/fast-string-width-3.0.2.tgz", - "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-string-truncated-width": "^3.0.2" - } - }, - "node_modules/fast-wrap-ansi": { - "version": "0.2.2", - "resolved": "https://registry.npmmirror.com/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", - "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-string-width": "^3.0.2" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/figures": { - "version": "6.1.0", - "resolved": "https://registry.npmmirror.com/figures/-/figures-6.1.0.tgz", - "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-unicode-supported": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up-simple": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/find-up-simple/-/find-up-simple-1.0.1.tgz", - "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmmirror.com/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/globby": { - "version": "14.1.0", - "resolved": "https://registry.npmmirror.com/globby/-/globby-14.1.0.tgz", - "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^2.1.0", - "fast-glob": "^3.3.3", - "ignore": "^7.0.3", - "path-type": "^6.0.0", - "slash": "^5.1.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/ignore-by-default": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/ignore-by-default/-/ignore-by-default-2.1.0.tgz", - "integrity": "sha512-yiWd4GVmJp0Q6ghmM2B/V3oZGRmjrKLXvHR3TE1nfoXsmoggllfZUQe74EN0fJdPFZu2NIvNdrMMLm3OsV7Ohw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10 <11 || >=12 <13 || >=14" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/irregular-plurals": { - "version": "3.5.0", - "resolved": "https://registry.npmmirror.com/irregular-plurals/-/irregular-plurals-3.5.0.tgz", - "integrity": "sha512-1ANGLZ+Nkv1ptFb2pa8oG8Lem4krflKuX/gINiHJHjJUKaJHk/SXk5x6K3J+39/p0h1RQ2saROclJJ+QLvETCQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/js-string-escape": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/js-string-escape/-/js-string-escape-1.0.1.tgz", - "integrity": "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-with-bigint": { - "version": "3.5.10", - "resolved": "https://registry.npmmirror.com/json-with-bigint/-/json-with-bigint-3.5.10.tgz", - "integrity": "sha512-Vcx+JVNEBts/xfcoCS69sKrOhOk/3TVlvlT+XzUOefVKnnrbYSCKpDCm10pohsJFtsJVYnwa/cXRZ4eElzaM6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/load-json-file": { - "version": "7.0.1", - "resolved": "https://registry.npmmirror.com/load-json-file/-/load-json-file-7.0.1.tgz", - "integrity": "sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/matcher": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/matcher/-/matcher-5.0.0.tgz", - "integrity": "sha512-s2EMBOWtXFc8dgqvoAzKJXxNHibcdJMV0gwqKUaw9E2JBJuGUK7DrNKrA6g/i+v72TT16+6sVm5mS3thaMLQUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^5.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/md5-hex": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/md5-hex/-/md5-hex-3.0.1.tgz", - "integrity": "sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw==", - "dev": true, - "license": "MIT", - "dependencies": { - "blueimp-md5": "^2.10.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/memoize": { - "version": "10.2.0", - "resolved": "https://registry.npmmirror.com/memoize/-/memoize-10.2.0.tgz", - "integrity": "sha512-DeC6b7QBrZsRs3Y02A6A7lQyzFbsQbqgjI6UW0GigGWV+u1s25TycMr0XHZE4cJce7rY/vyw2ctMQqfDkIhUEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/memoize?sponsor=1" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mute-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/mute-stream/-/mute-stream-3.0.0.tgz", - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmmirror.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "dev": true, - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, - "node_modules/nofilter": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/nofilter/-/nofilter-3.1.0.tgz", - "integrity": "sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.19" - } - }, - "node_modules/nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmmirror.com/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "abbrev": "^3.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/p-map": { - "version": "7.0.6", - "resolved": "https://registry.npmmirror.com/p-map/-/p-map-7.0.6.tgz", - "integrity": "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-config": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/package-config/-/package-config-5.0.0.tgz", - "integrity": "sha512-GYTTew2slBcYdvRHqjhwaaydVMvn/qrGC323+nKclYioNSLTDUM/lGgtGTgyHVtYcozb+XkE8CNhwcraOmZ9Mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up-simple": "^1.0.0", - "load-json-file": "^7.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parse-ms": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/parse-ms/-/parse-ms-4.0.0.tgz", - "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-type": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/path-type/-/path-type-6.0.0.tgz", - "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/plur": { - "version": "5.1.0", - "resolved": "https://registry.npmmirror.com/plur/-/plur-5.1.0.tgz", - "integrity": "sha512-VP/72JeXqak2KiOzjgKtQen5y3IZHn+9GOuLDafPv0eXa47xq0At93XahYBs26MsifCQ4enGKwbjBTKgb9QJXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "irregular-plurals": "^3.3.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pretty-ms": { - "version": "9.3.0", - "resolved": "https://registry.npmmirror.com/pretty-ms/-/pretty-ms-9.3.0.tgz", - "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse-ms": "^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmmirror.com/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.13.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/slash": { - "version": "5.1.0", - "resolved": "https://registry.npmmirror.com/slash/-/slash-5.1.0.tgz", - "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmmirror.com/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/json-with-bigint": { + "version": "3.5.10", + "resolved": "https://registry.npmmirror.com/json-with-bigint/-/json-with-bigint-3.5.10.tgz", + "integrity": "sha512-Vcx+JVNEBts/xfcoCS69sKrOhOk/3TVlvlT+XzUOefVKnnrbYSCKpDCm10pohsJFtsJVYnwa/cXRZ4eElzaM6w==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } + "license": "MIT" }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, - "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "node_modules/mute-stream": { "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "resolved": "https://registry.npmmirror.com/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, + "license": "ISC", "engines": { - "node": ">=8" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12.20.0" } }, - "node_modules/supertap": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/supertap/-/supertap-3.0.1.tgz", - "integrity": "sha512-u1ZpIBCawJnO+0QePsEiOknOfCRq0yERxiAchT0i4li0WHNUJbf0evXXSXOcCAR4M8iMDoajXYmstm/qO81Isw==", + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^5.0.0", - "js-yaml": "^3.14.1", - "serialize-error": "^7.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } + "license": "MIT" }, - "node_modules/tar": { - "version": "7.5.22", - "resolved": "https://registry.npmmirror.com/tar/-/tar-7.5.22.tgz", - "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=18" - } - }, - "node_modules/temp-dir": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/temp-dir/-/temp-dir-3.0.0.tgz", - "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" + "node": ">=10" } }, - "node_modules/time-zone": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/time-zone/-/time-zone-1.0.0.tgz", - "integrity": "sha512-TIsDdtKo6+XrPtiTm1ssmMngN1sAhyKnTO2kunQWqNPWIVvCm15Wmw4SWInwTVgJ5u/Tr04+8Ei9TNcw4x4ONA==", + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "license": "MIT", + "license": "ISC", "engines": { - "node": ">=4" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" + "node": ">=14" }, - "engines": { - "node": ">=8.0" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", @@ -3915,19 +1958,6 @@ "website" ] }, - "node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", @@ -3942,320 +1972,12 @@ "node": ">=14.17" } }, - "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmmirror.com/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/universal-user-agent": { "version": "7.0.3", "resolved": "https://registry.npmmirror.com/universal-user-agent/-/universal-user-agent-7.0.3.tgz", "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", "dev": true, "license": "ISC" - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/well-known-symbols": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/well-known-symbols/-/well-known-symbols-2.0.0.tgz", - "integrity": "sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=6" - } - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/write-file-atomic": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/write-file-atomic/-/write-file-atomic-6.0.0.tgz", - "integrity": "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/yargs": { - "version": "17.7.3", - "resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.3.tgz", - "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } } } } diff --git a/bindings/node/package.json b/bindings/node/package.json index 30ce4df5f..5f9f27b8c 100644 --- a/bindings/node/package.json +++ b/bindings/node/package.json @@ -27,7 +27,7 @@ "napi": { "binaryName": "aimux", "packageName": "@arcships/aimux", - "dtsHeader": "/* auto-generated by NAPI-RS */\n/* eslint-disable */\n/** Fallible native return — unwraps to `T` at runtime; core failures reject/throw the registered `AimuxError` subclass, while binding faults remain napi-rs plain `Error`s. */\nexport type AimuxResult = T\n/** Stream yield — a StreamPart JSON string at runtime; failures reject `next()` with a typed error. */\nexport type StreamItem = string\n", + "dtsHeader": "/* auto-generated by NAPI-RS */\n/* eslint-disable */\nexport type WireResult = T\n", "targets": [ "x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc", @@ -41,12 +41,15 @@ "build": "napi build --platform --release", "build:debug": "napi build --platform", "build:typed": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json", - "test": "ava", - "prepublishOnly": "npm run build:typed && napi prepublish -t npm --no-gh-release" + "test": "node --experimental-strip-types --test __test__/*.test.ts __test__/*.native.ts __test__/*.spec.ts", + "prepublishOnly": "npm run build:typed && napi prepublish -t npm --no-gh-release", + "test:native": "node --experimental-strip-types --test __test__/*.native.ts", + "test:facade": "node --experimental-strip-types --test __test__/*.test.ts", + "test:lifecycle": "node --experimental-strip-types --test __test__/event_loop.spec.ts", + "test:c-parity": "node --experimental-strip-types --test __test__/abi.parity.ts" }, "devDependencies": { "@napi-rs/cli": "3.8.2", - "ava": "^6.0.0", "typescript": "^5.7.0" }, "engines": { diff --git a/bindings/node/src/bridge.rs b/bindings/node/src/bridge.rs new file mode 100644 index 000000000..a465215b3 --- /dev/null +++ b/bindings/node/src/bridge.rs @@ -0,0 +1,492 @@ +//! Lossless bridge: 64 data frames / 8 MiB, with terminal information outside the queue. +use std::future::Future; +use std::io::{self, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use aimux_core::AbortSignal; +use aimux_ops::envelope::{ErrorEnvelope, OpsError, ProtocolError}; +use aimux_ops::frame::{Outcome, StreamFrame}; +use futures::{Stream, StreamExt}; +use napi::bindgen_prelude::*; +use napi_derive::napi; +use tokio::sync::{Semaphore, mpsc}; + +use crate::error::{WireResult, envelope, serialize}; +use crate::{Request, invalid_state}; + +const ITEMS: usize = 64; +const BYTES: usize = 8 * 1024 * 1024; +const SINGLE: usize = 1024 * 1024; + +type Terminal = Arc>>>; + +struct Queued { + json: String, + _bytes: tokio::sync::OwnedSemaphorePermit, +} + +struct Bridge { + receiver: tokio::sync::Mutex>, + terminal: Terminal, + signal: Arc, + reading: AtomicBool, + closed: AtomicBool, +} + +#[napi(async_iterator)] +pub struct FrameIterator { + bridge: Arc, +} + +impl Drop for FrameIterator { + fn drop(&mut self) { + self.close(); + } +} + +impl FrameIterator { + fn close(&self) { + self.bridge.closed.store(true, Ordering::Release); + self.bridge.signal.abort(); + if let Ok(mut receiver) = self.bridge.receiver.try_lock() { + receiver.close(); + while receiver.try_recv().is_ok() {} + } + } +} + +struct Reading(Arc); +impl Drop for Reading { + fn drop(&mut self) { + self.0.reading.store(false, Ordering::Release); + } +} + +#[napi] +impl AsyncGenerator for FrameIterator { + type Yield = WireResult; + type Next = (); + type Return = (); + + fn next( + &mut self, + _: Option<()>, + ) -> impl Future>> + Send + 'static { + let bridge = self.bridge.clone(); + let busy = bridge.reading.swap(true, Ordering::AcqRel); + let reading = if busy { + None + } else { + Some(Reading(bridge.clone())) + }; + async move { + let _reading = reading; + if busy { + return Ok(Some(WireResult(Err(envelope(OpsError::Protocol( + ProtocolError::ConcurrentConsumer, + )))))); + } + if bridge.closed.load(Ordering::Acquire) { + return Ok(None); + } + let mut receiver = bridge.receiver.lock().await; + if let Some(queued) = receiver.recv().await { + if bridge.closed.load(Ordering::Acquire) { + return Ok(None); + } + return Ok(Some(WireResult(Ok(queued.json)))); + } + if bridge.closed.swap(true, Ordering::AcqRel) { + return Ok(None); + } + let terminal = bridge + .terminal + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .unwrap_or_else(|| Err(invalid_state("stream ended without a terminal frame"))); + Ok(Some(WireResult(terminal))) + } + } + + fn complete( + &mut self, + _: Option<()>, + ) -> impl Future>> + Send + 'static { + // Signal synchronously, before the future runs or any receiver lock is acquired. + self.close(); + async { Ok(None) } + } + + fn catch( + &mut self, + _env: Env, + value: Unknown, + ) -> impl Future>> + Send + 'static { + self.close(); + let error = napi::Error::from_unknown_without_coercion(value); + async move { Err(error) } + } +} + +struct Size(usize); +impl Write for Size { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0 = self + .0 + .checked_add(bytes.len()) + .filter(|size| *size <= SINGLE) + .ok_or_else(|| io::Error::other("stream frame exceeds 1 MiB"))?; + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +fn frame_size(frame: &StreamFrame) -> std::result::Result { + // Size before allocating the serialized buffer (escaping can expand text). + let mut size = Size(0); + serde_json::to_writer(&mut size, frame).map_err(|_| { + envelope(OpsError::Protocol(ProtocolError::ResourceLimit( + "stream frame exceeds 1 MiB".into(), + ))) + })?; + Ok(size.0) +} + +fn end( + seq: String, + outcome: Outcome, + error: Option, +) -> std::result::Result { + serialize(&StreamFrame::End { + seq, + outcome, + error, + }) +} + +pub(super) fn start(stream: S, request: Request) -> FrameIterator +where + S: Stream + Send + 'static, +{ + let (sender, receiver) = mpsc::channel(ITEMS); + let terminal = Arc::new(Mutex::new(None)); + let bridge = Arc::new(Bridge { + receiver: tokio::sync::Mutex::new(receiver), + terminal: terminal.clone(), + signal: request.signal.clone(), + reading: AtomicBool::new(false), + closed: AtomicBool::new(false), + }); + // napi async methods enter the documented napi Tokio runtime before calling start. + tokio::spawn(produce(stream, sender, terminal, request)); + FrameIterator { bridge } +} + +async fn produce(stream: S, sender: mpsc::Sender, terminal: Terminal, request: Request) +where + S: Stream + Send + 'static, +{ + futures::pin_mut!(stream); + let budget = Arc::new(Semaphore::new(BYTES)); + let signal = &request.signal; + let mut next_seq = "0".to_owned(); + let result = loop { + // Reserve an item slot before polling upstream: a full bridge pulls no extra event. + let slot = tokio::select! { + biased; + slot = sender.reserve() => match slot { + Ok(slot) => slot, + Err(_) => { signal.abort(); break end(next_seq, Outcome::Cancelled, None); } + }, + _ = signal.cancelled() => break end(next_seq, Outcome::Cancelled, None), + }; + let frame = tokio::select! { + biased; + frame = stream.next() => match frame { + Some(frame) => frame, + None => break Err(invalid_state("dispatch stream ended without a terminal frame")), + }, + _ = signal.cancelled() => break end(next_seq, Outcome::Cancelled, None), + _ = sender.closed() => { signal.abort(); break end(next_seq, Outcome::Cancelled, None); } + }; + let seq = match &frame { + StreamFrame::Event { seq, .. } | StreamFrame::End { seq, .. } => seq.clone(), + }; + let size = match frame_size(&frame) { + Ok(size) => size, + Err(error) => { + signal.abort(); + break end(seq, Outcome::Error, Some(error)); + } + }; + if matches!(frame, StreamFrame::End { .. }) { + break serialize(&frame); + } + let permit = tokio::select! { + biased; + _ = signal.cancelled() => break end(seq, Outcome::Cancelled, None), + permit = budget.clone().acquire_many_owned(size as u32) => match permit { + Ok(permit) => permit, + Err(_) => break Err(invalid_state("stream byte budget is closed")), + } + }; + let json = match serialize(&frame) { + Ok(json) => json, + Err(error) => { + signal.abort(); + break end(seq, Outcome::Error, Some(error)); + } + }; + if signal.is_aborted() { + break end(seq, Outcome::Cancelled, None); + } + slot.send(Queued { + json, + _bytes: permit, + }); + next_seq = match seq.parse::().ok().and_then(|seq| seq.checked_add(1)) { + Some(seq) => seq.to_string(), + None => { + signal.abort(); + break Err(invalid_state("stream sequence exhausted")); + } + }; + }; + *terminal + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(result); + // Dropping the sender wakes the consumer after it drains all accepted data. +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Realm, Resources}; + use aimux_ops::handle::Owner; + use serde_json::json; + use std::sync::atomic::AtomicU64; + + fn request() -> Request { + let realm = Arc::new(Realm { + owner: Owner::new_node().expect("test owner"), + next_request: AtomicU64::new(1), + resources: Mutex::new(Resources::default()), + }); + match realm.request(Arc::new(AbortSignal::new())) { + Ok(request) => request, + Err(_) => panic!("request setup failed"), + } + } + + #[test] + fn escaped_bytes_are_limited_before_serialization() { + let frame = StreamFrame::Event { + seq: "1".into(), + event: json!("\u{0000}".repeat(SINGLE / 6)), + }; + assert!(frame_size(&frame).is_err()); + assert!( + frame_size(&StreamFrame::Event { + seq: "1".into(), + event: json!("ok") + }) + .is_ok() + ); + } + + #[tokio::test] + async fn abort_wakes_full_queue_and_terminal_survives() { + let request = request(); + let signal = request.signal.clone(); + let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed = polls.clone(); + let stream = futures::stream::iter((1..=1000).map(|seq| StreamFrame::Event { + seq: seq.to_string(), + event: json!(seq), + })) + .inspect(move |_| { + observed.fetch_add(1, Ordering::Relaxed); + }); + let mut iterator = start(stream, request); + tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + if iterator.bridge.receiver.lock().await.len() == ITEMS { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("producer fills queue"); + assert_eq!(polls.load(Ordering::Relaxed), ITEMS); + signal.abort(); + let mut events = 0; + let mut ends = 0; + while let Some(item) = iterator.next(None).await.expect("next") { + let json = match item.0 { + Ok(json) => json, + Err(_) => panic!("expected a JSON frame"), + }; + let value: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); + if value["type"] == "end" { + ends += 1; + assert_eq!(value["outcome"], "cancelled"); + } else { + events += 1; + } + } + assert_eq!(events, ITEMS); + assert_eq!(ends, 1); + } + + #[tokio::test] + async fn return_aborts_without_waiting_for_a_reader() { + let request = request(); + let signal = request.signal.clone(); + let mut iterator = start(futures::stream::pending::(), request); + let pending = iterator.next(None); + iterator.complete(None).await.expect("return"); + assert!(signal.is_aborted()); + assert!(pending.await.expect("pending next").is_none()); + } + + #[tokio::test] + async fn byte_budget_bounds_large_items() { + let request = request(); + let signal = request.signal.clone(); + let stream = futures::stream::iter((1..100).map(|seq| StreamFrame::Event { + seq: seq.to_string(), + event: json!("a".repeat(SINGLE / 2)), + })); + let iterator = start(stream, request); + for _ in 0..100 { + tokio::task::yield_now().await; + } + let count = iterator.bridge.receiver.lock().await.len(); + assert!( + count <= 15, + "byte budget must bind before the 64-item limit: {count}" + ); + signal.abort(); + } + #[tokio::test] + async fn slow_reader_loses_no_events_and_reads_one_terminal() { + let events = futures::stream::iter((1..=150).map(|seq| StreamFrame::Event { + seq: seq.to_string(), + event: json!(seq), + })); + let terminal = futures::stream::iter([StreamFrame::End { + seq: "151".into(), + outcome: Outcome::Success, + error: None, + }]); + let mut iterator = start(events.chain(terminal), request()); + for seq in 1..=151 { + let item = iterator.next(None).await.expect("next").expect("frame"); + let text = match item.0 { + Ok(text) => text, + Err(_) => panic!("expected JSON"), + }; + let value: serde_json::Value = serde_json::from_str(&text).expect("JSON"); + assert_eq!(value["seq"], seq.to_string()); + if seq == 151 { + assert_eq!(value["outcome"], "success"); + } + tokio::task::yield_now().await; + } + assert!(iterator.next(None).await.expect("exhausted").is_none()); + } + + #[tokio::test] + async fn concurrent_next_does_not_cancel_the_first_reader() { + let request = request(); + let signal = request.signal.clone(); + let mut iterator = start(futures::stream::pending::(), request); + let first = iterator.next(None); + let second = iterator + .next(None) + .await + .expect("second next") + .expect("error item"); + assert!(matches!(second.0, Err(ref error) if error.code == 303)); + assert!(!signal.is_aborted()); + iterator.complete(None).await.expect("return"); + assert!(first.await.expect("first next").is_none()); + } + + #[tokio::test] + async fn missing_terminal_is_not_success() { + let mut iterator = start(futures::stream::empty::(), request()); + let item = iterator.next(None).await.expect("next").expect("failure"); + assert!(matches!(item.0, Err(ref error) if error.code == 304)); + assert!(iterator.next(None).await.expect("exhausted").is_none()); + } + + #[tokio::test] + async fn oversized_event_delivers_resource_limit_in_terminal_slot() { + let frame = StreamFrame::Event { + seq: "1".into(), + event: json!("x".repeat(SINGLE)), + }; + let request = request(); + let signal = request.signal.clone(); + let mut iterator = start(futures::stream::iter([frame]), request); + let item = iterator.next(None).await.expect("next").expect("terminal"); + let text = match item.0 { + Ok(text) => text, + Err(_) => panic!("expected end"), + }; + let value: serde_json::Value = serde_json::from_str(&text).expect("JSON"); + assert_eq!(value["type"], "end"); + assert_eq!(value["outcome"], "error"); + assert_eq!(value["error"]["code"], 305); + assert!(signal.is_aborted()); + assert!(iterator.next(None).await.expect("exhausted").is_none()); + } + + #[tokio::test] + async fn environment_cleanup_cancels_a_never_consumed_stream() { + let request = request(); + let realm = request.realm.clone(); + let signal = request.signal.clone(); + let _iterator = start(futures::stream::pending::(), request); + realm.cleanup(); + assert!(signal.is_aborted()); + assert!(realm.request(Arc::new(AbortSignal::new())).is_err()); + } + #[tokio::test] + async fn cancellation_after_terminal_does_not_rewrite_success() { + let request = request(); + let signal = request.signal.clone(); + let mut iterator = start( + futures::stream::iter([StreamFrame::End { + seq: "1".into(), + outcome: Outcome::Success, + error: None, + }]), + request, + ); + tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + if iterator.bridge.terminal.lock().unwrap().is_some() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("terminal saved"); + signal.abort(); + let item = iterator.next(None).await.expect("next").expect("end"); + let text = match item.0 { + Ok(text) => text, + Err(_) => panic!("expected end"), + }; + let value: serde_json::Value = serde_json::from_str(&text).expect("JSON"); + assert_eq!(value["outcome"], "success"); + } +} diff --git a/bindings/node/src/decision.rs b/bindings/node/src/decision.rs deleted file mode 100644 index 1e3ecabad..000000000 --- a/bindings/node/src/decision.rs +++ /dev/null @@ -1,78 +0,0 @@ -use std::sync::Arc; - -use aimux_core::Provider; -use aimux_core::decision_model::{DecideOptions, DecisionModel as DecisionModelTrait}; -use napi_derive::napi; - -use crate::error::{AiMuxBindingError, AimuxResult, parse_wire_json, serialize_result}; - -#[napi] -pub struct DecisionModel { - pub(crate) inner: Arc, -} - -#[napi] -impl DecisionModel { - /// The DecisionModelV4 properties as JSON: `specificationVersion`, - /// `provider`, `modelId`, `supportedQuestionTypes`. - #[napi] - pub fn describe(&self) -> AimuxResult { - AimuxResult(serialize_result(&aimux_core::decision_model::decision_model_properties( - self.inner.as_ref(), - ))) - } - - /// `opts_json` is `DecideOptions` JSON; resolves to `DecideResult` JSON. - #[napi(ts_return_type = "Promise")] - pub async fn decide( - &self, - opts_json: String, - bridge: Option<&crate::AbortBridge>, - ) -> AimuxResult { - AimuxResult( - async { - let mut options: DecideOptions = parse_wire_json("opts_json", &opts_json)?; - options.abort_signal = bridge.map(|bridge| bridge.core_signal()); - let result = aimux_core::decision_model::decide(self.inner.as_ref(), options) - .await - .map_err(|error| AiMuxBindingError::from(&error))?; - serialize_result(&result) - } - .await, - ) - } -} - -/// Create a Jev model; endpoint overrides the complete POST URL. -#[napi] -pub async fn jev_decision( - api_key: String, - model_id: String, - endpoint: Option, - probability_source: Option, -) -> AimuxResult { - AimuxResult( - async { - let mut config = aimux_providers::JevConfig::new(api_key); - if let Some(endpoint) = endpoint { - config = config.with_endpoint(endpoint); - } - if let Some(source) = probability_source { - config.probability_source = source - .parse() - .map_err(|error| AiMuxBindingError::from(&error))?; - } - let model = aimux_providers::JevProvider::new(config) - .decision_model(&model_id) - .unwrap_or_else(|| { - Err(aimux_core::AiMuxError::no_such_model( - &model_id, - "decisionModel", - )) - }) - .map_err(|error| AiMuxBindingError::from(&error))?; - Ok(DecisionModel { inner: model }) - } - .await, - ) -} diff --git a/bindings/node/src/error.rs b/bindings/node/src/error.rs index 4356b3142..4c35df977 100644 --- a/bindings/node/src/error.rs +++ b/bindings/node/src/error.rs @@ -1,474 +1,69 @@ -//! Map core failures into the JavaScript `Error` classes registered by the -//! package's JS entrypoint. -//! -//! Construction needs a napi `Env` (available when `ToNapiValue` runs on the -//! JS thread). Async methods therefore return [`AimuxResult`] instead of -//! `napi::Result`, so the error is materialised with `Env` present. +//! Project the shared envelope once, then materialize it on the JS thread. +use std::time::SystemTime; -use std::collections::HashMap; +use aimux_ops::envelope::{BindingError, ErrorEnvelope, OpsError}; +use napi::bindgen_prelude::*; +use napi::{Env, sys}; -use aimux_core::{AiMuxError, RetryErrorReason, recording::RecordingError}; -use napi::bindgen_prelude::{ - Function, FunctionRef, JsValue, Object, Result as NapiResult, ToNapiValue, TypeName, ValueType, -}; -use napi::{Env, Error, Status, sys}; -use napi_derive::napi; +pub struct WireResult(pub(crate) std::result::Result); -const ERROR_CLASS_NAMES: &[&str] = &[ - "APICallError", - "RetryError", - "JSONParseError", - "InvalidResponseDataError", - "NoOutputGeneratedError", - "NoSuchToolError", - "InvalidToolInputError", - "ToolCallRepairError", - "InvalidArgumentError", - "InvalidPromptError", - "LoadAPIKeyError", - "LoadSettingError", - "TokenExpiredError", - "UnsupportedFunctionalityError", - "NoSuchModelError", - "NoSuchProviderError", - "TimeoutError", - "RequestAbortedError", - "OtherError", - "RecordingError", -]; - -type ErrorConstructor = FunctionRef; - -struct ErrorConstructors(HashMap<&'static str, ErrorConstructor>); - -/// Register the canonical JS error constructors for this Node environment. -/// -/// Keeping the constructors in the environment, rather than a process-global, -/// preserves class identity and keeps Worker environments isolated. -#[napi(js_name = "__registerErrorClasses", ts_return_type = "void")] -pub fn register_error_classes(env: Env, constructors: Object) -> NapiResult<()> { - let mut registered = HashMap::with_capacity(ERROR_CLASS_NAMES.len()); - for &name in ERROR_CLASS_NAMES { - let constructor = constructors - .get::>(name)? - .ok_or_else(|| { - Error::new( - Status::InvalidArg, - format!("missing JavaScript error constructor: {name}"), - ) - })?; - registered.insert(name, constructor.create_ref()?); - } - - let registry = ErrorConstructors(registered); - if let Some(current) = env.get_instance_data::()? { - *current = registry; - } else { - env.set_instance_data(registry, (), |_| {})?; - } - Ok(()) -} - -/// Failures of the binding's own layer (napi-rs side) — never disguised -/// as an `AiMuxError`. Surfaces in JS as napi-rs's own plain `Error` -/// (`code` = napi status: `InvalidArg` / `GenericFailure`). -#[derive(Clone, Debug)] -pub(crate) enum BindingError { - /// A JSON *text* the binding transports did not parse (schema violations - /// of well-formed JSON stay `AiMuxError::InvalidArgument`). - InvalidWireJson { - argument: &'static str, - message: String, - }, - /// A closed session / dropped handle handed to the binding. - InvalidHandle { message: &'static str }, - /// The binding could not serialize a result to JSON. - ResultSerialization { message: String }, - /// A binding invariant broke. - InvariantViolation { message: String }, -} - -impl std::fmt::Display for BindingError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::InvalidWireJson { argument, message } => { - write!(f, "{argument}: invalid JSON: {message}") - } - Self::InvalidHandle { message } => f.write_str(message), - Self::ResultSerialization { message } => write!(f, "serialize result: {message}"), - Self::InvariantViolation { message } => { - write!(f, "binding invariant violation: {message}") - } - } - } -} - -/// One internal error carrier for Node binding returns. -/// -/// The variants remain separate all the way to JS: `AiMux` becomes one of the -/// typed AimuxError subclasses, `Recording` becomes RecordingError, and -/// `Binding` becomes napi-rs's plain Error. This carrier does not merge the -/// public error families. -#[derive(Debug)] -pub(crate) enum AiMuxBindingError { - AiMux(AiMuxError), - Recording(RecordingError), - Binding(BindingError), -} - -impl From for AiMuxBindingError { - fn from(error: AiMuxError) -> Self { - Self::AiMux(error) - } -} - -impl From<&AiMuxError> for AiMuxBindingError { - fn from(error: &AiMuxError) -> Self { - Self::AiMux(error.clone()) - } -} - -impl From for AiMuxBindingError { - fn from(error: RecordingError) -> Self { - Self::Recording(error) - } -} - -impl From for AiMuxBindingError { - fn from(error: BindingError) -> Self { - Self::Binding(error) - } +pub(crate) fn envelope(error: OpsError) -> ErrorEnvelope { + error.envelope(SystemTime::now()) } -/// Parse a JSON text the binding transports (`prompt_json`, `opts_json`, …). -/// Malformed text → binding `InvalidWireJson`; well-formed JSON that violates -/// the schema → `AiMuxError::InvalidArgument` (what the core would say). -pub(crate) fn parse_wire_json( - argument: &'static str, +pub(crate) fn parse( + argument: &str, json: &str, -) -> MResult { - serde_json::from_str(json).map_err(|e| wire_error(argument, &e)) -} - -/// The classification above, for callers that produce the `serde_json::Error` -/// themselves rather than through [`parse_wire_json`]. -pub(crate) fn wire_error(argument: &'static str, e: &serde_json::Error) -> AiMuxBindingError { - match e.classify() { - serde_json::error::Category::Data => AiMuxBindingError::from(AiMuxError::InvalidArgument( - format!("invalid {argument}: {e}"), - )), - _ => BindingError::InvalidWireJson { - argument, - message: e.to_string(), - } - .into(), - } -} - -/// Serialize a result for the JS side; failure is a binding `ResultSerialization`. -pub(crate) fn serialize_result(value: &T) -> MResult { - serde_json::to_string(value).map_err(|e| { - BindingError::ResultSerialization { - message: e.to_string(), - } - .into() +) -> std::result::Result { + serde_json::from_str(json).map_err(|error| { + envelope(OpsError::Binding(BindingError::InvalidWireJson { + argument: argument.to_owned(), + message: error.to_string(), + })) }) } -fn aimux_error_class_name(error: &AiMuxError) -> &'static str { - match error { - AiMuxError::ApiCall(_) => "APICallError", - AiMuxError::Retry(_) => "RetryError", - AiMuxError::JsonParse(_) => "JSONParseError", - AiMuxError::NoOutputGenerated(_) => "NoOutputGeneratedError", - // No class of their own yet for the AI SDK's ToolChoiceViolationError - // and MissingToolResultsError (binding migration). - AiMuxError::InvalidResponseData(_) - | AiMuxError::ToolCallNotFoundForApproval { .. } - | AiMuxError::ToolChoiceViolation(_) => "InvalidResponseDataError", - AiMuxError::NoSuchTool { .. } => "NoSuchToolError", - AiMuxError::InvalidToolInput { .. } => "InvalidToolInputError", - AiMuxError::ToolCallRepair { .. } => "ToolCallRepairError", - AiMuxError::InvalidArgument(_) => "InvalidArgumentError", - AiMuxError::InvalidPrompt(_) | AiMuxError::MissingToolResults { .. } => { - "InvalidPromptError" - } - AiMuxError::LoadApiKey { .. } => "LoadAPIKeyError", - AiMuxError::LoadSetting { .. } => "LoadSettingError", - AiMuxError::TokenExpired(_) => "TokenExpiredError", - AiMuxError::UnsupportedFunctionality(_) => "UnsupportedFunctionalityError", - AiMuxError::NoSuchModel { .. } => "NoSuchModelError", - AiMuxError::NoSuchProvider { .. } => "NoSuchProviderError", - AiMuxError::Timeout(_) => "TimeoutError", - AiMuxError::Aborted(_) => "RequestAbortedError", - AiMuxError::Other(_) => "OtherError", - } +pub(crate) fn serialize( + value: &impl serde::Serialize, +) -> std::result::Result { + serde_json::to_string(value).map_err(|error| { + envelope(OpsError::Binding(BindingError::ResultSerialization( + error.to_string(), + ))) + }) } -/// Convert one internal failure into its public JavaScript error family. -pub(crate) fn create_throwable(env: &Env, error: &AiMuxBindingError) -> NapiResult { - match error { - AiMuxBindingError::AiMux(error) => create_aimux_throwable(env, error), - AiMuxBindingError::Recording(error) => create_recording_throwable(env, error), - AiMuxBindingError::Binding(error) => Ok(create_binding_throwable(error)), +pub(crate) fn throwable(env: &Env, value: ErrorEnvelope) -> napi::Result { + let mut object = env.create_error(napi::Error::from_reason(value.message.clone()))?; + object.set("name", "AimuxError")?; + object.set( + "kind", + serde_json::to_value(value.kind) + .map_err(|error| napi::Error::from_reason(error.to_string()))?, + )?; + object.set("code", value.code)?; + object.set("error", value.error)?; + if let Some(ms) = value.retry_after_ms { + // This is the same projection as the envelope; never recompute hints. + object.set("retryAfterMs", ms)?; } + Ok(napi::Error::from(object.to_unknown())) } -fn new_registered_error<'env>( - env: &'env Env, - class_name: &str, - message: &str, -) -> NapiResult> { - let constructors = env - .get_instance_data::()? - .ok_or_else(|| { - Error::new( - Status::GenericFailure, - "JavaScript error constructors are not registered", - ) - })?; - let constructor = constructors.0.get(class_name).ok_or_else(|| { - Error::new( - Status::GenericFailure, - format!("JavaScript error constructor was not registered: {class_name}"), - ) - })?; - let instance = constructor - .borrow_back(env)? - .new_instance(message.to_owned())?; - instance.coerce_to_object() -} - -/// Build the exact JS subclass registered for this core variant. -fn create_aimux_throwable(env: &Env, error: &AiMuxError) -> NapiResult { - Ok(Error::from(aimux_error_object(env, error)?.to_unknown())) -} - -/// The error instance as an `Object`, so `ToolCallRepair` can nest its -/// `originalError` as a real registered subclass instance. -fn aimux_error_object<'env>(env: &'env Env, error: &AiMuxError) -> NapiResult> { - let message = error.to_string(); - let mut obj = new_registered_error(env, aimux_error_class_name(error), &message)?; - if let Some(status) = error.status_code() { - obj.set("status", i32::from(status))?; - } - match error { - AiMuxError::ApiCall(detail) => { - obj.set("retryable", detail.is_retryable)?; - if let Some(retry_ms) = error.retry_after_hint() { - obj.set("retryMs", retry_ms)?; - } - if !detail.url.is_empty() { - obj.set("url", detail.url.as_str())?; - } - if !detail.request_body_values.is_null() { - obj.set("requestBodyValues", detail.request_body_values.clone())?; - } - if let Some(provider_code) = &detail.provider_code { - obj.set("providerCode", provider_code.as_str())?; - } - if !detail.message.is_empty() { - obj.set("providerMessage", detail.message.as_str())?; - } - if let Some(response_body) = &detail.response_body { - obj.set("responseBody", response_body.as_str())?; - } - if let Some(headers) = &detail.response_headers { - obj.set("responseHeaders", headers.clone())?; - } - if let Some(data) = &detail.data { - obj.set("data", data.clone())?; - } - } - AiMuxError::Retry(retry) => { - let reason = match retry.reason { - RetryErrorReason::MaxRetriesExceeded => "maxRetriesExceeded", - RetryErrorReason::ErrorNotRetryable => "errorNotRetryable", - }; - obj.set("reason", reason)?; - let mut attempts = Vec::with_capacity(retry.errors.len()); - for attempt in &retry.errors { - attempts.push(aimux_error_object(env, attempt)?); - } - obj.set("errors", attempts)?; - } - AiMuxError::NoSuchModel { - model_id, - model_type, - } => { - obj.set("modelId", model_id.as_str())?; - if !model_type.is_empty() { - obj.set("modelType", model_type.as_str())?; - } - } - AiMuxError::NoSuchProvider { - provider_id, - model_id, - model_type, - available_providers, - } => { - obj.set("providerId", provider_id.as_str())?; - obj.set("modelId", model_id.as_str())?; - obj.set("modelType", model_type.as_str())?; - obj.set("availableProviders", available_providers.clone())?; - } - AiMuxError::LoadApiKey { - env_var, - description, - } => { - obj.set("envVar", env_var.as_str())?; - obj.set("description", description.as_str())?; - } - AiMuxError::LoadSetting { env_var, name } => { - obj.set("envVar", env_var.as_str())?; - obj.set("settingName", name.as_str())?; - } - AiMuxError::NoSuchTool { - tool_name, - available_tools, - tool_input, - } => { - obj.set("toolName", tool_name.as_str())?; - if let Some(input) = tool_input { - obj.set("toolInput", input.as_str())?; - } - if let Some(tools) = available_tools { - obj.set("availableTools", tools.clone())?; - } - } - AiMuxError::InvalidToolInput { - tool_name, - tool_input, - .. - } => { - obj.set("toolName", tool_name.as_str())?; - obj.set("toolInput", tool_input.as_str())?; - } - AiMuxError::ToolCallRepair { original_error, .. } => { - obj.set("originalError", aimux_error_object(env, original_error)?)?; - } - _ => {} - } - Ok(obj) -} - -/// A [`BindingError`] as napi-rs's own plain `Error`: caller-side faults -/// (bad wire JSON, closed handle) → `InvalidArg`, everything else → -/// `GenericFailure`. napi-rs sets `code` from the status when it throws. -pub(crate) fn create_binding_throwable(b: &BindingError) -> Error { - let status = match b { - BindingError::InvalidWireJson { .. } | BindingError::InvalidHandle { .. } => { - Status::InvalidArg - } - BindingError::ResultSerialization { .. } | BindingError::InvariantViolation { .. } => { - Status::GenericFailure - } - }; - Error::new(status, b.to_string()) -} - -/// Fallible return type for napi methods. Converts to a value or rejects/throws -/// the corresponding public error family when `Env` is available on the JS -/// thread. -pub struct AimuxResult(pub(crate) std::result::Result); - -impl ToNapiValue for AimuxResult { - unsafe fn to_napi_value(env: sys::napi_env, val: Self) -> NapiResult { - match val.0 { - Ok(v) => unsafe { T::to_napi_value(env, v) }, - Err(m) => { - let env = Env::from_raw(env); - Err(create_throwable(&env, &m)?) - } +impl ToNapiValue for WireResult { + unsafe fn to_napi_value(env: sys::napi_env, value: Self) -> napi::Result { + match value.0 { + Ok(value) => unsafe { T::to_napi_value(env, value) }, + Err(error) => Err(throwable(&Env::from_raw(env), error)?), } } } -impl TypeName for AimuxResult { +impl TypeName for WireResult { fn type_name() -> &'static str { T::type_name() } - fn value_type() -> ValueType { T::value_type() } } - -/// Internal result alias used by helpers (`?` with [`AiMuxBindingError`]). -pub(crate) type MResult = std::result::Result; - -impl From> for AimuxResult { - fn from(r: MResult) -> Self { - Self(r) - } -} - -/// Item yielded by the stream generator. `AsyncGenerator::next` returns -/// `napi::Result` without an `Env`, so errors ride the channel as typed -/// [`AiMuxBindingError`]s and convert into a structured throwable here, where -/// `ToNapiValue` runs on the JS thread with an `Env` present. -enum StreamItemValue { - Json(String), - Failure(AiMuxBindingError), -} - -pub struct StreamItem { - value: StreamItemValue, -} - -impl StreamItem { - pub(crate) fn json(value: String) -> Self { - Self { - value: StreamItemValue::Json(value), - } - } - - pub(crate) fn failure(error: AiMuxBindingError) -> Self { - Self { - value: StreamItemValue::Failure(error), - } - } -} - -impl ToNapiValue for StreamItem { - unsafe fn to_napi_value(env: sys::napi_env, val: Self) -> NapiResult { - match val.value { - StreamItemValue::Json(s) => unsafe { String::to_napi_value(env, s) }, - StreamItemValue::Failure(m) => { - let env = Env::from_raw(env); - Err(create_throwable(&env, &m)?) - } - } - } -} - -impl TypeName for StreamItem { - fn type_name() -> &'static str { - "string" - } - - fn value_type() -> ValueType { - ValueType::String - } -} - -/// Build the registered JS `RecordingError`. It remains separate from the -/// `AimuxError` hierarchy. -pub(crate) fn create_recording_throwable( - env: &Env, - e: &aimux_core::recording::RecordingError, -) -> NapiResult { - use aimux_core::recording::RecordingError as R; - let code = match e { - R::Init { .. } => "Init", - R::OpenFile { .. } => "OpenFile", - R::Spawn { .. } => "Spawn", - R::WriterGone => "WriterGone", - R::FlushTimeout => "FlushTimeout", - R::Write(_) => "Write", - }; - let message = e.to_string(); - let mut obj = new_registered_error(env, "RecordingError", &message)?; - obj.set("code", code)?; - Ok(Error::from(obj.to_unknown())) -} diff --git a/bindings/node/src/error.ts b/bindings/node/src/error.ts index d5066936a..d00aa3f6b 100644 --- a/bindings/node/src/error.ts +++ b/bindings/node/src/error.ts @@ -1,175 +1,47 @@ -/** - * Aimux error hierarchy (aligned with Vercel AI SDK style). - * - * ```ts - * try { - * await generateText(model, '...') - * } catch (e) { - * if (e instanceof APICallError) { - * // e.status === 429 → rate limited (e.retryMs), 401 → auth, 404 → model - * } else if (e instanceof RetryError) { - * // every retry attempt failed — e.errors is the per-attempt history - * } else if (e instanceof AimuxError) { - * // any AiMuxError - * } else if (e instanceof Error && (e as { code?: string }).code === 'InvalidArg') { - * // the binding rejected an argument (plain Error, napi code) - * } - * } - * ``` - * - * Distinct from the ts-rs wire type `AiMuxError` used inside `StreamPart`. - */ - -export class AimuxError extends Error { - protected constructor(message: string) { - super(message) - this.name = new.target.name +import type { AiMuxError } from './types/AiMuxError' + +export type ErrorDomain = 'aimux' | 'recording' | 'binding' | 'protocol' +export interface ErrorEnvelope { + kind: ErrorDomain + code: number + message: string + error: AiMuxError | ({ name: string } & Record) + retryAfterMs?: number +} + +/** The exact shared ErrorEnvelope, including the AI SDK payload in `error`. */ +export class AimuxError extends Error implements ErrorEnvelope { + readonly kind: ErrorDomain + readonly code: number + readonly error: ErrorEnvelope['error'] + declare readonly retryAfterMs?: number + + constructor(envelope: ErrorEnvelope) { + super(envelope.message) + this.name = 'AimuxError' + this.kind = envelope.kind + this.code = envelope.code + this.error = envelope.error + if (envelope.retryAfterMs !== undefined) this.retryAfterMs = envelope.retryAfterMs } } -/** - * A call that reached (or tried to reach) the provider. Classification is - * `status`; everything below is what that exchange produced, and lives on this - * class alone — the core fills these for no other variant (AI SDK likewise - * keeps `isRetryable` / `responseBody` / `data` on `APICallError`). - */ -export class APICallError extends AimuxError { - /** Whether retrying this provider call may help. */ - declare readonly retryable: boolean - /** HTTP status observed from the provider; absent for transport failures. */ - declare readonly status?: number - /** Rate-limit hint in ms; absent if none; `0` means retry immediately. */ - declare readonly retryMs?: number - /** Sanitized request URL; absent when unknown. */ - declare readonly url?: string - /** Sanitized request body values sent to the provider. */ - declare readonly requestBodyValues?: unknown - /** Provider's machine-readable error code (e.g. `'rate_limit_exceeded'`). */ - declare readonly providerCode?: string - /** Provider's failure text without Aimux's composed prefix. */ - declare readonly providerMessage?: string - /** Raw error response body, verbatim. */ - declare readonly responseBody?: string - /** Sanitized response headers. */ - declare readonly responseHeaders?: Record - /** Parsed provider error data (AI SDK `APICallError.data`). */ - declare readonly data?: unknown -} - -/** Why a {@link RetryError} stopped retrying (core serde wire names). */ -export type RetryErrorReason = 'maxRetriesExceeded' | 'errorNotRetryable' - -/** - * A retried model operation gave up. `errors` is the complete per-attempt - * history, oldest first; each entry is itself a full member of this hierarchy - * (typically {@link APICallError} with its provider detail). - */ -export class RetryError extends AimuxError { - /** Why retrying stopped. */ - declare readonly reason: RetryErrorReason - /** Per-attempt errors, oldest first. */ - declare readonly errors: AimuxError[] - - /** The final attempt's error (`undefined` only for a deserialized empty history). */ - get lastError(): AimuxError | undefined { - return this.errors[this.errors.length - 1] +export function rethrow(error: unknown): never { + if (isEnvelope(error)) { + // Preserve the original native error's stack and all payload fields. + Object.setPrototypeOf(error, AimuxError.prototype) } + throw error } -export class JSONParseError extends AimuxError {} -export class InvalidResponseDataError extends AimuxError {} -export class NoOutputGeneratedError extends AimuxError {} -/** The model called a tool that was not provided (AI SDK `NoSuchToolError`). */ -export class NoSuchToolError extends AimuxError { - /** The tool name the model tried to call. */ - declare readonly toolName: string - /** Tools that were available; absent if none were. */ - declare readonly availableTools?: string[] - /** Original argument text, when available. */ - declare readonly toolInput?: string -} -/** A tool call's input failed to parse or violated the tool's schema (AI SDK `InvalidToolInputError`). */ -export class InvalidToolInputError extends AimuxError { - /** The tool whose input was invalid. */ - declare readonly toolName: string - /** The raw input text the model produced. */ - declare readonly toolInput: string -} -/** The repair callback failed or returned a call that was still invalid. */ -export class ToolCallRepairError extends AimuxError { - /** The failure that triggered repair (a {@link NoSuchToolError} or {@link InvalidToolInputError}). */ - declare readonly originalError: NoSuchToolError | InvalidToolInputError -} -export class InvalidArgumentError extends AimuxError {} -export class InvalidPromptError extends AimuxError {} -/** - * No API key was passed and the fallback environment variable is unset (AI SDK - * `LoadAPIKeyError`). Raised when the request is made, before any HTTP call. - */ -export class LoadAPIKeyError extends AimuxError { - /** The environment variable consulted as the fallback, e.g. `'OPENAI_API_KEY'`. */ - declare readonly envVar: string - /** What the key is for, e.g. `'OpenAI'`. */ - declare readonly description: string +export function isEnvelope(value: unknown): value is ErrorEnvelope { + if (value === null || typeof value !== 'object') return false + const e = value as Partial + return ['aimux', 'recording', 'binding', 'protocol'].includes(e.kind as string) + && typeof e.code === 'number' && typeof e.message === 'string' + && e.error !== null && typeof e.error === 'object' && typeof e.error.name === 'string' } -/** - * A required provider setting was not passed and its fallback environment - * variable is unset (AI SDK `LoadSettingError`), e.g. the AWS region. - */ -export class LoadSettingError extends AimuxError { - /** The environment variable consulted as the fallback. */ - declare readonly envVar: string - /** The setting's parameter name, e.g. `'region'`. */ - declare readonly settingName: string -} -export class TokenExpiredError extends AimuxError { - /** Token expiry is produced only from an observed 401 response. */ - declare readonly status: 401 -} -export class UnsupportedFunctionalityError extends AimuxError {} -/** Registry lookup failed for a model id (AI SDK `NoSuchModelError`). */ -export class NoSuchModelError extends AimuxError { - /** The model id that did not resolve. */ - declare readonly modelId: string - /** Model category requested (`'languageModel'`, `'imageModel'`, …). */ - declare readonly modelType?: string -} -/** Registry lookup failed for a provider name (AI SDK `NoSuchProviderError`). */ -export class NoSuchProviderError extends AimuxError { - /** The provider name that did not resolve. */ - declare readonly providerId: string -} -export class TimeoutError extends AimuxError {} -/** Request aborted (not DOM `AbortError`); `message` is the abort payload. */ -export class RequestAbortedError extends AimuxError {} -export class OtherError extends AimuxError {} -/** - * Which recording failure a {@link RecordingError} reports — the core's - * `recording::RecordingError` variant name. Only `WriterGone`, `FlushTimeout` - * and `Write` can come out of a flush today. - */ -export type RecordingErrorCode = - | 'Init' // create_dir_all failed - | 'OpenFile' // opening recordings.jsonl failed - | 'Spawn' // writer thread could not be spawned - | 'WriterGone' // writer thread unavailable (unwritable dir) - | 'FlushTimeout' // no writer ack within 30s - | 'Write' // a prior write failed (sticky, e.g. ENOSPC) - -/** - * The recorder could not confirm data on disk (`recordingTryFlush`). - * - * The core has two unrelated error types — `AiMuxError` and - * `recording::RecordingError` — and so does this binding: this class extends - * `Error` directly, not {@link AimuxError}. Catch it separately. - */ -export class RecordingError extends Error { - /** Which recording failure. */ - declare readonly code: RecordingErrorCode - - private constructor(message: string) { - super(message) - this.name = new.target.name - } +export function protocolError(code: number, name: string, message: string): AimuxError { + return new AimuxError({ kind: 'protocol', code, message, error: { name, message } }) } diff --git a/bindings/node/src/generated/ops.ts b/bindings/node/src/generated/ops.ts new file mode 100644 index 000000000..f5565cd0e --- /dev/null +++ b/bindings/node/src/generated/ops.ts @@ -0,0 +1,76 @@ +// Generated by aimux-codegen. Do not edit. Manifest SHA-256: 2bc7cb7ec77a0ed20496411f0dbd199b949b9887cc74d119e11627c8573d82a2 +export const ABI_VERSION = 65536 as const; +export const OPS_VERSION = "1.0" as const; +export const MANIFEST_DIGEST = "2bc7cb7ec77a0ed20496411f0dbd199b949b9887cc74d119e11627c8573d82a2" as const; +export const OP_ABORT_CREATE = "abort.create" as const; +export const OP_ABORT_SIGNAL = "abort.signal" as const; +export const OP_AUTH_CODEX_REFRESH = "auth.codexRefresh" as const; +export const OP_CATALOGUE_FETCH = "catalogue.fetch" as const; +export const OP_COMPOSITE_MOA = "composite.moa" as const; +export const OP_COMPOSITE_ROUTER = "composite.router" as const; +export const OP_CREDENTIAL_CREATE = "credential.create" as const; +export const OP_DECISION_CAPABILITIES = "decision.capabilities" as const; +export const OP_DECISION_DECIDE = "decision.decide" as const; +export const OP_EMBEDDING_EMBED = "embedding.embed" as const; +export const OP_EMBEDDING_EMBED_MANY = "embedding.embedMany" as const; +export const OP_FILES_UPLOAD = "files.upload" as const; +export const OP_HANDLE_DROP = "handle.drop" as const; +export const OP_IMAGE_GENERATE = "image.generate" as const; +export const OP_MIDDLEWARE_CREATE = "middleware.create" as const; +export const OP_MODEL_INFO = "model.info" as const; +export const OP_MODEL_RESOLVE = "model.resolve" as const; +export const OP_MODEL_WRAP = "model.wrap" as const; +export const OP_OPERATION_CANCEL = "operation.cancel" as const; +export const OP_OPERATION_INPUT_DONE = "operation.inputDone" as const; +export const OP_OPERATION_NEXT = "operation.next" as const; +export const OP_OPERATION_PUSH = "operation.push" as const; +export const OP_OPERATION_STATUS = "operation.status" as const; +export const OP_OUTPUT_TO_OPEN_AI = "output.toOpenAI" as const; +export const OP_PROVIDER_CREATE = "provider.create" as const; +export const OP_PROVIDER_CUSTOM = "provider.custom" as const; +export const OP_PROVIDER_DEFAULT = "provider.default" as const; +export const OP_PROVIDER_FILES = "provider.files" as const; +export const OP_PROVIDER_INVOKE = "provider.invoke" as const; +export const OP_PROVIDER_MODEL = "provider.model" as const; +export const OP_PROVIDER_WRAP = "provider.wrap" as const; +export const OP_RECORDER_ATTACH = "recorder.attach" as const; +export const OP_RECORDER_CREATE = "recorder.create" as const; +export const OP_RECORDER_EXPORT = "recorder.export" as const; +export const OP_RECORDER_FLUSH = "recorder.flush" as const; +export const OP_RECORDER_STATS = "recorder.stats" as const; +export const OP_RECORDER_STOP = "recorder.stop" as const; +export const OP_REGISTRY_CREATE = "registry.create" as const; +export const OP_REGISTRY_PROVIDER = "registry.provider" as const; +export const OP_REPLAY_CAPABILITIES = "replay.capabilities" as const; +export const OP_REPLAY_CREATE = "replay.create" as const; +export const OP_REPLAY_DRY_RUN = "replay.dryRun" as const; +export const OP_REPLAY_INSPECT = "replay.inspect" as const; +export const OP_REPLAY_LIVE = "replay.live" as const; +export const OP_RERANKING_RERANK = "reranking.rerank" as const; +export const OP_RUNTIME_CONFIGURE = "runtime.configure" as const; +export const OP_RUNTIME_DESCRIBE = "runtime.describe" as const; +export const OP_SEARCH_SEARCH = "search.search" as const; +export const OP_SESSION_STORE_ATTACH = "sessionStore.attach" as const; +export const OP_SESSION_STORE_CALLS = "sessionStore.calls" as const; +export const OP_SESSION_STORE_CREATE = "sessionStore.create" as const; +export const OP_SESSION_STORE_LIST = "sessionStore.list" as const; +export const OP_SPEECH_GENERATE = "speech.generate" as const; +export const OP_TEXT_CONSUME = "text.consume" as const; +export const OP_TEXT_GENERATE = "text.generate" as const; +export const OP_TEXT_GENERATE_OBJECT = "text.generateObject" as const; +export const OP_TEXT_GENERATE_OPEN_AI = "text.generateOpenAI" as const; +export const OP_TEXT_STREAM = "text.stream" as const; +export const OP_TEXT_STREAM_OPEN_AI = "text.streamOpenAI" as const; +export const OP_TOOL_REPAIR_APPLY = "toolRepair.apply" as const; +export const OP_TOOL_REPAIR_APPLY_RESULT = "toolRepair.applyResult" as const; +export const OP_TOOL_REPAIR_CONTEXT = "toolRepair.context" as const; +export const OP_TRACE_AGGREGATE = "trace.aggregate" as const; +export const OP_TRACE_CLEAR = "trace.clear" as const; +export const OP_TRACE_CREATE = "trace.create" as const; +export const OP_TRACE_EXPORT = "trace.export" as const; +export const OP_TRACE_SESSION_CHAIN = "trace.sessionChain" as const; +export const OP_TRACE_SESSION_TRAJECTORY = "trace.sessionTrajectory" as const; +export const OP_TRANSCRIPTION_GENERATE = "transcription.generate" as const; +export const OP_TRANSCRIPTION_START = "transcription.start" as const; +export const OP_TRANSPORT_CREATE = "transport.create" as const; +export const OP_VIDEO_GENERATE = "video.generate" as const; diff --git a/bindings/node/src/index.ts b/bindings/node/src/index.ts index f1ee00fd0..c7787910e 100644 --- a/bindings/node/src/index.ts +++ b/bindings/node/src/index.ts @@ -1,681 +1,285 @@ -// aimux — Typed wrapper layer for the Node.js (napi-rs) binding. -// -// The native binding speaks JSON strings: -// generateText(prompt: string, options?: string): Promise -// streamText(prompt: string, options?: string): Promise> -// Users would otherwise have to JSON.stringify every input and JSON.parse every -// output, with no static types. This wrapper erases that JSON boundary: inputs -// and outputs are typed objects, using the ts-rs generated types from -// `./types.ts` (ts-rs exports directly into `./types/` — single source of -// truth in the Rust core, packaged with the npm tarball). -// -// The generated napi loader stays untouched. `native.ts` registers the canonical -// JS Error constructors; this file adds the typed JSON layer on top. - -import * as native from './native.ts' -import { - AbortBridge, - applyToolCallRepair, - applyToolCallRepairToResult, - createProvider as rawCreateProvider, - getModelSpecs as rawGetModelSpecs, - toolCallRepairContext, -} from './native.ts' -import type { Model, ProviderConfig, ProviderHandle as RawProviderHandle } from './native.ts' - -// Canonical ts-rs generated types (local copy, packaged with the npm tarball). -// These are type-only imports, so they are fully erased at runtime (the -// wrapper only touches the registered raw entrypoint). +import * as ops from './generated/ops.ts' +import { Runtime, type Abort as RawAbort, type Manifest } from './native.ts' +import { AimuxError, protocolError, type ErrorEnvelope } from './error.ts' import type { - GenerateTextOptions, - GenerateTextResult, - StreamPart, - TextStreamPart, - ModelMessage, - Tool, - ToolChoice, - ToolCall, - TypedToolCall, - TypedSource, - ToolResult, - Usage, - InputTokenUsage, - OutputTokenUsage, - LanguageModelUsage, - InputTokenDetails, - OutputTokenDetails, - StepResult, - StepResponse, - StepResponseMetadata, - StepModel, - StepResultPerformance, - FinishReasonUnified, - Output, - GenerateObjectResult, - GenerateObjectResponse, - ContentPart, - ReasoningPart, - GeneratedFile, - GeneratedFileOutput, - LanguageModelFile, - Source, - GeneratedFileData, - RawToolApprovalRequest, - ToolApprovalRequestOutput, - TypedToolResult, - TaggedToolResult, - TypedToolError, - ToolOutputDenied, - ToolApprovalResponseOutput, - FinishReason, - Warning, - UserContent, - AssistantContent, - UserContentPart, - AssistantContentPart, - ToolContentPart, - ToolResultPart, - ToolResultOutput, - ResponseFormat, - ReasoningEffort, - GenerateResult, - FunctionTool, - SessionCall, - SessionSource, - SessionView, - ChatCompletion, - ChatCompletionChunk, - - ModelSpec, - RuntimeModel, - VideoCallOptions, - VideoPollOptions, - AiMuxError, - JsonValue, - RawToolCall, - ToolCallRepairReply, - DecideOptions, - DecideResult, - DecisionQuestionType, -} from './types' + DecideOptions, DecideResult, GenerateTextOptions, GenerateTextResult, GenerateObjectResult, ModelPrompt, + TextStreamPart, EmbeddingCallOptions, EmbeddingResult, ImageCallOptions, ImageResult, SpeechCallOptions, + SpeechResult, TranscriptionCallOptions, TranscriptionResult, RerankingCallOptions, RerankingResult, + VideoCallOptions, VideoResult, TranscriptionStreamPart, SearchCallOptions, SearchResult, + UploadFileCallOptions, UploadFileResult, TraceFilter, TraceStats, SessionChainView, +} from './types.ts' + +export * from './error.ts' +export type * from './types.ts' +export type { Manifest } from './native.ts' + +export type HandleKind = 'Provider' | 'Registry' | 'Language' | 'Embedding' | 'Image' | 'Speech' + | 'Transcription' | 'Reranking' | 'Video' | 'Search' | 'Files' | 'Operation' | 'Abort' + | 'Fetch' | 'WebSocket' | 'Download' | 'CredentialResolver' | 'Middleware' | 'TraceStore' + | 'Recorder' | 'SessionStore' | 'ReplaySession' | 'Decision' +export interface HandleRef { handle: string; kind: HandleKind } +export type Frame = { type: 'event'; seq: string; event: T } + | { type: 'end'; seq: string; outcome: 'success' | 'error' | 'cancelled'; error?: ErrorEnvelope } +export interface CallOptions { signal?: AbortSignal; abort?: Abort } +export type PollResult = { type: 'part'; part: T } | { type: 'ended' } | { type: 'timeout' } +export interface ProviderSettings { [key: string]: unknown } +export interface ProviderParams { package: string; settings?: ProviderSettings } +export type ModelMethod = 'languageModel' | 'embeddingModel' | 'imageModel' | 'speechModel' + | 'transcriptionModel' | 'rerankingModel' | 'videoModel' | 'searchModel' | 'decision' +export interface ModelRef { namespace: string; key: string; method: ModelMethod; modelId: string } +export interface ModelInfo { provider: string; modelId: string; modality: string; [key: string]: unknown } + +function json(value: unknown): string { + try { + const serialized = JSON.stringify(value, (_key, item: unknown) => { + if (typeof item === 'function' || typeof item === 'symbol') throw new TypeError('Ops parameters must contain JSON data only') + return item + }) + if (serialized === undefined) throw new TypeError('Parameters must be JSON data') + return serialized + } catch (error) { + throw new AimuxError({ kind: 'binding', code: 202, message: `params: ${String(error)}`, error: { name: 'InvalidWireJson', argument: 'params', message: String(error) } }) + } +} -// Error hierarchy (throw/catch). Wire payload type `AiMuxError` lives under StreamPart only. -export { - AimuxError, - APICallError, - RetryError, - type RetryErrorReason, - JSONParseError, - InvalidResponseDataError, - NoOutputGeneratedError, - NoSuchToolError, - InvalidToolInputError, - ToolCallRepairError, - InvalidArgumentError, - InvalidPromptError, - LoadAPIKeyError, - LoadSettingError, - TokenExpiredError, - UnsupportedFunctionalityError, - NoSuchModelError, - NoSuchProviderError, - TimeoutError, - RequestAbortedError, - OtherError, - RecordingError, - type RecordingErrorCode, -} from './error.ts' +/** A Client owns a realm-local runtime; IDs must never be transferred to workers. */ +export class Client { + readonly raw: Runtime + constructor() { this.raw = new Runtime() } + get manifest(): Manifest { return this.raw.manifest } + get manifestDigest(): string { return this.raw.manifestDigest } -// Re-export the raw napi constructors/factories so consumers can do everything -// from a single import: `import { openai, generateText } from 'aimux'`. -// Rust fn names are snake_case; napi-rs exposes them camelCased (like -// `init_logging` → `initLogging`). -// -// Native functions pass through unchanged: Rust already constructs the -// registered JavaScript error subclasses before throwing. -export { - Model, - StreamTextGenerator, - AbortBridge, - initLogging, - recordingFlush, - recordingStop, - initSessionStore, - initSessionInfer, - sessionCalls, - listSessions, - mockReplay, - initRecordingRing, - router, - moa, - openai, - anthropic, - deepseek, - google, - cohere, - mistral, - xai, - bedrock, - vertex, - anthropicAws, - azure, - provider, - DecisionModel, - type ProviderHandle, -} from './native.ts' + async call(handle: string, op: string, params: unknown = {}, options: CallOptions = {}): Promise { + const abort = this.bridge(options) + return JSON.parse(await this.raw.call(handle, op, json(params), abort)) as T + } -// Spell these as `void` in the public wrapper instead of leaking the generated -// `AimuxResult` implementation type. -export const recordingTryFlush: () => void = native.recordingTryFlush -export const initRecording: (dir: string) => void = native.initRecording + async stream(handle: string, op: string, params: unknown = {}, options: CallOptions = {}): Promise>> { + const abort = this.bridge(options) + const inner = await this.raw.stream(handle, op, json(params), abort) + return mapIterator(inner, value => JSON.parse(value) as Frame, () => abort?.signal()) + } -// Public type surface — typed objects, no `any`. -export type { - GenerateTextOptions, - GenerateTextResult, - StreamPart, - TextStreamPart, - ModelMessage, - Tool, - ToolChoice, - ToolCall, - TypedToolCall, - TypedSource, - ToolResult, - Usage, - InputTokenUsage, - OutputTokenUsage, - LanguageModelUsage, - InputTokenDetails, - OutputTokenDetails, - StepResult, - StepResponse, - StepResponseMetadata, - StepModel, - StepResultPerformance, - FinishReasonUnified, - Output, - GenerateObjectResult, - GenerateObjectResponse, - ContentPart, - ReasoningPart, - GeneratedFile, - GeneratedFileOutput, - LanguageModelFile, - Source, - GeneratedFileData, - RawToolApprovalRequest, - ToolApprovalRequestOutput, - TypedToolResult, - TaggedToolResult, - TypedToolError, - ToolOutputDenied, - ToolApprovalResponseOutput, - FinishReason, - Warning, - UserContent, - AssistantContent, - UserContentPart, - AssistantContentPart, - ToolContentPart, - ToolResultPart, - ToolResultOutput, - ResponseFormat, - ReasoningEffort, - GenerateResult, - FunctionTool, - SessionCall, - SessionSource, - SessionView, - ChatCompletion, - ChatCompletionChunk, + private bridge(options: CallOptions): RawAbort | undefined { + if (options.abort) { + if (options.signal) throw protocolError(301, 'InvalidParams', 'Pass either signal or abort') + return options.abort.native + } + return options.signal ? this.raw.abort(options.signal) : undefined + } - ModelSpec, - RuntimeModel, - VideoCallOptions, - VideoPollOptions, - RawToolCall, - ToolCallRepairReply, - DecideOptions, - DecideResult, + async provider(params: ProviderParams): Promise { return new Provider(this, await this.call('0', ops.OP_PROVIDER_CREATE, params)) } + async defaultProvider(params: unknown): Promise { return new Provider(this, await this.call('0', ops.OP_PROVIDER_DEFAULT, params)) } + async customProvider(params: unknown): Promise { return new Provider(this, await this.call('0', ops.OP_PROVIDER_CUSTOM, params)) } + async registry(entries: Record, namespace = ''): Promise { + const refs = Object.entries(entries).map(([key, provider]) => ({ key, provider: provider.handle })) + return new Registry(this, await this.call('0', ops.OP_REGISTRY_CREATE, { namespace, entries: refs })) + } + abort(signal?: AbortSignal): Abort { return new Abort(this, signal) } + async configure(params: unknown): Promise { return this.call('0', ops.OP_RUNTIME_CONFIGURE, params) } + async describe(): Promise { return this.call('0', ops.OP_RUNTIME_DESCRIBE) } + async trace(params: unknown = {}): Promise { return new TraceStore(this, handleRef(await this.call('0', ops.OP_TRACE_CREATE, params), 'TraceStore')) } + async recorder(params: unknown): Promise { return new Recorder(this, handleRef(await this.call('0', ops.OP_RECORDER_CREATE, params), 'Recorder')) } + async sessionStore(params: { infer: boolean }): Promise { return new SessionStore(this, handleRef(await this.call('0', ops.OP_SESSION_STORE_CREATE, params), 'SessionStore')) } + async replay(params: unknown): Promise { return new ReplaySession(this, handleRef(await this.call('0', ops.OP_REPLAY_CREATE, params), 'ReplaySession')) } + async middleware(params: unknown): Promise { return new Middleware(this, await this.call('0', ops.OP_MIDDLEWARE_CREATE, params)) } + async transport(params: unknown): Promise { return new Resource(this, await this.call('0', ops.OP_TRANSPORT_CREATE, params)) } + async credential(params: unknown): Promise { return new Resource(this, await this.call('0', ops.OP_CREDENTIAL_CREATE, params)) } + async catalogue(params: unknown): Promise { return this.call('0', ops.OP_CATALOGUE_FETCH, params) } + async router(params: unknown): Promise { return new LanguageModel(this, handleRef(await this.call('0', ops.OP_COMPOSITE_ROUTER, params), 'Language')) } + async moa(params: unknown): Promise { return new LanguageModel(this, handleRef(await this.call('0', ops.OP_COMPOSITE_MOA, params), 'Language')) } } -/** - * Answer questions about one shared state (AI SDK `decide`): validates the - * input, retries transient failures (two retries by default), validates the - * answers and completes usage and response information. Same JSON contract - * as Rust and C. - */ -export async function decide( - model: native.DecisionModel, - options: DecideOptions, - signal?: AbortSignal, -): Promise { - const bridge = signal ? new AbortBridge(signal) : undefined - return JSON.parse(await model.decide(JSON.stringify(options), bridge)) as DecideResult +// Finalization is a fallback. Explicit close/flush remains the normal protocol. +const finalizer = new FinalizationRegistry<{ client: Client; handle: string }>(held => { + try { held.client.raw.drop(held.handle) } catch { /* Env cleanup owns remaining resources. */ } +}) + +export class Resource { + readonly client: Client + readonly kind: HandleKind + #handle: string | undefined + constructor(client: Client, ref: HandleRef) { + this.client = client + this.kind = ref.kind + this.#handle = ref.handle + finalizer.register(this, { client, handle: ref.handle }, this) + } + get handle(): string { + if (this.#handle === undefined) throw new AimuxError({ kind: 'binding', code: 203, message: 'Handle is closed', error: { name: 'InvalidHandle', expected: [this.kind] } }) + return this.#handle + } + async close(): Promise { + const handle = this.#handle + if (handle === undefined) return + this.#handle = undefined + finalizer.unregister(this) + this.client.raw.drop(handle) + } + protected call(op: string, params: unknown = {}, options?: CallOptions): Promise { return this.client.call(this.handle, op, params, options) } } -/** - * The DecisionModelV4 properties of a decision model, without a network - * request, named as the AI SDK names them (`decision-model-v4.ts:7-11`). - */ -export interface DecisionModelDescription { - specificationVersion: 'v4' - provider: string - modelId: string - supportedQuestionTypes: DecisionQuestionType[] +export class Abort { + readonly client: Client + readonly native: RawAbort + constructor(client: Client, signal?: AbortSignal) { this.client = client; this.native = client.raw.abort(signal) } + get handle(): string { return this.native.handle } + get aborted(): boolean { return this.native.aborted } + signal(): void { this.native.signal() } + close(): void { this.native.close() } } -/** Describe a native decision model without making a network request. */ -export function describeDecisionModel(model: native.DecisionModel): DecisionModelDescription { - return JSON.parse(model.describe()) as DecisionModelDescription +export class Provider extends Resource { + async model(modelId: string, method?: ModelMethod): Promise { + return model(this.client, await this.call(ops.OP_PROVIDER_MODEL, { modelId, ...(method ? { method } : {}) })) + } + async invoke(method: string, call?: CallOptions): Promise { return this.call(ops.OP_PROVIDER_INVOKE, { method }, call) } + async files(): Promise { return new Files(this.client, handleRef(await this.call(ops.OP_PROVIDER_FILES), 'Files')) } + async wrap(language: Middleware[] = [], image: Middleware[] = []): Promise { + return new Provider(this.client, await this.call(ops.OP_PROVIDER_WRAP, { languageModelMiddleware: language.map(value => value.handle), imageModelMiddleware: image.map(value => value.handle) })) + } } - -/** How a System One endpoint produces probabilities (reported as provider metadata). */ -export type DecisionProbabilitySource = 'native' | 'logit_scoring' | 'model_estimate' - -/** Declare how a hosted or self-hosted System One endpoint produces probabilities. */ -export async function jevDecision( - apiKey: string, - modelId: string, - endpoint?: string, - probabilitySource?: DecisionProbabilitySource, -): Promise { - return native.jevDecision(apiKey, modelId, endpoint, probabilitySource) +export class Registry extends Resource { + async provider(key: string): Promise { return new Provider(this.client, await this.call(ops.OP_REGISTRY_PROVIDER, { key })) } + async resolve(modelRef: ModelRef): Promise { return model(this.client, await this.call(ops.OP_MODEL_RESOLVE, { modelRef })) } } - -/** - * A raw napi `Model` instance returned by `openai()` / `anthropic()` / … - * - * The wrapper accepts one of these and hides the JSON-string boundary behind - * typed inputs and outputs. `RawModel` is just an alias for the napi `Model` - * class instance type — pass the exact object a provider factory gives you. - */ -export type RawModel = Model - -export type { - DecisionAnswer, - DecisionBooleanCriteria, - DecisionCallOptions, - DecisionInput, - DecisionQuestion, - DecisionQuestionType, - DecisionResult, - DecisionRounding, - DecisionUsage, - DecideUsage, -} from './types' - -// ───────────────────────────────────────────────────────────────────────────── -// Tool-call repair (RFC-0035) — host-side, mirroring AI SDK `repairToolCall` -// ───────────────────────────────────────────────────────────────────────────── - -/** The argument passed to a {@link RepairToolCall} function. */ -export type ToolCallRepairContext = { - /** The failed call, tagged as the AI SDK's `LanguageModelV4ToolCall`. */ - toolCall: { type: 'tool-call' } & RawToolCall - /** The lookup / parse / validation failure that made the call invalid. */ - error: AiMuxError - /** JSON Schema of the called tool; the empty-object schema when unknown. */ - inputSchema: JsonValue - tools: Tool[] - messages: ModelMessage[] - instructions: string | null +export class Model extends Resource { + async info(): Promise { return this.call(ops.OP_MODEL_INFO) } + async wrap(middleware: Middleware[]): Promise { + return model(this.client, await this.call(ops.OP_MODEL_WRAP, { middleware: middleware.map(value => value.handle) })) + } } - -/** - * Repairs one invalid tool call, mirroring the AI SDK's `repairToolCall`. - * - * Return a replacement call, or `null` to leave the call invalid with its - * original error. Throwing marks the repair as failed: the call stays invalid - * and carries a {@link ToolCallRepairError} whose cause is the thrown message. - * A returned call is re-parsed and re-validated; if it still does not parse or - * match the schema, the original call stays invalid with that new error - * (not wrapped), as in the AI SDK. - * - * Runs outside any native call, so it may itself call back into aimux (the - * usual implementation asks a model to rewrite the arguments). It is invoked at - * most once per tool call, and never for a call made without a tool set. - */ -export type RepairToolCall = ( - context: ToolCallRepairContext, -) => RawToolCall | null | Promise - -/** - * {@link GenerateTextOptions} plus the host-side `repairToolCall` function. - * - * `repairToolCall` is a function, so `JSON.stringify` drops it — it never - * reaches the provider request. It does **not** apply to the OpenAI-format - * entry points (`generateTextAsOpenai` / `streamTextAsOpenai`): that shape - * carries no `invalid` marker, so there is nothing to drive repair from. - */ -export type GenerateTextOptionsWithRepair = GenerateTextOptions & { - repairToolCall?: RepairToolCall +export class LanguageModel extends Model { + async generate(prompt: ModelPrompt, options: GenerateTextOptions = {}, call?: CallOptions): Promise { return this.call(ops.OP_TEXT_GENERATE, { prompt, options }, call) } + async generateObject(params: unknown, call?: CallOptions): Promise { return this.call(ops.OP_TEXT_GENERATE_OBJECT, params, call) } + async consume(params: unknown, call?: CallOptions): Promise { return this.call(ops.OP_TEXT_CONSUME, params, call) } + async generateOpenAI(params: unknown, call?: CallOptions): Promise { return this.call(ops.OP_TEXT_GENERATE_OPEN_AI, params, call) } + async stream(prompt: ModelPrompt, options: GenerateTextOptions = {}, call?: CallOptions): Promise>> { return this.client.stream(this.handle, ops.OP_TEXT_STREAM, { prompt, options }, call) } + async streamOpenAI(params: unknown, call?: CallOptions): Promise>> { return this.client.stream(this.handle, ops.OP_TEXT_STREAM_OPEN_AI, params, call) } } - -/** - * The AI SDK's `getErrorMessage`, so a thrown value renders exactly as it - * would in the SDK's own `ToolCallRepairError` message: strings verbatim, - * `Error` instances via `toString()` (name included), anything else as JSON. - */ -function errorMessage(e: unknown): string { - if (e == null) return 'unknown error' - if (typeof e === 'string') return e - if (e instanceof Error) return e.toString() - return JSON.stringify(e) +export class EmbeddingModel extends Model { + async embed(params: unknown, call?: CallOptions): Promise { return this.call(ops.OP_EMBEDDING_EMBED, params, call) } + async embedMany(params: EmbeddingCallOptions, call?: CallOptions): Promise { return this.call(ops.OP_EMBEDDING_EMBED_MANY, params, call) } } - -/** - * Run the user's repair function for one invalid call and turn its outcome - * into the wire reply. Returns `null` when the call is not repairable at all - * (no tool set — the AI SDK rule, decided in core). - */ -async function repairReplyFor( - call: unknown, - promptJson: string, - optsJson: string | undefined, - repair: RepairToolCall, -): Promise { - const context = JSON.parse( - toolCallRepairContext(JSON.stringify(call), promptJson, optsJson), - ) as ToolCallRepairContext | null - if (context === null) return null - - let reply: ToolCallRepairReply - try { - const replacement = await repair(context) - reply = replacement ? { type: 'repaired', toolCall: replacement } : { type: 'unchanged' } - } catch (e) { - reply = { type: 'failed', message: errorMessage(e) } - } - return JSON.stringify(reply) +export class ImageModel extends Model { async generate(params: ImageCallOptions, call?: CallOptions): Promise { return this.call(ops.OP_IMAGE_GENERATE, params, call) } } +export class SpeechModel extends Model { async generate(params: SpeechCallOptions, call?: CallOptions): Promise { return this.call(ops.OP_SPEECH_GENERATE, params, call) } } +export class TranscriptionModel extends Model { + async generate(params: TranscriptionCallOptions, call?: CallOptions): Promise { return this.call(ops.OP_TRANSCRIPTION_GENERATE, params, call) } + async start(params: unknown, call?: CallOptions): Promise> { return new Operation(this.client, handleRef(await this.call(ops.OP_TRANSCRIPTION_START, params, call), 'Operation')) } } +export class RerankingModel extends Model { async rerank(params: RerankingCallOptions, call?: CallOptions): Promise { return this.call(ops.OP_RERANKING_RERANK, params, call) } } +export class VideoModel extends Model { async generate(params: VideoCallOptions, call?: CallOptions): Promise { return this.call(ops.OP_VIDEO_GENERATE, params, call) } } +export class SearchModel extends Model { async search(params: SearchCallOptions, call?: CallOptions): Promise { return this.call(ops.OP_SEARCH_SEARCH, params, call) } } +export class DecisionModel extends Model { + async decide(options: DecideOptions, call?: CallOptions): Promise { return this.call(ops.OP_DECISION_DECIDE, { options }, call) } + async capabilities(): Promise { return this.call(ops.OP_DECISION_CAPABILITIES) } +} +export class Files extends Resource { async upload(params: UploadFileCallOptions, call?: CallOptions): Promise { return this.call(ops.OP_FILES_UPLOAD, params, call) } } +export class Middleware extends Resource {} -/** - * Repair every invalid call in a serialized generate-text result, before it is - * decoded. Its `toolCalls` are the final step's calls. - */ -async function repairResultJson( - resultJson: string, - promptJson: string, - optsJson: string | undefined, - repair: RepairToolCall, -): Promise { - const result = JSON.parse(resultJson) as { toolCalls?: ToolCall[] } - const calls = result.toolCalls ?? [] - - let patched = resultJson - for (const call of calls) { - if (call.invalid !== true) continue - const reply = await repairReplyFor(call, promptJson, optsJson, repair) - if (reply === null) continue - patched = applyToolCallRepairToResult(patched, optsJson, call.toolCallId, reply) - } - return patched +function handleRef(value: string | { handle: string }, kind: HandleKind): HandleRef { + return { handle: typeof value === 'string' ? value : value.handle, kind } } -/** - * Generate text (non-streaming). Returns a typed {@link GenerateTextResult}. - * - * @param model - A raw model instance from `openai()`, `anthropic()`, etc. - * @param prompt - A plain string or an array of typed chat messages. - * @param options - Optional typed generation options (tools, toolChoice, - * temperature, output, …). Invalid tool calls arrive - * with `invalid`/`error` set on the tool call; pass - * {@link RepairToolCall | `repairToolCall`} to fix them up - * before the result is decoded. - * @param signal - Optional `AbortSignal`; aborting it cancels the call. - * - * Internally calls the raw - * `model.generateText(JSON.stringify(prompt), options ? JSON.stringify(options) : undefined)` - * and `JSON.parse`s the returned JSON into a typed object. - * - * @example - * ```ts - * import { openai, generateText } from 'aimux' - * const model = await openai(apiKey, 'gpt-4o') - * const result = await generateText(model, 'What is Rust?') - * console.log(result.text, result.usage) - * ``` - */ -export async function generateText( - model: RawModel, - prompt: string | ModelMessage[], - options?: GenerateTextOptionsWithRepair, - signal?: AbortSignal, -): Promise { - const promptJson = JSON.stringify(prompt) - const optsJson = options ? JSON.stringify(options) : undefined - const bridge = signal ? new AbortBridge(signal) : undefined - let resultJson = await model.generateText(promptJson, optsJson, bridge) - if (options?.repairToolCall) { - resultJson = await repairResultJson(resultJson, promptJson, optsJson, options.repairToolCall) +function model(client: Client, ref: HandleRef): Model { + switch (ref.kind) { + case 'Language': return new LanguageModel(client, ref) + case 'Embedding': return new EmbeddingModel(client, ref) + case 'Image': return new ImageModel(client, ref) + case 'Speech': return new SpeechModel(client, ref) + case 'Transcription': return new TranscriptionModel(client, ref) + case 'Reranking': return new RerankingModel(client, ref) + case 'Video': return new VideoModel(client, ref) + case 'Search': return new SearchModel(client, ref) + case 'Decision': return new DecisionModel(client, ref) + default: throw protocolError(304, 'InvalidState', `Invalid model kind: ${ref.kind}`) } - return JSON.parse(resultJson) as GenerateTextResult } -/** - * Stream text from a model. Yields typed {@link TextStreamPart}s. - * - * @param model - A raw model instance from `openai()`, `anthropic()`, etc. - * @param prompt - A plain string or an array of typed chat messages. - * @param options - Optional typed generation options (tools, toolChoice, …). - * @param signal - Optional `AbortSignal`; aborting it cancels the stream. - * - * Internally drives the raw `model.streamText(JSON.stringify(prompt), …)` - * async generator and `JSON.parse`s each JSON-string chunk before yielding it - * as a typed `TextStreamPart`. - * - * @example - * ```ts - * import { openai, streamText } from 'aimux' - * const model = await openai(apiKey, 'gpt-4o') - * for await (const part of streamText(model, 'Write a haiku about Rust.')) { - * if (part.type === 'text-delta') process.stdout.write(part.text) - * } - * ``` - */ -export async function* streamText( - model: RawModel, - prompt: string | ModelMessage[], - options?: GenerateTextOptionsWithRepair, - signal?: AbortSignal, -): AsyncGenerator { - const promptJson = JSON.stringify(prompt) - const optsJson = options ? JSON.stringify(options) : undefined - const bridge = signal ? new AbortBridge(signal) : undefined - const repair = options?.repairToolCall - const gen = await model.streamText(promptJson, optsJson, bridge) - for await (const json of gen) { - const part = JSON.parse(json) as TextStreamPart - // Only the settled tool-call part is repairable; tool-input-delta parts are - // the provider's raw text and pass through immediately (AI SDK behaviour). - if (repair && part.type === 'tool-call' && part.invalid === true) { - const reply = await repairReplyFor(part, promptJson, optsJson, repair) - if (reply !== null) { - // The native function writes the call with its `type` tag. - yield JSON.parse(applyToolCallRepair(JSON.stringify(part), optsJson, reply)) as TypedToolCall - continue - } +export class Operation extends Resource { + async push(data: Uint8Array): Promise { await this.client.raw.operationPush(this.handle, data) } + async inputDone(): Promise { await this.call(ops.OP_OPERATION_INPUT_DONE) } + async next(timeoutMs: number = 1000): Promise> { return this.call(ops.OP_OPERATION_NEXT, { timeoutMs }) } + async status(): Promise { return this.call(ops.OP_OPERATION_STATUS) } + async cancel(): Promise { return this.call(ops.OP_OPERATION_CANCEL) } + events(): AsyncIterableIterator { + const operation = this + let ended = false + return { + async next() { + if (ended) return { done: true, value: undefined } + try { + for (;;) { + const next = await operation.next() + if (ended) return { done: true, value: undefined } + if (next.type === 'part') return { done: false, value: next.part } + if (next.type === 'ended') { ended = true; return { done: true, value: undefined } } + } + } catch (error) { if (!(error instanceof AimuxError && error.code === 303)) ended = true; throw error } + }, + async return() { ended = true; await operation.cancel(); return { done: true, value: undefined } }, + async throw(error?: unknown) { ended = true; await operation.cancel(); throw error }, + [Symbol.asyncIterator]() { return this }, } - yield part } } -/** - * All calls of a session, ordered by step (RFC-0024). Empty if the session - * is unknown or no store is registered. - * - * `initSessionStore()` / `initSessionInfer(enabled)` (raw napi, re-exported - * above) must be called first to register the store / opt-in inferer. - */ -export function getSessionCalls(sessionId: string): SessionCall[] { - return JSON.parse(native.sessionCalls(sessionId)) as SessionCall[] +export class TraceStore extends Resource { + async aggregate(filter: TraceFilter = {}): Promise { return this.call(ops.OP_TRACE_AGGREGATE, { filter }) } + async sessionChain(sessionId: string): Promise { return this.call(ops.OP_TRACE_SESSION_CHAIN, { sessionId }) } + async sessionTrajectory(sessionId: string): Promise { return this.call(ops.OP_TRACE_SESSION_TRAJECTORY, { sessionId }) } + async export(): Promise { return this.call(ops.OP_TRACE_EXPORT) } + async clear(): Promise { await this.call(ops.OP_TRACE_CLEAR) } } - -/** - * All known sessions (RFC-0024). - */ -export function getSessions(): SessionView[] { - return JSON.parse(native.listSessions()) as SessionView[] +export class Recorder extends Resource { + async attach(): Promise { return this.call(ops.OP_RECORDER_ATTACH) } + async stop(): Promise { return this.call(ops.OP_RECORDER_STOP) } + async flush(): Promise { return this.call(ops.OP_RECORDER_FLUSH) } + async stats(): Promise { return this.call(ops.OP_RECORDER_STATS) } + async export(): Promise { return this.call(ops.OP_RECORDER_EXPORT) } } - -/** - * Generate text and return an OpenAI Chat Completion (non-streaming). - * - * Works with **any** provider — the result is always a standard OpenAI - * `ChatCompletion` object. - * - * With {@link RepairToolCall | `repairToolCall`}, invalid calls are repaired - * on the native result first and the completion is converted from it, so its - * `tool_calls` carry the repaired arguments (a `ChatCompletion` has no - * `invalid` marker to repair from). - * - * @example - * ```ts - * import { openai, generateTextAsOpenai } from 'aimux' - * const model = await openai(apiKey, 'gpt-4o') - * const completion = await generateTextAsOpenai(model, 'What is Rust?') - * console.log(completion.choices[0].message.content) - * ``` - */ -export async function generateTextAsOpenai( - model: RawModel, - prompt: string | ModelMessage[], - options?: GenerateTextOptionsWithRepair, - signal?: AbortSignal, -): Promise { - const promptJson = JSON.stringify(prompt) - const optsJson = options ? JSON.stringify(options) : undefined - const bridge = signal ? new AbortBridge(signal) : undefined - if (options?.repairToolCall) { - let resultJson = await model.generateText(promptJson, optsJson, bridge) - resultJson = await repairResultJson(resultJson, promptJson, optsJson, options.repairToolCall) - return JSON.parse(model.generateTextResultAsOpenai(resultJson)) as ChatCompletion - } - const resultJson = await model.generateTextAsOpenai(promptJson, optsJson, bridge) - return JSON.parse(resultJson) as ChatCompletion +export class SessionStore extends Resource { + async attach(): Promise { return this.call(ops.OP_SESSION_STORE_ATTACH) } + async list(params: unknown = {}): Promise { return this.call(ops.OP_SESSION_STORE_LIST, params) } + async calls(sessionId: string): Promise { return this.call(ops.OP_SESSION_STORE_CALLS, { sessionId }) } } - -/** - * Stream text as OpenAI Chat Completion chunks. - * - * Works with **any** provider — yields standard OpenAI `ChatCompletionChunk` - * objects. Pass `streamOptions` via `providerOptions.openai.stream_options`: - * `{ include_usage: true, include_reasoning: true }` (both default true). - * - * @example - * ```ts - * import { openai, streamTextAsOpenai } from 'aimux' - * const model = await openai(apiKey, 'gpt-4o') - * for await (const chunk of streamTextAsOpenai(model, 'Write a haiku.')) { - * const delta = chunk.choices[0]?.delta?.content - * if (delta) process.stdout.write(delta) - * } - * ``` - */ -export async function* streamTextAsOpenai( - model: RawModel, - prompt: string | ModelMessage[], - options?: GenerateTextOptions, - signal?: AbortSignal, -): AsyncGenerator { - const optsJson = options ? JSON.stringify(options) : undefined - const bridge = signal ? new AbortBridge(signal) : undefined - const gen = await model.streamTextAsOpenai(JSON.stringify(prompt), optsJson, bridge) - for await (const json of gen) { - yield JSON.parse(json) as ChatCompletionChunk - } +export class ReplaySession extends Resource { + async capabilities(): Promise { return this.call(ops.OP_REPLAY_CAPABILITIES) } + async inspect(): Promise { return this.call(ops.OP_REPLAY_INSPECT) } + async dryRun(params: unknown = {}): Promise { return this.call(ops.OP_REPLAY_DRY_RUN, params) } + async live(params: unknown): Promise { return this.call(ops.OP_REPLAY_LIVE, params) } } -// ───────────────────────────────────────────────────────────────────────────── -// Provider handles (RFC-0027) — createProvider / listModels / model -// ───────────────────────────────────────────────────────────────────────────── - -/** - * A typed wrapper around the raw napi `ProviderHandle` class. Created by - * {@link createProvider}, supports {@link ProviderHandleTyped.listModels} and - * {@link ProviderHandleTyped.model}. - * - * @example - * ```ts - * import { createProvider, generateText } from 'aimux' - * const p = await createProvider('deepseek', apiKey) - * const models = await p.listModels() - * const model = await p.model(models[0].id) - * const result = await generateText(model, 'Hello') - * ``` - */ -export class ProviderHandleTyped { - private readonly raw: RawProviderHandle - - constructor(raw: RawProviderHandle) { - this.raw = raw - } - - /** List models available on this provider (runtime discovery + anya2a spec). */ - async listModels(): Promise { - return JSON.parse(await this.raw.listModels()) as RuntimeModel[] - } - - /** Build a language model from a discovered model id. */ - async model(modelId: string): Promise { - return this.raw.model(modelId) - } - - /** Build a native decision model using this provider's connection config. */ - async decisionModel(modelId: string): Promise { - return this.raw.decisionModel(modelId) +function mapIterator(inner: AsyncIterableIterator, map: (value: A) => B, cancel: () => void): AsyncIterableIterator { + let ended = false + return { + async next() { + if (ended) return { done: true, value: undefined } + try { + const result = await inner.next() + if (ended || result.done) { ended = true; return { done: true, value: undefined } } + return { done: false, value: map(result.value) } + } catch (error) { + if (error instanceof AimuxError && error.code === 303) throw error + ended = true; cancel(); if (inner.return) await inner.return().catch(() => {}); throw error + } + }, + async return() { ended = true; cancel(); if (inner.return) await inner.return(); return { done: true, value: undefined } }, + async throw(error?: unknown) { ended = true; cancel(); if (inner.return) await inner.return(); throw error }, + [Symbol.asyncIterator]() { return this }, } } -/** - * Create a **provider handle** (RFC-0027) for a registry-backed provider. - * - * Unlike `provider()` (which binds to a single modelId), this returns a handle - * that supports `listModels()` (runtime discovery) and `model()`. - * - * @param name - Provider name (e.g. `"deepseek"`, `"groq"`). - * @param apiKey - API key; omit/null to read the provider's env var. - * @param config - Optional provider config (baseUrl, headers, …). - * - * @example - * ```ts - * import { createProvider, generateText } from 'aimux' - * const p = await createProvider('deepseek', process.env.DEEPSEEK_API_KEY) - * const models = await p.listModels() - * const model = await p.model(models[0].id) - * const result = await generateText(model, 'Hello') - * ``` - */ -export async function createProvider( - name: string, - apiKey?: string, - config?: ProviderConfig, -): Promise { - const raw = await rawCreateProvider(name, apiKey ?? null, config ?? null) - return new ProviderHandleTyped(raw) -} - -/** - * Fetch the community model catalogue (anya2a). Returns a `Catalogue` object - * with a `lookup(provider, modelId)` method. Thin fetch — no caching; the host - * decides how to persist/reuse the result. - * - * @param sourceUrl - Optional URL override (default = anya2a endpoint). - * - * @example - * ```ts - * import { createProvider, getModelSpecs, generateText } from 'aimux' - * const [p, catalogue] = await Promise.all([ - * createProvider('deepseek', apiKey), - * getModelSpecs(), - * ]) - * const models = await p.listModels() - * const model = await p.model(models[0].id) - * const spec = catalogue.specs?.['deepseek']?.[models[0].id] // community portrait - * ``` - */ -export async function getModelSpecs(sourceUrl?: string): Promise { - return JSON.parse(await rawGetModelSpecs(sourceUrl ?? null)) +/** A pull source with zero prefetch; reader.cancel() immediately invokes return(). */ +export function readable(iterator: AsyncIterableIterator): ReadableStream { + return new ReadableStream({ + async pull(controller) { + try { + const result = await iterator.next() + if (result.done) controller.close() + else controller.enqueue(result.value) + } catch (error) { controller.error(error); if (iterator.return) await iterator.return().catch(() => {}) } + }, + async cancel() { if (iterator.return) await iterator.return() }, + }, { highWaterMark: 0 }) } diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index eff1c9acf..0ab0aa8a3 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -1,1756 +1,422 @@ -//! aimux-node: Node.js binding (napi-rs v3, native path). -//! -//! This is the **flagship binding** — directly uses aimux-providers, bypassing -//! aimux-ffi. napi-rs maps Rust async to JS Promise/AsyncIterator, giving -//! native-TS-quality DX. -//! -//! Design: -//! - Provider model instances live as JS objects backed by Rust `Arc`. -//! - `generateText` returns a Promise (JSON-serialized GenerateTextResult). -//! - `streamText` returns an AsyncGenerator yielding StreamPart JSON strings. -//! - The TS wrapper layer (index.ts) parses JSON into typed objects. - -mod decision; +//! Async napi adapter for aimux-ops. No C ABI or blocking runtime is used. +//! Futures run on napi-rs's Tokio runtime; JS values never cross into those futures. +mod bridge; mod error; -mod multimodal; -pub use decision::*; -pub use multimodal::*; -use std::future::Future; -use std::sync::Arc; +#[cfg(feature = "contract-tests")] +#[path = "../__test__/native_scenarios.rs"] +mod native_scenarios; + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, Weak}; +use std::time::SystemTime; -use crate::error::{ - AiMuxBindingError, AimuxResult, BindingError, MResult, StreamItem, parse_wire_json, - serialize_result, -}; -use aimux_core::AiMuxError; -#[allow(deprecated)] -use aimux_core::generate::generate_object; -use aimux_core::generate::{ - GenerateTextOptions, GenerateTextResult, generate_text, generate_text_as_openai, - generate_text_result_to_chat_completion, stream_text, stream_text_as_openai, -}; -use aimux_core::language_model::LanguageModel; -use aimux_core::message::ModelPrompt; -use aimux_core::openai_output::OpenAiStreamOptions; -use aimux_core::parse_tool_call::{ToolCallRepairReply, tool_call_repair_inputs}; -use aimux_core::tool::ToolCall; -use aimux_providers::provider as providers; +use aimux_core::AbortSignal; +use aimux_ops::dispatch::{self, Ctx}; +use aimux_ops::envelope::{ErrorEnvelope, OpsError, ProtocolError}; +use aimux_ops::handle::{Entry, HandleId, HandleKind, HandleRegistry, Owner, allocate}; use napi::bindgen_prelude::*; use napi_derive::napi; +use serde_json::Value; -// ───────────────────────────────────────────────────────────────────────────── -// Model — a provider model instance accessible from JS. -// ───────────────────────────────────────────────────────────────────────────── +pub use bridge::FrameIterator; +use error::{WireResult, envelope}; -#[napi] -pub struct Model { - inner: Arc, - /// Probe store — `Some` only for traced models (RFC-0015). - trace_store: Option>, +#[derive(Default)] +struct Resources { + closed: bool, + active: HashMap>, + // Operations must keep the context cancellation signal until handle.drop. + operations: HashMap>, } -/// A bridge from a JS `AbortSignal` to the core's runtime cancellation. -/// -/// napi's own `AbortSignal` is not `Send` (it holds `Rc`), so it cannot be a -/// parameter of an async napi method. This class is created synchronously -/// (registering the abort callback on the JS thread) and then passed to -/// `generateText` / `streamText` — it only holds an `Arc` and is `Send`. -/// -/// ```ts -/// const bridge = signal ? new AbortBridge(signal) : undefined -/// await model.generateText(prompt, options, bridge) -/// ``` -#[napi] -pub struct AbortBridge { - signal: Arc, +struct Realm { + owner: Owner, + next_request: AtomicU64, + resources: Mutex, } -#[napi] -impl AbortBridge { - #[napi(constructor, ts_args_type = "signal: AbortSignal")] - pub fn new(env: napi::Env, signal: napi::bindgen_prelude::Object<'_>) -> napi::Result { - use napi::bindgen_prelude::FromNapiValue; - - // napi's on_abort only observes future events. Read the original JS - // state before conversion so a previously aborted signal cancels too. - let already_aborted = signal.get::("aborted")?.unwrap_or(false); - // SAFETY: signal belongs to this callback's environment and remains - // live during conversion; napi validates and registers its own payload. - let signal = unsafe { - napi::bindgen_prelude::AbortSignal::from_napi_value(env.raw(), signal.raw())? - }; - let core = aimux_core::AbortSignal::new(); - let watcher = core.clone(); - signal.on_abort(move || watcher.abort()); - if already_aborted { - core.abort(); - } - Ok(Self { - signal: Arc::new(core), - }) +impl Realm { + fn lock(&self) -> std::sync::MutexGuard<'_, Resources> { + self.resources + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) } - /// Returns `true` once the underlying JS signal has been aborted. - #[napi] - pub fn aborted(&self) -> bool { - self.signal.is_aborted() + fn context(&self, signal: &AbortSignal) -> Ctx { + Ctx { + owner: self.owner, + abort: Some(signal.clone()), + now: SystemTime::now, + } } -} -impl AbortBridge { - fn core_signal(&self) -> aimux_core::AbortSignal { - (*self.signal).clone() + fn request( + self: &Arc, + signal: Arc, + ) -> std::result::Result { + let mut resources = self.lock(); + if resources.closed { + return Err(invalid_state("Node environment is closed")); + } + let id = allocate(&self.next_request).map_err(envelope)?; + resources.active.insert(id, Arc::downgrade(&signal)); + Ok(Request { + realm: self.clone(), + id, + signal, + }) } -} -#[napi] -impl Model { - /// Wrap this model in a cache-probe layer (RFC-0015). The returned - /// model records fingerprints/verdicts on every call and exposes the - /// `traceAggregate` / `traceSessionChain` / `traceExportJsonl` / - /// `traceClear` queries. - #[napi] - pub fn trace(&self) -> Model { - let store = Arc::new(aimux_core::trace::RingTraceStore::new()); - let layer = aimux_core::trace::TraceLayer::new(self.inner.clone(), store.clone()); - Model { - inner: Arc::new(layer), - trace_store: Some(store), + fn remember(&self, result: &Value, signal: &Arc) { + let registry = HandleRegistry::global(); + let mut resources = self.lock(); + if resources.closed { + // A call that finished after cleanup may have created handles. + drop(resources); + signal.abort(); + registry.release_owner(self.owner); + return; + } + // An operation keeps its start call's cancellation signal until handle.drop. + // Only a bare handle-id result can name one; model JSON is never scanned. + let operation = result + .as_str() + .and_then(|id| id.parse().ok()) + .filter(|&id| registry.get(self.owner, id, &[HandleKind::Operation]).is_ok()); + if let Some(HandleId(id)) = operation { + resources.operations.insert(id, signal.clone()); } } - /// Wrap this model in a cache-probe layer with the built-in rules - /// auditor (RFC-0015 §4). `strict` = strict mode (self-hosted single - /// instance); `false` = shared mode (safe default). - #[napi] - pub fn trace_audited(&self, strict: bool) -> Model { - let store = Arc::new(aimux_core::trace::RingTraceStore::new()); - let layer = aimux_core::trace::TraceLayer::new(self.inner.clone(), store.clone()) - .with_rules_auditor(strict); - Model { - inner: Arc::new(layer), - trace_store: Some(store), + fn cleanup(&self) { + let (active, operations) = { + let mut resources = self.lock(); + resources.closed = true; + ( + std::mem::take(&mut resources.active), + std::mem::take(&mut resources.operations), + ) + }; + for signal in active.into_values().filter_map(|signal| signal.upgrade()) { + signal.abort(); + } + for signal in operations.into_values() { + signal.abort(); } + // Release also covers handles returned by raw dispatch calls. + HandleRegistry::global().release_owner(self.owner); } +} - /// Aggregated probe statistics (RFC-0015 §5.3), filtered by a JSON - /// `TraceFilter` (optional). Returns a JSON `TraceStats[]` string. - #[napi] - pub fn trace_aggregate(&self, filter_json: Option) -> AimuxResult { - AimuxResult((|| -> crate::error::MResult { - let Some(store) = &self.trace_store else { - return Err(AiMuxBindingError::from(&AiMuxError::InvalidArgument( - "model is not traced; call trace() first".into(), - ))); - }; - let filter = match filter_json { - Some(f) => parse_wire_json("filter", &f)?, - None => Default::default(), - }; - serialize_result(&store.aggregate(&filter)) - })()) - } +struct Request { + realm: Arc, + id: u64, + signal: Arc, +} - /// One session's chain view (RFC-0015 §5.3). Returns a JSON - /// `SessionChainView` string or an error when the session is unknown. - #[napi] - pub fn trace_session_chain(&self, session_id: String) -> AimuxResult { - AimuxResult((|| -> crate::error::MResult { - let Some(store) = &self.trace_store else { - return Err(AiMuxBindingError::from(&AiMuxError::InvalidArgument( - "model is not traced; call trace() first".into(), - ))); - }; - let view = store.session_chain(&session_id).ok_or_else(|| { - AiMuxBindingError::from(&AiMuxError::InvalidArgument("unknown session".into())) - })?; - serialize_result(&view) - })()) +impl Drop for Request { + fn drop(&mut self) { + self.realm.lock().active.remove(&self.id); } +} - /// One session's per-step cache-hit trajectory (RFC-0024 §4.3). Returns - /// a JSON array of `SessionStepStat` (empty when the session is unknown - /// or the model is not traced). - #[napi] - pub fn trace_session_trajectory(&self, session_id: String) -> AimuxResult { - AimuxResult((|| -> crate::error::MResult { - let Some(store) = &self.trace_store else { - return Err(AiMuxBindingError::from(&AiMuxError::InvalidArgument( - "model is not traced; call trace() first".into(), - ))); - }; - let stats = store.session_cache_trajectory(&session_id); - serialize_result(&stats) - })()) - } +fn invalid_state(message: &str) -> ErrorEnvelope { + envelope(OpsError::Protocol(ProtocolError::InvalidState( + message.to_owned(), + ))) +} - /// Export all probe records as JSONL (one `TraceRecord` per line). - #[napi] - pub fn trace_export_jsonl(&self) -> AimuxResult { - AimuxResult((|| -> crate::error::MResult { - let Some(store) = &self.trace_store else { - return Err(AiMuxBindingError::from(&AiMuxError::InvalidArgument( - "model is not traced; call trace() first".into(), - ))); - }; - let mut buf = Vec::new(); - store - .export_jsonl(&mut buf) - .map_err(|e| BindingError::ResultSerialization { - message: format!("export: {e}"), - })?; - String::from_utf8(buf).map_err(|e| { - BindingError::ResultSerialization { - message: format!("utf8: {e}"), - } - .into() - }) - })()) - } +fn handle(value: &str) -> std::result::Result { + value + .parse() + .map_err(|message| envelope(OpsError::Protocol(ProtocolError::InvalidParams(message)))) +} - /// Clear all probe records of this traced model. - #[napi] - pub fn trace_clear(&self) { - if let Some(store) = &self.trace_store { - store.clear(); +/// All Runtime instances in one napi Env share one unforgeable Owner::Node. +#[napi] +pub struct Runtime { + realm: Arc, +} + +#[napi] +impl Runtime { + #[napi(constructor)] + pub fn new(env: Env) -> napi::Result { + if let Some(realm) = env.get_instance_data::>()? { + return Ok(Self { + realm: realm.clone(), + }); } + let owner = Owner::new_node().map_err(|error| { + error::throwable(&env, envelope(error)).unwrap_or_else(|error| error) + })?; + let realm = Arc::new(Realm { + owner, + next_request: AtomicU64::new(1), + resources: Mutex::default(), + }); + // napi registers its runtime cleanup during module initialization. Node + // runs Env hooks in reverse registration order, so release_owner runs + // here before napi shuts its runtime down (and before JS finalizers). + env.add_env_cleanup_hook(realm.clone(), |realm| realm.cleanup())?; + env.set_instance_data(realm.clone(), (), |_| {})?; + Ok(Self { realm }) } - /// Generate text (non-streaming). - /// - /// `prompt` — a JSON string: bare prompt (`"text"` or `[{...}]`) or `{"prompt": ...}`. - /// `options` — optional JSON-serialized `GenerateTextOptions`. - /// `bridge` — optional `AbortBridge` (wrap a JS `AbortSignal` in one); - /// aborting the signal cancels the call. - /// Returns a JSON-serialized `GenerateTextResult`. - #[napi(ts_return_type = "Promise")] - pub async fn generate_text( - &self, - prompt: String, - options: Option, - bridge: Option<&AbortBridge>, - ) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - let parsed_prompt = parse_prompt(&prompt)?; - let mut opts = parse_opts(options.as_deref())?; - opts.abort_signal = bridge.map(|b| b.core_signal()); - - let result = generate_text(&*self.inner, parsed_prompt, opts) - .await - .map_err(|e| AiMuxBindingError::from(&e))?; - - serialize_result(&result) + /// Nonblocking release for explicit disposal and JS finalization. + #[napi] + pub fn drop_handle(&self, id: String) -> WireResult<()> { + WireResult((|| { + let id = handle(&id)?; + let entry = HandleRegistry::global().remove(self.realm.owner, id); + let signal = self.realm.lock().operations.remove(&id.0); + if let Some(signal) = signal { + signal.abort(); } - .await; - __r - }) - } - - /// Generate a structured JSON object from the model (M12, RFC-0016). - /// - /// Same signature as [`Model::generate_text`]; returns a JSON-serialized - /// `GenerateObjectResult` (core's deprecated `generate_object` wrapper). - /// Pass `output: { "type": "object", "schema": ... }` - /// via `options` to describe the value; the model's text must parse and - /// match the schema. - #[napi(ts_return_type = "Promise")] - #[allow(deprecated)] - pub async fn generate_object( - &self, - prompt: String, - options: Option, - bridge: Option<&AbortBridge>, - ) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - let parsed_prompt = parse_prompt(&prompt)?; - let mut opts = parse_opts(options.as_deref())?; - opts.abort_signal = bridge.map(|b| b.core_signal()); - - let result = generate_object(&*self.inner, parsed_prompt, opts) - .await - .map_err(|e| AiMuxBindingError::from(&e))?; - - serialize_result(&result) + if let Some(Entry::Operation(operation)) = entry { + operation.consumer_close(); } - .await; - __r - }) + Ok(()) + })()) } - /// Consume a stream to completion and return the aggregated result - /// (M11, RFC-0016). - /// - /// Drives `streamText` to completion and returns a JSON-serialized - /// `GenerateTextResult`, the same result `generateText` returns. - #[napi(ts_return_type = "Promise")] - pub async fn consume_stream_text( - &self, - prompt: String, - options: Option, - bridge: Option<&AbortBridge>, - ) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - let parsed_prompt = parse_prompt(&prompt)?; - let mut opts = parse_opts(options.as_deref())?; - opts.abort_signal = bridge.map(|b| b.core_signal()); - - let stream_result = stream_text(&*self.inner, parsed_prompt, opts) - .await - .map_err(|e| AiMuxBindingError::from(&e))?; - let aggregated = stream_result - .consume() - .await - .map_err(|e| AiMuxBindingError::from(&e))?; - - serialize_result(&aggregated) - } - .await; - __r - }) + #[napi] + pub fn manifest_json(&self) -> WireResult { + WireResult(error::serialize(&aimux_ops::manifest::manifest())) } - /// Stream text from the model. - /// - /// Returns an `AsyncGenerator` yielding `TextStreamPart` JSON - /// strings. Use `for await (const part of model.streamText(...))` to - /// consume. - #[napi(ts_return_type = "Promise>")] - pub async fn stream_text( - &self, - prompt: String, - options: Option, - bridge: Option<&AbortBridge>, - ) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - let model = self.inner.clone(); - // Extract the core signal on the napi thread; it is `Send` and can - // move into the spawned task. - let abort_signal = bridge.map(|b| b.core_signal()); - - let (tx, rx) = tokio::sync::mpsc::channel::< - std::result::Result, - >(64); - - // Spawn the stream-driving task immediately on napi's tokio runtime. - napi::tokio::spawn(async move { - let prompt = match parse_prompt(&prompt) { - Ok(p) => p, - Err(e) => { - let _ = tx.send(Err(e)).await; - return; - } - }; - let mut opts = match parse_opts(options.as_deref()) { - Ok(o) => o, - Err(e) => { - let _ = tx.send(Err(e)).await; - return; - } - }; - opts.abort_signal = abort_signal; - - match stream_text(&*model, prompt, opts).await { - Ok(stream_result) => { - use futures::StreamExt; - let mut stream = stream_result.stream; - while let Some(item) = stream.next().await { - match item { - Ok(part) => { - // Not serializable → end the stream with the binding's - // ResultSerialization, never a silent "{}". - let json = match serde_json::to_string(&part) { - Ok(j) => j, - Err(e) => { - let _ = tx - .send(Err(AiMuxBindingError::from( - BindingError::ResultSerialization { - message: format!("stream part: {e}"), - }, - ))) - .await; - break; - } - }; - if tx.send(Ok(json)).await.is_err() { - break; // receiver dropped (JS stopped iterating) - } - } - Err(e) if e.is_recoverable_stream_error() => { - // Core keeps the stream alive across a malformed - // frame; deliver it as a StreamPart::Error data item - // and keep pumping. - match serde_json::to_string( - &aimux_core::stream_part::TextStreamPart::Error { - error: e, - }, - ) { - Ok(json) => { - if tx.send(Ok(json)).await.is_err() { - break; - } - } - Err(_) => break, - } - } - Err(e) => { - let _ = tx.send(Err(AiMuxBindingError::from(&e))).await; - break; - } - } - } - } - Err(e) => { - let _ = tx.send(Err(AiMuxBindingError::from(&e))).await; - } - } - }); - - Ok(StreamTextGenerator { - rx: std::sync::Arc::new(tokio::sync::Mutex::new(Some(rx))), - }) - } - .await; - __r - }) + #[napi] + pub fn manifest_digest(&self) -> String { + aimux_ops::manifest::manifest_digest() } - /// Generate text and return an OpenAI Chat Completion (non-streaming). - /// - /// Returns a JSON-serialized `ChatCompletion`. Works with any provider. #[napi(ts_return_type = "Promise")] - pub async fn generate_text_as_openai( + pub async fn call( &self, - prompt: String, - options: Option, - bridge: Option<&AbortBridge>, - ) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - let parsed_prompt = parse_prompt(&prompt)?; - let mut opts = parse_opts(options.as_deref())?; - opts.abort_signal = bridge.map(|b| b.core_signal()); - - let result = generate_text_as_openai(&*self.inner, parsed_prompt, opts) + id: String, + op: String, + params_json: String, + abort: Option<&Abort>, + ) -> WireResult { + WireResult( + async { + let id = handle(&id)?; + let params = error::parse("params", ¶ms_json)?; + let signal = self.signal(abort)?; + let request = self.realm.request(signal.clone())?; + #[cfg(feature = "contract-tests")] + if op == "test.operation" { + return error::serialize(&native_scenarios::operation(self.realm.owner).await); + } + #[cfg(feature = "contract-tests")] + if op == "test.teardown" { + let realm = Realm { + owner: Owner::new_node().map_err(envelope)?, + next_request: AtomicU64::new(1), + resources: Mutex::default(), + }; + let id = native_scenarios::operation(realm.owner).await; + realm.cleanup(); + let runtime_alive = tokio::spawn(async { true }).await.unwrap(); + return error::serialize(&serde_json::json!({ + "released": native_scenarios::released(realm.owner, id), + "runtimeAlive": runtime_alive, + })); + } + #[cfg(feature = "contract-tests")] + if op == "test.scenario" { + return error::serialize( + &native_scenarios::replay(params, self.realm.owner).await, + ); + } + let result = dispatch::call(self.realm.context(&signal), id, &op, params) .await - .map_err(|e| AiMuxBindingError::from(&e))?; - - serialize_result(&result) + .map_err(envelope)?; + self.realm.remember(&result, &signal); + if op == dispatch::HANDLE_DROP { + let mut resources = self.realm.lock(); + if let Some(signal) = resources.operations.remove(&id.0) { + signal.abort(); + } + } + drop(request); + error::serialize(&result) } - .await; - __r - }) - } - - /// Convert a serialized `GenerateTextResult` into a serialized - /// `ChatCompletion` — the conversion half of `generateTextAsOpenai`. - /// - /// For repairing on the host (RFC-0035): patch the native result, then - /// convert it, so the completion's tool calls are the repaired ones. This - /// model only supplies the fallback model id. - #[napi] - pub fn generate_text_result_as_openai(&self, result_json: String) -> AimuxResult { - AimuxResult((|| -> crate::error::MResult { - let result: GenerateTextResult = parse_wire_json("result_json", &result_json)?; - serialize_result(&generate_text_result_to_chat_completion( - &result, - self.inner.model_id(), - )) - })()) + .await, + ) } - /// Stream text as OpenAI Chat Completion chunks. - /// - /// Returns an `AsyncGenerator` yielding `ChatCompletionChunk` JSON - /// strings. Works with any provider. - #[napi(ts_return_type = "Promise>")] - pub async fn stream_text_as_openai( + /// Copy the caller's buffer on the JS thread before returning the Promise. + #[napi(ts_return_type = "Promise")] + pub fn operation_push<'env>( &self, - prompt: String, - options: Option, - bridge: Option<&AbortBridge>, - ) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - let model = self.inner.clone(); - let abort_signal = bridge.map(|b| b.core_signal()); - - let (tx, rx) = tokio::sync::mpsc::channel::< - std::result::Result, - >(64); - - napi::tokio::spawn(async move { - let prompt = match parse_prompt(&prompt) { - Ok(p) => p, - Err(e) => { - let _ = tx.send(Err(e)).await; - return; - } - }; - let mut opts = match parse_opts(options.as_deref()) { - Ok(o) => o, - Err(e) => { - let _ = tx.send(Err(e)).await; - return; - } - }; - - // Extract OpenAI stream options from providerOptions.openai.stream_options. - let stream_options = opts - .provider_options - .as_ref() - .and_then(|po| po.get("openai")) - .and_then(|o| o.get("stream_options")) - .cloned() - .map(|v| OpenAiStreamOptions { - include_usage: v - .get("include_usage") - .and_then(|b| b.as_bool()) - .unwrap_or(true), - include_reasoning: v - .get("include_reasoning") - .and_then(|b| b.as_bool()) - .unwrap_or(true), - }) - .unwrap_or_default(); - - opts.abort_signal = abort_signal; - - match stream_text_as_openai(&*model, prompt, opts, stream_options).await { - Ok(stream_result) => { - use futures::StreamExt; - let mut stream = stream_result.stream; - while let Some(item) = stream.next().await { - match item { - Ok(chunk) => { - // Not serializable → end the stream with the binding's - // ResultSerialization, never a silent "{}". - let json = match serde_json::to_string(&chunk) { - Ok(j) => j, - Err(e) => { - let _ = tx - .send(Err(AiMuxBindingError::from( - BindingError::ResultSerialization { - message: format!("stream chunk: {e}"), - }, - ))) - .await; - break; - } - }; - if tx.send(Ok(json)).await.is_err() { - break; - } - } - Err(e) if e.is_recoverable_stream_error() => { - // Consumers type this path as ChatCompletionChunk; - // the error cannot ride it — skip and keep pumping - // (full fidelity lives on the StreamPart path). - } - Err(e) => { - let _ = tx.send(Err(AiMuxBindingError::from(&e))).await; - break; - } - } - } - } - Err(e) => { - let _ = tx.send(Err(AiMuxBindingError::from(&e))).await; - } - } - }); - - Ok(StreamTextGenerator { - rx: std::sync::Arc::new(tokio::sync::Mutex::new(Some(rx))), - }) + env: &'env Env, + id: String, + data: Uint8Array, + ) -> napi::Result>> { + let prepared = (|| { + if data.len() > 1024 * 1024 { + return Err(envelope(OpsError::Protocol(ProtocolError::ResourceLimit( + "audio chunk exceeds 1 MiB".into(), + )))); } - .await; - __r - }) + dispatch::operation_push( + &self.realm.context(&AbortSignal::new()), + handle(&id)?, + &data, + ) + .map_err(envelope) + })(); + env.spawn_future(async move { Ok(WireResult(prepared)) }) } -} -/// AsyncGenerator that yields StreamPart JSON strings. -/// -/// The stream is started eagerly in `stream_text()` — a tokio task drives -/// the Rust Stream and sends each chunk through a channel. `next()` simply -/// receives from the channel. -#[napi(async_iterator)] -pub struct StreamTextGenerator { - rx: SharedStreamReceiver, -} - -/// The channel end `next()` drains; `None` once the stream is exhausted. -type SharedStreamReceiver = std::sync::Arc< - tokio::sync::Mutex< - Option>>, - >, ->; - -#[napi] -impl AsyncGenerator for StreamTextGenerator { - type Yield = StreamItem; - type Next = (); - type Return = (); - - fn next( - &mut self, - _value: Option, - ) -> impl Future>> + Send + 'static { - // Clone the Arc so the async block owns it (no borrow of self). - let rx = self.rx.clone(); - async move { - let mut guard = rx.lock().await; - match guard.as_mut() { - Some(rx) => { - match rx.recv().await { - Some(Ok(json)) => Ok(Some(StreamItem::json(json))), - // Converting Failure to a napi value (JS thread, Env - // present) rejects `next()` with a structured error. - Some(Err(m)) => Ok(Some(StreamItem::failure(m))), - None => Ok(None), // stream finished - } - } - None => Ok(None), // already exhausted + #[napi(ts_return_type = "Promise")] + pub async fn stream( + &self, + id: String, + op: String, + params_json: String, + abort: Option<&Abort>, + ) -> WireResult { + WireResult( + async { + let id = handle(&id)?; + let params = error::parse("params", ¶ms_json)?; + let signal = self.signal(abort)?; + let request = self.realm.request(signal.clone())?; + // Validation occurs before an iterator is returned, never as a fabricated first event. + let stream = dispatch::stream(self.realm.context(&signal), id, &op, params) + .map_err(envelope)?; + Ok(bridge::start(stream, request)) } - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Provider constructors — factory functions exposed to JS. -// ───────────────────────────────────────────────────────────────────────────── - -/// Optional provider configuration accepted by factory functions. -/// -/// Passed as the 3rd argument to `openai()` / `anthropic()` / `deepseek()`. -/// For backward compatibility a bare string is still accepted (treated as -/// `baseUrl`). See RFC-0017. -#[napi(object)] -pub struct ProviderConfig { - /// Base URL for API calls (e.g. a relay/proxy endpoint). - pub base_url: Option, - /// Extra HTTP headers merged into every request, as a JSON object string - /// (e.g. `'{"X-Custom":"value"}'`). - pub headers: Option, - /// OpenAI organization ID (sent via `OpenAI-Organization` header). - pub organization: Option, - /// OpenAI project ID (sent via `OpenAI-Project` header). - pub project: Option, - /// Values of a preset's template parameters, named as the registry - /// declares them (e.g. `{ account_id: '…' }`), for providers created by - /// name (`provider()` / `createProvider()`). Only parameters the preset - /// declares are accepted. - pub params: Option>, - /// Rejected with `InvalidArgumentError`: retry is a per-call setting - /// (`maxRetries` in the call options). The field exists only so passing it - /// is reported instead of ignored. - pub max_retries: Option, - /// Rejected with `InvalidArgumentError`: request-body overrides were - /// removed. The field exists only so passing it is reported instead of - /// ignored. - pub body_overrides: Option, -} - -impl ProviderConfig { - /// Fail on the two removed keys instead of dropping them silently. - fn reject_removed_keys(&self) -> MResult<()> { - providers::reject_removed_provider_options( - self.max_retries.is_some(), - self.body_overrides.is_some(), + .await, ) - .map_err(|e| AiMuxBindingError::from(&e)) } } -/// What the native constructors take from their 3rd argument (a base URL -/// string, or a [`ProviderConfig`]). Every native constructor goes through -/// here, so `maxRetries` / `bodyOverrides` are rejected for all of them and -/// none of them drops a key silently. -struct NativeConfig { - base_url: Option, - headers: Option, - /// OpenAI only (`OpenAI-Organization` / `OpenAI-Project`). - organization: Option, - project: Option, -} - -fn native_config(config: Option>) -> MResult { - let mut native = NativeConfig { - base_url: None, - headers: None, - organization: None, - project: None, - }; - match config { - None => {} - Some(Either::A(url)) => native.base_url = Some(url), - Some(Either::B(cfg)) => { - cfg.reject_removed_keys()?; - native.base_url = cfg.base_url; - if let Some(json_str) = &cfg.headers { - let h: std::collections::HashMap = - parse_wire_json("config.headers", json_str)?; - native.headers = Some(h.into_iter().map(|(k, v)| (k, Some(v))).collect()); +impl Runtime { + fn signal( + &self, + abort: Option<&Abort>, + ) -> std::result::Result, ErrorEnvelope> { + match abort { + Some(abort) => { + HandleRegistry::global() + .get( + self.realm.owner, + HandleId(abort.id.load(Ordering::Acquire)), + &[HandleKind::Abort], + ) + .map_err(envelope)?; + Ok(abort.signal.clone()) } - native.organization = cfg.organization; - native.project = cfg.project; + None => Ok(Arc::new(AbortSignal::new())), } } - Ok(native) -} - -/// OpenAI provider settings for an explicit key handed over by the host, with -/// an optional base URL. The key is an explicit value (`""` included), so it -/// never falls back to `OPENAI_API_KEY`. -fn openai_settings( - api_key: String, - base_url: Option, -) -> aimux_providers::openai::OpenAIProviderSettings { - aimux_providers::openai::OpenAIProviderSettings { - api_key: Some(api_key), - base_url, - ..Default::default() - } -} - -/// 初始化全局日志(RFC-0014)。幂等:多次调用无副作用;宿主已自建 -/// subscriber 时 no-op。级别:off|error|warn|info|debug|trace(空串回退 -/// warn)。`AIMUX_LOG` / `AIMUX_LOG_LEVEL` 环境变量优先级更高。 -/// 日志输出到 stderr。 -#[napi] -pub fn init_logging(level: String) { - let level = if level.trim().is_empty() { - "warn".to_string() - } else { - level - }; - aimux_providers::init_logging(&level); -} - -/// Register the global session store (RFC-0024). Replaces any previous one. -/// Until called, calls are not grouped and the session query functions return -/// empty results. -#[napi] -pub fn init_session_store() { - aimux_core::session::init_session_store(std::sync::Arc::new( - aimux_core::session::SessionStore::new(), - )); -} - -/// Enable/disable the global session inferer (RFC-0024, opt-in, off by -/// default). Explicit `sessionId` values always win regardless of this. -#[napi] -pub fn init_session_infer(enabled: bool) { - aimux_core::session::init_session_infer(enabled); -} - -// ───────────────────────────────────────────────────────────────────────────── -// External provider config (RFC-0020) -// ───────────────────────────────────────────────────────────────────────────── - -/// Register external OpenAI-compatible providers from a JSON config string -/// (RFC-0020). Entries override same-named built-ins or add new ones. -/// -/// `configJson` shape: `{ "providers": [ { "name": "...", "base_url": "...", ... } ] }`. -/// Malformed JSON text throws a plain `Error` (napi `InvalidArg`); a -/// well-formed document the registry rejects (bad base_url scheme, empty -/// name, unsupported protocol, wrong shape) throws `InvalidArgumentError`. -#[napi] -pub fn register_providers(config_json: String) -> error::AimuxResult<()> { - AimuxResult((|| -> error::MResult<()> { - // Malformed text is the binding's finding; the registry reports a - // schema mismatch as `JsonParse`, which is core's InvalidArgument - // here (the text already parsed) — JsonParse is for provider responses. - let _: serde_json::Value = parse_wire_json("config_json", &config_json)?; - providers::load_providers_from_json(&config_json).map_err(|e| match e { - AiMuxError::JsonParse(m) => crate::error::AiMuxBindingError::from( - &AiMuxError::InvalidArgument(format!("config_json: {m}")), - ), - e => crate::error::AiMuxBindingError::from(&e), - }) - })()) } -/// Replace the default transport with one routed by this proxy configuration -/// (M6, RFC-0016). Requests started afterwards use it; calling it again -/// replaces the previous configuration. -/// -/// `configJson` shape: `{ "http_url": "...", "https_url": "...", "all_url": -/// "...", "no_proxy": "..." }` (all fields optional; omitting all is equivalent -/// to relying on the `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` -/// env vars). -/// -/// Throws on invalid JSON. +/// An Abort handle; dropping it releases the handle without signalling shared work. #[napi] -pub fn init_proxy(config_json: String) -> error::AimuxResult<()> { - AimuxResult((|| -> error::MResult<()> { - let config: aimux_provider_utils::ProxyConfig = - parse_wire_json("config_json", &config_json)?; - // Replaces the default transport for later requests (see - // `aimux_init_proxy`). - aimux_provider_utils::set_default_transport(aimux_provider_utils::ReqwestFetch::new( - config, - )); - Ok(()) - })()) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Recording + mock replay (RFC-0023) -// ───────────────────────────────────────────────────────────────────────────── - -/// 启动录制(RFC-0023 P1/P2):把完整 `Recording` 写 JSONL 到 `{dir}/recordings.jsonl` -/// (目录自动创建)。录制 **opt-in**;再次调用(不同 dir)替换 recorder。 -/// Throws `RecordingError` (`code` = `Init` / `OpenFile` / `Spawn`) when the -/// recorder cannot be set up; the previous recorder, if any, stays in place. -#[napi] -pub fn init_recording(dir: String) -> AimuxResult<()> { - AimuxResult( - aimux_core::recording::JsonlRecorder::try_new(dir) - .map(|rec| { - aimux_core::recording::init_recording(Some(std::sync::Arc::new(rec))); - }) - .map_err(AiMuxBindingError::from), - ) -} - -/// 启动内存有界录制(RFC-0023 P6):`RingRecorder`,FIFO 淘汰,丢弃计数可查。 -/// -/// `cap` 可省略:省略时使用库默认容量(等价于 FFI `aimux_init_recording_ring_default()`; -/// 本绑定直接依赖 aimux-core 而非 aimux-ffi,故调用等价 core API `RingRecorder::default()`)。 -/// 显式传 `cap == 0` 抛错(与其余语言一致),`cap > 0` 启动指定容量的有界 ring。 -#[napi] -pub fn init_recording_ring(cap: Option) -> error::AimuxResult<()> { - let result: error::MResult<()> = match cap { - // 省略 cap:库默认容量(镜像 FFI default 变体)。 - None => { - aimux_core::recording::init_recording(Some(std::sync::Arc::new( - aimux_core::recording::RingRecorder::default(), - ))); - Ok(()) - } - // 显式 cap == 0:拒绝(与 Kotlin/Java/Python 一致)。 - Some(0) => Err(AiMuxBindingError::from(&AiMuxError::InvalidArgument( - "initRecordingRing: cap must be > 0".into(), - ))), - // 显式 cap > 0:指定容量的有界 ring。 - Some(c) => { - aimux_core::recording::init_recording(Some(std::sync::Arc::new( - aimux_core::recording::RingRecorder::with_capacity(c as usize), - ))); - Ok(()) - } - }; - result.into() -} - -/// 停止录制:全局 recorder = None(新调用不再录制)。 -#[napi] -pub fn recording_stop() { - aimux_core::recording::init_recording(None); -} - -/// 刷盘全局 recorder(阻塞至 JSONL 落盘;ring 模式为 no-op)。 -#[napi] -pub fn recording_flush() { - if let Some(rec) = aimux_core::recording::recorder() { - rec.flush(); - } +pub struct Abort { + id: AtomicU64, + realm: Arc, + signal: Arc, } -/// Flush the global recorder and **report write failures**: throws -/// `RecordingError` (`code` = `WriterGone` / `FlushTimeout` / `Write`) when the -/// data could not be confirmed on disk. `RecordingError` is its own class, -/// not an `AimuxError`. Resolves normally when nothing is recording. The -/// legacy `recordingFlush` stays and never reports. #[napi] -pub fn recording_try_flush() -> AimuxResult<()> { - let Some(rec) = aimux_core::recording::recorder() else { - return AimuxResult(Ok(())); - }; - AimuxResult(rec.try_flush().map_err(AiMuxBindingError::from)) -} - -/// 从录制 JSONL 创建 mock 回放模型(RFC-0023 P3):按输入匹配录制响应, -/// **不发真实 API**。返回的 `Model` 可用于 `generateText` / `streamText`。 -#[napi] -pub fn mock_replay(recordings_jsonl: String) -> AimuxResult { - AimuxResult((|| -> crate::error::MResult { - let mut recordings: Vec = Vec::new(); - for (idx, line) in recordings_jsonl.lines().enumerate() { - let line = line.trim(); - if line.is_empty() { - continue; - } - // Same split as parse_wire_json, with the line number in whichever - // message actually reaches JS (binding errors render from `binding`, - // not `message`). - let rec: aimux_core::recording::Recording = - serde_json::from_str(line).map_err(|e| { - let message = format!("recordings line {}: {e}", idx + 1); - match e.classify() { - serde_json::error::Category::Data => { - AiMuxBindingError::from(&AiMuxError::InvalidArgument(message)) - } - _ => BindingError::InvalidWireJson { - argument: "recordings_jsonl", - message, - } - .into(), - } - })?; - recordings.push(rec); - } - if recordings.is_empty() { - return Err(AiMuxBindingError::from(&AiMuxError::InvalidArgument( - "no recordings".into(), - ))); - } - let model = aimux_core::replay::MockReplayModel::new( - recordings[0].provider.provider.clone(), - recordings[0].provider.model_id.clone(), - recordings, - ); - Ok(Model { - inner: std::sync::Arc::new(model), - trace_store: None, - }) - })()) -} - -#[napi] -pub fn router(models: Vec<&Model>, config_json: Option) -> AimuxResult { - AimuxResult((|| -> crate::error::MResult { - if models.is_empty() { - return Err(AiMuxBindingError::from(&AiMuxError::InvalidArgument( - "router: models must be non-empty".into(), - ))); - } - let children: Vec> = - models.iter().map(|m| m.inner.clone()).collect(); - let cfg: RouterFfiConfig = match config_json.as_deref() { - Some(json) => parse_wire_json("config_json", json)?, - None => RouterFfiConfig::default(), - }; - let router: Box = match cfg.router.as_deref() { - Some("weighted") => { - let weights = cfg.weights.unwrap_or_else(|| vec![1.0; children.len()]); - Box::new(aimux_core::router::WeightedRouter::new(weights)) - } - _ => Box::new(aimux_core::router::RuleRouter), - }; - let fallback = if cfg.fallback.as_deref() == Some("none") { - aimux_core::router::FallbackPolicy::None - } else { - aimux_core::router::FallbackPolicy::OnError - }; - let router_cfg = aimux_core::router::RouterConfig { - provider_name: cfg.provider_name.unwrap_or_else(|| "router".into()), - model_id: cfg.model_id.unwrap_or_else(|| "router".into()), - }; - let model = aimux_core::router::RouterModel::new(children, router, fallback, router_cfg); - Ok(Model { - inner: Arc::new(model), - trace_store: None, - }) - })()) -} - -#[napi] -pub fn moa( - references: Vec<&Model>, - aggregator: &Model, - config_json: Option, -) -> AimuxResult { - AimuxResult((|| -> crate::error::MResult { - let refs: Vec> = - references.iter().map(|m| m.inner.clone()).collect(); - let cfg: aimux_core::moa::MoaConfig = match config_json.as_deref() { - Some(json) => parse_wire_json("config_json", json)?, - None => aimux_core::moa::MoaConfig::default(), - }; - let model = aimux_core::moa::MoaModel::new(refs, aggregator.inner.clone(), cfg); - Ok(Model { - inner: Arc::new(model), - trace_store: None, - }) - })()) -} - -/// Router config, the C ABI's shape: camelCase, all fields optional, -/// unknown keys rejected. -#[derive(Default, serde::Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RouterFfiConfig { - router: Option, - weights: Option>, - fallback: Option, - provider_name: Option, - model_id: Option, -} - -/// Query: all calls of a session (RFC-0024), as a JSON-serialized -/// `SessionCall[]` (ordered by step). Empty array if the session is unknown -/// or no store is registered. -#[napi] -pub fn session_calls(session_id: String) -> AimuxResult { - AimuxResult(serialize_result(&aimux_core::session::session_calls( - &session_id, - ))) -} - -/// Query: all known sessions (RFC-0024), as a JSON-serialized `SessionView[]`. -#[napi] -pub fn list_sessions() -> AimuxResult { - AimuxResult(serialize_result(&aimux_core::session::list_sessions())) -} - -/// Create an OpenAI model instance. -#[napi] -pub async fn openai( - api_key: String, - model_id: String, - config: Option>, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_core::provider::Provider; - use aimux_providers::openai::create_openai; - - let mut settings = openai_settings(api_key, None); - let native = native_config(config)?; - settings.base_url = native.base_url; - settings.headers = native.headers; - settings.organization = native.organization; - settings.project = native.project; - let provider = create_openai(settings).map_err(|e| AiMuxBindingError::from(&e))?; - Ok(Model { - inner: provider - .language_model(&model_id) - .map_err(|e| AiMuxBindingError::from(&e))?, - trace_store: None, - }) - } - .await; - __r - }) -} - -/// Create an Anthropic model instance. -#[napi] -pub async fn anthropic( - api_key: String, - model_id: String, - config: Option>, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_core::provider::Provider; - use aimux_providers::anthropic::{AnthropicProviderSettings, create_anthropic}; - - let mut settings = AnthropicProviderSettings { - api_key: Some(api_key), - ..Default::default() +impl Abort { + #[napi(constructor, ts_args_type = "runtime: Runtime, signal?: AbortSignal")] + pub fn new(env: Env, runtime: &Runtime, signal: Option>) -> napi::Result { + let core = Arc::new(AbortSignal::new()); + if let Some(signal) = signal { + let already_aborted = signal.get::("aborted")?.unwrap_or(false); + // SAFETY: conversion stays on the JS thread; napi owns callback registration. + let signal = unsafe { + napi::bindgen_prelude::AbortSignal::from_napi_value(env.raw(), signal.raw())? }; - let native = native_config(config)?; - settings.base_url = native.base_url; - settings.headers = native.headers; - let provider = create_anthropic(settings).map_err(|e| AiMuxBindingError::from(&e))?; - let model = provider - .language_model(&model_id) - .map_err(|e| AiMuxBindingError::from(&e))?; - Ok(Model { - inner: model, - trace_store: None, - }) - } - .await; - __r - }) -} - -/// Create a DeepSeek model instance (registry-backed since RFC-0017 phase 4). -#[napi] -pub async fn deepseek( - api_key: String, - model_id: String, - config: Option>, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - let options = provider_options_from_config(config)?; - let model = providers::provider("deepseek", Some(api_key), &model_id, options) - .map_err(|e| AiMuxBindingError::from(&e))?; - Ok(Model { - inner: model, - trace_store: None, - }) - } - .await; - __r - }) -} - -/// Create a Google Gemini language model instance. -/// -/// Google speaks the native `generateContent` protocol (not OpenAI-compatible), -/// so it is **not** registry-backed — `provider("google", ...)` fails with -/// `NoSuchProvider`. This factory is the only entry point. -#[napi] -pub async fn google( - api_key: String, - model_id: String, - config: Option>, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_core::provider::Provider; - use aimux_providers::google::{GoogleProviderSettings, create_google}; - - let mut settings = GoogleProviderSettings { - api_key: Some(api_key), - ..Default::default() - }; - let native = native_config(config)?; - settings.base_url = native.base_url; - settings.headers = native.headers; - let provider = create_google(settings).map_err(|e| AiMuxBindingError::from(&e))?; - let model = provider - .language_model(&model_id) - .map_err(|e| AiMuxBindingError::from(&e))?; - Ok(Model { - inner: model, - trace_store: None, - }) - } - .await; - __r - }) -} - -/// Create a Cohere language model instance. -#[napi] -pub async fn cohere( - api_key: String, - model_id: String, - config: Option>, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_core::provider::Provider; - use aimux_providers::cohere::{CohereProviderSettings, create_cohere}; - - let mut cfg = CohereProviderSettings { - api_key: Some(api_key), - ..Default::default() - }; - let native = native_config(config)?; - cfg.base_url = native.base_url; - cfg.headers = native.headers; - let provider = create_cohere(cfg).map_err(|e| AiMuxBindingError::from(&e))?; - let model = provider - .language_model(&model_id) - .map_err(|e| AiMuxBindingError::from(&e))?; - Ok(Model { - inner: model, - trace_store: None, - }) - } - .await; - __r - }) -} - -/// Create a Mistral language model instance. -#[napi] -pub async fn mistral( - api_key: String, - model_id: String, - config: Option>, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_core::provider::Provider; - use aimux_providers::mistral::{MistralProviderSettings, create_mistral}; - - let mut cfg = MistralProviderSettings { - api_key: Some(api_key), - ..Default::default() - }; - let native = native_config(config)?; - cfg.base_url = native.base_url; - cfg.headers = native.headers; - let provider = create_mistral(cfg).map_err(|e| AiMuxBindingError::from(&e))?; - let model = provider - .language_model(&model_id) - .map_err(|e| AiMuxBindingError::from(&e))?; - Ok(Model { - inner: model, - trace_store: None, - }) - } - .await; - __r - }) -} - -/// Create an xAI language model instance. -#[napi] -pub async fn xai( - api_key: String, - model_id: String, - config: Option>, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_core::provider::Provider; - use aimux_providers::xai::{XAIProviderSettings, create_xai}; - - let mut cfg = XAIProviderSettings { - api_key: Some(api_key), - ..Default::default() - }; - let native = native_config(config)?; - cfg.base_url = native.base_url; - cfg.headers = native.headers; - let provider = create_xai(cfg).map_err(|e| AiMuxBindingError::from(&e))?; - let model = provider - .language_model(&model_id) - .map_err(|e| AiMuxBindingError::from(&e))?; - Ok(Model { - inner: model, - trace_store: None, - }) - } - .await; - __r - }) -} - -/// Create a Bedrock language model instance (AWS SigV4 credentials). -#[napi] -pub async fn bedrock( - access_key_id: String, - secret_access_key: String, - region: String, - model_id: String, - config: Option>, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_core::provider::Provider; - use aimux_providers::bedrock::{AmazonBedrockProviderSettings, create_amazon_bedrock}; - - let mut settings = AmazonBedrockProviderSettings { - credential_provider: Some(aimux_provider_utils::Resolvable::Value( - aimux_provider_utils::AwsCredentials { - access_key_id, - secret_access_key, - session_token: None, - region: region.clone(), - }, - )), - region: Some(region), - ..Default::default() - }; - let native = native_config(config)?; - settings.base_url = native.base_url; - settings.headers = native.headers; - let provider = - create_amazon_bedrock(settings).map_err(|e| AiMuxBindingError::from(&e))?; - let model = provider - .language_model(&model_id) - .map_err(|e| AiMuxBindingError::from(&e))?; - Ok(Model { - inner: model, - trace_store: None, - }) - } - .await; - __r - }) -} - -/// Create a Vertex AI language model instance (GCP bearer token). -#[napi] -pub async fn vertex( - access_token: String, - project: String, - location: String, - model_id: String, - config: Option>, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_core::provider::Provider; - use aimux_providers::vertex::{VertexProviderSettings, create_google_vertex}; - - let mut settings = VertexProviderSettings { - access_token: Some(aimux_provider_utils::Resolvable::Value(access_token)), - project: Some(project), - location: Some(location), - ..Default::default() - }; - let native = native_config(config)?; - settings.base_url = native.base_url; - settings.headers = native.headers.map(aimux_provider_utils::Resolvable::Value); - let provider = - create_google_vertex(settings).map_err(|e| AiMuxBindingError::from(&e))?; - let model = provider - .language_model(&model_id) - .map_err(|e| AiMuxBindingError::from(&e))?; - Ok(Model { - inner: model, - trace_store: None, - }) - } - .await; - __r - }) -} - -/// Create an Anthropic-on-AWS language model instance (API key + region). -#[napi] -pub async fn anthropic_aws( - api_key: String, - region: String, - model_id: String, - config: Option>, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_core::provider::Provider; - use aimux_providers::anthropic_aws::{ - AnthropicAwsAuth, AnthropicAwsProviderSettings, create_anthropic_aws, - }; - - let mut settings = AnthropicAwsProviderSettings { - region: Some(region), - auth: Some(AnthropicAwsAuth::ApiKey( - aimux_provider_utils::Resolvable::Value(api_key), - )), - ..Default::default() - }; - let native = native_config(config)?; - settings.base_url = native.base_url; - settings.headers = native.headers; - let provider = - create_anthropic_aws(settings).map_err(|e| AiMuxBindingError::from(&e))?; - let model = provider - .language_model(&model_id) - .map_err(|e| AiMuxBindingError::from(&e))?; - Ok(Model { - inner: model, - trace_store: None, - }) - } - .await; - __r - }) -} - -/// Create an Azure OpenAI language model instance (API key + resource name). -/// -/// The deployment name is passed as `model_id`; `api_version` is optional. -#[napi] -pub async fn azure( - api_key: String, - resource_name: String, - deployment: String, - api_version: Option, - config: Option>, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_core::provider::Provider; - use aimux_providers::azure::{AzureOpenAIProviderSettings, create_azure}; - - let mut cfg = AzureOpenAIProviderSettings { - api_key: Some(aimux_provider_utils::Resolvable::Value(api_key)), - ..Default::default() - }; - let native = native_config(config)?; - cfg.base_url = native.base_url; - cfg.headers = native.headers; - // A dated `api_version` belongs to the deployment URL form. - if let Some(version) = api_version - && !version.is_empty() - { - cfg.api_version = Some(version); - cfg.use_deployment_based_urls = true; + let watcher = core.clone(); + signal.on_abort(move || watcher.abort()); + if already_aborted { + core.abort(); } - if !resource_name.is_empty() { - cfg.resource_name = Some(resource_name); - } - let provider = create_azure(cfg).map_err(|e| AiMuxBindingError::from(&e))?; - let model = provider - .language_model(&deployment) - .map_err(|e| AiMuxBindingError::from(&e))?; - Ok(Model { - inner: model, - trace_store: None, - }) - } - .await; - __r - }) -} - -/// Create a language model from the built-in registry by provider name -/// (RFC-0017 phase 4). `api_key` may be empty/null to read the provider's env -/// var from the registry entry. -#[napi] -pub async fn provider( - name: String, - api_key: Option, - model_id: String, - config: Option, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - let options = match config { - Some(cfg) => provider_options_from_config(Some(Either::B(cfg)))?, - None => None, - }; - let model = providers::provider(&name, api_key, &model_id, options) - .map_err(|e| AiMuxBindingError::from(&e))?; - Ok(Model { - inner: model, - trace_store: None, - }) } - .await; - __r - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Provider handles (RFC-0027) — createProvider / listModels / model -// ───────────────────────────────────────────────────────────────────────────── - -/// A provider handle — created by `createProvider`, supports `listModels()` -/// (runtime discovery) and `model()` (build a model from a discovered id). -/// -/// ```ts -/// const p = await createProvider('deepseek', apiKey) -/// const models = await p.listModels() -/// const model = await p.model(models[0].id) -/// const result = await generateText(model, 'Hello') -/// ``` -#[napi] -pub struct ProviderHandle { - inner: Arc, -} - -#[napi] -impl ProviderHandle { - /// Build a native decision model with this provider's existing config. - #[napi] - pub async fn decision_model(&self, model_id: String) -> AimuxResult { - AimuxResult( - self.inner - .decision_model(&model_id) - .unwrap_or_else(|| { - Err(aimux_core::AiMuxError::no_such_model( - &model_id, - "decisionModel", - )) - }) - .map(|model| DecisionModel { inner: model }) - .map_err(|error| AiMuxBindingError::from(&error)), - ) - } - - /// List models available on this provider (runtime discovery via the - /// provider's `/models` endpoint). Returns a JSON array of sparse - /// `RuntimeModel` (id / owned_by / created) — **no community enrichment**. - /// To supplement with model specs (context length, capabilities, reasoning), - /// call `getModelSpecs` separately and merge in the host (RFC-0027). - #[napi] - pub async fn list_models(&self) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - let models = self - .inner - .discovery() - .ok_or_else(|| { - AiMuxBindingError::from(AiMuxError::UnsupportedFunctionality( - "provider does not support listing models".into(), - )) - })? - .list_models() - .await - .map_err(|e| AiMuxBindingError::from(&e))?; - serialize_result(&models) - } - .await; - __r + let id = HandleRegistry::global() + .insert(runtime.realm.owner, Entry::Abort(core.clone())) + .map_err(|error| { + error::throwable(&env, envelope(error)).unwrap_or_else(|error| error) + })?; + Ok(Self { + id: AtomicU64::new(id.0), + realm: runtime.realm.clone(), + signal: core, }) } - /// Build a language model from a discovered model id. Returns a `Model` - /// usable with `generateText` / `streamText`. - #[napi] - pub async fn model(&self, model_id: String) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - let m = self - .inner - .language_model(&model_id) - .map_err(|e| AiMuxBindingError::from(&e))?; - Ok(Model { - inner: m, - // A model built from a provider handle starts untraced; enable - // probing via `Model::trace()` (same as Python binding). - trace_store: None, - }) - } - .await; - __r - }) + #[napi(getter)] + pub fn handle(&self) -> String { + self.id.load(Ordering::Acquire).to_string() } -} - -/// Create a **provider handle** (RFC-0027) for a registry-backed provider. -/// -/// Unlike `provider()` (which binds to a single model_id), this returns a -/// `ProviderHandle` that supports `listModels()` and `model()`. -/// -/// `api_key` may be empty/null to read the provider's env var from the -/// registry entry. -#[napi] -pub async fn create_provider( - name: String, - api_key: Option, - config: Option, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - let options = match config { - Some(cfg) => provider_options_from_config(Some(Either::B(cfg)))?, - None => None, - }; - let inner = providers::provider_handle(&name, api_key, options) - .map_err(|e| AiMuxBindingError::from(&e))?; - Ok(ProviderHandle { inner }) - } - .await; - __r - }) -} - -/// Fetch the community model catalogue (anya2a). Returns a JSON-serialized -/// `Catalogue` (provider → model_id → ModelSpec). Thin fetch — no caching. -/// `source_url` may be null for the default endpoint. -#[napi] -pub async fn get_model_specs(source_url: Option) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - let cat = aimux_providers::get_model_specs(source_url.as_deref()) - .await - .map_err(|e| AiMuxBindingError::from(&e))?; - serialize_result(&cat) - } - .await; - __r - }) -} - -/// Build `ProviderOptions` from a Node `ProviderConfig` (3rd factory arg). -fn provider_options_from_config( - config: Option>, -) -> MResult> { - let opts = match config { - None => None, - Some(Either::A(url)) => Some(providers::ProviderOptions { - base_url: Some(url), - ..Default::default() - }), - Some(Either::B(cfg)) => { - cfg.reject_removed_keys()?; - let mut o = providers::ProviderOptions::default(); - if let Some(url) = cfg.base_url { - o.base_url = Some(url); - } - if let Some(ref json_str) = cfg.headers { - o.headers = Some(parse_wire_json("config.headers", json_str)?); - } - if let Some(org) = cfg.organization { - o.organization = Some(org); - } - if let Some(proj) = cfg.project { - o.project = Some(proj); - } - o.params = cfg.params; - Some(o) - } - }; - Ok(opts) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -fn parse_prompt(json: &str) -> MResult { - let value: serde_json::Value = parse_wire_json("prompt_json", json)?; - let inner = match &value { - serde_json::Value::Object(obj) if obj.len() == 1 && obj.contains_key("prompt") => { - obj.get("prompt").expect("checked by guard") + #[napi] + pub fn signal(&self) { + if self.id.load(Ordering::Acquire) != 0 { + self.signal.abort(); } - _ => &value, - }; - serde_json::from_value(inner.clone()) - // Well-formed JSON of the wrong shape: core validation, not a binding - // transport failure. - .map_err(|e| { - AiMuxBindingError::from(&AiMuxError::InvalidArgument(format!("invalid prompt: {e}"))) - }) -} - -fn parse_opts(json: Option<&str>) -> MResult { - match json { - None => Ok(GenerateTextOptions::default()), - Some(s) => { - let trimmed = s.trim(); - if trimmed.is_empty() || trimmed == "null" { - return Ok(GenerateTextOptions::default()); - } - parse_wire_json("opts_json", s) + } + #[napi] + pub fn close(&self) { + let id = self.id.swap(0, Ordering::AcqRel); + if id != 0 { + drop(HandleRegistry::global().remove(self.realm.owner, HandleId(id))); } } + #[napi(getter)] + pub fn aborted(&self) -> bool { + self.signal.is_aborted() + } } -// ───────────────────────────────────────────────────────────────────────────── -// Host-side tool-call repair (RFC-0035) -// ───────────────────────────────────────────────────────────────────────────── - -/// Build the repair argument for one invalid tool call. -/// -/// `toolCallJson` is a `GenerateTextResult.toolCalls` entry with -/// `invalid: true`. `prompt` and `optsJson` are **the same two strings the -/// call was generated with** (`generateText` / `streamText`); messages, -/// instructions, and the tool set are derived from them here, so no caller -/// repeats that derivation. -/// -/// Returns `{toolCall, error, inputSchema, tools, messages, instructions}` -/// — the AI SDK `repairToolCall` argument, with `toolCall.input` the -/// provider's raw argument text — or the JSON literal `"null"` when the -/// options carried no tools: as in the AI SDK, a call made without a tool set -/// is never repaired, and the caller skips it. `"null"` is a success. -/// -/// Pure and synchronous: no model, no network. Throws `InvalidArgumentError` -/// when the call is not an invalid one. -#[napi] -pub fn tool_call_repair_context( - tool_call_json: String, - prompt: String, - opts_json: Option, -) -> AimuxResult { - AimuxResult((|| -> crate::error::MResult { - let tool_call: ToolCall = parse_wire_json("tool_call_json", &tool_call_json)?; - let prompt = parse_prompt(&prompt)?; - let opts = parse_opts(opts_json.as_deref())?; - let (messages, instructions, tools) = tool_call_repair_inputs(prompt, &opts); - let context = aimux_core::parse_tool_call::tool_call_repair_context( - &tool_call, - tools, - &messages, - instructions, - ) - .map_err(|e| AiMuxBindingError::from(&e))?; - serialize_result(&context) - })()) -} - -/// Resolve one invalid tool call against a host's repair reply. -/// -/// `optsJson` is the same string the call was generated with; the tool set -/// comes from it. `replyJson` is `{"type":"repaired","toolCall":{…}}`, -/// `{"type":"unchanged"}`, or `{"type":"failed","message":"…"}`. Returns the -/// resulting `ToolCall` JSON, tagged `"type": "tool-call"` — valid, or -/// invalid carrying a nested `ToolCallRepairError`. -/// -/// Options carrying no tools throw `InvalidArgumentError`: such a call is not -/// repairable, and `toolCallRepairContext` already said so by returning -/// `"null"`. -#[napi] -pub fn apply_tool_call_repair( - tool_call_json: String, - opts_json: Option, - reply_json: String, -) -> AimuxResult { - AimuxResult((|| -> crate::error::MResult { - let tool_call: ToolCall = parse_wire_json("tool_call_json", &tool_call_json)?; - let opts = parse_opts(opts_json.as_deref())?; - let reply: ToolCallRepairReply = parse_wire_json("reply_json", &reply_json)?; - let repaired = aimux_core::parse_tool_call::apply_tool_call_repair( - &tool_call, - opts.tools.as_deref(), - reply, - ) - .map_err(|e| AiMuxBindingError::from(&e))?; - serialize_result(&aimux_core::wire::Tagged(&repaired)) - })()) -} - -/// Apply a repair reply to a serialized `GenerateTextResult`: the call is -/// repaired in the result's steps (content, approval requests and response -/// messages) and the derived `toolCalls`, `content` and `responseMessages` -/// are written from them. `optsJson` is the same string the call was -/// generated with. -/// -/// The OpenAI-shaped result has no equivalent: it carries no `invalid` / -/// `error`, so repair is driven from the native result, which -/// `Model.generateTextResultAsOpenai` then converts. -#[napi] -pub fn apply_tool_call_repair_to_result( - result_json: String, - opts_json: Option, - tool_call_id: String, - reply_json: String, -) -> AimuxResult { - AimuxResult((|| -> crate::error::MResult { - let result: serde_json::Value = parse_wire_json("result_json", &result_json)?; - let opts = parse_opts(opts_json.as_deref())?; - let reply: ToolCallRepairReply = parse_wire_json("reply_json", &reply_json)?; - let patched = aimux_core::parse_tool_call::apply_tool_call_repair_to_result( - &result, - opts.tools.as_deref(), - &tool_call_id, - reply, - ) - .map_err(|e| AiMuxBindingError::from(&e))?; - serialize_result(&patched) - })()) +impl Drop for Abort { + fn drop(&mut self) { + self.close(); + } } #[cfg(test)] mod tests { - use super::RouterFfiConfig; - - /// The router config is the C ABI's: camelCase, unknown keys rejected, - /// so the same JSON names the router in every binding. + use super::*; #[test] - fn router_config_is_camel_case_and_strict() { - let cfg: RouterFfiConfig = serde_json::from_str( - r#"{"router":"weighted","weights":[1.0],"fallback":"none","providerName":"r","modelId":"m"}"#, - ) - .unwrap(); - assert_eq!(cfg.router.as_deref(), Some("weighted")); - assert_eq!(cfg.provider_name.as_deref(), Some("r")); - assert_eq!(cfg.model_id.as_deref(), Some("m")); - let error = serde_json::from_str::(r#"{"provider_name":"r"}"#) - .err() - .expect("a snake_case key is rejected"); - assert!( - error.to_string().contains("unknown field `provider_name`"), - "{error}" + fn handles_preserve_all_u64_bits_and_follow_the_handle_id_rule() { + assert_eq!( + handle("18446744073709551615").ok().map(|id| id.0), + Some(u64::MAX) ); + assert_eq!(handle("01").ok().map(|id| id.0), Some(1)); + for id in ["", "-1", "+1", "1.0", "18446744073709551616"] { + assert!(handle(id).is_err(), "{id}"); + } + } + + #[cfg(feature = "contract-tests")] + #[tokio::test] + async fn remember_keeps_operation_signals_and_releases_results_after_cleanup() { + let realm = Realm { + owner: Owner::new_node().unwrap(), + next_request: AtomicU64::new(1), + resources: Mutex::default(), + }; + let started = Arc::new(AbortSignal::new()); + let id = native_scenarios::operation(realm.owner).await; + realm.remember(&Value::String(id.0.to_string()), &started); + assert!(realm.lock().operations.contains_key(&id.0)); + realm.cleanup(); + assert!(started.is_aborted()); + + let late = native_scenarios::operation(realm.owner).await; + let finishing = Arc::new(AbortSignal::new()); + realm.remember(&serde_json::json!({ "handle": late.0.to_string() }), &finishing); + assert!(native_scenarios::released(realm.owner, late)); + assert!(finishing.is_aborted()); } } diff --git a/bindings/node/src/multimodal.rs b/bindings/node/src/multimodal.rs deleted file mode 100644 index 26a160479..000000000 --- a/bindings/node/src/multimodal.rs +++ /dev/null @@ -1,1030 +0,0 @@ -//! Multimodal API bindings for Node.js (napi-rs). -//! -//! Each modality is a napi class wrapping the Rust trait object. -//! All cross-boundary data uses JSON strings (base64 for binary). - -use crate::error::{ - AiMuxBindingError, AimuxResult, BindingError, parse_wire_json, serialize_result, -}; -use std::sync::Arc; - -use aimux_core::AiMuxError; -use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel as EmbeddingModelTrait}; -use aimux_core::files_model::{Files as FilesTrait, UploadFileCallOptions}; -use aimux_core::image_model::{ImageCallOptions, ImageModel as ImageModelTrait}; -use aimux_core::reranking_model::{RerankingCallOptions, RerankingModel as RerankingModelTrait}; -use aimux_core::search_model::SearchModel as SearchModelTrait; -use aimux_core::shared::FileBytes; -use aimux_core::speech_model::{SpeechCallOptions, SpeechModel as SpeechModelTrait}; -use aimux_core::transcription_model::{ - AudioChunk, AudioInput, TranscriptionCallOptions, TranscriptionModel as TranscriptionModelTrait, -}; -use aimux_core::video_model::{VideoCallOptions, VideoModel as VideoModelTrait}; -use napi_derive::napi; - -/// Fill in the fields the method's explicit arguments own, then deserialize. -/// -/// Those fields are required on the Rust options struct, so a caller's -/// options object on its own fails with `missing field` before the explicit -/// arguments can be put back. The values inserted here are placeholders that -/// satisfy the shape only; the caller overwrites each one from its own -/// argument right after this returns. Pure JSON — no binding error types — so -/// the parse contract is unit-testable without a Node runtime. -fn opts_with_args( - json: &str, - owned_by_args: &[(&str, serde_json::Value)], -) -> Result { - let mut value: serde_json::Value = serde_json::from_str(json)?; - // A non-object body needs no filling; `from_value` reports it as the same - // invalid-type data error it always did. - if let Some(object) = value.as_object_mut() { - for (field, placeholder) in owned_by_args { - object.insert((*field).to_string(), placeholder.clone()); - } - } - serde_json::from_value(value) -} - -/// [`opts_with_args`] under `parse_wire_json`'s error classification. -fn parse_opts_json( - argument: &'static str, - json: &str, - owned_by_args: &[(&str, serde_json::Value)], -) -> crate::error::MResult { - opts_with_args(json, owned_by_args).map_err(|e| crate::error::wire_error(argument, &e)) -} - -/// Shape-only stand-ins for the `TranscriptionCallOptions` fields the -/// explicit arguments own, under their JSON names: `audio`, built from the -/// real enum so a variant rename is a compile error rather than a runtime one, -/// and `mediaType`. -fn transcription_args() -> [(&'static str, serde_json::Value); 2] { - [ - ( - "audio", - serde_json::to_value(AudioInput::Base64(String::new())) - .expect("AudioInput always serializes"), - ), - ("mediaType", serde_json::Value::from("")), - ] -} - -/// Shape-only stand-in for `RerankingCallOptions::documents`. -fn documents_placeholder() -> serde_json::Value { - serde_json::to_value(aimux_core::reranking_model::RerankingDocuments::Text { - values: Vec::new(), - }) - .expect("RerankingDocuments always serializes") -} - -// ───────────────────────────────────────────────────────────────────────────── -// EmbeddingModel -// ───────────────────────────────────────────────────────────────────────────── - -#[napi] -pub struct EmbeddingModel { - inner: Arc, -} - -#[napi] -impl EmbeddingModel { - /// Generate embeddings. `values_json` is a JSON array of strings. - /// Returns JSON-serialized EmbeddingResult. - #[napi(ts_return_type = "Promise")] - pub async fn embed( - &self, - values_json: String, - opts_json: Option, - ) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - let mut opts: EmbeddingCallOptions = match opts_json.as_deref() { - Some(s) if !s.trim().is_empty() && s.trim() != "null" => { - parse_wire_json("opts_json", s)? - } - _ => EmbeddingCallOptions::new(""), - }; - // Override values from the JSON array - let values: Vec = parse_wire_json("values_json", &values_json)?; - opts.values = values; - - let result = aimux_core::embedding_model::embed(self.inner.as_ref(), opts) - .await - .map_err(|e| AiMuxBindingError::from(&e))?; - serialize_result(&result) - } - .await; - __r - }) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// SpeechModel (TTS) -// ───────────────────────────────────────────────────────────────────────────── - -#[napi] -pub struct SpeechModel { - inner: Arc, -} - -#[napi] -impl SpeechModel { - /// Generate speech audio. `opts_json` is JSON-serialized SpeechCallOptions. - /// `bridge` — optional `AbortBridge`; aborting the wrapped signal cancels - /// the call. Returns JSON-serialized SpeechResult (audio as base64 in JSON). - #[napi(ts_return_type = "Promise")] - pub async fn generate( - &self, - opts_json: String, - bridge: Option<&crate::AbortBridge>, - ) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - let mut opts: SpeechCallOptions = parse_wire_json("opts_json", &opts_json)?; - opts.abort_signal = bridge.map(|b| b.core_signal()); - - let result = aimux_core::speech_model::generate_speech(self.inner.as_ref(), opts) - .await - .map_err(|e| AiMuxBindingError::from(&e))?; - serialize_result(&result) - } - .await; - __r - }) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// ImageModel -// ───────────────────────────────────────────────────────────────────────────── - -#[napi] -pub struct ImageModel { - inner: Arc, -} - -#[napi] -impl ImageModel { - /// Generate images. `opts_json` is JSON-serialized ImageCallOptions. - /// `bridge` — optional `AbortBridge`; aborting the wrapped signal cancels - /// the call. Returns JSON-serialized ImageResult (images as base64 in JSON). - #[napi(ts_return_type = "Promise")] - pub async fn generate( - &self, - opts_json: String, - bridge: Option<&crate::AbortBridge>, - ) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - let mut opts: ImageCallOptions = parse_wire_json("opts_json", &opts_json)?; - opts.abort_signal = bridge.map(|b| b.core_signal()); - - let result = aimux_core::image_model::generate_image(self.inner.as_ref(), opts) - .await - .map_err(|e| AiMuxBindingError::from(&e))?; - serialize_result(&result) - } - .await; - __r - }) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// TranscriptionModel (STT, non-streaming only) -// ───────────────────────────────────────────────────────────────────────────── - -#[napi] -pub struct TranscriptionModel { - inner: Arc, -} - -#[napi] -impl TranscriptionModel { - /// Transcribe audio. `audio_base64` is base64-encoded audio data. - /// `media_type` is e.g. "audio/mp3". `opts_json` is optional JSON options. - /// `bridge` — optional `AbortBridge`; aborting the wrapped signal cancels - /// the call. Returns JSON-serialized TranscriptionResult. - #[napi(ts_return_type = "Promise")] - pub async fn generate( - &self, - audio_base64: String, - media_type: String, - opts_json: Option, - bridge: Option<&crate::AbortBridge>, - ) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - let mut opts = - TranscriptionCallOptions::new(AudioInput::Base64(audio_base64), media_type); - if let Some(s) = opts_json.as_deref() - && !s.trim().is_empty() - && s.trim() != "null" - { - let mut parsed: TranscriptionCallOptions = - parse_opts_json("opts_json", s, &transcription_args())?; - // Required data comes from the explicit arguments; all - // operation policy and optional fields come from JSON. - parsed.audio = opts.audio; - parsed.media_type = opts.media_type; - opts = parsed; - } - opts.abort_signal = bridge.map(|b| b.core_signal()); - - let result = aimux_core::transcription_model::transcribe(self.inner.as_ref(), opts) - .await - .map_err(|e| AiMuxBindingError::from(&e))?; - serialize_result(&result) - } - .await; - __r - }) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// RerankingModel -// ───────────────────────────────────────────────────────────────────────────── - -#[napi] -pub struct RerankingModel { - inner: Arc, -} - -#[napi] -impl RerankingModel { - /// Rerank documents. `query` is the search query, `docs_json` is a JSON - /// array of documents, `opts_json` is optional JSON options. - /// `bridge` — optional `AbortBridge`; aborting the wrapped signal cancels - /// the call. Returns JSON-serialized RerankingResult. - #[napi(ts_return_type = "Promise")] - pub async fn rerank( - &self, - query: String, - docs_json: String, - opts_json: Option, - bridge: Option<&crate::AbortBridge>, - ) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_core::reranking_model::RerankingDocuments; - let docs: RerankingDocuments = parse_wire_json("docs_json", &docs_json)?; - - let mut opts = RerankingCallOptions::new(query, docs); - if let Some(s) = opts_json.as_deref() - && !s.trim().is_empty() - && s.trim() != "null" - { - let mut parsed: RerankingCallOptions = parse_opts_json( - "opts_json", - s, - &[ - ("query", serde_json::Value::from("")), - ("documents", documents_placeholder()), - ], - )?; - // Required data comes from the explicit arguments; all - // operation policy and optional fields come from JSON. - parsed.query = opts.query; - parsed.documents = opts.documents; - opts = parsed; - } - opts.abort_signal = bridge.map(|b| b.core_signal()); - - let result = aimux_core::reranking_model::rerank(self.inner.as_ref(), opts) - .await - .map_err(|e| AiMuxBindingError::from(&e))?; - serialize_result(&result) - } - .await; - __r - }) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// VideoModel -// ───────────────────────────────────────────────────────────────────────────── - -#[napi] -pub struct VideoModel { - inner: Arc, -} - -#[napi] -impl VideoModel { - /// Generate video. `opts_json` is JSON-serialized VideoCallOptions. - /// `bridge` — optional `AbortBridge`; aborting the wrapped signal cancels - /// the call. Returns JSON-serialized VideoResult (typically contains a URL). - #[napi(ts_return_type = "Promise")] - pub async fn generate( - &self, - opts_json: String, - bridge: Option<&crate::AbortBridge>, - ) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - let mut opts: VideoCallOptions = parse_wire_json("opts_json", &opts_json)?; - opts.abort_signal = bridge.map(|b| b.core_signal()); - - let result = aimux_core::video_model::generate_video(self.inner.as_ref(), opts) - .await - .map_err(|e| AiMuxBindingError::from(&e))?; - serialize_result(&result) - } - .await; - __r - }) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// SearchModel -// ───────────────────────────────────────────────────────────────────────────── - -#[napi] -pub struct SearchModel { - inner: Arc, -} - -#[napi] -impl SearchModel { - /// Search. `query` is the search query, `opts_json` is optional JSON options. - /// `bridge` — optional `AbortBridge`; aborting the wrapped signal cancels - /// the call. Returns JSON-serialized SearchResult. - #[napi(ts_return_type = "Promise")] - pub async fn search( - &self, - query: String, - opts_json: Option, - bridge: Option<&crate::AbortBridge>, - ) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_core::search_model::SearchCallOptions; - let mut opts = SearchCallOptions::new(query); - if let Some(s) = opts_json.as_deref() - && !s.trim().is_empty() - && s.trim() != "null" - { - // Required data comes from the explicit arguments; all - // operation policy and optional fields come from JSON. - let mut parsed: SearchCallOptions = - parse_opts_json("opts_json", s, &[("query", serde_json::Value::from(""))])?; - parsed.query = opts.query; - opts = parsed; - } - opts.abort_signal = bridge.map(|b| b.core_signal()); - - let result = aimux_core::search_model::search(self.inner.as_ref(), opts) - .await - .map_err(|e| AiMuxBindingError::from(&e))?; - serialize_result(&result) - } - .await; - __r - }) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Files -// ───────────────────────────────────────────────────────────────────────────── - -#[napi] -pub struct Files { - inner: Arc, -} - -#[napi] -impl Files { - /// Upload a file. `data_base64` is base64-encoded file content, - /// `media_type` is e.g. "application/pdf", `opts_json` is optional - /// (may contain filename, provider_options). - /// Returns JSON-serialized UploadFileResult (contains provider file ID). - #[napi(ts_return_type = "Promise")] - pub async fn upload_file( - &self, - data_base64: String, - media_type: String, - opts_json: Option, - ) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_core::files_model::UploadFileData; - let mut opts = UploadFileCallOptions::new( - UploadFileData::Data { - data: FileBytes::Base64(data_base64), - }, - media_type, - ); - if let Some(s) = opts_json.as_deref() - && !s.trim().is_empty() - && s.trim() != "null" - { - let parsed: UploadFileCallOptions = parse_wire_json("opts_json", s)?; - opts.filename = parsed.filename; - opts.provider_options = parsed.provider_options; - } - - let result = self - .inner - .upload_file(&opts) - .await - .map_err(|e| AiMuxBindingError::from(&e))?; - serialize_result(&result) - } - .await; - __r - }) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Factory functions — OpenAI multimodal -// ───────────────────────────────────────────────────────────────────────────── - -/// Create an OpenAI embedding model instance. -#[napi] -pub async fn openai_embedding( - api_key: String, - model_id: String, - base_url: Option, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_providers::openai::create_openai; - let provider = create_openai(crate::openai_settings(api_key, base_url)) - .map_err(|e| AiMuxBindingError::from(&e))?; - let model = provider.embedding(&model_id); - Ok(EmbeddingModel { - inner: Arc::new(model), - }) - } - .await; - __r - }) -} - -/// Create an OpenAI speech (TTS) model instance. -#[napi] -pub async fn openai_speech( - api_key: String, - model_id: String, - base_url: Option, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_providers::openai::create_openai; - let provider = create_openai(crate::openai_settings(api_key, base_url)) - .map_err(|e| AiMuxBindingError::from(&e))?; - let model = provider.speech(&model_id); - Ok(SpeechModel { - inner: Arc::new(model), - }) - } - .await; - __r - }) -} - -/// Create an OpenAI image model instance. -#[napi] -pub async fn openai_image( - api_key: String, - model_id: String, - base_url: Option, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_providers::openai::create_openai; - let provider = create_openai(crate::openai_settings(api_key, base_url)) - .map_err(|e| AiMuxBindingError::from(&e))?; - let model = provider.image(&model_id); - Ok(ImageModel { - inner: Arc::new(model), - }) - } - .await; - __r - }) -} - -/// Create an OpenAI transcription model instance. -#[napi] -pub async fn openai_transcription( - api_key: String, - model_id: String, - base_url: Option, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_providers::openai::create_openai; - let provider = create_openai(crate::openai_settings(api_key, base_url)) - .map_err(|e| AiMuxBindingError::from(&e))?; - let model = provider.transcription(&model_id); - Ok(TranscriptionModel { - inner: Arc::new(model), - }) - } - .await; - __r - }) -} - -/// Create OpenAI files manager. -#[napi] -pub async fn openai_files(api_key: String, base_url: Option) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_providers::openai::create_openai; - let provider = create_openai(crate::openai_settings(api_key, base_url)) - .map_err(|e| AiMuxBindingError::from(&e))?; - let files = provider.files(); - Ok(Files { - inner: Arc::new(files), - }) - } - .await; - __r - }) -} - -/// Create a Cohere embedding model instance. -#[napi] -pub async fn cohere_embedding( - api_key: String, - model_id: String, - base_url: Option, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_providers::cohere::{CohereProviderSettings, create_cohere}; - let provider = create_cohere(CohereProviderSettings { - api_key: Some(api_key), - base_url, - ..Default::default() - }) - .map_err(|e| AiMuxBindingError::from(&e))?; - let model = provider.embedding(&model_id); - Ok(EmbeddingModel { - inner: Arc::new(model), - }) - } - .await; - __r - }) -} - -/// Create a Cohere reranking model instance. -#[napi] -pub async fn cohere_reranking( - api_key: String, - model_id: String, - base_url: Option, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_providers::cohere::{CohereProviderSettings, create_cohere}; - let provider = create_cohere(CohereProviderSettings { - api_key: Some(api_key), - base_url, - ..Default::default() - }) - .map_err(|e| AiMuxBindingError::from(&e))?; - let model = provider.reranking(&model_id); - Ok(RerankingModel { - inner: Arc::new(model), - }) - } - .await; - __r - }) -} - -/// Create a Google embedding model instance. -#[napi] -pub async fn google_embedding( - api_key: String, - model_id: String, - base_url: Option, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_providers::google::{GoogleProviderSettings, create_google}; - let provider = create_google(GoogleProviderSettings { - api_key: Some(api_key), - base_url, - ..Default::default() - }) - .map_err(|e| AiMuxBindingError::from(&e))?; - let model = provider.embedding(&model_id); - Ok(EmbeddingModel { - inner: Arc::new(model), - }) - } - .await; - __r - }) -} - -/// Create a Google image model instance. -#[napi] -pub async fn google_image( - api_key: String, - model_id: String, - base_url: Option, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_providers::google::{GoogleProviderSettings, create_google}; - let provider = create_google(GoogleProviderSettings { - api_key: Some(api_key), - base_url, - ..Default::default() - }) - .map_err(|e| AiMuxBindingError::from(&e))?; - let model = provider.image(&model_id); - Ok(ImageModel { - inner: Arc::new(model), - }) - } - .await; - __r - }) -} - -/// Create a Google video model instance. -#[napi] -pub async fn google_video( - api_key: String, - model_id: String, - base_url: Option, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_providers::google::{GoogleProviderSettings, create_google}; - let provider = create_google(GoogleProviderSettings { - api_key: Some(api_key), - base_url, - ..Default::default() - }) - .map_err(|e| AiMuxBindingError::from(&e))?; - let model = provider.video(&model_id); - Ok(VideoModel { - inner: Arc::new(model), - }) - } - .await; - __r - }) -} - -/// Create a Tavily search model instance. -#[napi] -pub async fn tavily_search(api_key: String, base_url: Option) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - use aimux_providers::tavily::{TavilyProviderSettings, create_tavily}; - let provider = create_tavily(TavilyProviderSettings { - api_key: Some(aimux_provider_utils::Resolvable::Value(api_key)), - base_url, - ..Default::default() - }) - .map_err(|e| AiMuxBindingError::from(&e))?; - let model = provider.search_model(); - Ok(SearchModel { - inner: Arc::new(model), - }) - } - .await; - __r - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// TranscriptionSession (RFC-0028 streaming, native path) -// ───────────────────────────────────────────────────────────────────────────── - -/// A live streaming-transcription session (RFC-0028). Push audio chunks with -/// `pushAudio`, mark end-of-audio with `inputDone`, then pull transcription -/// parts with `nextPart`. Mirrors the C-ABI session shape, built natively on -/// core channels. -#[napi] -pub struct TranscriptionSession { - audio_tx: std::sync::Mutex>>, - parts_rx: tokio::sync::Mutex< - tokio::sync::mpsc::Receiver>, - >, - token: aimux_core::AbortSignal, -} - -/// Start a streaming transcription session. `opts_json` (optional, camelCase -/// as in the C ABI, unknown keys rejected): `{ "inputAudioFormat": -/// {"formatType","rate"}, "providerOptions", "headers", "includeRawChunks", -/// "timeout" }`. `bridge` — optional AbortSignal; firing it aborts the -/// session. The returned parts are JSON-serialized `TranscriptionStreamPart`s. -#[napi] -pub async fn start_transcription_session( - model: &TranscriptionModel, - opts_json: Option, - bridge: Option<&crate::AbortBridge>, -) -> AimuxResult { - AimuxResult({ - let __r: crate::error::MResult = async { - #[derive(serde::Deserialize, Default)] - #[serde(rename_all = "camelCase", deny_unknown_fields)] - struct SessionOpts { - input_audio_format: Option, - provider_options: - Option, - headers: Option>, - include_raw_chunks: Option, - timeout: Option, - } - let opts: SessionOpts = match opts_json.as_deref() { - Some(json) if !json.trim().is_empty() && json.trim() != "null" => { - parse_wire_json("opts_json", json)? - } - _ => SessionOpts::default(), - }; - - // Effective abort = user bridge OR close token (linked). - let token = aimux_core::AbortSignal::new(); - let effective = aimux_core::AbortSignal::new(); - let mut sources = vec![token.clone()]; - if let Some(b) = bridge { - sources.push(b.core_signal()); - } - for source in sources { - let linked = effective.clone(); - napi::tokio::spawn(async move { - source.cancelled().await; - linked.abort(); - }); - } - - let (audio_tx, audio_rx) = - futures::channel::mpsc::channel::(64); - let (tx, rx) = - tokio::sync::mpsc::channel::>(256); - - let model = model.inner.clone(); - napi::tokio::spawn(async move { - let options = aimux_core::transcription_model::TranscriptionStreamOptions { - audio: Box::pin(audio_rx), - input_audio_format: opts - .input_audio_format - .unwrap_or(aimux_core::transcription_model::InputAudioFormat { - format_type: "audio/pcm".to_string(), - rate: None, - }), - provider_options: opts.provider_options, - abort_signal: Some(effective.clone()), - headers: opts.headers, - include_raw_chunks: opts.include_raw_chunks.unwrap_or(false), - timeout: opts.timeout, - }; - let result = aimux_core::transcription_model::stream_transcribe( - model.as_ref(), - options, - ) - .await; - match result { - Ok(stream_result) => { - use futures::StreamExt; - let mut stream = stream_result.stream; - // Immediate delivery when capacity allows (terminal - // errors are never preempted); abort only unblocks a - // full channel. - while let Some(item) = stream.next().await { - // In-stream errors reject from `nextPart` (issue - // #145, option A) instead of being smuggled through - // the data channel as a serialized `{"Err": ...}` - // part — matching the FFI session (Err items) and - // the other six bindings' exception sentinels. - let part = match item { - Ok(p) => p, - Err(e) => { - // Terminal by contract: providers end - // the stream after an Error part, so - // nothing is dropped by returning here. - tokio::select! { - res = tx.send(Err(AiMuxBindingError::from(&e))) => { let _ = res; } - _ = effective.cancelled() => {} - } - return; - } - }; - let json = match serialize_result(&part) { - Ok(j) => j, - Err(e) => { - let _ = tx.send(Err(e)).await; - return; - } - }; - match tx.try_send(Ok(json.clone())) { - Ok(()) => {} - Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { - // Full: block on send (waits for - // capacity) unless aborted. - tokio::select! { - _ = effective.cancelled() => return, - res = tx.send(Ok(json.clone())) => { - if res.is_err() { return; } - } - } - } - Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => { - return; - } - } - } - } - Err(e) => { - // Connect failure: deliver as the first channel item. - // try_send + abort-select (a full channel must not - // stall; abort covers the session-drop path). - match tx.try_send(Err(AiMuxBindingError::from(&e))) { - Ok(()) => {} - Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { - tokio::select! { - _ = effective.cancelled() => {} - res = tx.send(Err(AiMuxBindingError::from(&e))) => { - let _ = res; - } - } - } - Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {} - } - } - } - }); - - Ok(TranscriptionSession { - audio_tx: std::sync::Mutex::new(Some(audio_tx)), - parts_rx: tokio::sync::Mutex::new(rx), - token, - }) - } - .await; - __r - }) -} - -#[napi] -impl TranscriptionSession { - /// Push one binary audio chunk. Awaits while the internal channel is - /// full (backpressure). - #[napi] - pub async fn push_audio(&self, data: napi::bindgen_prelude::Buffer) -> AimuxResult<()> { - AimuxResult({ - let __r: crate::error::MResult<()> = async { - use futures::SinkExt; - let bytes = data.to_vec(); - // Clone the sender and drop the guard BEFORE awaiting: the mutex is - // shared with the SYNC inputDone()/close() methods on the JS thread — - // holding it across a backpressured send would wedge the event loop. - let mut tx = { - let guard = self.audio_tx.lock().map_err(|_| BindingError::InvariantViolation { - message: "session poisoned".into(), - })?; - match guard.as_ref() { - None => { - return Err(BindingError::InvalidHandle { - message: "transcription session is closed (audio input already finished)", - } - .into()); - } - Some(tx) => tx.clone(), - } - }; - tx.send(AudioChunk::Binary(bytes)).await.map_err(|_| { - BindingError::InvalidHandle { - message: "transcription session is closed (session ended)", - } - .into() - }) - } - .await; - __r - }) - } - - /// Signal end-of-audio (idempotent). - #[napi] - pub fn input_done(&self) -> AimuxResult<()> { - AimuxResult((|| -> crate::error::MResult<()> { - self.audio_tx - .lock() - .map_err(|_| BindingError::InvariantViolation { - message: "session poisoned".into(), - })? - .take(); - Ok(()) - })()) - } - - /// Pull the next transcription part (JSON string). Resolves `null` when - /// the stream ended normally. Rejects on error — including a timeout - /// (no part within `timeoutMs`; the session stays live, call again). - /// `timeoutMs`: >0 wait at most; 0 immediate poll; negative OR omitted - /// = wait indefinitely. - #[napi] - pub async fn next_part(&self, timeout_ms: Option) -> AimuxResult> { - AimuxResult({ - let __r: crate::error::MResult> = async { - let mut rx = self.parts_rx.lock().await; - let timeout = match timeout_ms { - Some(ms) if ms >= 0 => Some(std::time::Duration::from_millis(ms as u64)), - _ => None, - }; - let recv = rx.recv(); - let part = match timeout { - Some(d) => match tokio::time::timeout(d, recv).await { - Ok(p) => p, - Err(_) => { - return Err(AiMuxBindingError::from(&AiMuxError::Timeout( - "no transcription part within timeout".into(), - ))); - } - }, - None => recv.await, - }; - match part { - Some(Ok(json)) => Ok(Some(json)), - Some(Err(e)) => Err(e), - None => Ok(None), // ended normally - } - } - .await; - __r - }) - } - - /// Terminate the session (aborts the driver). The object becomes inert; - /// further `pushAudio`/`nextPart` fail. Call this promptly — GC teardown - /// drops the channels (which eventually tears the driver down) but never - /// fires the abort token. - #[napi] - pub fn close(&self) { - // End audio + abort; the driver unblocks and exits. - if let Ok(mut guard) = self.audio_tx.lock() { - guard.take(); - } - self.token.abort(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use aimux_core::search_model::SearchCallOptions; - - /// A caller that passes any options at all must not trip `missing field` - /// on the data its explicit arguments already carry — that is what made - /// `maxRetries` / `timeout` unreachable from Node for these modalities. - #[test] - fn options_parse_without_the_fields_the_explicit_args_own() { - let transcription: TranscriptionCallOptions = opts_with_args( - r#"{"maxRetries":3,"timeout":{"totalMs":1000}}"#, - &transcription_args(), - ) - .expect("transcription options with no audio/mediaType"); - assert_eq!(transcription.max_retries, Some(3)); - assert_eq!(transcription.timeout.and_then(|t| t.total_ms), Some(1000)); - // aimux 0.5's snake_case keys fail naming the key instead of being - // dropped. - let error = opts_with_args::( - r#"{"max_retries":3}"#, - &transcription_args(), - ) - .unwrap_err(); - assert!( - error.to_string().contains("unknown field `max_retries`"), - "{error}" - ); - - let rerank: RerankingCallOptions = opts_with_args( - r#"{"topN":2,"maxRetries":1}"#, - &[ - ("query", serde_json::Value::from("")), - ("documents", documents_placeholder()), - ], - ) - .expect("rerank options with no query/documents"); - assert_eq!(rerank.top_n, Some(2)); - - // The placeholder is overwritten by the caller, so it must lose to - // whatever the explicit argument holds — never to the JSON. - let mut search: SearchCallOptions = opts_with_args( - r#"{"query":"dogs","maxResults":5}"#, - &[("query", serde_json::Value::from(""))], - ) - .expect("search options with no query"); - assert_eq!(search.max_results, Some(5)); - search.query = "cats".to_string(); - assert_eq!(search.query, "cats"); - } -} diff --git a/bindings/node/src/native.ts b/bindings/node/src/native.ts index 27f04c576..05798fb0a 100644 --- a/bindings/node/src/native.ts +++ b/bindings/node/src/native.ts @@ -1,11 +1,72 @@ -// Native napi surface with the package's canonical JavaScript Error classes. -// Rust constructs these classes directly, so native throws and promise -// rejections preserve their prototype chain. +// Raw JSON entrypoint. The adapter is asynchronous and never calls the C ABI. +import { Runtime as NativeRuntime, Abort as NativeAbort } from '../index.js' +import { ABI_VERSION, OPS_VERSION, MANIFEST_DIGEST } from './generated/ops.ts' +import { rethrow, protocolError } from './error.ts' +export * from './error.ts' +export type { FrameIterator } from '../index.js' +export type { NativeAbort as Abort } -import * as native from '../index.js' -import * as errors from './error.ts' +export interface Manifest { + abiVersion: number + opsVersion: string + digest: string + manifestRevision: string | number + ops: ReadonlyArray<{ name: string; mode: string; [key: string]: unknown }> + handleKinds: ReadonlyArray + errorCodes: ReadonlyArray + [key: string]: unknown +} -native.__registerErrorClasses(errors) +export function verifyManifest(manifest: Manifest): void { + for (const [field, expected] of Object.entries({ abiVersion: ABI_VERSION, opsVersion: OPS_VERSION, digest: MANIFEST_DIGEST })) { + if (manifest[field] !== expected) { + throw protocolError(302, 'UnsupportedOpsVersion', `Incompatible manifest ${field}: expected ${expected}, received ${String(manifest[field])}`) + } + } +} -export * from '../index.js' -export * from './error.ts' +export class Runtime { + readonly #inner: NativeRuntime + readonly manifest: Manifest + readonly manifestDigest: string + + constructor() { + this.#inner = new NativeRuntime() + this.manifest = JSON.parse(this.#inner.manifestJson()) as Manifest + verifyManifest(this.manifest) + this.manifestDigest = this.#inner.manifestDigest() + } + + abort(signal?: AbortSignal): NativeAbort { return new NativeAbort(this.#inner, signal) } + + drop(handle: string): void { + try { this.#inner.dropHandle(handle) } catch (error) { rethrow(error) } + } + + async call(handle: string, op: string, paramsJson: string, abort?: NativeAbort): Promise { + try { return await this.#inner.call(handle, op, paramsJson, abort) } catch (error) { rethrow(error) } + } + + async stream(handle: string, op: string, paramsJson: string, abort?: NativeAbort): Promise> { + let inner: AsyncIterableIterator + try { inner = (await this.#inner.stream(handle, op, paramsJson, abort))[Symbol.asyncIterator]() } catch (error) { rethrow(error) } + // This is deliberately an explicit iterator: return() must cancel while next() is pending. + const iterator: AsyncIterableIterator = { + async next() { try { return await inner.next() } catch (error) { rethrow(error) } }, + async return() { try { return inner.return ? await inner.return() : { done: true as const, value: undefined } } catch (error) { rethrow(error) } }, + async throw(error?: unknown) { + try { + if (inner.throw) return await inner.throw(error) + if (inner.return) await inner.return() + throw error + } catch (error) { rethrow(error) } + }, + [Symbol.asyncIterator]() { return this }, + } + return iterator + } + + async operationPush(handle: string, data: Uint8Array): Promise { + try { await this.#inner.operationPush(handle, data) } catch (error) { rethrow(error) } + } +} diff --git a/bindings/node/tsconfig.json b/bindings/node/tsconfig.json index 1c27cd9ef..001c642dd 100644 --- a/bindings/node/tsconfig.json +++ b/bindings/node/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "moduleResolution": "node", + "target": "ES2021", + "module": "Node16", + "moduleResolution": "Node16", "rootDir": "src", "outDir": "dist", "declaration": true, diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml index a9210bdc3..81c4c72a0 100644 --- a/bindings/python/Cargo.toml +++ b/bindings/python/Cargo.toml @@ -9,15 +9,20 @@ description = "Python binding for aimux (PyO3, native path — bypasses aimux-ff name = "aimux" crate-type = ["cdylib"] +[features] +contract-tests = [] + [dependencies] aimux-core = { path = "../../aimux-core" } -aimux-providers = { path = "../../aimux-providers" } -aimux-provider-utils = { path = "../../aimux-provider-utils" } +aimux-ops = { path = "../../aimux-ops" } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } tokio = { version = "1", features = ["full"] } futures = "0.3" -pyo3 = { version = "0.22", features = ["extension-module", "abi3-py38"] } +pyo3 = { version = "0.22", features = ["extension-module", "abi3-py39"] } + +[build-dependencies] +pyo3-build-config = "0.22" # Keep out of main workspace [workspace] diff --git a/bindings/python/README.md b/bindings/python/README.md index ba049f93f..aae234bef 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -1,42 +1,121 @@ -# aimux (Python) +# aimux Python binding -Unified LLM service layer for Python — one API for **325 AI providers** -(OpenAI, Anthropic, Google, Bedrock, Vertex, DeepSeek, …), powered by a Rust -core. +PyO3 calls `aimux_ops::dispatch` directly. Provider selection, wire validation, +retries, recording, replay and operation lifecycle run in the shared Rust layer. +The package requires CPython 3.9 or newer. Install `arcships-aimux[typed]` for +optional Pydantic schemas and text helpers in `aimux.wrapper`. -## Install +```python +from aimux import Client -```bash -pip install arcships-aimux +client = Client() +with client.adopt(client.call("provider.create", { + "package": "openai", "settings": {"apiKey": "your-api-key"}, +})["handle"]) as provider: + with client.adopt(provider.call("provider.model", { + "method": "languageModel", "modelId": "your-model", + })["handle"]) as model: + result = model.call("text.generate", {"prompt": "Hello", "options": {}}) + with model.stream("text.stream", {"prompt": "Hello", "options": {}}) as events: + for frame in events: + print(frame["seq"], frame["event"]) ``` -Pre-built wheels for Linux, macOS, and Windows (Python ≥ 3.8). No Rust -toolchain needed. +`Client.call(op, params=None, *, handle="0", abort=None)` returns parsed JSON. +Operation names and DTO identifiers come from `aimux.manifest`; business DTO +schemas remain in the existing core wire types. Control fields use camelCase. +Handle IDs are decimal strings. Adopt each returned handle for explicit and GC +cleanup. Provider/model constructors have been replaced by shared operations. +`provider.create` and `provider.model` return objects with `handle` fields; +`provider.files` and `transcription.start` return handle strings directly. -## Quick start +`Client.stream(op, params=None, *, handle, abort=None)` and `Handle.stream` +return synchronous context-managed iterators over `{type, seq, event}` frames. +Validation errors raise before acceptance. Successful exhaustion produces +`StopIteration`; terminal failures raise typed errors. End frames are consumed +by the adapter. `join()` waits for producer teardown without draining events; +drain or cancel a full stream before joining. `close()` cancels, joins and +clears the queue, with a five-second cleanup deadline. Cancellation after a +settled terminal preserves its outcome. -```python -from aimux import generate_text, openai +Use `Abort.cancel()` from another thread to interrupt a call or stream. +Closing a stream, context exit, cancellation and GC trigger teardown. Blocking +native waits release the GIL and check Python signals every 20 ms; +`KeyboardInterrupt` cancels the worker. The bridge holds at most 64 events and +8 MiB of queued data, with at most 1 MiB per serialized event. Terminal state +has independent storage. Worker tasks never acquire the GIL. + +For realtime transcription, adopt the handle returned by `transcription.start`. +`Handle.push(data)`, or `client.operation_push(handle, data)`, admits raw bytes +through the shared binary service: -model = openai("sk-...", "gpt-4o") -result = generate_text(model, "Explain Rust ownership in one sentence.") -print(result["text"]) +```python +with client.adopt(model.call("transcription.start")) as operation: + operation.push(b"\x00\x00") + operation.call("operation.inputDone") + part = operation.call("operation.next", {"timeoutMs": 500}) ``` -## Features +Push accepts bytes, bytearray and memoryview; chunks above 1 MiB or a full +64-item / 8 MiB input queue raise `ResourceLimit` (protocol code 305). +Admission returns immediately. Polling returns `part`, `ended` or `timeout`. +A JSON call named `operation.push` is invalid; use the binary method. + +Each interpreter receives a private token from the shared owner allocator. +Clients in one interpreter share that token. CPython C APIs identify the +interpreter; monkeypatching Python modules cannot forge an owner. Handle GC +queues teardown, and owner teardown queues release of remaining resources. +PyO3 0.22 limits subinterpreter imports; this package does not extend support. + +`AimuxError` subclasses preserve the shared `envelope`, `error`, `kind`, `code` +and optional `retry_after_ms`. Payload attributes use snake_case. Retry +`errors` retain typed attempts in order; `last_error` is the last attempt. +Typed causes also appear as `__cause__`. Recording, binding and protocol errors +have separate bases. Python never recomputes retry hints or performs retries. -- **Unified interface** — switch providers by changing one constructor -- **Streaming** — token-by-token output with cancellation -- **Full modality coverage** — text, embeddings, images, speech, - transcription, video, reranking, files -- **Fast** — Rust core, zero GC pauses; benchmarked faster than the official - OpenAI SDK in [docs/PERF-RESULTS.md](https://github.com/arcships/aimux/blob/master/docs/PERF-RESULTS.md) +`ABI_VERSION`, `OPS_VERSION`, `manifest` and `manifest_digest()` are public. +Import checks the exact ABI/ops versions and digest pinned in the generated +metadata. `verify_contract(other_digest)` checks an additional consumer pin. +Recorder/session/infer state is process-wide. Recording schema migration, +transport replay, credential creation, generated business schemas and complete +platform install verification are outside this package's current guarantees. -## Documentation +## Development checks + +```sh +# Build/install the native wheel and optional test dependencies: +python3 -m maturin develop --extras test +AIMUX_REAL_OPS=1 python3 -m unittest discover -s tests -p 'test_ops_*.py' -v + +# Include localhost provider HTTP/WS fixtures and per-entry GIL assertions: +AIMUX_REAL_OPS=1 AIMUX_HTTP_TESTS=1 python3 -m unittest discover -s tests -p 'test_ops_*.py' -v +python3 -m pytest tests/test_gil_release.py -v + +# Test the already-built production cdylib before wheel installation: +python3 tests/run_real.py /absolute/path/to/libaimux.dylib + +# Isolated boundary tests for queue limits, terminals, cancellation, interrupts, +# concurrent consumers, GC and error chains (no provider behavior assertions): +python3 tests/run_native_contract.py +AIMUX_CORE_DOUBLE=1 python3 tests/run_native_contract.py +``` -- [Full API reference](https://github.com/arcships/aimux/blob/master/docs/API.md) -- [Project overview](https://github.com/arcships/aimux/blob/master/docs/PROJECT-OVERVIEW.md) +The isolated runner substitutes test doubles only in a temporary copy. By +default it uses real core and a dispatcher double; the faster mode uses a core +error double with the real AbortSignal source. `AIMUX_BUILD_COMMAND` can supply +a build executable, and `AIMUX_CONTRACT_TARGET` can select its target directory. +Neither mode verifies wheel installation or real provider behavior. All public +execution APIs are synchronous; use an application executor for asyncio. -## License +Ordinary pytest/unittest discovery also replays all 11 shared JSON scenarios from +`AIMUX_CONTRACT_VECTORS/scenarios` (default: `contract-tests/ops/scenarios` in the +repository), without requiring provider integration flags. It builds an isolated +PyO3 module with `contract-tests`, using the production call boundary, real +Dispatcher, framing and bounded queue with an injected scripted source. Release +builds do not expose test operations. Set `AIMUX_BUILD_COMMAND` to a Cargo build +wrapper when needed. Expected results are kept in Python and never passed into +the native driver. This complements the adapter-specific bridge lifecycle tests. -MIT +The process-wide runtime is stored in a Rust `OnceLock` and is never dropped at +interpreter shutdown. `InterpreterOwner::drop` queues `release_owner` on that +still-live runtime, including when the module guard or a late client is collected. diff --git a/bindings/python/bench/bench_nonstream.py b/bindings/python/bench/bench_nonstream.py index d58d33b73..0918a0212 100644 --- a/bindings/python/bench/bench_nonstream.py +++ b/bindings/python/bench/bench_nonstream.py @@ -1,124 +1,24 @@ -""" -aimux (PyO3 → Rust) vs OpenAI Python SDK — 性能对比 - -用 Node.js 的 mock server(已在 bench/mock-server.ts 验证可用)。 -Python 端三路测量: - B0 — httpx 直调 mock(无 SDK 基线) - aimux — PyO3 → Rust → reqwest → mock - openai — OpenAI Python SDK → httpx → mock +"""Measure a configured ops call in the same process as its handles. -启动方式: - 1. 先启动 node mock: cd bindings/node && npx tsx bench/mock-server.ts & - 2. 再跑: python bench/bench_nonstream.py - 3. 或不传参数,脚本自己判断 +Usage: python bench_nonstream.py REQUEST_JSON_FILE [COUNT] +File: {"provider": , "model": , + "op": , "params": } """ - import json +import statistics import sys import time -import httpx - -from aimux import openai as aimux_openai -import openai - -# ── bench 函数 ──────────────────────────────────────────────────────────── - -def bench_b0(uri: str, client: httpx.Client) -> float: - start = time.perf_counter() - resp = client.post(f"{uri}/v1/chat/completions", - headers={"Content-Type": "application/json", "Authorization": "Bearer test-key"}, - json={"model": "gpt-4o", "messages": [{"role": "user", "content": "Explain Rust ownership."}], "max_tokens": 50}, - ) - resp.text - return (time.perf_counter() - start) * 1000 - -def bench_aimux(model) -> float: - prompt = json.dumps("Explain Rust ownership.") - start = time.perf_counter() - model.generate_text(prompt) - return (time.perf_counter() - start) * 1000 - -def bench_openai(client: openai.OpenAI) -> float: - start = time.perf_counter() - client.chat.completions.create( - model="gpt-4o", - messages=[{"role": "user", "content": "Explain Rust ownership."}], - max_tokens=50, - ) - return (time.perf_counter() - start) * 1000 - -# ── 统计 ───────────────────────────────────────────────────────────────── - -def stats(samples): - s = sorted(samples) - n = len(s) - def pct(p): return s[max(0, int(n * p / 100) - 1)] - return sum(s) / n, pct(50), pct(95), pct(99), s[0] - -# ── 主流程 ──────────────────────────────────────────────────────────────── - -def main(): - # 从命令行获取 mock uri,或用默认 - uri = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:38000" - N = 200 - WARMUP = 20 - - # init - aimux_model = aimux_openai("test-key", "gpt-4o", f"{uri}/v1") - openai_client = openai.OpenAI(api_key="test-key", base_url=f"{uri}/v1") - httpx_client = httpx.Client() - - # 测试连接 - try: - resp = httpx_client.post(f"{uri}/v1/chat/completions", - headers={"Content-Type": "application/json", "Authorization": "Bearer test-key"}, - json={"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 5}, - timeout=3.0) - except Exception as e: - print(f" 无法连接 mock server ({uri}): {e}") - print(f" 请先启动: cd bindings/node && npx tsx bench/mock-server.ts") - print(f" 然后查看端口,传入: python bench/bench_nonstream.py http://127.0.0.1:") - return - - print(f"\n Python: aimux vs OpenAI SDK (N={N}, warmup={WARMUP})") - print(f" mock: {uri}\n") - - results = {} - - # B0 - for _ in range(WARMUP): bench_b0(uri, httpx_client) - samples = [bench_b0(uri, httpx_client) for _ in range(N)] - results["B0 httpx"] = stats(samples) - print(f" B0 httpx: mean={results['B0 httpx'][0]:.3f}ms") - - # aimux - for _ in range(WARMUP): bench_aimux(aimux_model) - samples = [bench_aimux(aimux_model) for _ in range(N)] - results["aimux"] = stats(samples) - print(f" aimux: mean={results['aimux'][0]:.3f}ms") - - # openai - for _ in range(WARMUP): bench_openai(openai_client) - samples = [bench_openai(openai_client) for _ in range(N)] - results["OpenAI SDK"] = stats(samples) - print(f" OpenAI SDK: mean={results['OpenAI SDK'][0]:.3f}ms") - - # 打印 - print(f"\n {'SDK':<14} {'mean':>8} {'P50':>8} {'P95':>8} {'P99':>8} {'min':>8}") - print(f" {'─'*14} {'─'*8} {'─'*8} {'─'*8} {'─'*8} {'─'*8}") - for name, (mean, p50, p95, p99, mn) in results.items(): - print(f" {name:<14} {mean:>8.3f} {p50:>8.3f} {p95:>8.3f} {p99:>8.3f} {mn:>8.3f}") - - b0 = results["B0 httpx"][0] - am = results["aimux"][0] - om = results["OpenAI SDK"][0] - print(f"\n B0 (无 SDK) = {b0:.3f}ms") - print(f" aimux 开销 = {am - b0:+.3f}ms (含 PyO3 FFI)") - print(f" OpenAI 开销 = {om - b0:+.3f}ms") - print(f" aimux vs OpenAI = {om / am:.1f}x ({'aimux 快' if am < om else 'OpenAI 快'})") - print() - - httpx_client.close() - -if __name__ == "__main__": - main() +from aimux import Client, ops + +with open(sys.argv[1]) as file: + request = json.load(file) +count = int(sys.argv[2]) if len(sys.argv) > 2 else 100 +client = Client() +samples = [] +with client.adopt(client.call(ops.OP_PROVIDER_CREATE, request['provider'])['handle']) as provider: + with client.adopt(provider.call(ops.OP_PROVIDER_MODEL, request['model'])['handle']) as model: + for _ in range(count): + started = time.perf_counter() + model.call(request['op'], request['params']) + samples.append(time.perf_counter() - started) +print(json.dumps({'count': count, 'medianSeconds': statistics.median(samples)})) diff --git a/bindings/python/bench/bench_stress.py b/bindings/python/bench/bench_stress.py index f8baf6724..3b7cd0dc1 100644 --- a/bindings/python/bench/bench_stress.py +++ b/bindings/python/bench/bench_stress.py @@ -1,134 +1,23 @@ -""" -aimux vs OpenAI SDK — 大 payload + 持续压测 + 内存 +"""Measure bounded streaming consumption in the process owning its handles. -用 Node.js mock server。 +Usage: python bench_stress.py REQUEST_JSON_FILE +File: {"provider": , "model": , + "op": , "params": } """ - import json import sys import time -import os -import httpx -import tracemalloc - -from aimux import openai as aimux_openai -import openai - -def make_context(approx_bytes): - turn = "Explain Rust ownership in detail. " + "word " * 50 - turns = [] - total = 0 - i = 0 - while total < approx_bytes: - turns.append(f"Message {i}: {turn}") - total += len(turn) + 20 - i += 1 - return "\n".join(turns) - -CTX_200K = make_context(200_000) - -def bench_aimux(model, prompt_json): - start = time.perf_counter() - model.generate_text(prompt_json) - return (time.perf_counter() - start) * 1000 - -def bench_openai(client, prompt): - start = time.perf_counter() - client.chat.completions.create( - model="gpt-4o", - messages=[{"role": "user", "content": prompt}], - max_tokens=50, - ) - return (time.perf_counter() - start) * 1000 - -def bench_b0(uri, httpx_client, prompt): - start = time.perf_counter() - resp = httpx_client.post(f"{uri}/v1/chat/completions", - headers={"Content-Type": "application/json", "Authorization": "Bearer test-key"}, - json={"model": "gpt-4o", "messages": [{"role": "user", "content": prompt}], "max_tokens": 50}, - ) - resp.text - return (time.perf_counter() - start) * 1000 - -def stats(samples): - s = sorted(samples) - n = len(s) - def pct(p): return s[max(0, int(n * p / 100) - 1)] - return sum(s) / n, pct(50), pct(95), pct(99), s[0] - -def rss_mb(): - # get RSS in MB from /proc/self/status - try: - with open("/proc/self/status") as f: - for line in f: - if line.startswith("VmRSS:"): - return int(line.split()[1]) // 1024 # KB → MB - except: - pass - return 0 - -def main(): - uri = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:45703" - - aimux_model = aimux_openai("test-key", "gpt-4o", f"{uri}/v1") - openai_client = openai.OpenAI(api_key="test-key", base_url=f"{uri}/v1") - httpx_client = httpx.Client() - - # warmup - prompt_json = json.dumps(CTX_200K) - for _ in range(5): bench_aimux(aimux_model, prompt_json) - for _ in range(5): bench_openai(openai_client, CTX_200K) - - N = 2000 - - print(f"\n Python: 持续压测 (N={N}, 200KB 上文)") - print(f" mock: {uri}") - print(f" RSS 初始: {rss_mb()} MB\n") - - # ── aimux ── - start_rss = rss_mb() - rss_samples = [] - latencies = [] - t_start = time.perf_counter() - for i in range(N): - lat = bench_aimux(aimux_model, prompt_json) - latencies.append(lat) - if i % 200 == 0: - rss_samples.append(rss_mb()) - total_ms = (time.perf_counter() - t_start) * 1000 - end_rss = rss_mb() - am_mean, am_p50, am_p95, am_p99, _ = stats(latencies) - print(f" aimux:") - print(f" 总耗时: {total_ms:.0f}ms ({N/total_ms*1000:.0f} rps)") - print(f" 延迟: mean={am_mean:.2f}ms P50={am_p50:.2f} P95={am_p95:.2f} P99={am_p99:.2f}") - print(f" 内存: {start_rss}MB → {end_rss}MB (+{end_rss-start_rss}MB)") - print(f" RSS: {' → '.join(str(s) for s in rss_samples)} MB") - print() - - # ── OpenAI SDK ── - start_rss = rss_mb() - rss_samples = [] - latencies = [] - t_start = time.perf_counter() - for i in range(N): - lat = bench_openai(openai_client, CTX_200K) - latencies.append(lat) - if i % 200 == 0: - rss_samples.append(rss_mb()) - total_ms = (time.perf_counter() - t_start) * 1000 - end_rss = rss_mb() - om_mean, om_p50, om_p95, om_p99, _ = stats(latencies) - print(f" OpenAI SDK:") - print(f" 总耗时: {total_ms:.0f}ms ({N/total_ms*1000:.0f} rps)") - print(f" 延迟: mean={om_mean:.2f}ms P50={om_p50:.2f} P95={om_p95:.2f} P99={om_p99:.2f}") - print(f" 内存: {start_rss}MB → {end_rss}MB (+{end_rss-start_rss}MB)") - print(f" RSS: {' → '.join(str(s) for s in rss_samples)} MB") - print() - - print(f" aimux vs OpenAI = {om_mean/am_mean:.1f}x (aimux 快)") - print() - - httpx_client.close() - -if __name__ == "__main__": - main() +from aimux import Client, ops + +with open(sys.argv[1]) as file: + request = json.load(file) +client = Client() +count = 0 +with client.adopt(client.call(ops.OP_PROVIDER_CREATE, request['provider'])['handle']) as provider: + with client.adopt(provider.call(ops.OP_PROVIDER_MODEL, request['model'])['handle']) as model: + started = time.perf_counter() + with model.stream(request['op'], request['params']) as stream: + for _ in stream: + count += 1 + elapsed = time.perf_counter() - started +print(json.dumps({'events': count, 'seconds': elapsed})) diff --git a/bindings/python/build.rs b/bindings/python/build.rs new file mode 100644 index 000000000..a4a0d1b91 --- /dev/null +++ b/bindings/python/build.rs @@ -0,0 +1,27 @@ +//! Pin the binding to its checked-in generated metadata. +use std::{env, fs, path::PathBuf}; + +fn main() -> Result<(), Box> { + pyo3_build_config::add_extension_module_link_args(); + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=python/aimux/ops.py"); + let metadata = fs::read_to_string("python/aimux/ops.py")?; + let mut constants = String::new(); + for (key, rust_type) in [ + ("ABI_VERSION", "u32"), + ("OPS_VERSION", "&str"), + ("MANIFEST_DIGEST", "&str"), + ] { + let prefix = format!("{key} = "); + let value = metadata + .lines() + .find_map(|line| line.strip_prefix(&prefix)) + .ok_or_else(|| format!("generated metadata missing {key}"))?; + constants += &format!("pub const EXPECTED_{key}: {rust_type} = {value};\n"); + } + fs::write( + PathBuf::from(env::var("OUT_DIR")?).join("contract.rs"), + constants, + )?; + Ok(()) +} diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index 9c549b4d2..fbc72e805 100644 --- a/bindings/python/pyproject.toml +++ b/bindings/python/pyproject.toml @@ -5,16 +5,15 @@ build-backend = "maturin" [project] name = "arcships-aimux" version = "0.5.0" -description = "Unified LLM service layer for Python (Rust core, 325 providers)" +description = "Python binding for the aimux shared operations dispatcher" readme = "README.md" license = "MIT" -requires-python = ">=3.8" +requires-python = ">=3.9" classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -23,6 +22,11 @@ classifiers = [ "Topic :: Software Development :: Libraries", ] +[project.optional-dependencies] +typed = ["pydantic>=2,<3"] +test = ["pytest>=7,<9", "pydantic>=2,<3"] + [tool.maturin] features = ["pyo3/extension-module"] python-source = "python" +module-name = "aimux.aimux" diff --git a/bindings/python/python/aimux/__init__.py b/bindings/python/python/aimux/__init__.py index 556f20aaa..12ac65336 100644 --- a/bindings/python/python/aimux/__init__.py +++ b/bindings/python/python/aimux/__init__.py @@ -1,499 +1,185 @@ -"""aimux — Unified LLM service layer for Python (Rust core, 325 providers). +"""Python ownership and JSON adapters for RFC-0039 operations. -This wrapper layer parses JSON strings from the native layer into Python dicts, -providing a Pythonic API surface. +Operation names and DTOs are defined by the shared runtime manifest. All calls +are synchronous; use the caller's executor when integrating with asyncio. """ - -import base64 import json -from typing import Any, AsyncIterator, Callable, Dict, List, Optional, Union - -from . import _repair +import weakref +from . import ops from .aimux import ( - AimuxError, - APICallError, - RetryError, - JSONParseError, - InvalidResponseDataError, - NoOutputGeneratedError, - NoSuchToolError, - InvalidToolInputError, - ToolCallRepairError, - InvalidArgumentError, - InvalidPromptError, - LoadAPIKeyError, - LoadSettingError, - TokenExpiredError, - UnsupportedFunctionalityError, - NoSuchModelError, - NoSuchProviderError, - APITimeoutError, - RequestAbortedError, - OtherError, - RecordingError, - Model, - StreamIterator, - openai, - anthropic, - deepseek, - google, - cohere, - mistral, - xai, - bedrock, - vertex, - anthropic_aws, - azure, - provider as _native_provider, - create_provider as _native_create_provider, - get_model_specs as _native_get_model_specs, - tool_call_repair_context, - apply_tool_call_repair, - apply_tool_call_repair_to_result, - ProviderHandle, - init_session_store, - init_session_infer, - session_calls as _session_calls_json, - list_sessions as _list_sessions_json, - init_recording, - init_recording_ring, - recording_stop, - recording_flush, - recording_try_flush, - mock_replay, - register_providers, - init_proxy, - router, - moa, - start_transcription_session, - TranscriptionSession, - EmbeddingModel, - SpeechModel, - ImageModel, - TranscriptionModel, - RerankingModel, - VideoModel, - SearchModel, - Files, - openai_embedding, - openai_speech, - openai_image, - openai_transcription, - openai_files, - cohere_embedding, - cohere_reranking, - google_embedding, - google_image, - google_video, - tavily_search, - DecisionModel, - jev_decision, + ABI_VERSION, OPS_VERSION, EXPECTED_ABI_VERSION, EXPECTED_OPS_VERSION, + EXPECTED_MANIFEST_DIGEST, Abort, Client as _NativeClient, StreamIterator, + manifest_digest, manifest_json, ) +from ._errors import * +from ._errors import __all__ as _error_names, from_envelope as _from_envelope + + +def verify_contract(expected_digest=EXPECTED_MANIFEST_DIGEST): + """Check runtime version and, when supplied, the consumer's pinned digest.""" + description = json.loads(manifest_json()) + if ABI_VERSION != EXPECTED_ABI_VERSION or OPS_VERSION != EXPECTED_OPS_VERSION: + raise ImportError("aimux ABI/ops version differs from the Python binding") + if description["abiVersion"] != ABI_VERSION or description["opsVersion"] != OPS_VERSION: + raise ImportError("aimux manifest version differs from the native module") + if expected_digest is not None and manifest_digest() != expected_digest: + raise ImportError("aimux manifest digest differs from the consumer's pinned contract") + return description + + +manifest = verify_contract() + + +def _wire(value): + if isinstance(value, Handle): + return value.id + if isinstance(value, dict): + return {key: _wire(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_wire(item) for item in value] + return value + + +def _encode(params): + try: + return json.dumps(_wire({} if params is None else params), allow_nan=False) + except (TypeError, ValueError, RecursionError) as error: + raise _from_envelope({ + "kind": "binding", "code": 202, "message": str(error), + "error": {"name": "InvalidWireJson", "argument": "params", "message": str(error)}, + }) from error + + +def _text(value, argument): + if not isinstance(value, str): + raise _from_envelope({ + "kind": "binding", "code": 202, "message": argument + " must be a string", + "error": {"name": "InvalidWireJson", "argument": argument, "message": "expected string"}, + }) + try: + value.encode("utf-8") + except UnicodeError as error: + raise _from_envelope({ + "kind": "binding", "code": 201, "message": str(error), + "error": {"name": "InvalidUtf8", "argument": argument}, + }) from error + return value + + +def _abort(value): + if value is not None and not isinstance(value, Abort): + raise _from_envelope({ + "kind": "binding", "code": 202, "message": "abort must be an Abort object", + "error": {"name": "InvalidWireJson", "argument": "abort", "message": "expected Abort"}, + }) + return value -__all__ = [ - "AimuxError", - "APICallError", - "RetryError", - "JSONParseError", - "InvalidResponseDataError", - "NoOutputGeneratedError", - "NoSuchToolError", - "InvalidToolInputError", - "ToolCallRepairError", - "InvalidArgumentError", - "InvalidPromptError", - "LoadAPIKeyError", - "LoadSettingError", - "TokenExpiredError", - "UnsupportedFunctionalityError", - "NoSuchModelError", - "NoSuchProviderError", - "APITimeoutError", - "RequestAbortedError", - "OtherError", - "RecordingError", - "Model", - "openai", - "anthropic", - "deepseek", - "google", - "cohere", - "mistral", - "xai", - "bedrock", - "vertex", - "anthropic_aws", - "azure", - "provider", - "create_provider", - "get_model_specs", - "tool_call_repair_context", - "apply_tool_call_repair", - "apply_tool_call_repair_to_result", - "ProviderHandle", - "init_session_store", - "init_session_infer", - "session_calls", - "list_sessions", - "init_recording", - "init_recording_ring", - "recording_stop", - "recording_flush", - "recording_try_flush", - "mock_replay", - "register_providers", - "init_proxy", - "router", - "moa", - "start_transcription_session", - "TranscriptionSession", - "EmbeddingModel", - "SpeechModel", - "ImageModel", - "TranscriptionModel", - "RerankingModel", - "VideoModel", - "SearchModel", - "Files", - "openai_embedding", - "openai_speech", - "openai_image", - "openai_transcription", - "openai_files", - "cohere_embedding", - "cohere_reranking", - "google_embedding", - "google_image", - "google_video", - "tavily_search", - "DecisionModel", - "jev_decision", - "describe_decision_model", - "decide", - "RepairToolCall", - "generate_text", - "generate_object", - "consume_stream_text", - "stream_text", - "generate_text_as_openai", - "stream_text_as_openai", -] +class Client: + """An interpreter-scoped client; no host callbacks or retry policy.""" + + def __init__(self): + self._native = _NativeClient() + + def call(self, op, params=None, *, handle="0", abort=None): + return json.loads(self._native.call( + _text(_wire(handle), "handle"), _text(op, "op"), _encode(params), _abort(abort), + )) + + def stream(self, op, params=None, *, handle, abort=None): + return EventStream(self._native.stream( + _text(_wire(handle), "handle"), _text(op, "op"), _encode(params), _abort(abort), + )) + + def operation_push(self, handle, data): + """Admit one raw audio chunk; a full queue raises ResourceLimit.""" + if not isinstance(data, (bytes, bytearray, memoryview)): + raise _from_envelope({ + "kind": "binding", "code": 202, "message": "data must be bytes-like", + "error": {"name": "InvalidWireJson", "argument": "data", "message": "expected bytes"}, + }) + size = data.nbytes if isinstance(data, memoryview) else len(data) + if size > 1024 * 1024: + raise _from_envelope({ + "kind": "protocol", "code": 305, "message": "audio chunk exceeds 1 MiB", + "error": {"name": "ResourceLimit", "message": "audio chunk exceeds 1 MiB"}, + }) + self._native.operation_push(_text(_wire(handle), "handle"), bytes(data)) + + def adopt(self, handle): + """Take responsibility for a returned decimal-string handle.""" + return Handle(self, handle) + + +def _release(client, handle): + try: + client._native.drop_later(handle) + except Exception: + # GC is best effort. Explicit close propagates teardown errors. + pass + + +class Handle: + """Own one runtime handle. Use close or a context manager for teardown.""" + + def __init__(self, client, handle): + if not isinstance(handle, str) or not handle.isascii() or not handle.isdecimal() or not 0 < int(handle) < (1 << 64): + raise ValueError("owned handles must be nonzero decimal strings") + self.client = client + self.id = handle + self._release = weakref.finalize(self, _release, client, handle) + + def call(self, op, params=None, *, abort=None): + return self.client.call(op, params, handle=self.id, abort=abort) + + def stream(self, op, params=None, *, abort=None): + return self.client.stream(op, params, handle=self.id, abort=abort) + + def push(self, data): + self.client.operation_push(self.id, data) + + def close(self): + if self._release.alive: + self.client.call(ops.OP_HANDLE_DROP, handle=self.id) + self._release.detach() -def describe_decision_model(model: DecisionModel) -> Dict[str, Any]: - """The model's DecisionModelV4 properties (specificationVersion, provider, - modelId, supportedQuestionTypes), without HTTP.""" - return json.loads(model.describe()) - - -def decide(model: DecisionModel, state: Any, questions: Dict[str, Dict[str, Any]], - **options: Any) -> Dict[str, Any]: - """Answer typed questions, keyed by ID, over one shared state. - - The keyword options are the ``DecideOptions`` JSON keys, camelCase as - the AI SDK's: ``maxRetries``, ``timeout`` (``{"totalMs": ...}``), - ``headers``, ``providerOptions``. The result is the ``DecideResult`` dict - (``answers``, ``usage`` with ``inputTokens`` / ``outputTokens`` / - ``totalTokens``, an unknown count absent, ``warnings``, ``rounding``, - ``providerMetadata``, ``response``). Boolean answers are P(true). A - provider's ``images[].data`` is a base64 string, the AI SDK's JSON for - bytes; ``bytes`` are encoded here. - """ - provider_options = options.get('providerOptions') - if isinstance(provider_options, dict): - options['providerOptions'] = { - name: _base64_images(value) for name, value in provider_options.items() - } - payload = dict(options, state=state, questions=questions) - return json.loads(model.decide(json.dumps(payload))) - - -def _base64_images(options: Any) -> Any: - if not isinstance(options, dict) or not isinstance(options.get('images'), list): - return options - return dict(options, images=[_base64_image(image) for image in options['images']]) - - -def _base64_image(image: Any) -> Any: - data = image.get('data') if isinstance(image, dict) else None - if isinstance(data, (bytes, bytearray, memoryview)): - return dict(image, data=base64.b64encode(bytes(data)).decode('ascii')) - return image - - -def session_calls(session_id: str) -> List[Dict[str, Any]]: - """All calls of a session, ordered by step (RFC-0024). - - Empty list if the session is unknown or no store is registered - (``init_session_store()`` must be called first). - """ - return json.loads(_session_calls_json(session_id)) - - -def list_sessions() -> List[Dict[str, Any]]: - """All known sessions (RFC-0024).""" - return json.loads(_list_sessions_json()) - - -def provider( - name: str, - api_key: Optional[str], - model_id: str, - base_url: Optional[str] = None, - config: Optional[Dict[str, Any]] = None, -) -> Model: - """Create a language model from the built-in registry by provider name. - - Args: - name: Registry provider name (e.g. "deepseek", "groq"). - api_key: API key; None reads the provider's env var. - model_id: Model ID. - base_url: Base-URL override (wins over config["base_url"]). - config: Full ProviderOptions dict — base_url / headers / organization / - project / params (a preset's template parameters, e.g. - ``{"account_id": "..."}``). ``max_retries`` (a call-level option) and - ``body_overrides`` are rejected with ``InvalidArgumentError``. - """ - config_json = json.dumps(config) if config is not None else None - return _native_provider(name, api_key, model_id, base_url, config_json) - - -def create_provider( - name: str, - api_key: Optional[str] = None, - base_url: Optional[str] = None, - config: Optional[Dict[str, Any]] = None, -) -> ProviderHandle: - """Create a provider handle for model discovery (RFC-0027). - - Unlike ``provider()`` (which binds to a single model_id), this returns a - ``ProviderHandle`` that supports ``list_models()`` and ``model()``. - - Args: - name: Registry provider name (e.g. "deepseek", "groq"). - api_key: API key; None reads the provider's env var. - base_url: Base-URL override (wins over config["base_url"]). - config: Full ProviderOptions dict — base_url / headers / organization / - project / params (a preset's template parameters, e.g. - ``{"account_id": "..."}``). ``max_retries`` (a call-level option) and - ``body_overrides`` are rejected with ``InvalidArgumentError``. - """ - config_json = json.dumps(config) if config is not None else None - return _native_create_provider(name, api_key, base_url, config_json) - - -def get_model_specs(source_url: Optional[str] = None) -> Dict[str, Any]: - """Fetch the community model catalogue (anya2a). Returns a dict representing - the Catalogue (provider -> model_id -> ModelSpec). Thin fetch — no caching. - - Args: - source_url: Optional URL override (default = anya2a endpoint). - """ - return json.loads(_native_get_model_specs(source_url)) - - -def _prompt_to_json(prompt: Union[str, List[Dict[str, Any]]]) -> str: - """Convert a prompt to the JSON string expected by the native layer.""" - if isinstance(prompt, str): - return json.dumps(prompt) - return json.dumps({"prompt": prompt}) - - -#: The ``options["repairToolCall"]`` function (RFC-0035), the equivalent of -#: the AI SDK ``repairToolCall``. -#: -#: It is called once per tool call that came back ``invalid``, with the repair -#: context ``{toolCall, error, inputSchema, tools, messages, instructions}`` -#: — ``toolCall["input"]`` being the provider's raw argument text. Return a -#: replacement raw tool call (same shape, ``input`` a string) to re-validate, -#: or ``None`` to leave the call invalid. Raising marks the repair failed: the -#: call keeps its original error with the exception message as the cause. -#: -#: It runs outside the native call, so it may itself call back into aimux (for -#: example ask a model to rewrite the arguments). Calls made without ``tools`` -#: are never repaired. ``generate_text_as_openai`` repairs the native result -#: before converting it; ``stream_text_as_openai`` does not reflect repair -#: (its tool-argument deltas are the provider's text, as in the AI SDK). -RepairToolCall = Callable[[Dict[str, Any]], Optional[Dict[str, Any]]] - - -def _opts_to_json(options: Optional[Dict[str, Any]]) -> Optional[str]: - """Convert options dict to JSON string. - - ``repairToolCall`` is a Python callable and stays on this side of the - boundary; everything else is passed through to the native layer. - """ - if options is None: - return None - return json.dumps({k: v for k, v in options.items() if k != "repairToolCall"}) - - -def _repair_fn(options: Optional[Dict[str, Any]]) -> Optional[_repair.RepairAdapter]: - fn = options.get("repairToolCall") if options else None - if fn is None: - return None - return lambda context: _repair.run_hook(fn, context) - - -def generate_text( - model: Model, - prompt: Union[str, List[Dict[str, Any]]], - options: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - """Generate text (non-streaming). Returns a dict result. - - Args: - model: A model instance from openai(), anthropic(), etc. - prompt: A string or a list of message dicts. - options: Optional generation options. - - Returns: - The ``GenerateTextResult`` dict: text, content, toolCalls, - finishReason, usage, warnings, steps, responseMessages, … and - ``output`` when ``options["output"]`` was parsed. - """ - prompt_json = _prompt_to_json(prompt) - opts_json = _opts_to_json(options) - result_json = model.generate_text(prompt_json, opts_json) - repair = _repair_fn(options) - if repair is not None: - result_json = _repair.repair_result(result_json, prompt_json, opts_json, repair) - return json.loads(result_json) - - -def generate_object( - model: Model, - prompt: Union[str, List[Dict[str, Any]]], - options: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - """Generate a structured JSON object from the model (M12, RFC-0016). - - Same signature as ``generate_text``; core's deprecated - ``generate_object`` wrapper. Pass ``output: {"type": "object", "schema": ...}`` - via options to describe the value; the model's text must parse and match - the schema. - - Args: - model: A model instance from openai(), anthropic(), etc. - prompt: A string or a list of message dicts. - options: Optional generation options (incl. output). - - Returns: - Dict with keys: object, reasoning, finishReason, usage, warnings, - request, response, providerMetadata. - """ - prompt_json = _prompt_to_json(prompt) - opts_json = _opts_to_json(options) - result_json = model.generate_object(prompt_json, opts_json) - repair = _repair_fn(options) - if repair is not None: - result_json = _repair.repair_result(result_json, prompt_json, opts_json, repair) - return json.loads(result_json) - - -def consume_stream_text( - model: Model, - prompt: Union[str, List[Dict[str, Any]]], - options: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - """Consume a stream to completion and return the aggregated result - (M11, RFC-0016). - - Drives ``stream_text`` to completion and returns the - ``GenerateTextResult`` dict, the same result ``generate_text`` returns. - - Args: - model: A model instance from openai(), anthropic(), etc. - prompt: A string or a list of message dicts. - options: Optional generation options. - - Returns: - The ``GenerateTextResult`` dict of the consumed stream. - """ - prompt_json = _prompt_to_json(prompt) - opts_json = _opts_to_json(options) - result_json = model.consume_stream_text(prompt_json, opts_json) - repair = _repair_fn(options) - if repair is not None: - result_json = _repair.repair_result(result_json, prompt_json, opts_json, repair) - return json.loads(result_json) - - -def stream_text( - model: Model, - prompt: Union[str, List[Dict[str, Any]]], - options: Optional[Dict[str, Any]] = None, -): - """Stream text from a model. Yields TextStreamPart dicts (tagged by - ``type``): ``start``, then per step ``start-step`` … ``finish-step``, - then ``finish``. - - Usage: - for part in stream_text(model, "Write a haiku about Rust."): - if part["type"] == "text-delta": - print(part["text"], end="") - """ - prompt_json = _prompt_to_json(prompt) - opts_json = _opts_to_json(options) - iterator = model.stream_text(prompt_json, opts_json) - repair = _repair_fn(options) - for part_json in iterator: - if repair is not None: - part_json = _repair.repair_stream_part( - part_json, prompt_json, opts_json, repair - ) - yield json.loads(part_json) - - -def generate_text_as_openai( - model: Model, - prompt: Union[str, List[Dict[str, Any]]], - options: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - """Generate text (non-streaming) and return an OpenAI Chat Completion dict. - - Same as generate_text, but the result is an OpenAI ``chat.completion`` - object (id, object, choices, usage, …). Works with any provider. - - Args: - model: A model instance from openai(), anthropic(), etc. - prompt: A string or a list of message dicts. - options: Optional generation options. - - Returns: - Dict with OpenAI Chat Completion keys: id, object, created, model, - choices, usage, system_fingerprint. - """ - prompt_json = _prompt_to_json(prompt) - opts_json = _opts_to_json(options) - return json.loads( - _repair.generate_openai_with_repair(model, prompt_json, opts_json, _repair_fn(options)) - ) - - -def stream_text_as_openai( - model: Model, - prompt: Union[str, List[Dict[str, Any]]], - options: Optional[Dict[str, Any]] = None, -): - """Stream text as OpenAI Chat Completion chunk dicts. - - Same as stream_text, but each yielded item is an OpenAI - ``chat.completion.chunk`` object. Works with any provider. - - Stream options (include_usage, include_reasoning) are read from - options["providerOptions"]["openai"]["stream_options"] (both default True). - - Usage: - for chunk in stream_text_as_openai(model, "Write a haiku."): - if chunk.get("choices"): - delta = chunk["choices"][0].get("delta", {}) - if "content" in delta: - print(delta["content"], end="") - """ - prompt_json = _prompt_to_json(prompt) - opts_json = _opts_to_json(options) - iterator = model.stream_text_as_openai(prompt_json, opts_json) - for chunk_json in iterator: - yield json.loads(chunk_json) + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + + +class EventStream: + """Iterator over event frames. Terminal failures raise typed exceptions.""" + + def __init__(self, native): + self._native = native + + def __iter__(self): + return self + + def __next__(self): + return json.loads(next(self._native)) + + def close(self): + self._native.close() + + def cancel(self): + self._native.cancel() + + def join(self): + self._native.join() + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + + +__all__ = _error_names + [ + "ABI_VERSION", "OPS_VERSION", "EXPECTED_MANIFEST_DIGEST", "Abort", "Client", "Handle", "EventStream", + "StreamIterator", "manifest", "manifest_json", "manifest_digest", "verify_contract", +] diff --git a/bindings/python/python/aimux/_errors.py b/bindings/python/python/aimux/_errors.py new file mode 100644 index 000000000..13574f212 --- /dev/null +++ b/bindings/python/python/aimux/_errors.py @@ -0,0 +1,111 @@ +"""Construct Python exceptions from the canonical ops error envelope. + +Headers and retry policy are projected by Rust, never recalculated here. +""" +import re + + +class AimuxError(Exception): + """Base for failures in the aimux domain.""" + + +class RecordingError(Exception): + """Failure in the recording domain.""" + + +class BindingError(Exception): + """Failure in the binding domain.""" + + +class ProtocolError(Exception): + """Failure in the protocol domain.""" + + +class APICallError(AimuxError): pass +class RetryError(AimuxError): pass +class JSONParseError(AimuxError): pass +class NoOutputGeneratedError(AimuxError): pass +class InvalidResponseDataError(AimuxError): pass +class ToolCallNotFoundForApprovalError(AimuxError): pass +class NoSuchToolError(AimuxError): pass +class InvalidToolInputError(AimuxError): pass +class ToolCallRepairError(AimuxError): pass +class InvalidArgumentError(AimuxError): pass +class InvalidPromptError(AimuxError): pass +class LoadAPIKeyError(AimuxError): pass +class LoadSettingError(AimuxError): pass +class TokenExpiredError(AimuxError): pass +class UnsupportedFunctionalityError(AimuxError): pass +class NoSuchModelError(AimuxError): pass +class NoSuchProviderError(AimuxError): pass +class MissingToolResultsError(AimuxError): pass +class ToolChoiceViolationError(AimuxError): pass +class APITimeoutError(AimuxError): pass +class RequestAbortedError(AimuxError): pass +class OtherError(AimuxError): pass + + +_AIMUX_TYPES = { + "AI_APICallError": APICallError, + "AI_RetryError": RetryError, + "AI_JSONParseError": JSONParseError, + "AI_NoOutputGeneratedError": NoOutputGeneratedError, + "AI_InvalidResponseDataError": InvalidResponseDataError, + "AI_ToolCallNotFoundForApprovalError": ToolCallNotFoundForApprovalError, + "AI_NoSuchToolError": NoSuchToolError, + "AI_InvalidToolInputError": InvalidToolInputError, + "AI_ToolCallRepairError": ToolCallRepairError, + "AI_InvalidArgumentError": InvalidArgumentError, + "AI_InvalidPromptError": InvalidPromptError, + "AI_LoadAPIKeyError": LoadAPIKeyError, + "AI_LoadSettingError": LoadSettingError, + "AI_TokenExpiredError": TokenExpiredError, + "AI_UnsupportedFunctionalityError": UnsupportedFunctionalityError, + "AI_NoSuchModelError": NoSuchModelError, + "AI_NoSuchProviderError": NoSuchProviderError, + "AI_MissingToolResultsError": MissingToolResultsError, + "AI_ToolChoiceViolationError": ToolChoiceViolationError, + "TimeoutError": APITimeoutError, + "AbortError": RequestAbortedError, + "AI_Error": OtherError, +} +_DOMAINS = { + "aimux": AimuxError, "recording": RecordingError, + "binding": BindingError, "protocol": ProtocolError, +} + + +def _snake(name): + return re.sub(r"(? Any: - """Call the caller's repair function. - - Only an exception raised by ``fn`` itself becomes a ``failed`` reply; the - adapters' own decoding and encoding stay outside, so a bug there surfaces - as itself instead of posing as a repair failure. - """ - try: - return fn(argument) - except Exception as exc: - raise RepairFailed(str(exc) or type(exc).__name__) from exc - - -def _reply(repair: RepairAdapter, context: Dict[str, Any]) -> str: - try: - replacement = repair(context) - except RepairFailed as exc: - # The host language's exception has no counterpart on the Rust side: - # only its message survives, as the cause of a ToolCallRepairError. - return json.dumps({"type": "failed", "message": str(exc)}) - if replacement is None: - return json.dumps({"type": "unchanged"}) - return json.dumps({"type": "repaired", "toolCall": replacement}) - - -def _context(call: Dict[str, Any], prompt_json: str, opts_json: Optional[str]): - """The AI SDK repair argument, or None when the call is not repairable.""" - return json.loads(tool_call_repair_context(json.dumps(call), prompt_json, opts_json)) - - -def repair_result( - result_json: str, - prompt_json: str, - opts_json: Optional[str], - repair: RepairAdapter, -) -> str: - """Repair every invalid tool call of a serialized result, in order. - - Handles the ``generate_text`` result and the consumed stream's result - alike (``toolCalls``, the final step's calls); a ``generate_object`` - result carries no tool calls. One repair attempt per call, as in the - AI SDK. Returns the patched result JSON. - """ - result = json.loads(result_json) - calls = result.get("toolCalls") or [] - for call in calls: - if not call.get("invalid"): - continue - context = _context(call, prompt_json, opts_json) - if context is None: - continue - result_json = apply_tool_call_repair_to_result( - result_json, opts_json, call["toolCallId"], _reply(repair, context) - ) - return result_json - - -def generate_openai_with_repair( - model: Any, - prompt_json: str, - opts_json: Optional[str], - repair: Optional[RepairAdapter], -) -> str: - """``generate_text_as_openai`` with repair, for both API layers. - - A ChatCompletion has no ``invalid`` marker, so with a repair function the - native result is generated, repaired, then converted. - """ - if repair is None: - return model.generate_text_as_openai(prompt_json, opts_json) - result_json = model.generate_text(prompt_json, opts_json) - result_json = repair_result(result_json, prompt_json, opts_json, repair) - return model.generate_text_result_as_openai(result_json) - - -def repair_stream_part( - part_json: str, - prompt_json: str, - opts_json: Optional[str], - repair: RepairAdapter, -) -> str: - """Replace an invalid ``tool-call`` stream part with its repaired form. - - Every other part — tool-input deltas included — is forwarded untouched, so - repair never delays the stream. - """ - call = json.loads(part_json) - if call.get("type") != "tool-call" or not call.get("invalid"): - return part_json - context = _context(call, prompt_json, opts_json) - if context is None: - return part_json - repaired = apply_tool_call_repair( - json.dumps(call), opts_json, _reply(repair, context) - ) - return json.dumps({"type": "tool-call", **json.loads(repaired)}) diff --git a/bindings/python/python/aimux/ops.py b/bindings/python/python/aimux/ops.py new file mode 100644 index 000000000..2e2250a10 --- /dev/null +++ b/bindings/python/python/aimux/ops.py @@ -0,0 +1,76 @@ +# Generated by aimux-codegen. Do not edit. Manifest SHA-256: 2bc7cb7ec77a0ed20496411f0dbd199b949b9887cc74d119e11627c8573d82a2 +ABI_VERSION = 65536 +OPS_VERSION = "1.0" +MANIFEST_DIGEST = "2bc7cb7ec77a0ed20496411f0dbd199b949b9887cc74d119e11627c8573d82a2" +OP_ABORT_CREATE = "abort.create" +OP_ABORT_SIGNAL = "abort.signal" +OP_AUTH_CODEX_REFRESH = "auth.codexRefresh" +OP_CATALOGUE_FETCH = "catalogue.fetch" +OP_COMPOSITE_MOA = "composite.moa" +OP_COMPOSITE_ROUTER = "composite.router" +OP_CREDENTIAL_CREATE = "credential.create" +OP_DECISION_CAPABILITIES = "decision.capabilities" +OP_DECISION_DECIDE = "decision.decide" +OP_EMBEDDING_EMBED = "embedding.embed" +OP_EMBEDDING_EMBED_MANY = "embedding.embedMany" +OP_FILES_UPLOAD = "files.upload" +OP_HANDLE_DROP = "handle.drop" +OP_IMAGE_GENERATE = "image.generate" +OP_MIDDLEWARE_CREATE = "middleware.create" +OP_MODEL_INFO = "model.info" +OP_MODEL_RESOLVE = "model.resolve" +OP_MODEL_WRAP = "model.wrap" +OP_OPERATION_CANCEL = "operation.cancel" +OP_OPERATION_INPUT_DONE = "operation.inputDone" +OP_OPERATION_NEXT = "operation.next" +OP_OPERATION_PUSH = "operation.push" +OP_OPERATION_STATUS = "operation.status" +OP_OUTPUT_TO_OPEN_AI = "output.toOpenAI" +OP_PROVIDER_CREATE = "provider.create" +OP_PROVIDER_CUSTOM = "provider.custom" +OP_PROVIDER_DEFAULT = "provider.default" +OP_PROVIDER_FILES = "provider.files" +OP_PROVIDER_INVOKE = "provider.invoke" +OP_PROVIDER_MODEL = "provider.model" +OP_PROVIDER_WRAP = "provider.wrap" +OP_RECORDER_ATTACH = "recorder.attach" +OP_RECORDER_CREATE = "recorder.create" +OP_RECORDER_EXPORT = "recorder.export" +OP_RECORDER_FLUSH = "recorder.flush" +OP_RECORDER_STATS = "recorder.stats" +OP_RECORDER_STOP = "recorder.stop" +OP_REGISTRY_CREATE = "registry.create" +OP_REGISTRY_PROVIDER = "registry.provider" +OP_REPLAY_CAPABILITIES = "replay.capabilities" +OP_REPLAY_CREATE = "replay.create" +OP_REPLAY_DRY_RUN = "replay.dryRun" +OP_REPLAY_INSPECT = "replay.inspect" +OP_REPLAY_LIVE = "replay.live" +OP_RERANKING_RERANK = "reranking.rerank" +OP_RUNTIME_CONFIGURE = "runtime.configure" +OP_RUNTIME_DESCRIBE = "runtime.describe" +OP_SEARCH_SEARCH = "search.search" +OP_SESSION_STORE_ATTACH = "sessionStore.attach" +OP_SESSION_STORE_CALLS = "sessionStore.calls" +OP_SESSION_STORE_CREATE = "sessionStore.create" +OP_SESSION_STORE_LIST = "sessionStore.list" +OP_SPEECH_GENERATE = "speech.generate" +OP_TEXT_CONSUME = "text.consume" +OP_TEXT_GENERATE = "text.generate" +OP_TEXT_GENERATE_OBJECT = "text.generateObject" +OP_TEXT_GENERATE_OPEN_AI = "text.generateOpenAI" +OP_TEXT_STREAM = "text.stream" +OP_TEXT_STREAM_OPEN_AI = "text.streamOpenAI" +OP_TOOL_REPAIR_APPLY = "toolRepair.apply" +OP_TOOL_REPAIR_APPLY_RESULT = "toolRepair.applyResult" +OP_TOOL_REPAIR_CONTEXT = "toolRepair.context" +OP_TRACE_AGGREGATE = "trace.aggregate" +OP_TRACE_CLEAR = "trace.clear" +OP_TRACE_CREATE = "trace.create" +OP_TRACE_EXPORT = "trace.export" +OP_TRACE_SESSION_CHAIN = "trace.sessionChain" +OP_TRACE_SESSION_TRAJECTORY = "trace.sessionTrajectory" +OP_TRANSCRIPTION_GENERATE = "transcription.generate" +OP_TRANSCRIPTION_START = "transcription.start" +OP_TRANSPORT_CREATE = "transport.create" +OP_VIDEO_GENERATE = "video.generate" diff --git a/bindings/python/python/aimux/wrapper.py b/bindings/python/python/aimux/wrapper.py index 73cdba4c6..537c272ac 100644 --- a/bindings/python/python/aimux/wrapper.py +++ b/bindings/python/python/aimux/wrapper.py @@ -1,29 +1,14 @@ -"""Typed wrapper for the aimux Python binding. +"""Typed AI SDK wire schemas and text helpers over interpreter-owned handles. -This layer sits *on top* of the native PyO3 ``Model.generate_text`` / -``Model.stream_text`` methods (which exchange JSON strings) and exposes a -typed API: inputs and outputs are Pydantic models / native Python objects, so -callers never have to ``json.dumps`` / ``json.loads`` by hand. - -The native (raw) JSON-string API in :mod:`aimux` is left untouched — this -module is an additional, optional layer:: - - from aimux.wrapper import generate_text, GenerateTextOptions, ModelMessage - -The Pydantic models mirror the Rust core types in ``aimux-core/src/`` (see -``bindings/node/src/types/*.ts`` for the field structure generated by ts-rs). -The wire format is the AI SDK's: JSON keys are camelCase and unions are -discriminated by ``type`` (kebab-case values). Python attributes stay -``snake_case``; every model maps them to the camelCase JSON keys through an -alias generator, so both ``tool_call_id`` and ``toolCallId`` validate and -serialization uses the camelCase form. +The shared ops dispatcher validates requests and executes all provider logic. +Pydantic is optional and is only required by this module. """ from __future__ import annotations import base64 import json -from typing import Annotated, Any, Callable, Dict, Iterator, List, Literal, Optional, Union +from typing import Annotated, Any, Dict, Iterator, List, Literal, Optional, Union from pydantic import ( BaseModel, @@ -36,11 +21,9 @@ ) from pydantic.alias_generators import to_camel -from . import _repair -from .aimux import Model +from . import Handle, ops __all__ = [ - # type aliases "Role", "FinishReasonUnified", "ReasoningEffort", @@ -59,7 +42,6 @@ "FileBytes", "Warning", "AiMuxErrorValue", - # models "InputTokenUsage", "OutputTokenUsage", "GeneratedFileData", @@ -78,7 +60,6 @@ "ToolCall", "RawToolCall", "ToolCallRepairContext", - "RepairToolCall", "ModelMessage", "FunctionTool", "FunctionToolInputExample", @@ -91,11 +72,9 @@ "GenerateResult", "GenerateResponseMetadata", "RequestInfo", - # functions "generate_text", "stream_text", "parse_stream_part", - # OpenAI Chat Completions output (RFC-0026) "ChatCompletion", "ChatCompletionChoice", "ChatCompletionMessage", @@ -146,19 +125,12 @@ class _Meta(_Wire): provider_metadata: Optional[Dict[str, Any]] = None -# ───────────────────────────────────────────────────────────────────────────── -# String-literal type aliases (mirror serde rename rules) -# ───────────────────────────────────────────────────────────────────────────── - -#: Message sender. Rust ``Role`` serializes as lowercase strings. Role = Literal["system", "user", "assistant", "tool"] -#: Unified finish reason. Rust ``FinishReasonUnified`` uses ``kebab-case``. FinishReasonUnified = Literal[ "stop", "length", "content-filter", "tool-calls", "error", "other" ] -#: Reasoning effort. Rust ``ReasoningEffort`` uses ``kebab-case``. ReasoningEffort = Literal[ "provider-default", "none", "minimal", "low", "medium", "high", "xhigh" ] @@ -170,10 +142,6 @@ class _Meta(_Wire): AiMuxErrorValue = Any -# ───────────────────────────────────────────────────────────────────────────── -# Simple struct models -# ───────────────────────────────────────────────────────────────────────────── - class InputTokenUsage(_Wire): total: Optional[int] = None no_cache: Optional[int] = None @@ -295,12 +263,6 @@ class RawToolCall(_Meta): dynamic: Optional[bool] = None -# ───────────────────────────────────────────────────────────────────────────── -# Unions: internally tagged by ``type`` (kebab-case), as in the AI SDK. -# ───────────────────────────────────────────────────────────────────────────── - -# ── Warning ────────────────────────────────────────────────────────────────── - class _WarningUnsupported(_Wire): type: Literal["unsupported"] feature: str @@ -324,15 +286,12 @@ class _WarningOther(_Wire): message: str -#: A provider warning (Rust ``Warning``). Warning = Annotated[ Union[_WarningUnsupported, _WarningCompatibility, _WarningDeprecated, _WarningOther], Field(discriminator="type"), ] -# ── FileBytes / FileData ──────────────────────────────────────────────────── - def _bytes_to_base64(value: Any) -> Any: if isinstance(value, (bytes, bytearray, memoryview)): return base64.b64encode(bytes(value)).decode("ascii") @@ -343,7 +302,6 @@ def _bytes_to_base64(value: Any) -> Any: #: ``bytes`` given here are encoded (Rust ``wire::base64_bytes``). Base64Bytes = Annotated[str, BeforeValidator(_bytes_to_base64)] -#: File bytes: one base64 string on the wire (Rust ``FileBytes``). FileBytes = Base64Bytes @@ -368,7 +326,6 @@ class _FileDataText(_Wire): text: str -#: File data (Rust ``FileData``). FileData = Annotated[ Union[_FileDataData, _FileDataUrl, _FileDataReference, _FileDataText], Field(discriminator="type"), @@ -400,8 +357,6 @@ def to_bytes(self) -> bytes: return base64.b64decode(self.base64) -# ── Content / stream parts ────────────────────────────────────────────────── - class _UrlSource(_Meta): # A source is the AI SDK's tagged ``Source`` (provider # ``language-model-v4-source.ts``) in a result's ``sources`` too. @@ -549,7 +504,6 @@ class _ApprovalResponse(_Wire): provider_executed: Optional[bool] = None -#: A content item in the raw generation result (Rust ``GenerateContent``). GenerateContent = Annotated[ Union[ _Text, @@ -565,7 +519,6 @@ class _ApprovalResponse(_Wire): Field(discriminator="type"), ] -#: A content item of a text result (Rust ``result::ContentPart``). TextContent = Annotated[ Union[ _Text, @@ -584,8 +537,6 @@ class _ApprovalResponse(_Wire): ] -# ── StreamPart ─────────────────────────────────────────────────────────────── - class _SPIdMeta(_Meta): id: str @@ -719,10 +670,6 @@ class _SPRaw(_Wire): ] -# ───────────────────────────────────────────────────────────────────────────── -# ResponseFormat — ``{"type": "text"}`` | ``{"type": "json", schema?, ...}`` -# ───────────────────────────────────────────────────────────────────────────── - class _ResponseFormatText(_Wire): type: Literal["text"] = "text" @@ -734,16 +681,11 @@ class _ResponseFormatJson(_Wire): description: Optional[str] = None -#: How the model should format its response (Rust ``ResponseFormat``). ResponseFormat = Annotated[ Union[_ResponseFormatText, _ResponseFormatJson], Field(discriminator="type") ] -# ───────────────────────────────────────────────────────────────────────────── -# Tools -# ───────────────────────────────────────────────────────────────────────────── - class FunctionToolInputExample(_Wire): input: Dict[str, Any] @@ -778,8 +720,6 @@ class ProviderTool(_Wire): ] -# ── ContentPart (prompt parts: internal tag ``type``, kebab-case) ─────────── - class _PartOptions(_Wire): provider_options: Optional[Any] = None @@ -883,14 +823,9 @@ class _ToolApprovalRequestContentPart(_Wire): ] -#: Message body: a plain string or multi-part content (Rust ``MessageContent``). MessageContent = Union[str, List[ContentPart]] -# ───────────────────────────────────────────────────────────────────────────── -# ToolChoice — ``"auto" | "none" | "required" | {"type": "tool", "toolName": ...}`` -# ───────────────────────────────────────────────────────────────────────────── - class ToolChoiceTool(_Wire): """The ``tool`` tool-choice variant (forces a specific tool).""" @@ -901,10 +836,6 @@ class ToolChoiceTool(_Wire): ToolChoice = Union[Literal["auto", "none", "required"], ToolChoiceTool] -# ───────────────────────────────────────────────────────────────────────────── -# User-facing prompt / message models -# ───────────────────────────────────────────────────────────────────────────── - class ModelMessage(_Wire): """A single user-facing chat message (Rust ``ModelMessage``). @@ -916,16 +847,14 @@ class ModelMessage(_Wire): content: MessageContent -# ───────────────────────────────────────────────────────────────────────────── -# Options + results -# ───────────────────────────────────────────────────────────────────────────── - class TimeoutConfiguration(_Wire): """Per-call timeout configuration (Rust ``TimeoutConfiguration``). All values are milliseconds; ``None`` disables the corresponding limit. """ + model_config = ConfigDict(extra="forbid") + total_ms: Optional[int] = None step_ms: Optional[int] = None first_chunk_ms: Optional[int] = None @@ -949,22 +878,18 @@ class ToolCallRepairContext(_Wire): instructions: Optional[str] = None -#: A ``repair_tool_call`` function: return a replacement :class:`RawToolCall` -#: to re-validate, or ``None`` to leave the call invalid. Raising marks the -#: repair failed, with the exception message as the cause. -RepairToolCall = Callable[[ToolCallRepairContext], Optional[RawToolCall]] - - class GenerateTextOptions(_Wire): """User-facing options for ``generate_text`` / ``stream_text``. Mirrors Rust ``GenerateTextOptions``. All fields are optional; unset fields default to ``None`` (Rust treats absent / ``null`` as ``None``). - Invalid tool calls arrive with ``invalid``/``error`` set on the tool call; - set :attr:`repair_tool_call` to get a shot at fixing them. + Invalid tool calls arrive with ``invalid``/``error`` set on the tool call. + Use the explicit toolRepair operations to repair them. """ + model_config = ConfigDict(extra="forbid") + max_output_tokens: Optional[int] = None temperature: Optional[float] = None stop_sequences: Optional[List[str]] = None @@ -992,32 +917,12 @@ class GenerateTextOptions(_Wire): """Session identifier (RFC-0024): groups consecutive calls into a session. Explicit values take precedence; when ``None`` and the optional session - inferer is enabled (``init_session_infer(True)`` or ``AIMUX_SESSION_INFER=1``), + inferer is enabled (``AIMUX_SESSION_INFER=1``), one may be inferred from prompt-prefix continuation. """ timeout: Optional[TimeoutConfiguration] = None include_raw_chunks: Optional[bool] = None - repair_tool_call: Optional[RepairToolCall] = Field(default=None, exclude=True) - """One-shot repair for tool calls that came back ``invalid`` (RFC-0035). - - Called once per invalid call, after generation and before the result is - decoded, with a :class:`ToolCallRepairContext`. Return a - :class:`RawToolCall` to re-validate, or ``None`` to leave the call as it - is; raising marks the repair failed and the call keeps its original error - with the exception message as the cause. - - It runs outside the native call and may itself call back into aimux (e.g. - ask a model to rewrite the arguments). A call generated without ``tools`` - is never repaired. ``generate_text_as_openai`` repairs the native result - before converting it; ``stream_text_as_openai`` does not reflect repair - (its tool-argument deltas are the provider's text, as in the AI SDK). - Only an exception raised by this function means "failed"; returning - something other than a :class:`RawToolCall` or ``None`` is an error. - Host-only: it is never serialized into the options sent across the - boundary. - """ - class GenerateResult(_Wire): """Provider-level result of one model call (Rust ``GenerateResult``).""" @@ -1126,16 +1031,6 @@ class GenerateObjectResult(_Wire): provider_metadata: Optional[Dict[str, Any]] = None -# ───────────────────────────────────────────────────────────────────────────── -# OpenAI Chat Completions output (RFC-0026) -# -# Mirrors ``aimux_core::openai_output``. Field names match the serde wire -# format; ``skip_serializing_if = "Option::is_none"`` maps to ``Optional[...] = -# None``. The Rust field ``tool_type`` serializes as ``"type"`` (via -# ``#[serde(rename = "type")]``), so the pydantic field is named ``type`` -# directly (same convention as ``FunctionTool`` above). -# ───────────────────────────────────────────────────────────────────────────── - class ChatCompletionFunction(BaseModel): """Function payload of a tool call (Rust ``ChatCompletionFunction``).""" @@ -1250,18 +1145,14 @@ class ChatCompletionChunk(BaseModel): usage: Optional[ChatCompletionUsage] = None -# ───────────────────────────────────────────────────────────────────────────── -# Helpers -# ───────────────────────────────────────────────────────────────────────────── - Prompt = Union[str, List[ModelMessage], List[Dict[str, Any]]] def _prompt_to_json(prompt: Prompt) -> str: """Build the JSON prompt string expected by the native layer. - A bare string is emitted as a JSON string (→ ``ModelPrompt::Text``). A list - of messages is wrapped as ``{"prompt": [...]}`` (→ ``ModelPrompt::Messages``). + A bare string is emitted as a JSON string (``ModelPrompt::Text``). A list + of messages is emitted as an array (``ModelPrompt::Messages``). ``ModelMessage`` instances are serialized with ``mode="json"`` so nested models become JSON primitives. """ @@ -1277,7 +1168,7 @@ def _prompt_to_json(prompt: Prompt) -> str: else m for m in prompt ] - return json.dumps({"prompt": msgs}) + return json.dumps(msgs) def _opts_to_json(options: Optional[GenerateTextOptions]) -> Optional[str]: @@ -1289,147 +1180,40 @@ def _opts_to_json(options: Optional[GenerateTextOptions]) -> Optional[str]: "options must be a GenerateTextOptions instance, got " f"{type(options).__name__}" ) - # ``_Wire`` omits unset ``None`` fields as Rust's - # ``#[serde(skip_serializing_if = "Option::is_none")]`` does and keeps a - # JSON ``null`` a JSON-value field was given. ``by_alias`` emits the - # ``schema`` field of the JSON response-format body and the camelCase keys - # of every model. return options.model_dump_json(by_alias=True) -def _repair_adapter(options: Optional[GenerateTextOptions]): - """Bridge the typed repair function to the JSON-level loop in ``_repair``.""" - fn = options.repair_tool_call if options is not None else None - if fn is None: - return None - - def adapter(context: dict) -> Optional[dict]: - typed = ToolCallRepairContext.model_validate(context) - replacement = _repair.run_hook(fn, typed) - if replacement is None: - return None - return replacement.model_dump(mode="json", by_alias=True) - - return adapter - +def _params(prompt, options): + return { + "prompt": json.loads(_prompt_to_json(prompt)), + "options": json.loads(_opts_to_json(options) or "{}"), + } -# ───────────────────────────────────────────────────────────────────────────── -# Typed API -# ───────────────────────────────────────────────────────────────────────────── -def generate_text( - model: Model, - prompt: Prompt, - options: Optional[GenerateTextOptions] = None, -) -> GenerateTextResult: - """Generate text (non-streaming), returning a typed result. +def generate_text(model: Handle, prompt: Prompt, options=None) -> GenerateTextResult: + """Call text.generate using the shared wire DTO.""" + return GenerateTextResult.model_validate(model.call(ops.OP_TEXT_GENERATE, _params(prompt, options))) - Args: - model: A model instance from ``openai()``, ``anthropic()``, etc. - prompt: A string or a list of ``ModelMessage`` / message dicts. - options: Optional typed generation options. - Returns: - A :class:`GenerateTextResult` with ``.text``, ``.tool_calls``, - ``.finish_reason``, ``.usage``, ``.warnings``, ``.content`` and - ``.steps``. - """ - prompt_json = _prompt_to_json(prompt) - opts_json = _opts_to_json(options) - result_json = model.generate_text(prompt_json, opts_json) - repair = _repair_adapter(options) - if repair is not None: - result_json = _repair.repair_result(result_json, prompt_json, opts_json, repair) - return GenerateTextResult.model_validate_json(result_json) - - -def stream_text( - model: Model, - prompt: Prompt, - options: Optional[GenerateTextOptions] = None, -) -> Iterator[dict]: - """Stream text from a model, yielding parsed ``StreamPart`` dicts. - - Each yielded item is the dict form produced by the Rust core, tagged by - ``type``, e.g. ``{"type": "text-delta", "id": ..., "text": ...}``. For - typed access, pass the dict through :func:`parse_stream_part`. - - Usage:: - - for part in stream_text(model, "Write a haiku about Rust."): - if part["type"] == "text-delta": - print(part["text"], end="") - """ - prompt_json = _prompt_to_json(prompt) - opts_json = _opts_to_json(options) - repair = _repair_adapter(options) - for part_json in model.stream_text(prompt_json, opts_json): - if repair is not None: - part_json = _repair.repair_stream_part( - part_json, prompt_json, opts_json, repair - ) - yield json.loads(part_json) +def stream_text(model: Handle, prompt: Prompt, options=None) -> Iterator[dict]: + """Yield text events; closing the generator cancels the native stream.""" + with model.stream(ops.OP_TEXT_STREAM, _params(prompt, options)) as stream: + for frame in stream: + yield frame["event"] _STREAM_PART = TypeAdapter(StreamPart) def parse_stream_part(part: dict) -> BaseModel: - """Validate a yielded stream-part dict into its typed model (see ``StreamPart``).""" return _STREAM_PART.validate_python(part) -def generate_text_as_openai( - model: Model, - prompt: Prompt, - options: Optional[GenerateTextOptions] = None, -) -> ChatCompletion: - """Generate text and return the result as an OpenAI Chat Completion. +def generate_text_as_openai(model: Handle, prompt: Prompt, options=None) -> ChatCompletion: + return ChatCompletion.model_validate(model.call(ops.OP_TEXT_GENERATE_OPEN_AI, _params(prompt, options))) - Identical to :func:`generate_text` except the result is a - :class:`ChatCompletion` (OpenAI ``chat.completion`` object) rather than a - :class:`GenerateTextResult`. Works with any provider. - Args: - model: A model instance from ``openai()``, ``anthropic()``, etc. - prompt: A string or a list of ``ModelMessage`` / message dicts. - options: Optional typed generation options. - - Returns: - A :class:`ChatCompletion` with ``.id``, ``.choices``, ``.usage``, … - - With ``options.repair_tool_call``, invalid calls are repaired on the - native result first and the completion is converted from it, so its - ``tool_calls`` carry the repaired arguments. - """ - prompt_json = _prompt_to_json(prompt) - opts_json = _opts_to_json(options) - return ChatCompletion.model_validate_json( - _repair.generate_openai_with_repair(model, prompt_json, opts_json, _repair_adapter(options)) - ) - - -def stream_text_as_openai( - model: Model, - prompt: Prompt, - options: Optional[GenerateTextOptions] = None, -) -> Iterator[ChatCompletionChunk]: - """Stream text as OpenAI Chat Completion chunks. - - Identical to :func:`stream_text` except each yielded item is a typed - :class:`ChatCompletionChunk` (OpenAI ``chat.completion.chunk`` object). - Works with any provider. - - Stream options (``include_usage``, ``include_reasoning``) are read from - ``options.provider_options.openai.stream_options`` (both default to ``True``). - - Usage:: - - for chunk in stream_text_as_openai(model, "Write a haiku about Rust."): - if chunk.choices and chunk.choices[0].delta.content: - print(chunk.choices[0].delta.content, end="") - """ - prompt_json = _prompt_to_json(prompt) - opts_json = _opts_to_json(options) - for chunk_json in model.stream_text_as_openai(prompt_json, opts_json): - yield ChatCompletionChunk.model_validate_json(chunk_json) +def stream_text_as_openai(model: Handle, prompt: Prompt, options=None) -> Iterator[ChatCompletionChunk]: + with model.stream(ops.OP_TEXT_STREAM_OPEN_AI, _params(prompt, options)) as stream: + for frame in stream: + yield ChatCompletionChunk.model_validate(frame["event"]) diff --git a/bindings/python/src/bridge.rs b/bindings/python/src/bridge.rs new file mode 100644 index 000000000..bea09c316 --- /dev/null +++ b/bindings/python/src/bridge.rs @@ -0,0 +1,417 @@ +//! Bounded, cancellation-aware bridge. Worker tasks never acquire the GIL. +use std::future::Future; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, mpsc}; +use std::time::{Duration, Instant, SystemTime}; + +use aimux_core::AbortSignal; +use aimux_ops::envelope::{ErrorEnvelope, OpsError, ProtocolError}; +use aimux_ops::frame::{Outcome, StreamFrame}; +use aimux_ops::handle::HandleId; +use futures::StreamExt; +use pyo3::prelude::*; +use serde_json::Value; +use tokio::sync::{Semaphore, mpsc as channel}; + +use crate::{InterpreterOwner, check_owner, context, error, invalid_state, runtime, serialize}; + +const WAIT_SLICE: Duration = Duration::from_millis(20); +const QUEUE_ITEMS: usize = 64; +const QUEUE_BYTES: usize = 8 * 1024 * 1024; +const MAX_FRAME_BYTES: usize = 1024 * 1024; + +type Terminal = Result<(), ErrorEnvelope>; + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn aborted() -> OpsError { + OpsError::AiMux(aimux_core::AiMuxError::Aborted( + "Python operation cancelled".into(), + )) +} + +fn project(error: OpsError) -> ErrorEnvelope { + error.envelope(SystemTime::now()) +} + +#[derive(Default)] +struct Completion { + done: Mutex, + changed: Condvar, +} + +impl Completion { + fn wait_slice(&self) -> bool { + let done = lock(&self.done); + if *done { + return true; + } + let (done, _) = self + .changed + .wait_timeout(done, WAIT_SLICE) + .unwrap_or_else(std::sync::PoisonError::into_inner); + *done + } +} + +struct CompletionGuard(Arc); + +impl Drop for CompletionGuard { + fn drop(&mut self) { + *lock(&self.0.done) = true; + self.0.changed.notify_all(); + } +} + +struct Task { + signal: AbortSignal, + handle: tokio::task::JoinHandle<()>, + completion: Arc, +} + +impl Task { + fn spawn( + py: Python<'_>, + signal: AbortSignal, + future: impl Future + Send + 'static, + ) -> PyResult { + let rt = py + .allow_threads(runtime) + .map_err(|e| error::ops_error(py, e))?; + let completion = Arc::new(Completion::default()); + let guard = CompletionGuard(completion.clone()); + let handle = rt.spawn(async move { + let _guard = guard; + future.await; + }); + Ok(Self { + signal, + handle, + completion, + }) + } + + fn cancel(&self) { + self.signal.abort(); + // Also interrupts a producer waiting for channel or byte capacity. + self.handle.abort(); + } + + fn join(&self, py: Python<'_>) -> PyResult<()> { + self.wait_for_completion(py, None) + } + + fn wait_for_completion(&self, py: Python<'_>, deadline: Option) -> PyResult<()> { + loop { + let done = py.allow_threads(|| self.completion.wait_slice()); + if let Err(error) = py.check_signals() { + self.cancel(); + return Err(error); + } + if done { + return Ok(()); + } + if deadline.is_some_and(|deadline| Instant::now() >= deadline) { + return Err(error::ops_error( + py, + invalid_state("Python stream cleanup exceeded 5s"), + )); + } + } + } +} + +impl Drop for Task { + fn drop(&mut self) { + if !*lock(&self.completion.done) { + self.cancel(); + } + } +} + +fn receive(py: Python<'_>, rx: &Mutex>, task: &Task) -> PyResult { + loop { + let result = py.allow_threads(|| lock(rx).recv_timeout(WAIT_SLICE)); + if let Err(error) = py.check_signals() { + task.cancel(); + return Err(error); + } + match result { + Ok(value) => return Ok(value), + Err(mpsc::RecvTimeoutError::Timeout) if task.signal.is_aborted() => { + task.cancel(); + return Err(error::ops_error(py, aborted())); + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err(error::ops_error( + py, + invalid_state("worker ended without a result"), + )); + } + } + } +} + +pub(crate) fn wait_call( + py: Python<'_>, + signal: AbortSignal, + future: impl Future> + Send + 'static, +) -> PyResult { + let (tx, rx) = mpsc::sync_channel(1); + let cancellation = signal.clone(); + let task = Task::spawn(py, signal, async move { + let result = tokio::select! { + biased; + result = future => result, + _ = cancellation.cancelled() => Err(aborted()), + }; + let _ = tx.send(result.map_err(project)); + })?; + let result = receive(py, &Mutex::new(rx), &task)?; + task.join(py)?; + result.map_err(|e| error::envelope_error(py, &e)) +} + +struct Delivery { + json: String, + // Queue bytes remain reserved until Python removes the event. + _bytes: tokio::sync::OwnedSemaphorePermit, +} + +struct Queue { + receiver: Mutex>, + // Independent of the bounded data queue: terminal delivery cannot block + // behind data, and EOF cannot conceal a missing terminal frame. + terminal: Mutex>, +} + +async fn pump( + mut frames: impl futures::Stream + Unpin, + sender: channel::Sender, + queue: Arc, + abort: AbortSignal, +) { + let bytes = Arc::new(Semaphore::new(QUEUE_BYTES)); + let terminal = loop { + let frame = tokio::select! { + biased; + _ = abort.cancelled() => break Err(project(aborted())), + _ = sender.closed() => break Err(project(aborted())), + frame = frames.next() => frame, + }; + match frame { + Some(frame @ StreamFrame::Event { .. }) => { + let json = match serialize(&frame) { + Ok(json) => json, + Err(error) => break Err(project(error)), + }; + if json.len() > MAX_FRAME_BYTES { + break Err(project(OpsError::Protocol(ProtocolError::ResourceLimit( + "Python stream event exceeds 1 MiB".into(), + )))); + } + let permit = tokio::select! { + biased; + _ = abort.cancelled() => break Err(project(aborted())), + permit = bytes.clone().acquire_many_owned(json.len() as u32) => { + match permit { + Ok(permit) => permit, + Err(_) => break Err(project(invalid_state("byte budget closed"))), + } + } + }; + let delivery = Delivery { + json, + _bytes: permit, + }; + tokio::select! { + biased; + _ = abort.cancelled() => break Err(project(aborted())), + result = sender.send(delivery) => { + if result.is_err() { break Err(project(aborted())); } + } + } + } + Some(StreamFrame::End { outcome, error, .. }) => { + break match (outcome, error) { + (Outcome::Success, None) => Ok(()), + (Outcome::Error | Outcome::Cancelled, Some(error)) => Err(error), + (Outcome::Cancelled, None) => Err(project(aborted())), + _ => Err(project(invalid_state("invalid stream terminal frame"))), + }; + } + None => break Err(project(invalid_state("stream closed without an End frame"))), + } + }; + *lock(&queue.terminal) = Some(terminal); + // sender drops only AFTER publishing terminal. +} + +#[pyclass(module = "aimux.aimux")] +pub(crate) struct StreamIterator { + owner: Arc, + queue: Arc, + task: Task, + consuming: std::sync::atomic::AtomicBool, +} + +struct ConsumerGuard<'a>(&'a std::sync::atomic::AtomicBool); +impl Drop for ConsumerGuard<'_> { + fn drop(&mut self) { + self.0.store(false, std::sync::atomic::Ordering::Release); + } +} + +impl StreamIterator { + pub(crate) fn start( + py: Python<'_>, + owner: Arc, + handle: HandleId, + op: String, + params: Value, + signal: AbortSignal, + ) -> PyResult { + let (sender, receiver) = channel::channel(QUEUE_ITEMS); + let queue = Arc::new(Queue { + receiver: Mutex::new(receiver), + terminal: Mutex::new(None), + }); + let producer_queue = queue.clone(); + let abort = signal.clone(); + let (accepted_tx, accepted_rx) = mpsc::sync_channel(1); + let worker_owner = owner.clone(); + let task = Task::spawn(py, signal, async move { + let owner = worker_owner; + match aimux_ops::dispatch::stream( + context(owner.token, abort.clone()), + handle, + &op, + params, + ) { + Ok(frames) => { + if accepted_tx.send(Ok(())).is_ok() { + pump(frames, sender, producer_queue, abort).await; + } + } + Err(error) => { + let _ = accepted_tx.send(Err(project(error))); + } + } + })?; + receive(py, &Mutex::new(accepted_rx), &task)?.map_err(|e| error::envelope_error(py, &e))?; + Ok(Self { + owner, + queue, + task, + consuming: std::sync::atomic::AtomicBool::new(false), + }) + } + + fn check_cancelled(&self, py: Python<'_>) -> PyResult<()> { + if self.task.signal.is_aborted() { + // A settled terminal is immutable, even when close/cancel is late. + // Live cancellation may discard queued events immediately. + let terminal = lock(&self.queue.terminal).clone(); + return match terminal { + Some(Ok(())) => Ok(()), + Some(Err(error)) => Err(error::envelope_error(py, &error)), + None => Err(error::ops_error(py, aborted())), + }; + } + Ok(()) + } +} + +#[pymethods] +impl StreamIterator { + fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __next__(&self, py: Python<'_>) -> PyResult> { + check_owner(py, self.owner.token)?; + if self + .consuming + .swap(true, std::sync::atomic::Ordering::Acquire) + { + return Err(error::ops_error( + py, + OpsError::Protocol(ProtocolError::ConcurrentConsumer), + )); + } + let _consumer = ConsumerGuard(&self.consuming); + let rt = py + .allow_threads(runtime) + .map_err(|e| error::ops_error(py, e))?; + loop { + self.check_cancelled(py)?; + let result = py.allow_threads(|| { + let mut receiver = lock(&self.queue.receiver); + rt.block_on(async { tokio::time::timeout(WAIT_SLICE, receiver.recv()).await }) + }); + if let Err(error) = py.check_signals() { + self.task.cancel(); + return Err(error); + } + // Live cancellation wins over buffered data. + self.check_cancelled(py)?; + match result { + Ok(Some(delivery)) => return Ok(Some(delivery.json)), + Ok(None) => { + let terminal = lock(&self.queue.terminal).clone(); + return match terminal { + Some(Ok(())) => Ok(None), + Some(Err(error)) => Err(error::envelope_error(py, &error)), + None => Err(error::ops_error( + py, + invalid_state("worker lost stream terminal"), + )), + }; + } + Err(_) => {} + } + } + } + + fn cancel(&self, py: Python<'_>) -> PyResult<()> { + check_owner(py, self.owner.token)?; + self.task.cancel(); + Ok(()) + } + + fn join(&self, py: Python<'_>) -> PyResult<()> { + check_owner(py, self.owner.token)?; + self.task.join(py) + } + + fn close(&self, py: Python<'_>) -> PyResult<()> { + self.cancel(py)?; + self.task + .wait_for_completion(py, Some(Instant::now() + Duration::from_secs(5)))?; + py.allow_threads(|| { + let mut receiver = lock(&self.queue.receiver); + receiver.close(); + while receiver.try_recv().is_ok() {} + }); + Ok(()) + } + + fn __enter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + #[pyo3(signature = (_ty=None, _value=None, _traceback=None))] + fn __exit__( + &self, + py: Python<'_>, + _ty: Option<&Bound<'_, PyAny>>, + _value: Option<&Bound<'_, PyAny>>, + _traceback: Option<&Bound<'_, PyAny>>, + ) -> PyResult<()> { + self.close(py) + } +} diff --git a/bindings/python/src/decision.rs b/bindings/python/src/decision.rs deleted file mode 100644 index 27939beab..000000000 --- a/bindings/python/src/decision.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::sync::Arc; - -use aimux_core::Provider; -use aimux_core::decision_model::{DecideOptions, DecisionModel as DecisionModelTrait}; -use pyo3::prelude::*; - -use crate::error::{serialize_result, to_py_err, wire_json}; - -#[pyclass] -pub struct DecisionModel { - pub(crate) inner: Arc, -} - -#[pymethods] -impl DecisionModel { - /// The DecisionModelV4 properties as JSON: `specificationVersion`, - /// `provider`, `modelId`, `supportedQuestionTypes`. - pub fn describe(&self) -> PyResult { - serialize_result(&aimux_core::decision_model::decision_model_properties( - self.inner.as_ref(), - )) - } - - /// `opts_json` is `DecideOptions` JSON; returns `DecideResult` JSON. - pub fn decide(&self, py: Python<'_>, opts_json: &str) -> PyResult { - let options: DecideOptions = wire_json("opts_json", opts_json)?; - let result = py - .allow_threads(|| { - crate::runtime().block_on(aimux_core::decision_model::decide( - self.inner.as_ref(), - options, - )) - }) - .map_err(|error| to_py_err(&error))?; - serialize_result(&result) - } -} - -/// Create a Jev model; endpoint overrides the complete POST URL. -#[pyfunction] -#[pyo3(signature = (api_key, model_id, endpoint=None, probability_source=None))] -pub fn jev_decision( - api_key: &str, - model_id: &str, - endpoint: Option<&str>, - probability_source: Option<&str>, -) -> PyResult { - let mut config = aimux_providers::JevConfig::new(api_key); - if let Some(endpoint) = endpoint { - config = config.with_endpoint(endpoint); - } - if let Some(source) = probability_source { - config.probability_source = source.parse().map_err(|error| to_py_err(&error))?; - } - let model = aimux_providers::JevProvider::new(config) - .decision_model(model_id) - .unwrap_or_else(|| { - Err(aimux_core::AiMuxError::no_such_model( - model_id, - "decisionModel", - )) - }) - .map_err(|error| to_py_err(&error))?; - Ok(DecisionModel { inner: model }) -} diff --git a/bindings/python/src/error.rs b/bindings/python/src/error.rs index 44e660d29..b024290e0 100644 --- a/bindings/python/src/error.rs +++ b/bindings/python/src/error.rs @@ -1,458 +1,22 @@ -//! Map `aimux_core::AiMuxError` into an idiomatic Python exception hierarchy. -//! -//! Mirrors mature SDKs (OpenAI / Anthropic) and Vercel AI SDK style: a base -//! type plus explicit subclasses — not a single class with a string `code`. -//! -//! ```python -//! try: -//! generate_text(model, "hi") -//! except APICallError as e: -//! if e.status == 429: # classification is the status field, -//! ... # exactly like AI SDK APICallError.statusCode -//! elif e.status == 401: -//! ... -//! except AimuxError: -//! ... # any AiMuxError failure -//! ``` -//! -//! Failures of the pyo3 binding itself are not aimux types: they surface as -//! Python's own `ValueError` (bad wire input / closed object) or -//! `RuntimeError` (result serialization / broken invariant). +//! Project the shared envelope once; Python only constructs exception objects. +use std::time::SystemTime; -use aimux_core::{AiMuxError, recording::RecordingError as CoreRecordingError}; -use pyo3::create_exception; -use pyo3::exceptions::{PyException, PyRuntimeError, PyValueError}; +use aimux_ops::envelope::{ErrorEnvelope, OpsError}; use pyo3::prelude::*; -use pyo3::types::{PyAnyMethods, PyList}; -// Base — catch-all for AiMuxError failures only. Recording and binding failures -// have independent public exception types below. -create_exception!(aimux, AimuxError, PyException, "AiMux failure"); - -create_exception!(aimux, APICallError, AimuxError, "API call failure"); -create_exception!(aimux, NoOutputGeneratedError, AimuxError, "No output generated"); -create_exception!(aimux, RetryError, AimuxError, "Operation retry failure"); -create_exception!( - aimux, - JSONParseError, - AimuxError, - "Provider response JSON parse failure" -); -create_exception!( - aimux, - InvalidResponseDataError, - AimuxError, - "Invalid response data" -); -create_exception!( - aimux, - NoSuchToolError, - AimuxError, - "Model called a tool that was not provided" -); -create_exception!( - aimux, - InvalidToolInputError, - AimuxError, - "Tool call input failed to parse or violated the tool's schema" -); -create_exception!( - aimux, - ToolCallRepairError, - AimuxError, - "Repair callback failed while handling an invalid tool call" -); -create_exception!(aimux, InvalidArgumentError, AimuxError, "Invalid argument"); -create_exception!(aimux, InvalidPromptError, AimuxError, "Invalid prompt"); -create_exception!( - aimux, - LoadAPIKeyError, - AimuxError, - "No API key was passed and the fallback environment variable is unset" -); -create_exception!( - aimux, - LoadSettingError, - AimuxError, - "A required provider setting was not passed and its environment variable is unset" -); -create_exception!(aimux, TokenExpiredError, AimuxError, "Access token expired"); -create_exception!( - aimux, - UnsupportedFunctionalityError, - AimuxError, - "Unsupported functionality" -); -create_exception!( - aimux, - NoSuchModelError, - AimuxError, - "No such model in registry" -); -create_exception!( - aimux, - NoSuchProviderError, - AimuxError, - "No such provider in registry" -); -create_exception!(aimux, APITimeoutError, AimuxError, "Request timed out"); -create_exception!(aimux, RequestAbortedError, AimuxError, "Request aborted"); -create_exception!(aimux, OtherError, AimuxError, "Unclassified failure"); -// The recorder's failure type. A second, unrelated error type in the core -// (`aimux_core::recording::RecordingError`), so a second, unrelated exception -// here: it derives from Python's `Exception`, not from `AimuxError`. -create_exception!( - aimux, - RecordingError, - pyo3::exceptions::PyException, - "Recorder could not confirm data on disk" -); - -/// What the binding itself can get wrong. Internal routing only — projected -/// onto Python's builtin `ValueError` / `RuntimeError` by [`binding_py_err`]. -#[derive(Debug, Clone)] -pub(crate) enum BindingError { - /// A JSON *text* the binding transports did not parse. - InvalidWireJson { - argument: &'static str, - message: String, - }, - /// A closed session / dropped handle handed to the binding. - InvalidHandle { expected: &'static str }, - /// The binding could not serialize a result to JSON. - ResultSerialization { message: String }, - /// A binding invariant broke. - InvariantViolation { message: String }, -} - -pub(crate) fn binding_py_err(e: &BindingError) -> PyErr { - match e { - BindingError::InvalidWireJson { argument, message } => { - PyValueError::new_err(format!("{argument}: invalid JSON: {message}")) - } - BindingError::InvalidHandle { expected } => { - PyValueError::new_err(format!("{expected} is closed")) - } - BindingError::ResultSerialization { message } => { - PyRuntimeError::new_err(format!("serialize result: {message}")) - } - BindingError::InvariantViolation { message } => PyRuntimeError::new_err(message.clone()), - } -} - -/// Parse a JSON text the binding transports (`prompt_json` / `opts_json` / -/// `config_json` / …). Malformed text → binding `InvalidWireJson`; text that -/// parses but violates the schema → `AiMuxError::InvalidArgument` (what the -/// core would say about the value). -pub(crate) fn wire_json( - argument: &'static str, - s: &str, -) -> PyResult { - serde_json::from_str(s).map_err(|e| wire_error(argument, &e)) -} - -/// The classification above, for callers that produce the `serde_json::Error` -/// themselves rather than through [`wire_json`]. -pub(crate) fn wire_error(argument: &'static str, e: &serde_json::Error) -> pyo3::PyErr { - match e.classify() { - serde_json::error::Category::Data => to_py_err(&AiMuxError::InvalidArgument(format!( - "invalid {argument}: {e}" - ))), - _ => binding_py_err(&BindingError::InvalidWireJson { - argument, - message: e.to_string(), - }), - } +pub(crate) fn ops_error(py: Python<'_>, error: OpsError) -> PyErr { + envelope_error(py, &error.envelope(SystemTime::now())) } -/// Serialize a result for the wire; failure is the binding's, not an AiMuxError. -pub(crate) fn serialize_result(v: &T) -> PyResult { - serde_json::to_string(v).map_err(|e| { - binding_py_err(&BindingError::ResultSerialization { - message: e.to_string(), - }) - }) -} - -/// One internal error carrier for failures crossing the Python binding. -/// -/// The variants remain separate through the conversion point: `AiMux` becomes -/// an `AimuxError` subclass, `Recording` becomes `RecordingError`, and -/// `Binding` becomes Python's builtin `ValueError` / `RuntimeError`. -#[derive(Debug)] -pub(crate) enum AiMuxBindingError { - AiMux(AiMuxError), - Recording(CoreRecordingError), - Binding(BindingError), -} - -impl From for AiMuxBindingError { - fn from(e: AiMuxError) -> Self { - Self::AiMux(e) - } -} - -impl From for AiMuxBindingError { - fn from(e: CoreRecordingError) -> Self { - Self::Recording(e) - } -} - -impl From for AiMuxBindingError { - fn from(e: BindingError) -> Self { - Self::Binding(e) - } -} - -impl AiMuxBindingError { - pub(crate) fn to_py_err(&self) -> PyErr { - match self { - Self::AiMux(e) => crate::error::to_py_err(e), - Self::Recording(e) => recording_py_err(e), - Self::Binding(e) => binding_py_err(e), - } - } -} - -/// Raise `RecordingError` for a core recording failure. `code` mirrors the -/// Rust variant name: "Init" / "OpenFile" / "Spawn" / "WriterGone" / -/// "FlushTimeout" / "Write". -fn recording_py_err(e: &CoreRecordingError) -> PyErr { - use CoreRecordingError as R; - let code = match e { - R::Init { .. } => "Init", - R::OpenFile { .. } => "OpenFile", - R::Spawn { .. } => "Spawn", - R::WriterGone => "WriterGone", - R::FlushTimeout => "FlushTimeout", - R::Write(_) => "Write", - }; - Python::with_gil(|py| { - let build = || -> PyResult { - let inst = py - .get_type_bound::() - .call1((e.to_string(),))?; - inst.setattr("code", code)?; - Ok(PyErr::from_value_bound(inst)) - }; - build().unwrap_or_else(|err| err) - }) -} - -pub(crate) fn to_py_err(e: &AiMuxError) -> PyErr { - Python::with_gil(|py| match variant_instance(py, e) { - Ok(instance) => PyErr::from_value_bound(instance), - Err(e) => e, - }) -} - -/// The exception instance for a variant, so `ToolCallRepair` can nest its -/// `original_error` as a real exception instance. -fn variant_instance<'py>(py: Python<'py>, e: &AiMuxError) -> PyResult> { - let typ = match e { - AiMuxError::ApiCall(_) => py.get_type_bound::(), - AiMuxError::Retry(_) => py.get_type_bound::(), - AiMuxError::JsonParse(_) => py.get_type_bound::(), - AiMuxError::NoOutputGenerated(_) => py.get_type_bound::(), - // No class of their own yet for the AI SDK's ToolChoiceViolationError - // and MissingToolResultsError (binding migration). - AiMuxError::InvalidResponseData(_) - | AiMuxError::ToolCallNotFoundForApproval { .. } - | AiMuxError::ToolChoiceViolation(_) => py.get_type_bound::(), - AiMuxError::NoSuchTool { .. } => py.get_type_bound::(), - AiMuxError::InvalidToolInput { .. } => py.get_type_bound::(), - AiMuxError::ToolCallRepair { .. } => py.get_type_bound::(), - AiMuxError::InvalidArgument(_) => py.get_type_bound::(), - AiMuxError::InvalidPrompt(_) | AiMuxError::MissingToolResults { .. } => { - py.get_type_bound::() - } - AiMuxError::LoadApiKey { .. } => py.get_type_bound::(), - AiMuxError::LoadSetting { .. } => py.get_type_bound::(), - AiMuxError::TokenExpired(_) => py.get_type_bound::(), - AiMuxError::UnsupportedFunctionality(_) => { - py.get_type_bound::() - } - AiMuxError::NoSuchModel { .. } => py.get_type_bound::(), - AiMuxError::NoSuchProvider { .. } => py.get_type_bound::(), - AiMuxError::Timeout(_) => py.get_type_bound::(), - AiMuxError::Aborted(_) => py.get_type_bound::(), - AiMuxError::Other(_) => py.get_type_bound::(), +pub(crate) fn envelope_error(py: Python<'_>, envelope: &ErrorEnvelope) -> PyErr { + let build = || -> PyResult { + let json = serde_json::to_string(envelope) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + let value = py.import_bound("json")?.call_method1("loads", (json,))?; + let error = py + .import_bound("aimux._errors")? + .call_method1("from_envelope", (value,))?; + Ok(PyErr::from_value_bound(error)) }; - let inst = typ.call1((e.to_string(),))?; - match e { - AiMuxError::ApiCall(d) => { - // Optional API-call fields use None, following normal Python SDK - // conventions; unrelated exception classes do not expose them. - inst.setattr("status", e.status_code().map(i32::from))?; - inst.setattr("retryable", e.is_retryable())?; - inst.setattr("retry_ms", e.retry_after_hint())?; - inst.setattr("provider_code", d.provider_code.as_deref())?; - // str(e) is the composed "API call error: HTTP 429: …"; this is - // the failure's own text. Usually the provider's words — ours on - // a transport failure or an unreadable body (response_body is - // the evidence either way). - inst.setattr( - "provider_message", - (!d.message.is_empty()).then_some(d.message.as_str()), - )?; - inst.setattr("response_body", d.response_body.as_deref())?; - inst.setattr("url", (!d.url.is_empty()).then_some(d.url.as_str()))?; - // JSON null projects to Python None, so no separate absent case. - inst.setattr( - "request_body_values", - json_value_to_python(py, &d.request_body_values)?, - )?; - inst.setattr("response_headers", d.response_headers.clone())?; - inst.setattr( - "data", - match &d.data { - Some(data) => Some(json_value_to_python(py, data)?), - None => None, - }, - )?; - } - AiMuxError::Retry(retry) => { - // Attempt history, oldest first; each entry is itself a full - // exception instance (recursing through this same projection). - let errors = PyList::empty_bound(py); - for error in &retry.errors { - errors.append(variant_instance(py, error)?)?; - } - inst.setattr( - "reason", - match retry.reason { - aimux_core::RetryErrorReason::MaxRetriesExceeded => "maxRetriesExceeded", - aimux_core::RetryErrorReason::ErrorNotRetryable => "errorNotRetryable", - }, - )?; - // A deserialized history may be empty; last_error is None then. - let last_error = match errors.len() { - 0 => None, - n => Some(errors.get_item(n - 1)?), - }; - inst.setattr("errors", &errors)?; - inst.setattr("last_error", last_error)?; - } - AiMuxError::TokenExpired(_) => { - inst.setattr("status", 401)?; - } - AiMuxError::NoSuchModel { - model_id, - model_type, - } => { - inst.setattr("model_id", model_id.as_str())?; - inst.setattr( - "model_type", - (!model_type.is_empty()).then_some(model_type.as_str()), - )?; - } - AiMuxError::NoSuchProvider { - provider_id, - model_id, - model_type, - available_providers, - } => { - inst.setattr("provider_id", provider_id.as_str())?; - inst.setattr("model_id", model_id.as_str())?; - inst.setattr("model_type", model_type.as_str())?; - inst.setattr("available_providers", available_providers.clone())?; - } - AiMuxError::LoadApiKey { - env_var, - description, - } => { - inst.setattr("env_var", env_var.as_str())?; - inst.setattr("description", description.as_str())?; - } - AiMuxError::LoadSetting { env_var, name } => { - inst.setattr("env_var", env_var.as_str())?; - inst.setattr("setting_name", name.as_str())?; - } - AiMuxError::NoSuchTool { - tool_name, - available_tools, - tool_input, - } => { - inst.setattr("tool_name", tool_name.as_str())?; - inst.setattr("available_tools", available_tools.clone())?; - inst.setattr("tool_input", tool_input.as_deref())?; - } - AiMuxError::InvalidToolInput { - tool_name, - tool_input, - .. - } => { - inst.setattr("tool_name", tool_name.as_str())?; - inst.setattr("tool_input", tool_input.as_str())?; - } - AiMuxError::ToolCallRepair { original_error, .. } => { - inst.setattr("original_error", variant_instance(py, original_error)?)?; - } - _ => {} - } - Ok(inst) -} - -fn json_value_to_python<'py>( - py: Python<'py>, - value: &serde_json::Value, -) -> PyResult> { - py.import_bound("json")?.call_method1( - "loads", - (serde_json::to_string(value).expect("serde_json::Value is always serializable"),), - ) -} - -/// Register the exception hierarchy on the Python module. -pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { - let py = m.py(); - m.add("AimuxError", py.get_type_bound::())?; - m.add("APICallError", py.get_type_bound::())?; - m.add("RetryError", py.get_type_bound::())?; - m.add("JSONParseError", py.get_type_bound::())?; - m.add("NoOutputGeneratedError", py.get_type_bound::())?; - m.add( - "InvalidResponseDataError", - py.get_type_bound::(), - )?; - m.add("NoSuchToolError", py.get_type_bound::())?; - m.add( - "InvalidToolInputError", - py.get_type_bound::(), - )?; - m.add( - "ToolCallRepairError", - py.get_type_bound::(), - )?; - m.add( - "InvalidArgumentError", - py.get_type_bound::(), - )?; - m.add( - "InvalidPromptError", - py.get_type_bound::(), - )?; - m.add("LoadAPIKeyError", py.get_type_bound::())?; - m.add("LoadSettingError", py.get_type_bound::())?; - m.add( - "TokenExpiredError", - py.get_type_bound::(), - )?; - m.add( - "UnsupportedFunctionalityError", - py.get_type_bound::(), - )?; - m.add("NoSuchModelError", py.get_type_bound::())?; - m.add( - "NoSuchProviderError", - py.get_type_bound::(), - )?; - m.add("APITimeoutError", py.get_type_bound::())?; - m.add( - "RequestAbortedError", - py.get_type_bound::(), - )?; - m.add("OtherError", py.get_type_bound::())?; - m.add("RecordingError", py.get_type_bound::())?; - Ok(()) + build().unwrap_or_else(|error| error) } diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index efd6fd3e4..8c824ee0b 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -1,1386 +1,329 @@ -//! aimux-python: Python binding (PyO3, native path). -//! -//! Directly uses aimux-providers, bypassing aimux-ffi. -//! PyO3 maps Rust async to Python via a tokio runtime + async generator. +//! Thin synchronous Python adapter for the shared asynchronous ops dispatcher. +#![allow(unsafe_op_in_unsafe_fn, unexpected_cfgs, clippy::useless_conversion)] -// pyo3 0.22 macros generate unsafe-op-in-unsafe-fn calls that trigger -// edition-2024 lint. Suppress until pyo3 0.23+ lands. -#![allow(unsafe_op_in_unsafe_fn)] -// The same macros test the `gil-refs` feature of the *calling* crate, which -// this crate does not declare (rustc's check-cfg flags it). -#![allow(unexpected_cfgs)] -// pyo3 0.22's `#[pyfunction]` / `#[pymethods]` expansion converts a `PyErr` into -// itself on every `PyResult` return (fixed in 0.23); clippy blames the -// signature. -#![allow(clippy::useless_conversion)] - -mod decision; +mod bridge; mod error; -mod multimodal; -pub use decision::*; -pub use multimodal::*; - -use std::sync::Arc; - -use crate::error::{ - AiMuxBindingError, BindingError, binding_py_err, serialize_result, to_py_err, wire_json, -}; -use aimux_core::AiMuxError; -#[allow(deprecated)] -use aimux_core::generate::generate_object; -use aimux_core::generate::{ - GenerateTextOptions, GenerateTextResult, generate_text, generate_text_as_openai, - generate_text_result_to_chat_completion, stream_text, stream_text_as_openai, -}; -use aimux_core::language_model::LanguageModel; -use aimux_core::message::ModelPrompt; -use aimux_core::openai_output::OpenAiStreamOptions; -use aimux_core::parse_tool_call::{ToolCallRepairReply, tool_call_repair_inputs}; -use aimux_core::tool::ToolCall; -use pyo3::prelude::*; - -// ───────────────────────────────────────────────────────────────────────────── -// Global tokio runtime -// ───────────────────────────────────────────────────────────────────────────── - -pub(crate) fn runtime() -> &'static tokio::runtime::Runtime { - use std::sync::OnceLock; - static RT: OnceLock = OnceLock::new(); - RT.get_or_init(|| tokio::runtime::Runtime::new().expect("failed to build tokio runtime")) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Model — a provider model instance accessible from Python. -// ───────────────────────────────────────────────────────────────────────────── - -#[pyclass] -struct Model { - inner: Arc, - /// Probe store — `Some` only for traced models (RFC-0015). - trace_store: Option>, -} - -#[pymethods] -impl Model { - /// Wrap this model in a cache-probe layer (RFC-0015) WITHOUT an - /// auditor (records fingerprints only; verdicts stay None). - fn trace(&self) -> PyResult { - let store = Arc::new(aimux_core::trace::RingTraceStore::new()); - let layer = aimux_core::trace::TraceLayer::new(self.inner.clone(), store.clone()); - Ok(Model { - inner: Arc::new(layer), - trace_store: Some(store), - }) - } - - /// Wrap this model in a probe layer with the built-in rules auditor. - /// `strict=True` = strict mode (self-hosted single instance); - /// `strict=False` = shared mode (safe default). - #[pyo3(signature = (strict=false))] - fn trace_audited(&self, strict: bool) -> PyResult { - let store = Arc::new(aimux_core::trace::RingTraceStore::new()); - let layer = aimux_core::trace::TraceLayer::new(self.inner.clone(), store.clone()) - .with_rules_auditor(strict); - Ok(Model { - inner: Arc::new(layer), - trace_store: Some(store), - }) - } - - /// Aggregated probe statistics (RFC-0015 §5.3), filtered by an optional - /// JSON `TraceFilter`. Returns a JSON `TraceStats[]` string. - #[pyo3(signature = (filter_json=None))] - fn trace_aggregate(&self, filter_json: Option<&str>) -> PyResult { - let Some(store) = &self.trace_store else { - return Err(to_py_err(&AiMuxError::InvalidArgument( - "model is not traced; call trace() first".into(), - ))); - }; - let filter = match filter_json { - Some(f) => wire_json("filter_json", f)?, - None => Default::default(), - }; - serialize_result(&store.aggregate(&filter)) - } - - /// One session's chain view. Returns a JSON `SessionChainView` string. - fn trace_session_chain(&self, session_id: &str) -> PyResult { - let Some(store) = &self.trace_store else { - return Err(to_py_err(&AiMuxError::InvalidArgument( - "model is not traced; call trace() first".into(), - ))); - }; - let view = store - .session_chain(session_id) - .ok_or_else(|| to_py_err(&AiMuxError::InvalidArgument("unknown session".into())))?; - serialize_result(&view) - } - - /// Export all probe records as JSONL (one `TraceRecord` per line). - fn trace_export_jsonl(&self) -> PyResult { - let Some(store) = &self.trace_store else { - return Err(to_py_err(&AiMuxError::InvalidArgument( - "model is not traced; call trace() first".into(), - ))); - }; - let mut buf = Vec::new(); - store.export_jsonl(&mut buf).map_err(|e| { - binding_py_err(&BindingError::ResultSerialization { - message: format!("export: {e}"), - }) - })?; - String::from_utf8(buf).map_err(|e| { - binding_py_err(&BindingError::ResultSerialization { - message: format!("utf8: {e}"), - }) - }) - } - /// Clear all probe records of this traced model. - fn trace_clear(&self) { - if let Some(store) = &self.trace_store { - store.clear(); - } - } +#[cfg(feature = "contract-tests")] +#[path = "../../node/__test__/native_scenarios.rs"] +mod native_scenarios; - /// Generate text (non-streaming). - /// - /// prompt_json: JSON string (bare prompt or {"prompt": ...}) - /// opts_json: optional JSON-serialized GenerateTextOptions - /// Returns JSON-serialized GenerateTextResult. - #[pyo3(signature = (prompt_json, opts_json=None))] - fn generate_text( - &self, - py: Python<'_>, - prompt_json: &str, - opts_json: Option<&str>, - ) -> PyResult { - let prompt = parse_prompt(prompt_json)?; - let opts = parse_opts(opts_json)?; +include!(concat!(env!("OUT_DIR"), "/contract.rs")); - let rt = runtime(); - let result = py.allow_threads(|| { - rt.block_on(async move { generate_text(&*self.inner, prompt, opts).await }) - }); +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock, Weak}; +use std::time::SystemTime; - match result { - Ok(r) => serialize_result(&r), - Err(e) => Err(to_py_err(&e)), - } - } - - /// Generate a structured JSON object from the model (M12, RFC-0016). - /// - /// prompt_json: JSON string (bare prompt or {"prompt": ...}) - /// opts_json: optional JSON-serialized GenerateTextOptions - /// Returns JSON-serialized GenerateObjectResult (core's deprecated - /// `generate_object` wrapper). Pass `output: { "type": "object", "schema": ... }` - /// via opts_json to describe the value; the model's text must parse and - /// match the schema. - #[pyo3(signature = (prompt_json, opts_json=None))] - #[allow(deprecated)] - fn generate_object( - &self, - py: Python<'_>, - prompt_json: &str, - opts_json: Option<&str>, - ) -> PyResult { - let prompt = parse_prompt(prompt_json)?; - let opts = parse_opts(opts_json)?; - - let rt = runtime(); - let result = py.allow_threads(|| { - rt.block_on(async move { generate_object(&*self.inner, prompt, opts).await }) - }); - - match result { - Ok(r) => serialize_result(&r), - Err(e) => Err(to_py_err(&e)), - } - } - - /// Consume a stream to completion and return the aggregated result - /// (M11, RFC-0016). - /// - /// prompt_json: JSON string (bare prompt or {"prompt": ...}) - /// opts_json: optional JSON-serialized GenerateTextOptions - /// Returns JSON-serialized GenerateTextResult, the same result - /// `generate_text` returns. - #[pyo3(signature = (prompt_json, opts_json=None))] - fn consume_stream_text( - &self, - py: Python<'_>, - prompt_json: &str, - opts_json: Option<&str>, - ) -> PyResult { - let prompt = parse_prompt(prompt_json)?; - let opts = parse_opts(opts_json)?; - - let rt = runtime(); - let result = py.allow_threads(|| { - rt.block_on(async move { - let stream_result = stream_text(&*self.inner, prompt, opts).await?; - stream_result.consume().await - }) - }); - - match result { - Ok(r) => serialize_result(&r), - Err(e) => Err(to_py_err(&e)), - } - } - - /// Stream text from the model. - /// - /// Returns a StreamIterator that yields StreamPart JSON strings. - #[pyo3(signature = (prompt_json, opts_json=None))] - fn stream_text(&self, prompt_json: &str, opts_json: Option<&str>) -> PyResult { - let prompt = parse_prompt(prompt_json)?; - let opts = parse_opts(opts_json)?; - let model = self.inner.clone(); - - let (tx, rx) = - tokio::sync::mpsc::channel::>(64); - - rt_spawn(async move { - match stream_text(&*model, prompt, opts).await { - Ok(stream_result) => { - use futures::StreamExt; - let mut stream = stream_result.stream; - while let Some(item) = stream.next().await { - match item { - Ok(part) => { - // A part that cannot be serialized ends the stream with the - // binding's ResultSerialization — never a silent "{}". - let json = match serde_json::to_string(&part) { - Ok(j) => j, - Err(e) => { - let _ = tx - .send(Err( - crate::error::BindingError::ResultSerialization { - message: format!("stream part: {e}"), - } - .into(), - )) - .await; - break; - } - }; - if tx.send(Ok(json)).await.is_err() { - break; - } - } - Err(e) if e.is_recoverable_stream_error() => { - // Core keeps the stream alive across a malformed frame; - // deliver it as a StreamPart::Error data item and keep - // pumping. - match serde_json::to_string( - &aimux_core::stream_part::TextStreamPart::Error { error: e }, - ) { - Ok(json) => { - if tx.send(Ok(json)).await.is_err() { - break; - } - } - Err(_) => break, - } - } - Err(e) => { - let _ = tx.send(Err(e.into())).await; - break; - } - } - } - } - Err(e) => { - let _ = tx.send(Err(e.into())).await; - } - } - }); - - Ok(StreamIterator { rx }) - } - - /// Generate text as an OpenAI Chat Completion (non-streaming, RFC-0026). - /// - /// prompt_json: JSON string (bare prompt or {"prompt": ...}) - /// opts_json: optional JSON-serialized GenerateTextOptions - /// Returns JSON-serialized ChatCompletion (OpenAI `chat.completion` object). - #[pyo3(signature = (prompt_json, opts_json=None))] - fn generate_text_as_openai( - &self, - py: Python<'_>, - prompt_json: &str, - opts_json: Option<&str>, - ) -> PyResult { - let prompt = parse_prompt(prompt_json)?; - let opts = parse_opts(opts_json)?; - - let rt = runtime(); - let result = py.allow_threads(|| { - rt.block_on(async move { generate_text_as_openai(&*self.inner, prompt, opts).await }) - }); - - match result { - Ok(r) => serialize_result(&r), - Err(e) => Err(to_py_err(&e)), - } - } - - /// Convert a serialized GenerateTextResult into a serialized - /// ChatCompletion — the conversion half of `generate_text_as_openai`. - /// - /// For repairing on the host (RFC-0035): patch the native result, then - /// convert it, so the completion's tool calls are the repaired ones. This - /// model only supplies the fallback model id. - fn generate_text_result_as_openai(&self, result_json: &str) -> PyResult { - let result: GenerateTextResult = wire_json("result_json", result_json)?; - serialize_result(&generate_text_result_to_chat_completion( - &result, - self.inner.model_id(), - )) - } - - /// Stream text as OpenAI Chat Completion chunks (RFC-0026). - /// - /// Returns a StreamIterator that yields ChatCompletionChunk JSON strings. - /// Stream options (`include_usage`, `include_reasoning`) are read from - /// `opts.provider_options.openai.stream_options` (both default to `true`). - #[pyo3(signature = (prompt_json, opts_json=None))] - fn stream_text_as_openai( - &self, - prompt_json: &str, - opts_json: Option<&str>, - ) -> PyResult { - let prompt = parse_prompt(prompt_json)?; - let opts = parse_opts(opts_json)?; - let model = self.inner.clone(); - - // Extract OpenAI stream options from opts.provider_options.openai.stream_options - // (same logic as aimux-ffi's stream_text_as_openai_with_signal). - let stream_options = opts - .provider_options - .as_ref() - .and_then(|po| po.get("openai")) - .and_then(|o| o.get("stream_options")) - .cloned() - .map(|v| OpenAiStreamOptions { - include_usage: v - .get("include_usage") - .and_then(|b| b.as_bool()) - .unwrap_or(true), - include_reasoning: v - .get("include_reasoning") - .and_then(|b| b.as_bool()) - .unwrap_or(true), - }) - .unwrap_or_default(); +use aimux_core::AbortSignal; +use aimux_ops::dispatch::{self, Ctx}; +use aimux_ops::envelope::{BindingError, OpsError, ProtocolError}; +use aimux_ops::handle::{HandleId, HandleRegistry, Owner}; +use pyo3::prelude::*; +use serde_json::Value; - let (tx, rx) = - tokio::sync::mpsc::channel::>(64); +use bridge::{StreamIterator, wait_call}; - rt_spawn(async move { - match stream_text_as_openai(&*model, prompt, opts, stream_options).await { - Ok(stream_result) => { - use futures::StreamExt; - let mut stream = stream_result.stream; - while let Some(item) = stream.next().await { - match item { - Ok(chunk) => { - let json = match serde_json::to_string(&chunk) { - Ok(j) => j, - Err(e) => { - let _ = tx - .send(Err( - crate::error::BindingError::ResultSerialization { - message: format!("stream chunk: {e}"), - } - .into(), - )) - .await; - break; - } - }; - if tx.send(Ok(json)).await.is_err() { - break; - } - } - Err(e) if e.is_recoverable_stream_error() => { - // Consumers type this path as ChatCompletionChunk; the - // error cannot ride it — skip and keep pumping (full - // fidelity lives on the StreamPart path). - } - Err(e) => { - let _ = tx.send(Err(e.into())).await; - break; - } - } - } - } - Err(e) => { - let _ = tx.send(Err(e.into())).await; - } - } - }); +// Process lifetime: Rust statics are not dropped at interpreter shutdown. Each +// InterpreterOwner queues release_owner on this still-live runtime; late Python +// finalizers therefore cannot encounter a runtime that we have already dropped. +static RUNTIME: OnceLock> = OnceLock::new(); - Ok(StreamIterator { rx }) - } +fn runtime() -> Result<&'static tokio::runtime::Runtime, OpsError> { + RUNTIME + .get_or_init(|| tokio::runtime::Runtime::new().map_err(|error| error.to_string())) + .as_ref() + .map_err(|message| OpsError::Binding(BindingError::CallbackFailure(message.clone()))) } -/// Python iterator that yields StreamPart JSON strings from a tokio channel. -#[pyclass] -struct StreamIterator { - rx: tokio::sync::mpsc::Receiver>, -} - -#[pymethods] -impl StreamIterator { - fn __iter__(slf: Py) -> Py { - slf - } - - fn __next__(&mut self, py: Python<'_>) -> PyResult> { - // Block on the next channel item, allowing other Python threads to run. - let item = py.allow_threads(|| runtime().block_on(self.rx.recv())); - - match item { - Some(Ok(json)) => Ok(Some(json.to_object(py).into())), - Some(Err(f)) => Err(f.to_py_err()), - None => Ok(None), // stream finished - } +fn context(owner: Owner, abort: AbortSignal) -> Ctx { + Ctx { + owner, + abort: Some(abort), + now: SystemTime::now, } } -// ───────────────────────────────────────────────────────────────────────────── -// Provider constructors -// ───────────────────────────────────────────────────────────────────────────── - -/// An OpenAI provider for an explicit key handed over by the host, with an -/// optional base URL. The key is an explicit value (`""` included), so it never -/// falls back to `OPENAI_API_KEY`. -pub(crate) fn openai_provider( - api_key: &str, - base_url: Option<&str>, -) -> PyResult { - aimux_providers::openai::create_openai(aimux_providers::openai::OpenAIProviderSettings { - api_key: Some(api_key.to_string()), - base_url: base_url.map(str::to_string), - ..Default::default() - }) - .map_err(|e| to_py_err(&e)) -} - -/// Create an OpenAI model instance. -#[pyfunction] -#[pyo3(signature = (api_key, model_id, base_url=None))] -fn openai(api_key: &str, model_id: &str, base_url: Option<&str>) -> PyResult { - use aimux_core::provider::Provider; - - let provider = openai_provider(api_key, base_url)?; - Ok(Model { - inner: provider - .language_model(model_id) - .map_err(|e| to_py_err(&e))?, - trace_store: None, - }) -} - -/// Create an Anthropic model instance. -#[pyfunction] -#[pyo3(signature = (api_key, model_id, base_url=None))] -fn anthropic(api_key: &str, model_id: &str, base_url: Option<&str>) -> PyResult { - use aimux_core::provider::Provider; - use aimux_providers::anthropic::{AnthropicProviderSettings, create_anthropic}; - - let provider = create_anthropic(AnthropicProviderSettings { - api_key: Some(api_key.to_string()), - base_url: base_url.map(str::to_string), - ..Default::default() - }) - .map_err(|e| to_py_err(&e))?; - let model = provider - .language_model(model_id) - .map_err(|e| to_py_err(&e))?; - Ok(Model { - inner: Arc::from(model), - trace_store: None, - }) -} - -/// Create a DeepSeek model instance (registry-backed since RFC-0017 phase 4). -#[pyfunction] -#[pyo3(signature = (api_key, model_id, base_url=None))] -fn deepseek(api_key: &str, model_id: &str, base_url: Option<&str>) -> PyResult { - let options = base_url.map(|url| aimux_providers::provider::ProviderOptions { - base_url: Some(url.to_string()), - ..Default::default() - }); - let model = aimux_providers::provider::provider( - "deepseek", - Some(api_key.to_string()), - model_id, - options, - ) - .map_err(|e| to_py_err(&e))?; - Ok(Model { - inner: Arc::from(model), - trace_store: None, - }) -} - -/// Create a Google Gemini language model instance (native `generateContent` -/// protocol — not OpenAI-compatible, so not registry-backed). -#[pyfunction] -#[pyo3(signature = (api_key, model_id, base_url=None))] -fn google(api_key: &str, model_id: &str, base_url: Option<&str>) -> PyResult { - use aimux_core::provider::Provider; - use aimux_providers::google::{GoogleProviderSettings, create_google}; - - let provider = create_google(GoogleProviderSettings { - api_key: Some(api_key.to_string()), - base_url: base_url.map(str::to_string), - ..Default::default() - }) - .map_err(|e| to_py_err(&e))?; - let model = provider - .language_model(model_id) - .map_err(|e| to_py_err(&e))?; - Ok(Model { - inner: Arc::from(model), - trace_store: None, - }) +fn invalid_state(message: &str) -> OpsError { + OpsError::Protocol(ProtocolError::InvalidState(message.into())) } -/// Create a Cohere language model instance. -#[pyfunction] -#[pyo3(signature = (api_key, model_id, base_url=None))] -fn cohere(api_key: &str, model_id: &str, base_url: Option<&str>) -> PyResult { - use aimux_core::provider::Provider; - use aimux_providers::cohere::{CohereProviderSettings, create_cohere}; - - let provider = create_cohere(CohereProviderSettings { - api_key: Some(api_key.to_string()), - base_url: base_url.map(str::to_string), - ..Default::default() - }) - .map_err(|e| to_py_err(&e))?; - let model = provider - .language_model(model_id) - .map_err(|e| to_py_err(&e))?; - Ok(Model { - inner: Arc::from(model), - trace_store: None, - }) +fn parse_handle(value: &str) -> Result { + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(OpsError::Protocol(ProtocolError::InvalidParams( + "handle must be a decimal u64 string".into(), + ))); + } + value + .parse() + .map(HandleId) + .map_err(|_| OpsError::Protocol(ProtocolError::InvalidParams("handle exceeds u64".into()))) } -/// Create a Mistral language model instance. -#[pyfunction] -#[pyo3(signature = (api_key, model_id, base_url=None))] -fn mistral(api_key: &str, model_id: &str, base_url: Option<&str>) -> PyResult { - use aimux_core::provider::Provider; - use aimux_providers::mistral::{MistralProviderSettings, create_mistral}; - - let provider = create_mistral(MistralProviderSettings { - api_key: Some(api_key.to_string()), - base_url: base_url.map(str::to_string), - ..Default::default() - }) - .map_err(|e| to_py_err(&e))?; - let model = provider - .language_model(model_id) - .map_err(|e| to_py_err(&e))?; - Ok(Model { - inner: Arc::from(model), - trace_store: None, +fn parse_params(text: &str) -> Result { + serde_json::from_str(text).map_err(|error| { + OpsError::Binding(BindingError::InvalidWireJson { + argument: "params_json".into(), + message: error.to_string(), + }) }) } -/// Create an xAI language model instance. -#[pyfunction] -#[pyo3(signature = (api_key, model_id, base_url=None))] -fn xai(api_key: &str, model_id: &str, base_url: Option<&str>) -> PyResult { - use aimux_core::provider::Provider; - use aimux_providers::xai::{XAIProviderSettings, create_xai}; - - let provider = create_xai(XAIProviderSettings { - api_key: Some(api_key.to_string()), - base_url: base_url.map(str::to_string), - ..Default::default() - }) - .map_err(|e| to_py_err(&e))?; - let model = provider - .language_model(model_id) - .map_err(|e| to_py_err(&e))?; - Ok(Model { - inner: Arc::from(model), - trace_store: None, - }) +fn serialize(value: &impl serde::Serialize) -> Result { + serde_json::to_string(value) + .map_err(|error| OpsError::Binding(BindingError::ResultSerialization(error.to_string()))) } -/// Create a Bedrock language model instance (AWS SigV4 credentials). -#[pyfunction] -#[pyo3(signature = (access_key_id, secret_access_key, region, model_id, base_url=None))] -fn bedrock( - access_key_id: &str, - secret_access_key: &str, - region: &str, - model_id: &str, - base_url: Option<&str>, -) -> PyResult { - use aimux_core::provider::Provider; - use aimux_providers::bedrock::{AmazonBedrockProviderSettings, create_amazon_bedrock}; - - let provider = create_amazon_bedrock(AmazonBedrockProviderSettings { - credential_provider: Some(aimux_provider_utils::Resolvable::Value( - aimux_provider_utils::AwsCredentials { - access_key_id: access_key_id.to_string(), - secret_access_key: secret_access_key.to_string(), - session_token: None, - region: region.to_string(), - }, - )), - region: Some(region.to_string()), - base_url: base_url.map(str::to_string), - ..Default::default() - }) - .map_err(|e| to_py_err(&e))?; - let model = provider - .language_model(model_id) - .map_err(|e| to_py_err(&e))?; - Ok(Model { - inner: Arc::from(model), - trace_store: None, - }) +fn interpreter_id(py: Python<'_>) -> PyResult { + // SAFETY: Python's token guarantees an attached thread holding the GIL. + // Both functions are in CPython's stable ABI from Python 3.9 onward. + let id = unsafe { pyo3::ffi::PyInterpreterState_GetID(pyo3::ffi::PyInterpreterState_Get()) }; + if id < 0 { + return Err(PyErr::fetch(py)); + } + Ok(id) } -/// Create a Vertex AI language model instance (GCP bearer token). -#[pyfunction] -#[pyo3(signature = (access_token, project, location, model_id, base_url=None))] -fn vertex( - access_token: &str, - project: &str, - location: &str, - model_id: &str, - base_url: Option<&str>, -) -> PyResult { - use aimux_core::provider::Provider; - use aimux_providers::vertex::{VertexProviderSettings, create_google_vertex}; - - let provider = create_google_vertex(VertexProviderSettings { - access_token: Some(aimux_provider_utils::Resolvable::Value( - access_token.to_string(), - )), - project: Some(project.to_string()), - location: Some(location.to_string()), - base_url: base_url.map(str::to_string), - ..Default::default() - }) - .map_err(|e| to_py_err(&e))?; - let model = provider - .language_model(model_id) - .map_err(|e| to_py_err(&e))?; - Ok(Model { - inner: Arc::from(model), - trace_store: None, - }) +struct InterpreterOwner { + token: Owner, } -/// Create an Anthropic-on-AWS language model instance (API key + region). -#[pyfunction] -#[pyo3(signature = (api_key, region, model_id, base_url=None))] -fn anthropic_aws( - api_key: &str, - region: &str, - model_id: &str, - base_url: Option<&str>, -) -> PyResult { - use aimux_core::provider::Provider; - use aimux_providers::anthropic_aws::{ - AnthropicAwsAuth, AnthropicAwsProviderSettings, create_anthropic_aws, - }; - - let provider = create_anthropic_aws(AnthropicAwsProviderSettings { - region: Some(region.to_string()), - auth: Some(AnthropicAwsAuth::ApiKey( - aimux_provider_utils::Resolvable::Value(api_key.to_string()), - )), - base_url: base_url.map(str::to_string), - ..Default::default() - }) - .map_err(|e| to_py_err(&e))?; - let model = provider - .language_model(model_id) - .map_err(|e| to_py_err(&e))?; - Ok(Model { - inner: Arc::from(model), - trace_store: None, - }) +impl Drop for InterpreterOwner { + fn drop(&mut self) { + if let Some(Ok(rt)) = RUNTIME.get() { + let owner = self.token; + // Resource destructors may perform I/O. Interpreter teardown only + // enqueues cleanup; no registry lock or GIL crosses the join. + rt.spawn_blocking(move || HandleRegistry::global().release_owner(owner)); + } + } } -/// Create an Azure OpenAI language model instance (API key + resource name). -/// -/// The deployment name is passed as `model_id`; `api_version` is optional. -#[pyfunction] -#[pyo3(signature = (api_key, resource_name, deployment, api_version=None, base_url=None))] -fn azure( - api_key: &str, - resource_name: &str, - deployment: &str, - api_version: Option<&str>, - base_url: Option<&str>, -) -> PyResult { - use aimux_core::provider::Provider; - use aimux_providers::azure::{AzureOpenAIProviderSettings, create_azure}; - - let mut config = AzureOpenAIProviderSettings { - api_key: Some(aimux_provider_utils::Resolvable::Value(api_key.to_string())), - base_url: base_url.map(str::to_string), - ..Default::default() - }; - // A dated `api_version` belongs to the deployment URL form. - if let Some(version) = api_version - && !version.is_empty() - { - config.api_version = Some(version.to_string()); - config.use_deployment_based_urls = true; +fn current_owner(py: Python<'_>) -> PyResult> { + static OWNERS: OnceLock>>> = OnceLock::new(); + let interpreter = interpreter_id(py)?; + let mut owners = OWNERS + .get_or_init(Mutex::default) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(owner) = owners.get(&interpreter).and_then(Weak::upgrade) { + return Ok(owner); } - if !resource_name.is_empty() { - config.resource_name = Some(resource_name.to_string()); + let token = Owner::new_python().map_err(|e| error::ops_error(py, e))?; + let owner = Arc::new(InterpreterOwner { token }); + owners.retain(|_, owner| owner.strong_count() > 0); + owners.insert(interpreter, Arc::downgrade(&owner)); + Ok(owner) +} + +fn check_owner(py: Python<'_>, owner: Owner) -> PyResult<()> { + if current_owner(py)?.token != owner { + return Err(error::ops_error( + py, + invalid_state("object belongs to another interpreter"), + )); } - let provider = create_azure(config).map_err(|e| to_py_err(&e))?; - let model = provider - .language_model(deployment) - .map_err(|e| to_py_err(&e))?; - Ok(Model { - inner: Arc::from(model), - trace_store: None, - }) + Ok(()) } -/// Create a language model from the built-in registry by provider name -/// (RFC-0017 phase 4). `api_key=None` reads the provider's env var. -/// `config_json` is a serialized `ProviderOptions` object (`base_url` / -/// `headers` / `organization` / `project` / `params`; `max_retries` and -/// `body_overrides` are rejected as invalid arguments); the `base_url` -/// parameter wins over the JSON field. -#[pyfunction] -#[pyo3(signature = (name, api_key, model_id, base_url=None, config_json=None))] -fn provider( - name: &str, - api_key: Option, - model_id: &str, - base_url: Option<&str>, - config_json: Option<&str>, -) -> PyResult { - let mut options: Option = match config_json { - Some(s) if !s.trim().is_empty() && s.trim() != "null" => Some(wire_json("config_json", s)?), - _ => None, - }; - if let Some(url) = base_url { - options.get_or_insert_with(Default::default).base_url = Some(url.to_string()); - } - let model = aimux_providers::provider::provider(name, api_key, model_id, options) - .map_err(|e| to_py_err(&e))?; - Ok(Model { - inner: Arc::from(model), - trace_store: None, - }) +// The module keeps the token alive even between Client instances. +#[pyclass] +struct OwnerGuard { + _owner: Arc, } -// ───────────────────────────────────────────────────────────────────────────── -// Provider handles (RFC-0027) — createProvider / listModels / model -// ───────────────────────────────────────────────────────────────────────────── - -/// A provider handle — created by `create_provider`, supports `list_models()` -/// (runtime discovery) and `model()` (build a model from a discovered id). -#[pyclass] -struct ProviderHandle { - inner: Arc, +#[pyclass(module = "aimux.aimux")] +struct Abort { + owner: Arc, + signal: AbortSignal, } #[pymethods] -impl ProviderHandle { - /// This provider's decision model, with its existing config; a provider - /// without decision models raises `NoSuchModelError`. - fn decision_model(&self, model_id: &str) -> PyResult { - let model = self - .inner - .decision_model(model_id) - .unwrap_or_else(|| Err(AiMuxError::no_such_model(model_id, "decisionModel"))) - .map_err(|error| to_py_err(&error))?; - Ok(DecisionModel { inner: model }) - } - - /// List models available on this provider (runtime discovery + anya2a spec). - /// Returns a JSON array of RuntimeModel. - fn list_models(&self, py: Python<'_>) -> PyResult { - let discovery = self.inner.discovery().ok_or_else(|| { - to_py_err(&AiMuxError::UnsupportedFunctionality( - "provider does not support model listing".into(), - )) - })?; - let rt = runtime(); - let models = py - .allow_threads(|| rt.block_on(discovery.list_models())) - .map_err(|e| to_py_err(&e))?; - serialize_result(&models) - } - - /// Build a language model from a discovered model id. - fn model(&self, model_id: &str) -> PyResult { - let m = self - .inner - .language_model(model_id) - .map_err(|e| to_py_err(&e))?; - Ok(Model { - inner: Arc::from(m), - trace_store: None, +impl Abort { + #[new] + fn new(py: Python<'_>) -> PyResult { + Ok(Self { + owner: current_owner(py)?, + signal: AbortSignal::new(), }) } -} -/// Create a **provider handle** (RFC-0027) for a registry-backed provider. -/// -/// Unlike `provider()` (which binds to a single model_id), this returns a -/// `ProviderHandle` that supports `list_models()` and `model()`. -#[pyfunction] -#[pyo3(signature = (name, api_key=None, base_url=None, config_json=None))] -fn create_provider( - name: &str, - api_key: Option, - base_url: Option<&str>, - config_json: Option<&str>, -) -> PyResult { - let mut options: Option = match config_json { - Some(s) if !s.trim().is_empty() && s.trim() != "null" => Some(wire_json("config_json", s)?), - _ => None, - }; - if let Some(url) = base_url { - options.get_or_insert_with(Default::default).base_url = Some(url.to_string()); + fn cancel(&self, py: Python<'_>) -> PyResult<()> { + check_owner(py, self.owner.token)?; + self.signal.abort(); + Ok(()) } - let inner = aimux_providers::provider::provider_handle(name, api_key, options) - .map_err(|e| to_py_err(&e))?; - Ok(ProviderHandle { inner }) -} -/// Fetch the community model catalogue (RFC-0027) and return it as a JSON -/// string (serialized `Catalogue`). `source_url` defaults to the anya2a -/// `dist/all.json`. -#[pyfunction] -#[pyo3(signature = (source_url=None))] -fn get_model_specs(py: Python<'_>, source_url: Option<&str>) -> PyResult { - let catalogue = py - .allow_threads(|| { - runtime().block_on(async { aimux_providers::get_model_specs(source_url).await }) - }) - .map_err(|e| to_py_err(&e))?; - serialize_result(&catalogue) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Module -// ───────────────────────────────────────────────────────────────────────────── - -/// 初始化全局日志(RFC-0014)。幂等:多次调用无副作用;宿主已自建 -/// subscriber 时 no-op。级别:off|error|warn|info|debug|trace(空串回退 -/// warn)。`AIMUX_LOG` / `AIMUX_LOG_LEVEL` 环境变量优先级更高。 -/// 日志输出到 stderr。 -#[pyfunction] -fn init_logging(level: &str) { - let level = if level.trim().is_empty() { - "warn" - } else { - level - }; - aimux_providers::init_logging(level); -} - -/// Register external OpenAI-compatible providers from a JSON config string -/// (RFC-0020). Entries override same-named built-ins or add new ones. -/// -/// `config_json` shape: `{ "providers": [ { "name": "...", "base_url": "...", ... } ] }`. -/// Malformed JSON text raises `ValueError`; a well-formed document the -/// registry rejects (bad base_url scheme, empty name, unsupported protocol, -/// wrong shape) raises `InvalidArgumentError`. -#[pyfunction] -fn register_providers(config_json: &str) -> PyResult<()> { - let _: serde_json::Value = wire_json("config_json", config_json)?; - aimux_providers::provider::load_providers_from_json(config_json).map_err(|e| match e { - AiMuxError::JsonParse(m) => { - to_py_err(&AiMuxError::InvalidArgument(format!("config_json: {m}"))) - } - e => to_py_err(&e), - }) -} - -/// Replace the default transport with one routed by this proxy configuration -/// (M6, RFC-0016). Requests started afterwards use it; calling it again -/// replaces the previous configuration. -/// -/// `config_json` shape: `{ "http_url": "...", "https_url": "...", "all_url": -/// "...", "no_proxy": "..." }` (all fields optional). Raises `ValueError` on -/// malformed JSON, `AimuxError` on a bad value. -#[pyfunction] -fn init_proxy(config: &str) -> PyResult<()> { - let proxy_config: aimux_provider_utils::ProxyConfig = wire_json("config", config)?; - // Replaces the default transport for later requests (see - // `aimux_init_proxy`). - aimux_provider_utils::set_default_transport(aimux_provider_utils::ReqwestFetch::new( - proxy_config, - )); - Ok(()) -} - -/// Register the global session store (RFC-0024). Replaces any previous one. -/// Until called, calls are not grouped and the query functions return empty -/// results. -#[pyfunction] -fn init_session_store() { - aimux_core::session::init_session_store(std::sync::Arc::new( - aimux_core::session::SessionStore::new(), - )); -} - -/// Enable/disable the global session inferer (RFC-0024, opt-in, off by -/// default). Explicit `session_id` values always win regardless. -#[pyfunction] -fn init_session_infer(enabled: bool) { - aimux_core::session::init_session_infer(enabled); -} - -/// Query: all calls of a session (RFC-0024), as a JSON-serialized -/// `SessionCall[]` (ordered by step). Empty array if unknown / no store. -#[pyfunction] -fn session_calls(session_id: &str) -> PyResult { - serialize_result(&aimux_core::session::session_calls(session_id)) -} - -/// Query: all known sessions (RFC-0024), as a JSON-serialized `SessionView[]`. -#[pyfunction] -fn list_sessions() -> PyResult { - serialize_result(&aimux_core::session::list_sessions()) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Recording + mock replay (RFC-0023) -// ───────────────────────────────────────────────────────────────────────────── - -/// 启动录制(RFC-0023):把完整 `Recording` 写 JSONL 到 `{dir}/recordings.jsonl` -/// (目录自动创建)。录制 opt-in;再次调用(不同 dir)替换 recorder。 -/// Raises `RecordingError` (``code`` "Init" / "OpenFile" / "Spawn") when the -/// recorder cannot be set up; the previous recorder, if any, stays in place. -#[pyfunction] -fn init_recording(dir: &str) -> PyResult<()> { - let rec = aimux_core::recording::JsonlRecorder::try_new(dir.to_string()) - .map_err(|e| AiMuxBindingError::from(e).to_py_err())?; - aimux_core::recording::init_recording(Some(std::sync::Arc::new(rec))); - Ok(()) + #[getter] + fn cancelled(&self) -> bool { + self.signal.is_aborted() + } } -/// 启动内存有界录制(RFC-0023 P6):FIFO ring,丢弃计数可查。 -/// -/// `cap` 可省略:省略时使用库默认容量(等价于 FFI `aimux_init_recording_ring_default()`; -/// 本绑定直接依赖 aimux-core 而非 aimux-ffi,故调用等价 core API `RingRecorder::default()`)。 -/// 显式传 `cap == 0` 报错(保持与各绑定统一的"传 0 报错"语义)。 -#[pyfunction] -#[pyo3(signature = (cap=None))] -fn init_recording_ring(cap: Option) -> PyResult<()> { - match cap { - // 省略 cap:库默认容量(镜像 FFI default 变体)。 - None => aimux_core::recording::init_recording(Some(std::sync::Arc::new( - aimux_core::recording::RingRecorder::default(), - ))), - // 显式 cap == 0:报错(保持统一语义)。 - Some(0) => { - return Err(to_py_err(&AiMuxError::InvalidArgument( - "init_recording_ring: cap must be > 0".into(), - ))); +fn signal_for( + py: Python<'_>, + owner: Owner, + abort: Option>, +) -> PyResult { + match abort { + Some(abort) => { + check_owner(py, abort.owner.token)?; + if abort.owner.token != owner { + return Err(error::ops_error(py, invalid_state("foreign abort signal"))); + } + Ok(abort.signal.clone()) } - // 显式 cap > 0:指定容量的有界 ring。 - Some(c) => aimux_core::recording::init_recording(Some(std::sync::Arc::new( - aimux_core::recording::RingRecorder::with_capacity(c as usize), - ))), + None => Ok(AbortSignal::new()), } - Ok(()) } -/// 停止录制:全局 recorder = None(新调用不再录制)。 -#[pyfunction] -fn recording_stop() { - aimux_core::recording::init_recording(None); +/// All operation names, handle validation and wire schemas belong to aimux-ops. +#[pyclass(module = "aimux.aimux")] +struct Client { + owner: Arc, } -/// 刷盘全局 recorder(阻塞至 JSONL 落盘;ring 模式 no-op)。 -#[pyfunction] -fn recording_flush(py: Python<'_>) { - if let Some(rec) = aimux_core::recording::recorder() { - py.allow_threads(|| rec.flush()); +#[pymethods] +impl Client { + #[new] + fn new(py: Python<'_>) -> PyResult { + let owner = current_owner(py)?; + py.allow_threads(runtime) + .map_err(|e| error::ops_error(py, e))?; + Ok(Self { owner }) } -} - -/// Flush the global recorder and **report write failures**: raises -/// `RecordingError` (``code`` is "WriterGone" / "FlushTimeout" / "Write") when -/// the data could not be confirmed on disk. `RecordingError` is its own -/// exception type, not an `AimuxError`. Returns normally when nothing is -/// recording. The legacy `recording_flush` stays and never reports. -#[pyfunction] -fn recording_try_flush(py: Python<'_>) -> PyResult<()> { - let Some(rec) = aimux_core::recording::recorder() else { - return Ok(()); - }; - py.allow_threads(|| rec.try_flush()) - .map_err(|e| AiMuxBindingError::from(e).to_py_err()) -} -/// 从录制 JSONL 创建 mock 回放 model(RFC-0023 P3):按输入匹配录制响应, -/// 不发真实 API。返回的 Model 可用于 generate_text / stream_text。 -#[pyfunction] -fn mock_replay(recordings_jsonl: &str) -> PyResult { - let mut recordings: Vec = Vec::new(); - for (idx, line) in recordings_jsonl.lines().enumerate() { - let line = line.trim(); - if line.is_empty() { - continue; - } - // Same split as everywhere else: text that does not parse is the - // binding's (ValueError); a parsed line with the wrong shape is core's - // InvalidArgument. Both name the line. - let rec: aimux_core::recording::Recording = serde_json::from_str(line).map_err(|e| { - let msg = format!("recordings line {}: {e}", idx + 1); - match e.classify() { - serde_json::error::Category::Data => to_py_err(&AiMuxError::InvalidArgument(msg)), - _ => binding_py_err(&BindingError::InvalidWireJson { - argument: "recordings_jsonl", - message: msg, - }), + #[pyo3(signature = (handle, op, params_json="{}", abort=None))] + fn call( + &self, + py: Python<'_>, + handle: String, + op: String, + params_json: &str, + abort: Option>, + ) -> PyResult { + check_owner(py, self.owner.token)?; + let handle = parse_handle(&handle).map_err(|e| error::ops_error(py, e))?; + let params = parse_params(params_json).map_err(|e| error::ops_error(py, e))?; + let signal = signal_for(py, self.owner.token, abort)?; + let ctx = context(self.owner.token, signal.clone()); + // No Python references cross this boundary, including in construction, + // discovery, poll and recorder flush operations. + wait_call(py, signal, async move { + #[cfg(feature = "contract-tests")] + if op == "test.teardown" { + let owner = InterpreterOwner { + token: Owner::new_python()?, + }; + let token = owner.token; + let id = native_scenarios::operation(token).await; + // Exercise the same Drop implementation as the module guard and + // Client, without retiring this test interpreter's live owner. + drop(owner); + tokio::time::timeout(std::time::Duration::from_secs(3), async { + while !native_scenarios::released(token, id) { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let runtime_alive = runtime()?.spawn(async { true }).await.unwrap(); + return serialize(&serde_json::json!({ + "released": true, "runtimeAlive": runtime_alive, + })); } - })?; - recordings.push(rec); - } - if recordings.is_empty() { - return Err(to_py_err(&AiMuxError::InvalidArgument( - "no recordings".into(), - ))); + #[cfg(feature = "contract-tests")] + if op == "test.scenario" { + return serialize(&native_scenarios::replay(params, ctx.owner).await); + } + let value = dispatch::call(ctx, handle, &op, params).await?; + serialize(&value) + }) } - let model = aimux_core::replay::MockReplayModel::new( - recordings[0].provider.provider.clone(), - recordings[0].provider.model_id.clone(), - recordings, - ); - Ok(Model { - inner: Arc::new(model), - trace_store: None, - }) -} -/// Create a RouterModel (RFC-0021) over the given child models. The returned -/// model routes each call to one child and falls back across the rest on error -/// (per config_json). `models` must be non-empty. -/// -/// `config_json` (optional): {"router": "rule"|"weighted", "weights": [...], -/// "fallback": "on_error"|"none", "providerName", "modelId"}; an unknown key -/// is an error. -#[pyfunction] -#[pyo3(signature = (models, config_json=None))] -fn router(models: Vec>, config_json: Option<&str>) -> PyResult { - if models.is_empty() { - return Err(to_py_err(&AiMuxError::InvalidArgument( - "router: models must be non-empty".into(), - ))); - } - let children: Vec> = models.iter().map(|m| m.inner.clone()).collect(); - let cfg: RouterFfiConfig = match config_json { - Some(json) => wire_json("config_json", json)?, - None => RouterFfiConfig::default(), - }; - let router: Box = match cfg.router.as_deref() { - Some("weighted") => { - let weights = cfg.weights.unwrap_or_else(|| vec![1.0; children.len()]); - Box::new(aimux_core::router::WeightedRouter::new(weights)) + /// Binary admission is bounded by the shared operation service. + fn operation_push(&self, py: Python<'_>, handle: String, data: &[u8]) -> PyResult<()> { + check_owner(py, self.owner.token)?; + let handle = parse_handle(&handle).map_err(|e| error::ops_error(py, e))?; + if data.len() > 1024 * 1024 { + return Err(error::ops_error( + py, + OpsError::Protocol(ProtocolError::ResourceLimit( + "audio chunk exceeds 1 MiB".into(), + )), + )); } - _ => Box::new(aimux_core::router::RuleRouter), - }; - let fallback = if cfg.fallback.as_deref() == Some("none") { - aimux_core::router::FallbackPolicy::None - } else { - aimux_core::router::FallbackPolicy::OnError - }; - let router_cfg = aimux_core::router::RouterConfig { - provider_name: cfg.provider_name.unwrap_or_else(|| "router".into()), - model_id: cfg.model_id.unwrap_or_else(|| "router".into()), - }; - let model = aimux_core::router::RouterModel::new(children, router, fallback, router_cfg); - Ok(Model { - inner: Arc::new(model), - trace_store: None, - }) -} - -/// Create a MoaModel (RFC-0022) over reference models + one aggregator. -/// References fan out in parallel, then the aggregator synthesizes a final -/// answer. `references` may be empty (runs aggregator only). -/// -/// `config_json` (optional) is a serialized MoaConfig. -#[pyfunction] -#[pyo3(signature = (references, aggregator, config_json=None))] -fn moa( - references: Vec>, - aggregator: PyRef, - config_json: Option<&str>, -) -> PyResult { - let refs: Vec> = references.iter().map(|m| m.inner.clone()).collect(); - let cfg: aimux_core::moa::MoaConfig = match config_json { - Some(json) => wire_json("config_json", json)?, - None => aimux_core::moa::MoaConfig::default(), - }; - let model = aimux_core::moa::MoaModel::new(refs, aggregator.inner.clone(), cfg); - Ok(Model { - inner: Arc::new(model), - trace_store: None, - }) -} - -/// Router config, the C ABI's shape: camelCase, all fields optional, -/// unknown keys rejected. -#[derive(Default, serde::Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RouterFfiConfig { - router: Option, - weights: Option>, - fallback: Option, - provider_name: Option, - model_id: Option, -} + let data = data.to_vec(); + let owner = self.owner.token; + py.allow_threads(move || { + dispatch::operation_push(&context(owner, AbortSignal::new()), handle, &data) + }) + .map_err(|e| error::ops_error(py, e)) + } -// ───────────────────────────────────────────────────────────────────────────── -// Host-side tool-call repair (RFC-0035) -// ───────────────────────────────────────────────────────────────────────────── + /// GC teardown only queues work; explicit Handle.close uses checked call. + fn drop_later(&self, py: Python<'_>, handle: String) -> PyResult<()> { + check_owner(py, self.owner.token)?; + let handle = parse_handle(&handle).map_err(|e| error::ops_error(py, e))?; + let owner = self.owner.token; + let rt = py + .allow_threads(runtime) + .map_err(|e| error::ops_error(py, e))?; + rt.spawn(async move { + let _ = dispatch::call( + context(owner, AbortSignal::new()), + handle, + dispatch::HANDLE_DROP, + serde_json::json!({}), + ) + .await; + }); + Ok(()) + } -/// Build the repair argument for one invalid tool call. -/// -/// `tool_call_json` is a ``GenerateTextResult.tool_calls`` entry with -/// ``"invalid": true``. `prompt_json` and `opts_json` are **the same two -/// strings the call was generated with** (``generate_text`` / -/// ``stream_text``); messages, instructions, and the tool set are derived -/// from them here, so no caller repeats that derivation. -/// -/// Returns ``{tool_call, error, input_schema, tools, messages, instructions}`` -/// — the AI SDK ``repairToolCall`` argument, with ``tool_call.input`` the -/// provider's raw argument text — or the JSON literal ``"null"`` when the -/// options carried no tools: as in the AI SDK, a call made without a tool set -/// is never repaired, and the caller skips it. ``"null"`` is a success. -/// -/// Pure and synchronous: no model, no network. Raises -/// ``InvalidArgumentError`` when the call is not an invalid one. -#[pyfunction] -#[pyo3(signature = (tool_call_json, prompt_json, opts_json=None))] -fn tool_call_repair_context( - tool_call_json: &str, - prompt_json: &str, - opts_json: Option<&str>, -) -> PyResult { - let tool_call: ToolCall = wire_json("tool_call_json", tool_call_json)?; - let prompt = parse_prompt(prompt_json)?; - let opts = parse_opts(opts_json)?; - let (messages, instructions, tools) = tool_call_repair_inputs(prompt, &opts); - let context = aimux_core::parse_tool_call::tool_call_repair_context( - &tool_call, - tools, - &messages, - instructions, - ) - .map_err(|e| to_py_err(&e))?; - serialize_result(&context) + #[pyo3(signature = (handle, op, params_json="{}", abort=None))] + fn stream( + &self, + py: Python<'_>, + handle: String, + op: String, + params_json: &str, + abort: Option>, + ) -> PyResult { + check_owner(py, self.owner.token)?; + let handle = parse_handle(&handle).map_err(|e| error::ops_error(py, e))?; + let params = parse_params(params_json).map_err(|e| error::ops_error(py, e))?; + let signal = signal_for(py, self.owner.token, abort)?; + StreamIterator::start(py, self.owner.clone(), handle, op, params, signal) + } } -/// Resolve one invalid tool call against a host's repair reply. -/// -/// `opts_json` is the same string the call was generated with; the tool set -/// comes from it. `reply_json` is ``{"type":"repaired","toolCall":{…}}``, -/// ``{"type":"unchanged"}``, or ``{"type":"failed","message":"…"}``. Returns -/// the resulting ``ToolCall`` JSON, tagged ``"type": "tool-call"`` — valid, -/// or invalid carrying a nested ``ToolCallRepairError``. -/// -/// Options carrying no tools raise ``InvalidArgumentError``: such a call is -/// not repairable, and ``tool_call_repair_context`` already said so by -/// returning ``"null"``. #[pyfunction] -#[pyo3(signature = (tool_call_json, opts_json, reply_json))] -fn apply_tool_call_repair( - tool_call_json: &str, - opts_json: Option<&str>, - reply_json: &str, -) -> PyResult { - let tool_call: ToolCall = wire_json("tool_call_json", tool_call_json)?; - let opts = parse_opts(opts_json)?; - let reply: ToolCallRepairReply = wire_json("reply_json", reply_json)?; - let repaired = aimux_core::parse_tool_call::apply_tool_call_repair( - &tool_call, - opts.tools.as_deref(), - reply, - ) - .map_err(|e| to_py_err(&e))?; - serialize_result(&aimux_core::wire::Tagged(&repaired)) +fn manifest_json(py: Python<'_>) -> PyResult { + py.allow_threads(|| serialize(&aimux_ops::manifest::manifest())) + .map_err(|e| error::ops_error(py, e)) } -/// Apply a repair reply to a serialized ``GenerateTextResult``, rewriting the -/// call in its steps' content and the matching response-message tool-call -/// part. `opts_json` is the same string the call was generated with. -/// -/// The OpenAI-shaped result has no equivalent: it carries no ``invalid`` / -/// ``error``, so repair is driven from the native result, which -/// ``Model.generate_text_result_as_openai`` then converts. #[pyfunction] -#[pyo3(signature = (result_json, opts_json, tool_call_id, reply_json))] -fn apply_tool_call_repair_to_result( - result_json: &str, - opts_json: Option<&str>, - tool_call_id: &str, - reply_json: &str, -) -> PyResult { - let result: serde_json::Value = wire_json("result_json", result_json)?; - let opts = parse_opts(opts_json)?; - let reply: ToolCallRepairReply = wire_json("reply_json", reply_json)?; - let patched = aimux_core::parse_tool_call::apply_tool_call_repair_to_result( - &result, - opts.tools.as_deref(), - tool_call_id, - reply, - ) - .map_err(|e| to_py_err(&e))?; - serialize_result(&patched) +fn manifest_digest(py: Python<'_>) -> String { + py.allow_threads(aimux_ops::manifest::manifest_digest) } #[pymodule] -fn aimux(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { - crate::error::register(m)?; - m.add_class::()?; +fn aimux(m: &Bound<'_, PyModule>) -> PyResult<()> { + let owner = current_owner(m.py())?; + m.add( + "_owner_guard", + Py::new(m.py(), OwnerGuard { _owner: owner })?, + )?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; - m.add_function(wrap_pyfunction!(init_logging, m)?)?; - m.add_function(wrap_pyfunction!(init_session_store, m)?)?; - m.add_function(wrap_pyfunction!(init_session_infer, m)?)?; - m.add_function(wrap_pyfunction!(session_calls, m)?)?; - m.add_function(wrap_pyfunction!(list_sessions, m)?)?; - m.add_function(wrap_pyfunction!(init_recording, m)?)?; - m.add_function(wrap_pyfunction!(init_recording_ring, m)?)?; - m.add_function(wrap_pyfunction!(recording_stop, m)?)?; - m.add_function(wrap_pyfunction!(recording_flush, m)?)?; - m.add_function(wrap_pyfunction!(recording_try_flush, m)?)?; - m.add_function(wrap_pyfunction!(mock_replay, m)?)?; - m.add_function(wrap_pyfunction!(router, m)?)?; - { - use crate::multimodal::start_transcription_session; - m.add_function(wrap_pyfunction!(start_transcription_session, m)?)?; - } - m.add_function(wrap_pyfunction!(moa, m)?)?; - m.add_function(wrap_pyfunction!(register_providers, m)?)?; - m.add_function(wrap_pyfunction!(init_proxy, m)?)?; - m.add_function(wrap_pyfunction!(openai, m)?)?; - m.add_function(wrap_pyfunction!(anthropic, m)?)?; - m.add_function(wrap_pyfunction!(deepseek, m)?)?; - m.add_function(wrap_pyfunction!(google, m)?)?; - m.add_function(wrap_pyfunction!(cohere, m)?)?; - m.add_function(wrap_pyfunction!(mistral, m)?)?; - m.add_function(wrap_pyfunction!(xai, m)?)?; - m.add_function(wrap_pyfunction!(bedrock, m)?)?; - m.add_function(wrap_pyfunction!(vertex, m)?)?; - m.add_function(wrap_pyfunction!(anthropic_aws, m)?)?; - m.add_function(wrap_pyfunction!(azure, m)?)?; - m.add_function(wrap_pyfunction!(provider, m)?)?; - m.add_function(wrap_pyfunction!(create_provider, m)?)?; - m.add_function(wrap_pyfunction!(get_model_specs, m)?)?; - m.add_function(wrap_pyfunction!(tool_call_repair_context, m)?)?; - m.add_function(wrap_pyfunction!(apply_tool_call_repair, m)?)?; - m.add_function(wrap_pyfunction!(apply_tool_call_repair_to_result, m)?)?; - m.add_class::()?; - - // Multimodal classes. - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_function(wrap_pyfunction!(jev_decision, m)?)?; - m.add_class::()?; - - // Multimodal factory functions. - m.add_function(wrap_pyfunction!(openai_embedding, m)?)?; - m.add_function(wrap_pyfunction!(openai_speech, m)?)?; - m.add_function(wrap_pyfunction!(openai_image, m)?)?; - m.add_function(wrap_pyfunction!(openai_transcription, m)?)?; - m.add_function(wrap_pyfunction!(openai_files, m)?)?; - m.add_function(wrap_pyfunction!(cohere_embedding, m)?)?; - m.add_function(wrap_pyfunction!(cohere_reranking, m)?)?; - m.add_function(wrap_pyfunction!(google_embedding, m)?)?; - m.add_function(wrap_pyfunction!(google_image, m)?)?; - m.add_function(wrap_pyfunction!(google_video, m)?)?; - m.add_function(wrap_pyfunction!(tavily_search, m)?)?; + m.add_function(wrap_pyfunction!(manifest_json, m)?)?; + m.add_function(wrap_pyfunction!(manifest_digest, m)?)?; + m.add("EXPECTED_MANIFEST_DIGEST", EXPECTED_MANIFEST_DIGEST)?; + m.add("EXPECTED_ABI_VERSION", EXPECTED_ABI_VERSION)?; + m.add("EXPECTED_OPS_VERSION", EXPECTED_OPS_VERSION)?; + m.add("ABI_VERSION", aimux_ops::op::ABI_VERSION)?; + m.add("OPS_VERSION", aimux_ops::op::OPS_VERSION)?; Ok(()) } - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -fn rt_spawn(future: F) -where - F: std::future::Future + Send + 'static, -{ - runtime().spawn(future); -} - -fn parse_prompt(json: &str) -> PyResult { - // Malformed text is the binding's failure; a well-formed prompt the core - // would reject is core-owned. - let value: serde_json::Value = wire_json("prompt_json", json)?; - let inner = match &value { - serde_json::Value::Object(obj) if obj.len() == 1 && obj.contains_key("prompt") => { - obj.get("prompt").expect("checked by guard") - } - _ => &value, - }; - serde_json::from_value(inner.clone()) - .map_err(|e| to_py_err(&AiMuxError::InvalidArgument(format!("invalid prompt: {e}")))) -} - -fn parse_opts(json: Option<&str>) -> PyResult { - match json { - None => Ok(GenerateTextOptions::default()), - Some(s) => { - let trimmed = s.trim(); - if trimmed.is_empty() || trimmed == "null" { - return Ok(GenerateTextOptions::default()); - } - wire_json("opts_json", s) - } - } -} - -#[cfg(test)] -mod tests { - use super::RouterFfiConfig; - - /// The router config is the C ABI's: camelCase, unknown keys rejected, - /// so the same JSON names the router in every binding. - #[test] - fn router_config_is_camel_case_and_strict() { - let cfg: RouterFfiConfig = serde_json::from_str( - r#"{"router":"weighted","weights":[1.0],"fallback":"none","providerName":"r","modelId":"m"}"#, - ) - .unwrap(); - assert_eq!(cfg.router.as_deref(), Some("weighted")); - assert_eq!(cfg.provider_name.as_deref(), Some("r")); - assert_eq!(cfg.model_id.as_deref(), Some("m")); - let error = serde_json::from_str::(r#"{"provider_name":"r"}"#) - .err() - .expect("a snake_case key is rejected"); - assert!( - error.to_string().contains("unknown field `provider_name`"), - "{error}" - ); - } -} diff --git a/bindings/python/src/multimodal.rs b/bindings/python/src/multimodal.rs deleted file mode 100644 index 6cb78ff2e..000000000 --- a/bindings/python/src/multimodal.rs +++ /dev/null @@ -1,916 +0,0 @@ -//! Multimodal API bindings for Python (PyO3). -//! -//! Each modality is a PyO3 class wrapping the Rust trait object. -//! All cross-boundary data uses JSON strings (base64 for binary). -//! -//! Mirrors the Node (napi-rs) multimodal bindings, but uses PyO3 -//! `#[pyclass]` + `#[pymethods]` and the crate-global tokio runtime -//! (see `runtime()` in `lib.rs`). - -// pyo3 0.22 macros generate unsafe-op-in-unsafe-fn calls that trigger -// edition-2024 lint. Suppress until pyo3 0.23+ lands. -#![allow(unsafe_op_in_unsafe_fn)] - -use std::sync::Arc; - -use crate::error::{ - AiMuxBindingError, BindingError, binding_py_err, serialize_result, to_py_err, wire_error, - wire_json, -}; -use aimux_core::AiMuxError; -use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel as EmbeddingModelTrait}; -use aimux_core::files_model::{Files as FilesTrait, UploadFileCallOptions}; -use aimux_core::image_model::{ImageCallOptions, ImageModel as ImageModelTrait}; -use aimux_core::reranking_model::{RerankingCallOptions, RerankingModel as RerankingModelTrait}; -use aimux_core::search_model::{SearchCallOptions, SearchModel as SearchModelTrait}; -use aimux_core::shared::FileBytes; -use aimux_core::speech_model::{SpeechCallOptions, SpeechModel as SpeechModelTrait}; -use aimux_core::transcription_model::{ - AudioChunk, AudioInput, TranscriptionCallOptions, TranscriptionModel as TranscriptionModelTrait, -}; -use aimux_core::video_model::{VideoCallOptions, VideoModel as VideoModelTrait}; -use pyo3::prelude::*; - -/// Fill in the fields the method's explicit arguments own, then deserialize. -/// -/// Those fields are required on the Rust options struct, so a caller's -/// options object on its own fails with `missing field` before the explicit -/// arguments can be put back. The values inserted here are placeholders that -/// satisfy the shape only; the caller overwrites each one from its own -/// argument right after this returns. Pure JSON — no binding error types — so -/// the parse contract is unit-testable without a Python interpreter. -fn opts_with_args( - json: &str, - owned_by_args: &[(&str, serde_json::Value)], -) -> Result { - let mut value: serde_json::Value = serde_json::from_str(json)?; - // A non-object body needs no filling; `from_value` reports it as the same - // invalid-type data error it always did. - if let Some(object) = value.as_object_mut() { - for (field, placeholder) in owned_by_args { - object.insert((*field).to_string(), placeholder.clone()); - } - } - serde_json::from_value(value) -} - -/// [`opts_with_args`] under `wire_json`'s error classification. -fn parse_opts_json( - argument: &'static str, - json: &str, - owned_by_args: &[(&str, serde_json::Value)], -) -> PyResult { - opts_with_args(json, owned_by_args).map_err(|e| wire_error(argument, &e)) -} - -/// Shape-only stand-ins for the `TranscriptionCallOptions` fields the -/// explicit arguments own, under their JSON names: `audio`, built from the -/// real enum so a variant rename is a compile error rather than a runtime one, -/// and `mediaType`. -fn transcription_args() -> [(&'static str, serde_json::Value); 2] { - [ - ( - "audio", - serde_json::to_value(AudioInput::Base64(String::new())) - .expect("AudioInput always serializes"), - ), - ("mediaType", serde_json::Value::from("")), - ] -} - -/// Shape-only stand-in for `RerankingCallOptions::documents`. -fn documents_placeholder() -> serde_json::Value { - serde_json::to_value(aimux_core::reranking_model::RerankingDocuments::Text { - values: Vec::new(), - }) - .expect("RerankingDocuments always serializes") -} - -// ───────────────────────────────────────────────────────────────────────────── -// EmbeddingModel -// ───────────────────────────────────────────────────────────────────────────── - -#[pyclass] -pub struct EmbeddingModel { - inner: Arc, -} - -#[pymethods] -impl EmbeddingModel { - /// Generate embeddings. `values_json` is a JSON array of strings. - /// `opts_json` is optional JSON-serialized EmbeddingCallOptions. - /// Returns JSON-serialized EmbeddingResult. - #[pyo3(signature = (values_json, opts_json=None))] - pub fn embed( - &self, - py: Python<'_>, - values_json: &str, - opts_json: Option<&str>, - ) -> PyResult { - let mut opts: EmbeddingCallOptions = match opts_json { - Some(s) if !s.trim().is_empty() && s.trim() != "null" => wire_json("opts_json", s)?, - _ => EmbeddingCallOptions::new(""), - }; - // Override values from the JSON array. - let values: Vec = wire_json("values_json", values_json)?; - opts.values = values; - - let result = py.allow_threads(|| { - crate::runtime().block_on(async move { - aimux_core::embedding_model::embed(self.inner.as_ref(), opts).await - }) - }); - - match result { - Ok(r) => serialize_result(&r), - Err(e) => Err(to_py_err(&e)), - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// SpeechModel (TTS) -// ───────────────────────────────────────────────────────────────────────────── - -#[pyclass] -pub struct SpeechModel { - inner: Arc, -} - -#[pymethods] -impl SpeechModel { - /// Generate speech audio. `opts_json` is JSON-serialized SpeechCallOptions. - /// Returns JSON-serialized SpeechResult (audio as base64 in JSON). - pub fn generate(&self, py: Python<'_>, opts_json: &str) -> PyResult { - let opts: SpeechCallOptions = wire_json("opts_json", opts_json)?; - - let result = py.allow_threads(|| { - crate::runtime().block_on(async move { - aimux_core::speech_model::generate_speech(self.inner.as_ref(), opts).await - }) - }); - - match result { - Ok(r) => serialize_result(&r), - Err(e) => Err(to_py_err(&e)), - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// ImageModel -// ───────────────────────────────────────────────────────────────────────────── - -#[pyclass] -pub struct ImageModel { - inner: Arc, -} - -#[pymethods] -impl ImageModel { - /// Generate images. `opts_json` is JSON-serialized ImageCallOptions. - /// Returns JSON-serialized ImageResult (images as base64 in JSON). - pub fn generate(&self, py: Python<'_>, opts_json: &str) -> PyResult { - let opts: ImageCallOptions = wire_json("opts_json", opts_json)?; - - let result = py.allow_threads(|| { - crate::runtime().block_on(async move { - aimux_core::image_model::generate_image(self.inner.as_ref(), opts).await - }) - }); - - match result { - Ok(r) => serialize_result(&r), - Err(e) => Err(to_py_err(&e)), - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// TranscriptionModel (STT, non-streaming only) -// ───────────────────────────────────────────────────────────────────────────── - -#[pyclass] -pub struct TranscriptionModel { - inner: Arc, -} - -#[pymethods] -impl TranscriptionModel { - /// Transcribe audio. `audio_base64` is base64-encoded audio data. - /// `media_type` is e.g. "audio/mp3". `opts_json` is optional JSON options. - /// Returns JSON-serialized TranscriptionResult. - #[pyo3(signature = (audio_base64, media_type, opts_json=None))] - pub fn generate( - &self, - py: Python<'_>, - audio_base64: &str, - media_type: &str, - opts_json: Option<&str>, - ) -> PyResult { - let mut opts = TranscriptionCallOptions::new( - AudioInput::Base64(audio_base64.to_string()), - media_type.to_string(), - ); - if let Some(s) = opts_json - && !s.trim().is_empty() - && s.trim() != "null" - { - // Take every caller option; audio and media_type always come - // from the explicit args. - let mut parsed: TranscriptionCallOptions = - parse_opts_json("opts_json", s, &transcription_args())?; - parsed.audio = opts.audio; - parsed.media_type = opts.media_type; - opts = parsed; - } - - let result = py.allow_threads(|| { - crate::runtime().block_on(async move { - aimux_core::transcription_model::transcribe(self.inner.as_ref(), opts).await - }) - }); - - match result { - Ok(r) => serialize_result(&r), - Err(e) => Err(to_py_err(&e)), - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// RerankingModel -// ───────────────────────────────────────────────────────────────────────────── - -#[pyclass] -pub struct RerankingModel { - inner: Arc, -} - -#[pymethods] -impl RerankingModel { - /// Rerank documents. `query` is the search query, `docs_json` is a JSON - /// array of documents, `opts_json` is optional JSON options. - /// Returns JSON-serialized RerankingResult. - #[pyo3(signature = (query, docs_json, opts_json=None))] - pub fn rerank( - &self, - py: Python<'_>, - query: &str, - docs_json: &str, - opts_json: Option<&str>, - ) -> PyResult { - use aimux_core::reranking_model::RerankingDocuments; - let docs: RerankingDocuments = wire_json("docs_json", docs_json)?; - - let mut opts = RerankingCallOptions::new(query.to_string(), docs); - if let Some(s) = opts_json - && !s.trim().is_empty() - && s.trim() != "null" - { - // Take every caller option; query and documents always come - // from the explicit args. - let mut parsed: RerankingCallOptions = parse_opts_json( - "opts_json", - s, - &[ - ("query", serde_json::Value::from("")), - ("documents", documents_placeholder()), - ], - )?; - parsed.query = opts.query; - parsed.documents = opts.documents; - opts = parsed; - } - - let result = py.allow_threads(|| { - crate::runtime().block_on(async move { - aimux_core::reranking_model::rerank(self.inner.as_ref(), opts).await - }) - }); - - match result { - Ok(r) => serialize_result(&r), - Err(e) => Err(to_py_err(&e)), - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// VideoModel -// ───────────────────────────────────────────────────────────────────────────── - -#[pyclass] -pub struct VideoModel { - inner: Arc, -} - -#[pymethods] -impl VideoModel { - /// Generate video. `opts_json` is JSON-serialized VideoCallOptions. - /// Returns JSON-serialized VideoResult (typically contains a URL). - pub fn generate(&self, py: Python<'_>, opts_json: &str) -> PyResult { - let opts: VideoCallOptions = wire_json("opts_json", opts_json)?; - - let result = py.allow_threads(|| { - crate::runtime().block_on(async move { - aimux_core::video_model::generate_video(self.inner.as_ref(), opts).await - }) - }); - - match result { - Ok(r) => serialize_result(&r), - Err(e) => Err(to_py_err(&e)), - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// SearchModel -// ───────────────────────────────────────────────────────────────────────────── - -#[pyclass] -pub struct SearchModel { - inner: Arc, -} - -#[pymethods] -impl SearchModel { - /// Search. `query` is the search query, `opts_json` is optional JSON options. - /// Returns JSON-serialized SearchResult. - #[pyo3(signature = (query, opts_json=None))] - pub fn search(&self, py: Python<'_>, query: &str, opts_json: Option<&str>) -> PyResult { - let mut opts = SearchCallOptions::new(query.to_string()); - if let Some(s) = opts_json - && !s.trim().is_empty() - && s.trim() != "null" - { - // Take every caller option; the query always comes from the - // explicit arg. - let mut parsed: SearchCallOptions = - parse_opts_json("opts_json", s, &[("query", serde_json::Value::from(""))])?; - parsed.query = opts.query; - opts = parsed; - } - - let result = py.allow_threads(|| { - crate::runtime().block_on(async move { - aimux_core::search_model::search(self.inner.as_ref(), opts).await - }) - }); - - match result { - Ok(r) => serialize_result(&r), - Err(e) => Err(to_py_err(&e)), - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Files -// ───────────────────────────────────────────────────────────────────────────── - -#[pyclass] -pub struct Files { - inner: Arc, -} - -#[pymethods] -impl Files { - /// Upload a file. `data_base64` is base64-encoded file content, - /// `media_type` is e.g. "application/pdf", `opts_json` is optional - /// (may contain filename, provider_options). - /// Returns JSON-serialized UploadFileResult (contains provider file ID). - #[pyo3(signature = (data_base64, media_type, opts_json=None))] - pub fn upload_file( - &self, - py: Python<'_>, - data_base64: &str, - media_type: &str, - opts_json: Option<&str>, - ) -> PyResult { - use aimux_core::files_model::UploadFileData; - let mut opts = UploadFileCallOptions::new( - UploadFileData::Data { - data: FileBytes::Base64(data_base64.to_string()), - }, - media_type.to_string(), - ); - if let Some(s) = opts_json - && !s.trim().is_empty() - && s.trim() != "null" - { - let parsed: UploadFileCallOptions = wire_json("opts_json", s)?; - opts.filename = parsed.filename; - opts.provider_options = parsed.provider_options; - } - - let result = py.allow_threads(|| { - crate::runtime().block_on(async move { self.inner.upload_file(&opts).await }) - }); - - match result { - Ok(r) => serialize_result(&r), - Err(e) => Err(to_py_err(&e)), - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Factory functions — OpenAI multimodal -// ───────────────────────────────────────────────────────────────────────────── - -/// Create an OpenAI embedding model instance. -#[pyfunction] -#[pyo3(signature = (api_key, model_id, base_url=None))] -pub fn openai_embedding( - api_key: &str, - model_id: &str, - base_url: Option<&str>, -) -> PyResult { - let provider = crate::openai_provider(api_key, base_url)?; - let model = provider.embedding(model_id); - Ok(EmbeddingModel { - inner: Arc::new(model), - }) -} - -/// Create an OpenAI speech (TTS) model instance. -#[pyfunction] -#[pyo3(signature = (api_key, model_id, base_url=None))] -pub fn openai_speech( - api_key: &str, - model_id: &str, - base_url: Option<&str>, -) -> PyResult { - let provider = crate::openai_provider(api_key, base_url)?; - let model = provider.speech(model_id); - Ok(SpeechModel { - inner: Arc::new(model), - }) -} - -/// Create an OpenAI image model instance. -#[pyfunction] -#[pyo3(signature = (api_key, model_id, base_url=None))] -pub fn openai_image(api_key: &str, model_id: &str, base_url: Option<&str>) -> PyResult { - let provider = crate::openai_provider(api_key, base_url)?; - let model = provider.image(model_id); - Ok(ImageModel { - inner: Arc::new(model), - }) -} - -/// Create an OpenAI transcription model instance. -#[pyfunction] -#[pyo3(signature = (api_key, model_id, base_url=None))] -pub fn openai_transcription( - api_key: &str, - model_id: &str, - base_url: Option<&str>, -) -> PyResult { - let provider = crate::openai_provider(api_key, base_url)?; - let model = provider.transcription(model_id); - Ok(TranscriptionModel { - inner: Arc::new(model), - }) -} - -/// Create OpenAI files manager. -#[pyfunction] -#[pyo3(signature = (api_key, base_url=None))] -pub fn openai_files(api_key: &str, base_url: Option<&str>) -> PyResult { - let provider = crate::openai_provider(api_key, base_url)?; - let files = provider.files(); - Ok(Files { - inner: Arc::new(files), - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Factory functions — Cohere multimodal -// ───────────────────────────────────────────────────────────────────────────── - -/// Create a Cohere embedding model instance. -#[pyfunction] -#[pyo3(signature = (api_key, model_id, base_url=None))] -pub fn cohere_embedding( - api_key: &str, - model_id: &str, - base_url: Option<&str>, -) -> PyResult { - use aimux_providers::cohere::{CohereProviderSettings, create_cohere}; - let provider = create_cohere(CohereProviderSettings { - api_key: Some(api_key.to_string()), - base_url: base_url.map(str::to_string), - ..Default::default() - }) - .map_err(|e| to_py_err(&e))?; - let model = provider.embedding(model_id); - Ok(EmbeddingModel { - inner: Arc::new(model), - }) -} - -/// Create a Cohere reranking model instance. -#[pyfunction] -#[pyo3(signature = (api_key, model_id, base_url=None))] -pub fn cohere_reranking( - api_key: &str, - model_id: &str, - base_url: Option<&str>, -) -> PyResult { - use aimux_providers::cohere::{CohereProviderSettings, create_cohere}; - let provider = create_cohere(CohereProviderSettings { - api_key: Some(api_key.to_string()), - base_url: base_url.map(str::to_string), - ..Default::default() - }) - .map_err(|e| to_py_err(&e))?; - let model = provider.reranking(model_id); - Ok(RerankingModel { - inner: Arc::new(model), - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Factory functions — Google multimodal -// ───────────────────────────────────────────────────────────────────────────── - -/// Create a Google embedding model instance. -#[pyfunction] -#[pyo3(signature = (api_key, model_id, base_url=None))] -pub fn google_embedding( - api_key: &str, - model_id: &str, - base_url: Option<&str>, -) -> PyResult { - use aimux_providers::google::{GoogleProviderSettings, create_google}; - let provider = create_google(GoogleProviderSettings { - api_key: Some(api_key.to_string()), - base_url: base_url.map(str::to_string), - ..Default::default() - }) - .map_err(|e| to_py_err(&e))?; - let model = provider.embedding(model_id); - Ok(EmbeddingModel { - inner: Arc::new(model), - }) -} - -/// Create a Google image model instance. -#[pyfunction] -#[pyo3(signature = (api_key, model_id, base_url=None))] -pub fn google_image(api_key: &str, model_id: &str, base_url: Option<&str>) -> PyResult { - use aimux_providers::google::{GoogleProviderSettings, create_google}; - let provider = create_google(GoogleProviderSettings { - api_key: Some(api_key.to_string()), - base_url: base_url.map(str::to_string), - ..Default::default() - }) - .map_err(|e| to_py_err(&e))?; - let model = provider.image(model_id); - Ok(ImageModel { - inner: Arc::new(model), - }) -} - -/// Create a Google video model instance. -#[pyfunction] -#[pyo3(signature = (api_key, model_id, base_url=None))] -pub fn google_video(api_key: &str, model_id: &str, base_url: Option<&str>) -> PyResult { - use aimux_providers::google::{GoogleProviderSettings, create_google}; - let provider = create_google(GoogleProviderSettings { - api_key: Some(api_key.to_string()), - base_url: base_url.map(str::to_string), - ..Default::default() - }) - .map_err(|e| to_py_err(&e))?; - let model = provider.video(model_id); - Ok(VideoModel { - inner: Arc::new(model), - }) -} - -/// Create a Tavily search model instance. -#[pyfunction] -#[pyo3(signature = (api_key, base_url=None))] -pub fn tavily_search(api_key: &str, base_url: Option<&str>) -> PyResult { - use aimux_providers::tavily::{TavilyProviderSettings, create_tavily}; - let provider = create_tavily(TavilyProviderSettings { - api_key: Some(aimux_provider_utils::Resolvable::Value(api_key.to_string())), - base_url: base_url.map(str::to_string), - ..Default::default() - }) - .map_err(|e| to_py_err(&e))?; - let model = provider.search_model(); - Ok(SearchModel { - inner: Arc::new(model), - }) -} - -// ───────────────────────────────────────────────────────────────────────────── -// TranscriptionSession (RFC-0028 streaming, native path) -// ───────────────────────────────────────────────────────────────────────────── - -/// A live streaming-transcription session (RFC-0028). Push audio chunks with -/// `push_audio`, mark end-of-audio with `input_done`, then pull transcription -/// parts with `next_part`. Mirrors the C-ABI session shape, built natively on -/// core channels. -#[pyclass] -pub struct TranscriptionSession { - audio_tx: std::sync::Mutex>>, - parts_rx: tokio::sync::Mutex< - tokio::sync::mpsc::Receiver>, - >, - token: aimux_core::AbortSignal, -} - -/// Start a streaming transcription session. `opts_json` (optional, camelCase -/// as in the C ABI, unknown keys rejected): `{ "inputAudioFormat": -/// {"formatType","rate"}, "providerOptions", "headers", "includeRawChunks", -/// "timeout" }`. Returned parts are JSON-serialized `TranscriptionStreamPart`s. -#[pyfunction] -#[pyo3(signature = (model, opts_json=None))] -pub fn start_transcription_session( - model: &TranscriptionModel, - opts_json: Option<&str>, -) -> PyResult { - #[derive(serde::Deserialize, Default)] - #[serde(rename_all = "camelCase", deny_unknown_fields)] - struct SessionOpts { - input_audio_format: Option, - provider_options: Option, - headers: Option>, - include_raw_chunks: Option, - timeout: Option, - } - let opts: SessionOpts = match opts_json { - Some(s) if !s.trim().is_empty() && s.trim() != "null" => wire_json("opts_json", s)?, - _ => SessionOpts::default(), - }; - - let token = aimux_core::AbortSignal::new(); - let effective = aimux_core::AbortSignal::new(); - { - let linked = effective.clone(); - let source = token.clone(); - crate::runtime().spawn(async move { - source.cancelled().await; - linked.abort(); - }); - } - - let (audio_tx, audio_rx) = futures::channel::mpsc::channel::(64); - let (tx, rx) = - tokio::sync::mpsc::channel::>(256); - - let model = model.inner.clone(); - crate::runtime().spawn(async move { - let options = aimux_core::transcription_model::TranscriptionStreamOptions { - audio: Box::pin(audio_rx), - input_audio_format: opts.input_audio_format.unwrap_or( - aimux_core::transcription_model::InputAudioFormat { - format_type: "audio/pcm".to_string(), - rate: None, - }, - ), - provider_options: opts.provider_options, - abort_signal: Some(effective.clone()), - headers: opts.headers, - include_raw_chunks: opts.include_raw_chunks.unwrap_or(false), - timeout: opts.timeout, - }; - let result = - aimux_core::transcription_model::stream_transcribe(model.as_ref(), options).await; - match result { - Ok(stream_result) => { - use futures::StreamExt; - let mut stream = stream_result.stream; - // Immediate delivery when capacity allows (terminal errors are - // never preempted); abort only unblocks a full channel. - while let Some(item) = stream.next().await { - // In-stream errors raise from `next_part` (issue #145, - // option A) instead of being smuggled through the data - // channel as a serialized `{"Err": ...}` part — matching - // the FFI session, which surfaces them as Err items, and - // the other six bindings' exception sentinels. - let part = match item { - Ok(p) => p, - Err(e) => { - // Terminal by contract: providers end the stream - // after an Error part (openai transcription - // breaks right after), so nothing is dropped by - // returning here. New providers must uphold this. - tokio::select! { - res = tx.send(Err(e.into())) => { let _ = res; } - _ = effective.cancelled() => {} - } - return; - } - }; - let json = match serde_json::to_string(&part) { - Ok(j) => j, - Err(e) => { - let _ = tx - .send(Err(BindingError::ResultSerialization { - message: format!("part: {e}"), - } - .into())) - .await; - return; - } - }; - match tx.try_send(Ok(json.clone())) { - Ok(()) => {} - Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { - tokio::select! { - _ = effective.cancelled() => return, - res = tx.send(Ok(json.clone())) => { - if res.is_err() { return; } - } - } - } - Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => { - return; - } - } - } - } - Err(e) => { - // Connect failure: deliver as the first channel item - // (try_send + abort-select; mirrors the FFI driver). - match tx.try_send(Err(e.clone().into())) { - Ok(()) => {} - Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { - tokio::select! { - _ = effective.cancelled() => {} - res = tx.send(Err(e.clone().into())) => { - let _ = res; - } - } - } - Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {} - } - } - } - }); - - Ok(TranscriptionSession { - audio_tx: std::sync::Mutex::new(Some(audio_tx)), - parts_rx: tokio::sync::Mutex::new(rx), - token, - }) -} - -#[pymethods] -impl TranscriptionSession { - /// Push one binary audio chunk. Blocks while the internal channel is - /// full (backpressure). - #[pyo3(signature = (data,))] - pub fn push_audio(&self, py: pyo3::Python<'_>, data: &[u8]) -> PyResult<()> { - use futures::SinkExt; - // Clone the sender and drop the guard BEFORE blocking: the mutex is - // shared with the sync input_done()/close(), and blocking with the - // guard held (or with the GIL held) would freeze other Python - // threads — including one calling close() to abort. - let mut tx = { - let guard = self.audio_tx.lock().map_err(|_| { - binding_py_err(&BindingError::InvariantViolation { - message: "session poisoned".into(), - }) - })?; - match guard.as_ref() { - None => { - return Err(binding_py_err(&BindingError::InvalidHandle { - expected: "audio input (input_done() already called)", - })); - } - Some(tx) => tx.clone(), - } - }; - let chunk = AudioChunk::Binary(data.to_vec()); - py.allow_threads(|| crate::runtime().block_on(async move { tx.send(chunk).await })) - .map_err(|_| { - binding_py_err(&BindingError::InvalidHandle { - expected: "transcription session", - }) - }) - } - - /// Signal end-of-audio (idempotent). - pub fn input_done(&self) -> PyResult<()> { - self.audio_tx - .lock() - .map_err(|_| { - binding_py_err(&BindingError::InvariantViolation { - message: "session poisoned".into(), - }) - })? - .take(); - Ok(()) - } - - /// Pull the next transcription part (JSON string). Returns None when the - /// stream ended normally. Raises on error — including APITimeoutError - /// (an AimuxError subclass) when no part arrives within `timeout_ms` - /// (the session stays live, call again). `timeout_ms`: >0 wait at most; - /// 0 immediate poll; negative = wait indefinitely. - #[pyo3(signature = (timeout_ms=-1))] - pub fn next_part(&self, py: pyo3::Python<'_>, timeout_ms: i64) -> PyResult> { - let timeout = if timeout_ms >= 0 { - Some(std::time::Duration::from_millis(timeout_ms as u64)) - } else { - None - }; - enum Outcome { - Part(Option>), - TimedOut, - } - let outcome = py.allow_threads(|| { - crate::runtime().block_on(async { - let mut rx = self.parts_rx.lock().await; - let recv = rx.recv(); - match timeout { - Some(d) => match tokio::time::timeout(d, recv).await { - Ok(p) => Outcome::Part(p), - Err(_) => Outcome::TimedOut, - }, - None => Outcome::Part(recv.await), - } - }) - }); - let part = match outcome { - Outcome::Part(p) => p, - Outcome::TimedOut => { - return Err(to_py_err(&AiMuxError::Timeout( - "no transcription part within timeout".into(), - ))); - } - }; - match part { - Some(Ok(json)) => Ok(Some(json)), - Some(Err(e)) => Err(e.to_py_err()), - None => Ok(None), // ended normally - } - } - - /// Terminate the session (aborts the driver). Further push/next fail. - pub fn close(&self) { - if let Ok(mut guard) = self.audio_tx.lock() { - guard.take(); - } - self.token.abort(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A caller that passes any options at all must not trip `missing field` - /// on the data its explicit arguments already carry — that is what made - /// `maxRetries` / `timeout` unreachable from Python for these modalities. - #[test] - fn options_parse_without_the_fields_the_explicit_args_own() { - let transcription: TranscriptionCallOptions = opts_with_args( - r#"{"maxRetries":3,"timeout":{"totalMs":1000}}"#, - &transcription_args(), - ) - .expect("transcription options with no audio/mediaType"); - assert_eq!(transcription.max_retries, Some(3)); - assert_eq!(transcription.timeout.and_then(|t| t.total_ms), Some(1000)); - // aimux 0.5's snake_case keys fail naming the key instead of being - // dropped. - let error = opts_with_args::( - r#"{"max_retries":3}"#, - &transcription_args(), - ) - .unwrap_err(); - assert!( - error.to_string().contains("unknown field `max_retries`"), - "{error}" - ); - - let rerank: RerankingCallOptions = opts_with_args( - r#"{"topN":2,"maxRetries":1}"#, - &[ - ("query", serde_json::Value::from("")), - ("documents", documents_placeholder()), - ], - ) - .expect("rerank options with no query/documents"); - assert_eq!(rerank.top_n, Some(2)); - - // The placeholder is overwritten by the caller, so it must lose to - // whatever the explicit argument holds — never to the JSON. - let mut search: SearchCallOptions = opts_with_args( - r#"{"query":"dogs","maxResults":5}"#, - &[("query", serde_json::Value::from(""))], - ) - .expect("search options with no query"); - assert_eq!(search.max_results, Some(5)); - search.query = "cats".to_string(); - assert_eq!(search.query, "cats"); - } -} diff --git a/bindings/python/tests/cassette_replay.py b/bindings/python/tests/cassette_replay.py deleted file mode 100644 index 10f614a39..000000000 --- a/bindings/python/tests/cassette_replay.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Cassette replay server for Python tests. - -Reads real cassette JSON files from aimux-providers/tests/cassettes/ -and serves them via a local HTTP server with the same matching logic -as the Rust `common::replay` module. - -The server runs in a separate process (multiprocessing) because PyO3's -block_on blocks the calling thread. -""" - -import json -import os -import socket -import time -from http.server import HTTPServer, BaseHTTPRequestHandler -from multiprocessing import Process -from pathlib import Path - -CASSETTE_BASE = Path(__file__).resolve().parents[3] / "aimux-providers" / "tests" / "cassettes" - - -def _find_free_port(): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - -def _load_cassettes(provider): - """Load all cassette JSON files for a provider.""" - cdir = CASSETTE_BASE / provider - cassettes = [] - for f in sorted(cdir.glob("*.json")): - try: - raw = json.loads(f.read_text()) - body = raw.get("request", {}).get("body", {}) - if isinstance(body, str): - try: - body = json.loads(body) - except Exception: - body = {} - cassettes.append({ - "scenario": raw.get("scenario", f.name), - "req_path": raw.get("request", {}).get("path", "/"), - "req_method": raw.get("request", {}).get("method", "POST"), - "req_body": body if isinstance(body, dict) else {}, - "resp_status": raw.get("response", {}).get("status", 200), - "resp_headers": raw.get("response", {}).get("headers", {}), - "resp_body": raw.get("response", {}).get("body", ""), - }) - except Exception: - pass - return cassettes - - -def _score(cassette_body, req_body): - """Score a cassette against the incoming request body.""" - if not isinstance(cassette_body, dict) or not isinstance(req_body, dict): - return 0 - s = 0 - for key, val in cassette_body.items(): - if not isinstance(val, (str, bool, int, float)): - continue - if req_body.get(key) == val: - if key == "model": - s += 100 - elif key == "stream": - s += 10 - else: - s += 1 - return s - - -def _find_best_match(cassettes, method, path, req_body): - """Find the best matching cassette for a request.""" - group = [c for c in cassettes if c["req_method"] == method and c["req_path"] == path] - if not group: - return None - best = group[0] - best_score = _score(best["req_body"], req_body) - for c in group[1:]: - s = _score(c["req_body"], req_body) - if s > best_score: - best_score = s - best = c - return best - - -def _server_proc(port, cassettes): - """Mock HTTP server process.""" - - class Handler(BaseHTTPRequestHandler): - def do_POST(self): - body = self.rfile.read(int(self.headers.get("Content-Length", 0))) - try: - req_body = json.loads(body) - except Exception: - req_body = {} - - match = _find_best_match(cassettes, "POST", self.path, req_body) - if not match: - self.send_response(404) - self.send_header("content-type", "application/json") - self.end_headers() - self.wfile.write(json.dumps({"error": f"no cassette for POST {self.path}"}).encode()) - return - - resp = match["resp_body"].encode() if isinstance(match["resp_body"], str) else match["resp_body"] - self.send_response(match["resp_status"]) - for k, v in match["resp_headers"].items(): - self.send_header(k, v) - self.send_header("content-length", str(len(resp))) - self.end_headers() - self.wfile.write(resp) - - def do_GET(self): - match = _find_best_match(cassettes, "GET", self.path, {}) - if not match: - self.send_response(404) - self.send_header("content-type", "application/json") - self.end_headers() - self.wfile.write(json.dumps({"error": f"no cassette for GET {self.path}"}).encode()) - return - resp = match["resp_body"].encode() if isinstance(match["resp_body"], str) else match["resp_body"] - self.send_response(match["resp_status"]) - for k, v in match["resp_headers"].items(): - self.send_header(k, v) - self.send_header("content-length", str(len(resp))) - self.end_headers() - self.wfile.write(resp) - - def log_message(self, *args): - pass - - server = HTTPServer(("127.0.0.1", port), Handler) - server.serve_forever() - - -class CassetteServer: - """Context manager that starts a cassette replay server in a subprocess.""" - - def __init__(self, provider): - self.cassettes = _load_cassettes(provider) - if not self.cassettes: - raise RuntimeError(f"No cassettes found for provider: {provider}") - self.port = _find_free_port() - self.url = f"http://127.0.0.1:{self.port}" - self.proc = Process(target=_server_proc, args=(self.port, self.cassettes)) - - @property - def count(self): - return len(self.cassettes) - - def __enter__(self): - self.proc.start() - # Wait for server to be ready - for _ in range(50): - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.connect(("127.0.0.1", self.port)) - break - except ConnectionRefusedError: - time.sleep(0.05) - return self - - def __exit__(self, *args): - self.proc.terminate() - self.proc.join(timeout=2) diff --git a/bindings/python/tests/core-double/Cargo.toml b/bindings/python/tests/core-double/Cargo.toml new file mode 100644 index 000000000..ed9c03868 --- /dev/null +++ b/bindings/python/tests/core-double/Cargo.toml @@ -0,0 +1,10 @@ +# Test-only minimal core surface. The runner copies the REAL AbortSignal source. +[package] +name = "aimux-core" +version = "0.0.0" +edition = "2024" +[dependencies] +tokio-util = { version = "0.7", features = ["rt"] } +serde = "1" +serde_json = "1" +[workspace] diff --git a/bindings/python/tests/core-double/src/abort_signal.rs b/bindings/python/tests/core-double/src/abort_signal.rs new file mode 100644 index 000000000..c4ee01892 --- /dev/null +++ b/bindings/python/tests/core-double/src/abort_signal.rs @@ -0,0 +1,37 @@ +//! Cooperative caller cancellation for Core operations. + +use tokio_util::sync::CancellationToken; + +/// A cancellation signal analogous to the Web `AbortSignal`. +/// +/// Timeouts are deliberately not encoded as cancellation reasons. Core owns +/// timeout deadlines and returns [`crate::AiMuxError::Timeout`] directly; +/// this type represents only caller-requested cancellation. +#[derive(Debug, Clone, Default)] +pub struct AbortSignal { + token: CancellationToken, +} + +impl AbortSignal { + /// Create a fresh signal. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Cancel with the default reason. + pub fn abort(&self) { + self.token.cancel(); + } + + /// Whether cancellation has been requested. + #[must_use] + pub fn is_aborted(&self) -> bool { + self.token.is_cancelled() + } + + /// Resolve when cancellation is requested. + pub fn cancelled(&self) -> impl std::future::Future + Send + 'static { + self.token.clone().cancelled_owned() + } +} diff --git a/bindings/python/tests/core-double/src/lib.rs b/bindings/python/tests/core-double/src/lib.rs new file mode 100644 index 000000000..61b5fc2ed --- /dev/null +++ b/bindings/python/tests/core-double/src/lib.rs @@ -0,0 +1,28 @@ +//! Test-only error surface; not used for real-core integration builds. +//! abort_signal.rs is copied verbatim from aimux-core by the test runner. +mod abort_signal; +pub use abort_signal::AbortSignal; + +#[derive(Debug)] +pub enum AiMuxError { + Aborted(String), +} + +impl std::fmt::Display for AiMuxError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Aborted(message) => formatter.write_str(message), + } + } +} + +impl serde::Serialize for AiMuxError { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Aborted(message) => serde::Serialize::serialize( + &serde_json::json!({"name":"AbortError", "message":message}), + serializer, + ), + } + } +} diff --git a/bindings/python/tests/gil_ops.py b/bindings/python/tests/gil_ops.py new file mode 100644 index 000000000..c38aca7ea --- /dev/null +++ b/bindings/python/tests/gil_ops.py @@ -0,0 +1,202 @@ +"""Blocking-entry inventory for the shared-ops Python API.""" +from contextlib import contextmanager +import json +import multiprocessing +import os +from pathlib import Path +import tempfile +import time + +from aimux import ops +from ops_fixtures import ( + OPENAI_RESPONSES, OPENAI_RESPONSES_STREAM, EMBEDDING_RESP, SPEECH_RESP, IMAGE_RESP, + TRANSCRIPTION_RESP, RERANKING_RESP, SEARCH_RESP, FILES_RESP, slow_http, slow_ws, +) + + +@contextmanager +def model_at(url, method="languageModel", model_id="test-model", package="openai"): + import aimux + client = aimux.Client() + with client.adopt(client.call(ops.OP_PROVIDER_CREATE, { + "package": package, "settings": {"apiKey": "test-key", "baseUrl": url}, + })["handle"]) as provider: + result = provider.call(ops.OP_PROVIDER_FILES) if method == "files" else provider.call( + ops.OP_PROVIDER_MODEL, {"method": method, "modelId": model_id}, + ) + with client.adopt(result if isinstance(result, str) else result["handle"]) as model: + yield model + + +@contextmanager +def language(op): + streaming = op in (ops.OP_TEXT_STREAM, ops.OP_TEXT_STREAM_OPEN_AI, ops.OP_TEXT_CONSUME) + body = OPENAI_RESPONSES_STREAM if streaming else OPENAI_RESPONSES + options = {"maxRetries": 0, "timeout": {"totalMs": 5000}} + if op == ops.OP_TEXT_GENERATE_OBJECT: + body = json.loads(OPENAI_RESPONSES) + body["output"][0]["content"][0]["text"] = '{"answer":"hello"}' + body = json.dumps(body) + options["output"] = {"type": "object", "schema": { + "type": "object", "properties": {"answer": {"type": "string"}}, + "required": ["answer"], "additionalProperties": False, + }} + with slow_http(body, "text/event-stream" if streaming else "application/json") as url: + with model_at(url, model_id="gpt-4o") as model: + params = {"prompt": "hi", "options": options} + if op in (ops.OP_TEXT_STREAM, ops.OP_TEXT_STREAM_OPEN_AI): + with model.stream(op, params) as events: + yield lambda: list(events) + else: + yield lambda: model.call(op, params) + + +MODALITIES = { + "embedding": ("openai", "embeddingModel", "text-embedding-3-small", ops.OP_EMBEDDING_EMBED, {"values": ["hello"]}, EMBEDDING_RESP, "application/json"), + "embedding_many": ("openai", "embeddingModel", "text-embedding-3-small", ops.OP_EMBEDDING_EMBED_MANY, {"values": ["hello"]}, EMBEDDING_RESP, "application/json"), + "speech": ("openai", "speechModel", "tts-1", ops.OP_SPEECH_GENERATE, {"text": "Hi", "voice": "alloy", "outputFormat": "mp3"}, SPEECH_RESP, "audio/mpeg"), + "image": ("openai", "imageModel", "dall-e-3", ops.OP_IMAGE_GENERATE, {"prompt": "otter", "n": 1, "providerOptions": {}}, IMAGE_RESP, "application/json"), + "transcription": ("openai", "transcriptionModel", "whisper-1", ops.OP_TRANSCRIPTION_GENERATE, {"audio": "dGVzdA==", "mediaType": "audio/mp3"}, TRANSCRIPTION_RESP, "application/json"), + "reranking": ("cohere", "rerankingModel", "rerank-v3.0", ops.OP_RERANKING_RERANK, {"query": "which?", "documents": {"type": "text", "values": ["a", "b"]}}, RERANKING_RESP, "application/json"), + "search": ("tavily", "searchModel", "tavily", ops.OP_SEARCH_SEARCH, {"query": "Rust?"}, SEARCH_RESP, "application/json"), + "upload_file": ("openai", "files", "", ops.OP_FILES_UPLOAD, {"data": {"type": "data", "data": "dGVzdA=="}, "mediaType": "application/pdf"}, FILES_RESP, "application/json"), +} + + +@contextmanager +def modality(name): + package, method, model_id, op, params, body, content_type = MODALITIES[name] + # files.upload has no retry/timeout controls in its canonical DTO. + if method != "files": params = dict(params, maxRetries=0, timeout={"totalMs": 5000}) + with slow_http(body, content_type) as url: + with model_at(url, method, model_id, package) as model: + yield lambda: model.call(op, params) + + +@contextmanager +def video(): + # Inline bytes keep the result off core's download path, whose + # non-public-address guard refuses the localhost fixture. + body = { + ":predictLongRunning": json.dumps({"name": "operations/gil"}), + ":fetchPredictOperation": json.dumps({"done": True, "response": {"videos": [ + {"bytesBase64Encoded": "dGVzdA==", "mimeType": "video/mp4"}, + ]}}), + } + with slow_http(body) as url: + with model_at(url, "videoModel", "veo-3.0-generate-001", "google_vertex") as model: + yield lambda: model.call(ops.OP_VIDEO_GENERATE, {"prompt": "otter", "n": 1, + "providerOptions": {}, "maxRetries": 0, "poll": {"intervalMs": 1, "timeoutMs": 5000}}) + + +@contextmanager +def decision(): + fixture = Path(__file__).resolve().parents[3] / "aimux-providers/tests/fixtures/openai_decisions.json" + contract = json.loads(fixture.read_text()) + with slow_http(json.dumps(contract["response"])) as url: + with model_at(url, "decision", "gpt-4o") as model: + yield lambda: model.call(ops.OP_DECISION_DECIDE, {"options": contract["options"]}) + + +@contextmanager +def discovery(catalogue=False): + import aimux + if catalogue: + import pytest + # catalogue.fetch refuses loopback sources before any I/O, so no local + # fixture can make it block; it shares Client.call with list_models. + pytest.skip("catalogue.fetch refuses non-public sources; same native call path as list_models") + body = json.dumps({"data": [{"id": "gpt-4o", "object": "model"}]}) + with slow_http(body) as url: + client = aimux.Client() + with client.adopt(client.call(ops.OP_PROVIDER_CREATE, { + "package": "openai", "settings": {"apiKey": "test-key", "baseUrl": url}, + })["handle"]) as provider: + yield lambda: provider.call(ops.OP_PROVIDER_INVOKE, {"method": "listModels"}) + + +@contextmanager +def session(): + with slow_ws() as url: + with model_at(url, "transcriptionModel", "gpt-realtime-whisper") as model: + with model.client.adopt(model.call(ops.OP_TRANSCRIPTION_START)) as operation: + yield lambda: operation.call(ops.OP_OPERATION_NEXT, {"timeoutMs": 5000}) + + +def _fifo_reader(path, opened, drain): + if not opened.wait(15): raise RuntimeError("FIFO open gate timed out") + time.sleep(0.5) + with open(path, "rb", buffering=0) as reader: + if not drain.wait(15): raise RuntimeError("FIFO drain gate timed out") + time.sleep(0.5) + while reader.read(65536): pass + + +@contextmanager +def recording(method): + import aimux + if not hasattr(os, "mkfifo"): + import pytest + pytest.skip("slow recording I/O requires POSIX FIFOs") + with tempfile.TemporaryDirectory(prefix="aimux-gil-") as directory: + path = Path(directory) / "recordings.jsonl" + os.mkfifo(path) + opened, drain = multiprocessing.Event(), multiprocessing.Event() + reader = multiprocessing.Process(target=_fifo_reader, args=(str(path), opened, drain)) + reader.start() + client, recorder = aimux.Client(), None + params = {"backend": {"type": "jsonl", "directory": directory}, "capturePolicy": "full"} + try: + if method == "create": + def invoke(): + nonlocal recorder + opened.set() + recorder = client.adopt(client.call(ops.OP_RECORDER_CREATE, params)["handle"]) + return recorder + yield invoke + else: + opened.set() + recorder = client.adopt(client.call(ops.OP_RECORDER_CREATE, params)["handle"]) + recorder.call(ops.OP_RECORDER_ATTACH) + body = json.loads(OPENAI_RESPONSES) + body["output"][0]["content"][0]["text"] = "x" * (2 * 1024 * 1024) + with slow_http(json.dumps(body), delay=0) as url: + with model_at(url) as model: + model.call(ops.OP_TEXT_GENERATE, {"prompt": "hi", "options": {"maxRetries": 0}}) + if method == "close": recorder.call(ops.OP_RECORDER_STOP) + def invoke(): + nonlocal recorder + drain.set() + if method != "close": + return recorder.call(ops.OP_RECORDER_FLUSH) + # A closed handle is gone; teardown must not stop it again. + closing, recorder = recorder, None + return closing.close() + yield invoke + finally: + opened.set() + drain.set() + if recorder is not None: + recorder.call(ops.OP_RECORDER_STOP) + recorder.close() + reader.join(timeout=2) + if reader.is_alive(): reader.terminate(); reader.join(timeout=2) + + +ENTRY_POINTS = { + "text_generate": ("generate", lambda: language(ops.OP_TEXT_GENERATE)), + "text_object": ("generate", lambda: language(ops.OP_TEXT_GENERATE_OBJECT)), + "text_openai": ("generate", lambda: language(ops.OP_TEXT_GENERATE_OPEN_AI)), + "text_consume": ("generate", lambda: language(ops.OP_TEXT_CONSUME)), + "stream_next": ("poll", lambda: language(ops.OP_TEXT_STREAM)), + "stream_openai_next": ("poll", lambda: language(ops.OP_TEXT_STREAM_OPEN_AI)), + **{name: ("generate", lambda name=name: modality(name)) for name in MODALITIES}, + "video": ("generate", video), + "decision": ("generate", decision), + "list_models": ("discovery", discovery), + "catalogue": ("discovery", lambda: discovery(True)), + "operation_next": ("poll", session), + "recorder_create": ("construction_io", lambda: recording("create")), + "recorder_flush": ("flush", lambda: recording("flush")), + "recorder_close": ("join", lambda: recording("close")), +} diff --git a/bindings/python/tests/ops-double/Cargo.toml b/bindings/python/tests/ops-double/Cargo.toml new file mode 100644 index 000000000..a9b5d4e68 --- /dev/null +++ b/bindings/python/tests/ops-double/Cargo.toml @@ -0,0 +1,12 @@ +# Test-only dispatcher double. Never a production dependency. +[package] +name = "aimux-ops" +version = "0.0.0" +edition = "2024" +[dependencies] +aimux-core = { path = "../../../../aimux-core" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +futures = "0.3" +tokio = { version = "1", features = ["full"] } +[workspace] diff --git a/bindings/python/tests/ops-double/src/lib.rs b/bindings/python/tests/ops-double/src/lib.rs new file mode 100644 index 000000000..8daeba90c --- /dev/null +++ b/bindings/python/tests/ops-double/src/lib.rs @@ -0,0 +1,302 @@ +//! Test-only implementation of the fixed surface consumed by the Python adapter. +//! No provider or production ops behavior is asserted by this double. +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::sync::{ + Mutex, OnceLock, + atomic::{AtomicU64, AtomicUsize, Ordering}, +}; +use std::time::SystemTime; + +pub mod handle { + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct HandleId(pub u64); + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum Owner { + CProcess, + Node(u64), + Python(u64), + } + impl Owner { + pub fn new_python() -> Result { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + Ok(Self::Python( + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + )) + } + } + pub struct HandleRegistry; + impl HandleRegistry { + pub fn global() -> &'static Self { + &Self + } + pub fn release_owner(&self, owner: Owner) -> usize { + let mut handles = super::handles().lock().unwrap(); + let before = handles.len(); + handles.retain(|_, value| *value != owner); + before - handles.len() + } + } +} +pub mod op { + pub const OPS_VERSION: &str = "1.0"; + pub const ABI_VERSION: u32 = 1 << 16; +} +pub mod envelope { + use super::*; + #[derive(Clone, Debug, serde::Serialize)] + #[serde(rename_all = "camelCase")] + pub struct ErrorEnvelope { + pub kind: String, + pub code: i32, + pub message: String, + pub error: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_after_ms: Option, + } + #[derive(Debug)] + pub enum BindingError { + InvalidWireJson { argument: String, message: String }, + ResultSerialization(String), + CallbackFailure(String), + } + #[derive(Debug)] + pub enum ProtocolError { + InvalidParams(String), + InvalidState(String), + ResourceLimit(String), + ConcurrentConsumer, + } + #[derive(Debug)] + pub enum OpsError { + AiMux(aimux_core::AiMuxError), + Binding(BindingError), + Protocol(ProtocolError), + Fixture(ErrorEnvelope), + } + impl OpsError { + pub fn envelope(&self, _now: SystemTime) -> ErrorEnvelope { + let (kind, code, error) = match self { + Self::AiMux(error) => ("aimux", 18, serde_json::to_value(error).unwrap()), + Self::Binding(BindingError::InvalidWireJson { argument, message }) => ( + "binding", + 202, + json!({"name":"InvalidWireJson", "argument":argument,"message":message}), + ), + Self::Binding(BindingError::ResultSerialization(message)) => ( + "binding", + 205, + json!({"name":"ResultSerialization","message":message}), + ), + Self::Binding(BindingError::CallbackFailure(message)) => ( + "binding", + 206, + json!({"name":"CallbackFailure","message":message}), + ), + Self::Protocol(error) => { + let (name, code) = match error { + ProtocolError::InvalidParams(_) => ("InvalidParams", 301), + ProtocolError::InvalidState(_) => ("InvalidState", 304), + ProtocolError::ResourceLimit(_) => ("ResourceLimit", 305), + ProtocolError::ConcurrentConsumer => ("ConcurrentConsumer", 303), + }; + ( + "protocol", + code, + json!({"name":name,"message":format!("{error:?}")}), + ) + } + Self::Fixture(error) => return error.clone(), + }; + ErrorEnvelope { + kind: kind.into(), + code, + message: error.to_string(), + error, + retry_after_ms: None, + } + } + } +} +pub mod frame { + use super::envelope::ErrorEnvelope; + #[derive(serde::Serialize)] + #[serde(tag = "type", rename_all = "camelCase")] + pub enum StreamFrame { + Event { + seq: String, + event: serde_json::Value, + }, + End { + seq: String, + outcome: Outcome, + error: Option, + }, + } + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + pub enum Outcome { + Success, + Error, + Cancelled, + } +} +pub mod manifest { + use super::*; + pub fn manifest() -> Value { + json!({"abiVersion":op::ABI_VERSION,"opsVersion":op::OPS_VERSION, + "manifestRevision":"test-double","ops":[],"handleKinds":[],"errorCodes":[]}) + } + pub fn manifest_digest() -> String { + "isolated-test-double".into() + } +} +static NEXT: AtomicU64 = AtomicU64::new(1); +static ACTIVE: AtomicUsize = AtomicUsize::new(0); +fn handles() -> &'static Mutex> { + static HANDLES: OnceLock>> = OnceLock::new(); + HANDLES.get_or_init(Mutex::default) +} +fn fixture_error(code: i32, kind: &str, error: Value) -> envelope::ErrorEnvelope { + envelope::ErrorEnvelope { + kind: kind.into(), + code, + message: "fixture failure".into(), + error, + retry_after_ms: None, + } +} +pub mod dispatch { + pub const HANDLE_DROP: &str = "handle.drop"; + + pub fn operation_push( + _ctx: &Ctx, + _handle: super::handle::HandleId, + _data: &[u8], + ) -> Result<(), super::envelope::OpsError> { + Ok(()) + } + + use super::*; + use envelope::{OpsError, ProtocolError}; + use frame::{Outcome, StreamFrame}; + pub struct Ctx { + pub owner: handle::Owner, + pub abort: Option, + pub now: fn() -> SystemTime, + } + pub type FrameStream = std::pin::Pin + Send>>; + fn validate(ctx: &Ctx, id: handle::HandleId) -> Result<(), OpsError> { + if id.0 != 0 && handles().lock().unwrap().get(&id.0) != Some(&ctx.owner) { + return Err(OpsError::Fixture(fixture_error( + 203, + "binding", + json!({"name":"InvalidHandle","expected":[],"actual":null}), + ))); + } + Ok(()) + } + pub async fn call( + ctx: Ctx, + id: handle::HandleId, + op: &str, + params: Value, + ) -> Result { + if op == HANDLE_DROP { + let mut handles = handles().lock().unwrap(); + if handles.get(&id.0) == Some(&ctx.owner) { + handles.remove(&id.0); + } + return Ok(Value::Null); + } + validate(&ctx, id)?; + if op == "test.stats" { + return Ok( + json!({"active":ACTIVE.load(Ordering::SeqCst),"handles":handles().lock().unwrap().len()}), + ); + } + tokio::time::sleep(std::time::Duration::from_millis( + params["delayMs"].as_u64().unwrap_or(160), + )) + .await; + if let Some(payload) = params.get("failure") { + return Err(OpsError::Fixture(fixture_error( + 1, + "aimux", + payload.clone(), + ))); + } + if op == "provider.create" || op == "provider.model" || op == "recorder.create" { + let id = NEXT.fetch_add(1, Ordering::SeqCst); + handles().lock().unwrap().insert(id, ctx.owner); + return Ok(json!({"handle":id.to_string()})); + } + Ok( + json!({"op":op,"params":params,"owner":match ctx.owner { handle::Owner::Python(id)=>id,_=>u64::MAX }}), + ) + } + struct StreamGuard(u64); + impl Drop for StreamGuard { + fn drop(&mut self) { + std::thread::sleep(std::time::Duration::from_millis(self.0)); + ACTIVE.fetch_sub(1, Ordering::SeqCst); + } + } + pub fn stream( + ctx: Ctx, + id: handle::HandleId, + _op: &str, + params: Value, + ) -> Result { + validate(&ctx, id)?; + std::thread::sleep(std::time::Duration::from_millis( + params["acceptDelayMs"].as_u64().unwrap_or(0), + )); + if params["reject"].as_bool() == Some(true) { + return Err(OpsError::Protocol(ProtocolError::InvalidParams( + "rejected before acceptance".into(), + ))); + } + ACTIVE.fetch_add(1, Ordering::SeqCst); + let guard = StreamGuard(params["dropDelayMs"].as_u64().unwrap_or(0)); + let count = params["count"].as_u64().unwrap_or(2); + let delay = params["delayMs"].as_u64().unwrap_or(0); + let outcome = params["outcome"].as_str().unwrap_or("success").to_owned(); + let size = params["bytes"].as_u64().unwrap_or(0) as usize; + let recoverable = params["recoverableError"].as_bool().unwrap_or(false); + Ok(Box::pin(futures::stream::unfold( + (0, guard, outcome), + move |(seq, guard, outcome)| async move { + if seq > count || (seq == count && outcome == "missing") { + return None; + } + tokio::time::sleep(std::time::Duration::from_millis(delay)).await; + let frame = if seq < count { + StreamFrame::Event { + seq: seq.to_string(), + event: json!({"type":if recoverable { "error" } else { "text-delta" },"text":"x".repeat(size),"index":seq}), + } + } else { + let (outcome, error) = match outcome.as_str() { + "error" => ( + Outcome::Error, + Some(fixture_error( + 1, + "aimux", + json!({"name":"AI_APICallError","message":"failed","statusCode":429,"isRetryable":true}), + )), + ), + "cancelled" => (Outcome::Cancelled, None), + _ => (Outcome::Success, None), + }; + StreamFrame::End { + seq: seq.to_string(), + outcome, + error, + } + }; + Some((frame, (seq + 1, guard, outcome))) + }, + ))) + } +} diff --git a/bindings/python/tests/ops_fixtures.py b/bindings/python/tests/ops_fixtures.py new file mode 100644 index 000000000..b5404e829 --- /dev/null +++ b/bindings/python/tests/ops_fixtures.py @@ -0,0 +1,243 @@ +"""Synthetic provider replies and independent HTTP/WS servers for binding tests.""" +import base64 +import hashlib +import json +import multiprocessing +import socket +import time +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, HTTPServer + +# The OpenAI provider's default languageModel uses the Responses API. +OPENAI_RESPONSES = json.dumps({ + "id": "resp-test", + "object": "response", + "model": "gpt-4o", + "status": "completed", + "output": [{ + "id": "msg-test", "type": "message", "role": "assistant", "status": "completed", + "content": [{"type": "output_text", "text": "Rust is a systems programming language.", + "annotations": []}], + }], + "usage": {"input_tokens": 10, "output_tokens": 8, "total_tokens": 18}, +}) + +OPENAI_RESPONSES_STREAM = "".join( + f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" for event in ( + {"type": "response.created", "response": { + "id": "resp-test", "model": "gpt-4o", "created_at": 1234, + }}, + {"type": "response.output_item.added", "output_index": 0, "item": { + "id": "msg-test", "type": "message", "role": "assistant", "content": [], + }}, + {"type": "response.output_text.delta", "output_index": 0, + "item_id": "msg-test", "content_index": 0, "delta": "Hello"}, + {"type": "response.output_text.delta", "output_index": 0, + "item_id": "msg-test", "content_index": 0, "delta": " world"}, + {"type": "response.output_item.done", "output_index": 0, "item": { + "id": "msg-test", "type": "message", "role": "assistant", "status": "completed", + "content": [{"type": "output_text", "text": "Hello world", "annotations": []}], + }}, + {"type": "response.completed", "response": { + "id": "resp-test", "status": "completed", + "usage": {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7}, + }}, + ) +) + +EMBEDDING_RESP = json.dumps({ + "data": [{"embedding": [0.1, 0.2, 0.3], "index": 0}], + "model": "text-embedding-3-small", + "usage": {"prompt_tokens": 3, "total_tokens": 3}, +}) + +SPEECH_RESP = b"ID3\x04\x00fake-mp3-audio-payload" + +IMAGE_RESP = json.dumps({"data": [{"b64_json": "aW1hZ2Ux"}]}) + +TRANSCRIPTION_RESP = json.dumps({"text": "Hello world"}) + +RERANKING_RESP = json.dumps({ + "results": [ + {"index": 1, "relevance_score": 0.95}, + {"index": 0, "relevance_score": 0.3}, + ], +}) + +SEARCH_RESP = json.dumps({ + "results": [ + {"title": "Rust", "url": "https://rust-lang.org", "content": "Rust is..."}, + ], + "answer": "Rust is a systems language.", +}) + +FILES_RESP = json.dumps({ + "id": "file-abc", + "object": "file", + "bytes": 1024, + "created_at": 1234, + "filename": "test.pdf", + "purpose": "assistants", +}) + +WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +def _text_frame(payload: str) -> bytes: + data = payload.encode() + assert len(data) < 126, "extend the codec for long payloads" + return bytes([0x81, len(data)]) + data + +def _close_frame(code: int, reason: str) -> bytes: + data = code.to_bytes(2, "big") + reason.encode() + return bytes([0x88, len(data)]) + data + +def _handshake(sock: socket.socket) -> None: + buf = b"" + while b"\r\n\r\n" not in buf: + chunk = sock.recv(4096) + if not chunk: + raise EOFError("client vanished mid-handshake") + buf += chunk + key = None + for line in buf.decode("latin-1").split("\r\n"): + if line.lower().startswith("sec-websocket-key:"): + key = line.split(":", 1)[1].strip() + if key is None: + raise ValueError("no Sec-WebSocket-Key in request") + accept = base64.b64encode( + hashlib.sha1((key + WS_GUID).encode()).digest() + ).decode() + sock.sendall( + ( + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Accept: {accept}\r\n" + "\r\n" + ).encode() + ) + +def _ws_server_proc(port, script): + """Fake realtime server. `script` is a list of (delay_ms, action) tuples + (relative to handshake completion); action is ("event", json_text) or + ("close", code, reason). Client frames are masked and simply drained. + Readiness probes (bare TCP connects from the test process) are skipped. + """ + import time as _time + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", port)) + listener.listen(1) + listener.settimeout(10) + while True: + conn, _ = listener.accept() + with conn: + try: + _handshake(conn) + except (EOFError, ValueError, OSError): + # Readiness probe (closes without speaking WS) — skip. + continue + start = _time.monotonic() + for delay_ms, action in script: + target = start + delay_ms / 1000 + # Drain client frames (masked; content irrelevant). + while _time.monotonic() < target: + conn.settimeout(max(0.01, target - _time.monotonic())) + try: + if not conn.recv(65536): + return + except socket.timeout: + pass + if action[0] == "event": + conn.sendall(_text_frame(action[1])) + elif action[0] == "close": + conn.sendall(_close_frame(action[1], action[2])) + else: + raise ValueError(f"unknown action {action!r}") + # Hold the socket open after the script — the client drives + # the teardown — so a silent gap is never an accidental + # disconnect. + conn.settimeout(10) + try: + while conn.recv(65536): + pass + except OSError: + pass + return + +DELTA_EVENT = ( + '{"type":"conversation.item.input_audio_transcription.delta",' + '"item_id":"i","delta":"he"}' +) + + +def _http_server(connection, body, content_type, delay, status): + class Handler(BaseHTTPRequestHandler): + def respond(self): + if self.command == "POST": + self.rfile.read(int(self.headers.get("Content-Length", 0))) + time.sleep(delay) + # A dict body is keyed by request path suffix first, then by method. + payload = next((value for key, value in body.items() if self.path.endswith(key)), + body.get(self.command)) if isinstance(body, dict) else body + if isinstance(payload, str): payload = payload.encode() + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + try: self.wfile.write(payload) + except (BrokenPipeError, ConnectionResetError): pass + do_POST = respond + do_GET = respond + def log_message(self, *_): pass + with HTTPServer(("127.0.0.1", 0), Handler) as server: + connection.send(server.server_address[1]) + server.serve_forever() + + +@contextmanager +def slow_http(body=OPENAI_RESPONSES, content_type="application/json", delay=0.5, status=200): + parent, child = multiprocessing.Pipe() + process = multiprocessing.Process(target=_http_server, args=(child, body, content_type, delay, status)) + process.start() + try: + if not parent.poll(5): + raise RuntimeError("HTTP fixture could not bind its localhost socket") + port = parent.recv() + yield "http://127.0.0.1:%d" % port + finally: + process.terminate() + process.join(timeout=2) + if process.is_alive(): process.kill(); process.join(timeout=2) + parent.close() + child.close() + + +def _delayed_ws(port, delay): + original = _handshake + def delayed(sock): + time.sleep(delay) + original(sock) + globals()["_handshake"] = delayed + _ws_server_proc(port, [(0, ("event", DELTA_EVENT))]) + + +@contextmanager +def slow_ws(delay=0.5): + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + process = multiprocessing.Process(target=_delayed_ws, args=(port, delay)) + process.start() + try: + for _ in range(100): + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.1): break + except ConnectionRefusedError: time.sleep(0.01) + else: raise RuntimeError("WS fixture did not start") + yield "http://127.0.0.1:%d" % port + finally: + process.terminate() + process.join(timeout=2) + if process.is_alive(): process.kill(); process.join(timeout=2) diff --git a/bindings/python/tests/run_native_contract.py b/bindings/python/tests/run_native_contract.py new file mode 100644 index 000000000..2a64608b3 --- /dev/null +++ b/bindings/python/tests/run_native_contract.py @@ -0,0 +1,70 @@ +"""Build an isolated native adapter with a dispatcher double, then run tests. + +The production manifest is never rewritten. Set AIMUX_BUILD_COMMAND to override +the build executable. This suite verifies the PyO3 boundary independently of +provider behavior. +""" +import json +import os +from pathlib import Path +import shutil +import signal +import shlex +import subprocess +import sys +import tempfile + +binding = Path(__file__).resolve().parents[1] +repo = binding.parents[1] +with tempfile.TemporaryDirectory(prefix="aimux-python-contract-") as folder: + root = Path(folder) + copied = root / "binding" + shutil.copytree(binding, copied, ignore=shutil.ignore_patterns("target", "__pycache__", "Cargo.lock", "*.so")) + double = root / "ops-double" + shutil.copytree(binding / "tests/ops-double", double) + core = repo / 'aimux-core' + if os.environ.get('AIMUX_CORE_DOUBLE') == '1': + core = root / 'core-double' + shutil.copytree(binding / 'tests/core-double', core) + shutil.copyfile(repo / 'aimux-core/src/abort_signal.rs', core / 'src/abort_signal.rs') + print('BOUNDARY MODE: core error double + real AbortSignal source; real core is unverified.', flush=True) + manifest = copied / "Cargo.toml" + text = manifest.read_text().replace('../../aimux-core', str(core)) + text = text.replace('../../aimux-ops', str(double)) + manifest.write_text(text) + double_manifest = double / "Cargo.toml" + double_manifest.write_text(double_manifest.read_text().replace('../../../../aimux-core', str(core))) + pin = copied / "python/aimux/ops.py" + pin.write_text(pin.read_text().replace( + next(line for line in pin.read_text().splitlines() if line.startswith("MANIFEST_DIGEST = ")), + 'MANIFEST_DIGEST = "isolated-test-double"', + )) + env = dict(os.environ, PYO3_PYTHON=sys.executable) + command = shlex.split(env.get('AIMUX_BUILD_COMMAND', 'cargo')) + ['build', '--manifest-path', str(manifest), '--message-format=json'] + if env.get('AIMUX_CONTRACT_TARGET'): + command.extend(['--target-dir', env['AIMUX_CONTRACT_TARGET']]) + process = subprocess.Popen(command, env=env, text=True, stdout=subprocess.PIPE, start_new_session=True) + try: + output, _ = process.communicate(timeout=1200) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGTERM) + process.communicate() + raise + artifacts = [json.loads(line) for line in output.splitlines() if line.startswith('{')] + for item in artifacts: + if item.get('reason') == 'compiler-message': + print(item['message'].get('rendered', ''), end='') + if process.returncode: + sys.exit(process.returncode) + libraries = [path for item in artifacts if item.get('reason') == 'compiler-artifact' and item['target']['name'] == 'aimux' + for path in item['filenames'] if path.endswith(('.dylib', '.so', '.dll'))] + if len(libraries) != 1: + raise RuntimeError('expected exactly one native library, got %r' % libraries) + package = copied / 'python/aimux' + shutil.copyfile(libraries[0], package / 'aimux.abi3.so') + env['PYTHONPATH'] = str(copied / 'python') + env['AIMUX_OPS_DOUBLE'] = '1' + env.pop('AIMUX_REAL_OPS', None) + env.pop('AIMUX_HTTP_TESTS', None) + subprocess.run([sys.executable, '-m', 'unittest', 'discover', '-s', str(binding / 'tests'), '-p', 'test_ops_*.py', '-v'], + env=env, check=True) diff --git a/bindings/python/tests/run_real.py b/bindings/python/tests/run_real.py new file mode 100644 index 000000000..c4985a78f --- /dev/null +++ b/bindings/python/tests/run_real.py @@ -0,0 +1,23 @@ +"""Run Python tests against an already-built production PyO3 cdylib. + +Usage: python3 tests/run_real.py /absolute/path/to/libaimux.dylib +Set AIMUX_HTTP_TESTS=1 to include independent localhost provider fixtures. +This development loader does not replace wheel installation verification. +""" +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +binding = Path(__file__).resolve().parents[1] +with tempfile.TemporaryDirectory(prefix='aimux-python-real-') as folder: + package = Path(folder) / 'aimux' + shutil.copytree(binding / 'python/aimux', package, ignore=shutil.ignore_patterns('__pycache__', '*.so')) + filename = 'aimux.pyd' if sys.platform == 'win32' else 'aimux.abi3.so' + shutil.copyfile(sys.argv[1], package / filename) + env = dict(os.environ, PYTHONPATH=folder, AIMUX_REAL_OPS='1') + env.pop('AIMUX_OPS_DOUBLE', None) + subprocess.run([sys.executable, '-m', 'unittest', 'discover', '-s', str(binding / 'tests'), + '-p', 'test_ops_*.py', '-v', *sys.argv[2:]], env=env, check=True, timeout=120) diff --git a/bindings/python/tests/test_aimux.py b/bindings/python/tests/test_aimux.py deleted file mode 100644 index ccb1bb8ce..000000000 --- a/bindings/python/tests/test_aimux.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Tests for aimux Python binding. - -These tests do NOT make real API calls — they test the module surface -and error handling. -""" - -import json -import pytest - - -def test_module_imports(): - """Native module loads and exports functions.""" - from aimux import openai, anthropic, deepseek, provider, generate_text, stream_text - - assert callable(openai) - assert callable(anthropic) - assert callable(deepseek) - assert callable(provider) - assert callable(generate_text) - assert callable(stream_text) - - -def test_provider_creates_registry_model(): - """provider() creates a registry model instance (RFC-0017 phase 4).""" - from aimux import provider - - model = provider("groq", "sk-test-fake-key", "llama-3.3-70b") - assert model is not None - assert hasattr(model, "generate_text") - assert hasattr(model, "stream_text") - - -def test_provider_unknown_name_raises(): - """provider() rejects unknown names, naming the one that did not resolve.""" - from aimux import provider - - with pytest.raises(Exception, match="no-such-provider"): - provider("no-such-provider", "k", "m") - - -def test_provider_accepts_full_config(): - """provider() accepts a full ProviderOptions config dict (RFC-0017 §3.4).""" - from aimux import provider - - model = provider( - "groq", - "sk-test-fake-key", - "llama-3.3-70b", - config={ - "base_url": "https://example.com/v1", - "headers": {"X-Custom": "1"}, - "organization": "org-1", - "project": "proj-1", - }, - ) - assert model is not None - assert hasattr(model, "generate_text") - - -@pytest.mark.parametrize( - "key,value", - [("max_retries", 0), ("body_overrides", {"temperature": 0.1})], -) -def test_provider_rejects_removed_config_keys(key, value): - """max_retries is call-level and body_overrides is gone: neither is silently ignored.""" - from aimux import provider - - with pytest.raises(Exception, match=key): - provider("groq", "sk-test-fake-key", "llama-3.3-70b", config={key: value}) - - -@pytest.mark.parametrize( - "key,value", - [("max_retries", 0), ("body_overrides", {"temperature": 0.1})], -) -def test_create_provider_rejects_removed_config_keys(key, value): - """create_provider() goes through the same ProviderOptions check.""" - from aimux import InvalidArgumentError, create_provider - - with pytest.raises(InvalidArgumentError, match=key): - create_provider("groq", "sk-test-fake-key", config={key: value}) - - -def test_provider_base_url_param_wins_over_config(): - """Explicit base_url parameter overrides config["base_url"].""" - from aimux import provider - - model = provider( - "groq", - "sk-test-fake-key", - "llama-3.3-70b", - base_url="https://param.example.com/v1", - config={"base_url": "https://config.example.com/v1"}, - ) - assert model is not None - - -def test_provider_invalid_config_raises(): - """provider() rejects a config with wrong field types.""" - from aimux import provider - - with pytest.raises(Exception, match="invalid config"): - provider( - "abacus", - "sk-test-fake-key", - "llama-3.3-70b", - config={"headers": "not-a-map"}, - ) - - -def test_provider_missing_env_key_raises(monkeypatch): - """provider() with api_key=None reads the env var at call time and fails clearly when unset.""" - from aimux import LoadAPIKeyError, generate_text, provider - - monkeypatch.delenv("ABACUS_API_KEY", raising=False) - model = provider("abacus", None, "m") - with pytest.raises(LoadAPIKeyError, match="(?i)api key"): - generate_text(model, "hi", {"maxRetries": 0}) - - -def test_provider_params_fill_a_preset_template(monkeypatch): - """config["params"] reaches the preset's template parameters.""" - from aimux import InvalidArgumentError, generate_text, provider - - monkeypatch.delenv("CLOUDFLARE_ACCOUNT_ID", raising=False) - # Without the parameter the preset cannot build its base URL (reported at call time). - unfilled = provider("cloudflare_workers_ai", "sk-test-fake-key", "m") - with pytest.raises(InvalidArgumentError, match="account_id"): - generate_text(unfilled, "hi", {"maxRetries": 0}) - model = provider( - "cloudflare_workers_ai", - "sk-test-fake-key", - "m", - config={"params": {"account_id": "acct123"}}, - ) - assert model is not None - - -def test_provider_params_the_preset_does_not_declare_are_rejected(): - from aimux import InvalidArgumentError, provider - - with pytest.raises(InvalidArgumentError, match="no template parameter `account_id`"): - provider( - "abacus", - "sk-test-fake-key", - "m", - config={"params": {"account_id": "acct123"}}, - ) - - -def test_openai_creates_model(): - """openai() creates a model instance.""" - from aimux import openai - - model = openai("sk-test-fake-key", "gpt-4o-mini") - assert model is not None - assert hasattr(model, "generate_text") - assert hasattr(model, "stream_text") - - -def test_anthropic_creates_model(): - """anthropic() creates a model instance.""" - from aimux import anthropic - - model = anthropic("sk-ant-test-fake-key", "claude-3-5-sonnet-20241022") - assert model is not None - assert hasattr(model, "generate_text") - - -def test_deepseek_creates_model(): - """deepseek() creates a model instance.""" - from aimux import deepseek - - model = deepseek("sk-test-fake-key", "deepseek-chat") - assert model is not None - assert hasattr(model, "stream_text") - - -def test_generate_text_rejects_invalid_prompt(): - """A wire-JSON text that does not parse is the binding's own failure. - - pyo3 style: a ValueError naming the argument, never the engine's - InvalidPromptError. - """ - from aimux import openai - - model = openai("sk-test-fake-key", "gpt-4o-mini") - with pytest.raises(ValueError, match=r"^prompt_json: invalid JSON"): - model.generate_text("{invalid json}") - - -def test_stream_text_returns_iterator(): - """stream_text returns a StreamIterator.""" - from aimux import openai - - model = openai("sk-test-fake-key", "gpt-4o-mini") - it = model.stream_text('"hello"') - # Should be iterable - assert hasattr(it, "__iter__") - assert hasattr(it, "__next__") diff --git a/bindings/python/tests/test_cassette.py b/bindings/python/tests/test_cassette.py deleted file mode 100644 index 2141e17d6..000000000 --- a/bindings/python/tests/test_cassette.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Real cassette replay tests for Python binding. - -Hard asserts only — no catch-all pass. Providers with non-standard paths -are excluded, not faked. -""" - -import json -import pytest - -from aimux import openai, deepseek, anthropic, generate_text, stream_text -from tests.cassette_replay import CassetteServer - - -class TestOpenAICassette: - - def test_generate(self): - with CassetteServer("openai") as srv: - assert srv.count > 0 - model = openai("test-key", "gpt-4o", f"{srv.url}/v1") - result = generate_text(model, "Hello") - assert "error" not in result, f"unexpected error: {result.get('error')}" - assert isinstance(result["text"], str) - assert len(result["text"]) > 0, "text should be non-empty" - assert "usage" in result - assert "finishReason" in result - - def test_stream(self): - with CassetteServer("openai") as srv: - model = openai("test-key", "gpt-4o", f"{srv.url}/v1") - parts = list(stream_text(model, "Hello")) - assert len(parts) > 0, "should receive stream parts" - types = [p["type"] for p in parts] - assert types[0] == "start", "should open with start" - assert "start-step" in types, "should have start-step" - assert "finish" in types, "should have finish" - - -class TestAnthropicCassette: - - def test_generate(self): - with CassetteServer("anthropic") as srv: - assert srv.count > 0 - model = anthropic("test-key", "claude-sonnet-4-6", f"{srv.url}/v1") - result = generate_text(model, "Hello") - assert "error" not in result, f"unexpected error: {result.get('error')}" - assert isinstance(result["text"], str) - assert len(result["text"]) > 0, "text should be non-empty" - assert "usage" in result - - def test_stream(self): - with CassetteServer("anthropic") as srv: - model = anthropic("test-key", "claude-3-haiku-20240307", f"{srv.url}/v1") - parts = list(stream_text(model, "Hello")) - assert len(parts) > 0, "should receive stream parts" - - -class TestDeepSeekCassette: - - def test_generate(self): - with CassetteServer("deepseek") as srv: - assert srv.count > 0 - model = deepseek("test-key", "deepseek-chat", srv.url) - result = generate_text(model, "Hello") - assert "error" not in result, f"unexpected error: {result.get('error')}" - assert isinstance(result["text"], str) - - def test_generate_step_usage_raw_carries_vendor_fields(self): - """RFC-0016 M10: DeepSeek's vendor-specific usage fields survive in - the step's usage.raw (they are NOT part of the typed Usage model). - The result's ``usage`` is summed over the steps and, as the AI SDK's - addLanguageModelUsage, carries no raw object.""" - with CassetteServer("deepseek") as srv: - model = deepseek("test-key", "deepseek-chat", srv.url) - result = generate_text(model, "Hello") - - raw = result["steps"][0]["usage"].get("raw") - assert raw, "steps[0].usage.raw should be populated (RFC-0016 M10)" - assert isinstance(raw.get("prompt_cache_hit_tokens"), int) - assert isinstance(raw.get("prompt_cache_miss_tokens"), int) - assert result["usage"].get("raw") is None - # Typed totals still work alongside the raw object. - assert result["usage"]["inputTokens"] is not None - - -class TestGroqCassette: - - def test_generate(self): - with CassetteServer("groq") as srv: - assert srv.count > 0 - model = deepseek("test-key", "llama-3.3-70b-versatile", f"{srv.url}/openai/v1") - result = generate_text(model, "Hello") - assert "error" not in result, f"unexpected error: {result.get('error')}" - assert isinstance(result["text"], str) - - -class TestMistralCassette: - - def test_generate(self): - with CassetteServer("mistral") as srv: - assert srv.count > 0 - model = deepseek("test-key", "ministral-8b-latest", f"{srv.url}/v1") - result = generate_text(model, "Hello") - assert "error" not in result, f"unexpected error: {result.get('error')}" - assert isinstance(result["text"], str) - - -class TestOllamaCassette: - - def test_generate(self): - with CassetteServer("ollama") as srv: - assert srv.count > 0 - model = deepseek("test-key", "qwen3:4b", f"{srv.url}/v1") - result = generate_text(model, "Hello") - assert "error" not in result, f"unexpected error: {result.get('error')}" - assert isinstance(result["text"], str) - - -class TestPerplexityCassette: - - def test_generate(self): - with CassetteServer("perplexity") as srv: - assert srv.count > 0 - model = deepseek("test-key", "sonar", srv.url) - result = generate_text(model, "Hello") - assert "error" not in result, f"unexpected error: {result.get('error')}" - assert isinstance(result["text"], str) diff --git a/bindings/python/tests/test_cassette_exhaustive.py b/bindings/python/tests/test_cassette_exhaustive.py deleted file mode 100644 index 138656d34..000000000 --- a/bindings/python/tests/test_cassette_exhaustive.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Exhaustive cassette replay for Python binding. - -Iterates over EVERY chat/completions cassette file across all provider -directories, mounts each one individually, and verifies the full chain: - Python → PyO3 → aimux-core → single cassette → parse → result -""" - -import json -import os -import socket -import time -from http.server import HTTPServer, BaseHTTPRequestHandler -from multiprocessing import Process -from pathlib import Path - -import pytest - -from aimux import deepseek, generate_text, stream_text - -CASSETTE_BASE = Path(__file__).resolve().parents[3] / "aimux-providers" / "tests" / "cassettes" - - -def _find_free_port(): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - -def _load_chat_cassettes(): - """Load all cassettes whose request path ends with /chat/completions.""" - cassettes = [] - for provider_dir in sorted(CASSETTE_BASE.iterdir()): - if not provider_dir.is_dir(): - continue - provider = provider_dir.name - for f in sorted(provider_dir.glob("*.json")): - try: - raw = json.loads(f.read_text()) - req_path = raw.get("request", {}).get("path", "") - if not req_path.endswith("/chat/completions"): - continue - - body = raw.get("request", {}).get("body", {}) - if isinstance(body, str): - try: - body = json.loads(body) - except Exception: - body = {} - - headers = {} - for k, v in raw.get("response", {}).get("headers", {}).items(): - if isinstance(v, str): - headers[k] = v - - cassettes.append({ - "provider": provider, - "file": f.name, - "req_path": req_path, - "req_body": body if isinstance(body, dict) else {}, - "is_stream": body.get("stream", False) if isinstance(body, dict) else False, - "resp_status": raw.get("response", {}).get("status", 200), - "resp_headers": headers, - "resp_body": raw.get("response", {}).get("body", ""), - }) - except Exception: - pass - return cassettes - - -def _server_proc(port, resp_status, resp_headers, resp_body): - """Mock server that always returns the same response.""" - - class Handler(BaseHTTPRequestHandler): - def do_POST(self): - self.send_response(resp_status) - for k, v in resp_headers.items(): - self.send_header(k, v) - body = resp_body.encode() if isinstance(resp_body, str) else resp_body - self.send_header("content-length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, *args): - pass - - server = HTTPServer(("127.0.0.1", port), Handler) - server.serve_forever() - - -def _start_server(cass): - """Start a single-cassette mock server. Returns (proc, url).""" - port = _find_free_port() - proc = Process(target=_server_proc, args=(port, cass["resp_status"], cass["resp_headers"], cass["resp_body"])) - proc.start() - url = f"http://127.0.0.1:{port}" - # Wait for server - for _ in range(50): - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.connect(("127.0.0.1", port)) - break - except ConnectionRefusedError: - time.sleep(0.05) - return proc, url - - -def _stop_server(proc): - proc.terminate() - proc.join(timeout=2) - - -def _get_model(cass): - return cass["req_body"].get("model", "gpt-4o") - - -def _get_base_path(req_path): - if req_path.endswith("/chat/completions"): - return req_path[: -len("/chat/completions")] - return "" - - -def _extract_prompt(cass): - msgs = cass["req_body"].get("messages", []) - if isinstance(msgs, list): - for msg in reversed(msgs): - if msg.get("role") == "user": - content = msg.get("content") - if isinstance(content, str): - return content - if isinstance(content, list): - for p in content: - if "text" in p: - return p["text"] - return "Hello" - - -CASSETTES = _load_chat_cassettes() - - -class TestExhaustiveCassette: - - def test_all_chat_cassettes_replayed(self): - assert len(CASSETTES) > 700, f"expected 700+ cassettes, got {len(CASSETTES)}" - - passed = 0 - failed = 0 - errors = [] - - for cass in CASSETTES: - proc, url = _start_server(cass) - base_path = _get_base_path(cass["req_path"]) - base_url = f"{url}{base_path}" if base_path else url - model_id = _get_model(cass) - prompt = _extract_prompt(cass) - - try: - model = deepseek("test-key", model_id, base_url) - if cass["is_stream"]: - parts = list(stream_text(model, prompt)) - if len(parts) == 0: - raise RuntimeError("no stream parts") - else: - result = generate_text(model, prompt) - if "error" in result: - raise RuntimeError(result["error"]) - passed += 1 - except Exception as e: - msg = str(e) - if any(code in msg for code in ("404", "400", "401", "429", "500", - "model not found", "rate limited", "error decoding", "does not exist")): - passed += 1 # Error cassettes are acceptable - else: - failed += 1 - if len(errors) < 20: - errors.append(f'{cass["provider"]}/{cass["file"]}: {msg}') - finally: - _stop_server(proc) - - print(f"\nTotal: {len(CASSETTES)}, Passed: {passed}, Failed: {failed}") - for e in errors: - print(f" FAIL: {e}") - - pass_rate = passed / len(CASSETTES) - assert pass_rate > 0.9, f"pass rate {pass_rate*100:.1f}% too low: {passed}/{len(CASSETTES)}" diff --git a/bindings/python/tests/test_decision.py b/bindings/python/tests/test_decision.py deleted file mode 100644 index 7aa6f5d2c..000000000 --- a/bindings/python/tests/test_decision.py +++ /dev/null @@ -1,234 +0,0 @@ -"""Decision contract crosses Python -> PyO3 -> core -> HTTP -> Python dicts. - -A thread-based server also verifies the native decision call releases the GIL. -""" -import base64 -import json -import threading -from http.server import BaseHTTPRequestHandler, HTTPServer -from pathlib import Path - -import pytest -from aimux import APICallError, InvalidArgumentError, decide, jev_decision, describe_decision_model - - -def test_provider_handle_openai_decisions_answer_every_question(): - from aimux import create_provider - contract = json.loads((Path(__file__).resolve().parents[3] / - 'aimux-providers/tests/fixtures/openai_decisions.json').read_text()) - captured = [] - - class Handler(BaseHTTPRequestHandler): - def do_POST(self): - captured.append((self.path, self.headers['Authorization'], - json.loads(self.rfile.read(int(self.headers['Content-Length']))))) - self.send_response(200) - self.send_header('Content-Type', 'application/json') - self.end_headers() - self.wfile.write(json.dumps(contract['response']).encode()) - - def log_message(self, *_): - pass - - http = HTTPServer(('127.0.0.1', 0), Handler) - worker = threading.Thread(target=http.serve_forever, daemon=True) - worker.start() - try: - provider = create_provider('openai', 'test-key', base_url=f'http://127.0.0.1:{http.server_port}/v1') - model = provider.decision_model('gpt-6-luna') - assert describe_decision_model(model) == { - 'specificationVersion': 'v4', 'provider': 'openai', 'modelId': 'gpt-6-luna', - 'supportedQuestionTypes': ['choice', 'score', 'boolean']} - result = decide(model, **contract['options']) - assert captured == [('/v1/decisions', 'Bearer test-key', contract['request'])] - assert list(result['answers']) == ['urgent', 'team', 'severity'] - assert result['answers']['urgent'] == {'type': 'boolean', 'probability': 0.925} - finally: - http.shutdown() - worker.join() - http.server_close() - - -def test_decision_uses_unified_runtime_recording(server, tmp_path): - from aimux import init_recording, recording_flush, recording_stop - base, _ = server - init_recording(str(tmp_path)) - try: - result = decide(jev_decision('test-key', 'jev-latest', base + '/v1/systemone'), - {'message': 'Billed twice'}, QUESTIONS, maxRetries=0) - finally: - recording_flush() - recording_stop() - records = [json.loads(line) for line in (tmp_path / 'recordings.jsonl').read_text().splitlines()] - assert len(records) == 1 - record = records[0] - assert record['complete'] is True - assert record['input']['operation'] == 'decision' - assert record['input']['options']['questions'] == QUESTIONS - assert record['outcome']['decisionResult']['answers'] == result['answers'] - assert record['exchanges'][0]['attempt'] == 1 - assert 'test-key' not in json.dumps(record) - -FIXTURE = json.loads((Path(__file__).resolve().parents[3] / 'aimux-providers/tests/fixtures/jev_systemone.json').read_text())['response'] -QUESTIONS = { - 'is_urgent': {'type': 'boolean', 'instructions': 'The message conveys urgency or time-sensitivity'}, - 'department': {'type': 'choice', 'instructions': 'Which team should handle this', 'criteria': {label: None for label in ['billing', 'technical', 'sales']}}, - 'frustration': {'type': 'score', 'instructions': 'How frustrated the customer appears', 'criteria': ['Calm, just stating facts', 'Frustrated but civil', 'Very angry, strong language']}, -} - - -@pytest.mark.parametrize('usage', [{}, {'input_tokens': None, 'output_tokens': None}, - {'input_tokens': 12}, {'output_tokens': 7}]) -def test_optional_usage_counts_preserve_answers(server, monkeypatch, usage): - monkeypatch.setitem(FIXTURE, 'usage', usage) - base, _ = server - result = decide(jev_decision('test-key', 'jev-latest', base + '/v1/systemone'), - {'message': 'Billed twice'}, QUESTIONS, maxRetries=0) - assert len(result['answers']) == 3 - # An unknown count is absent, as the AI SDK's undefined is in JSON. - expected = {key: usage[wire] for key, wire in - [('inputTokens', 'input_tokens'), ('outputTokens', 'output_tokens')] - if usage.get(wire) is not None} - assert result['usage'] == expected - assert result['response']['body']['usage'] == usage - - -@pytest.fixture -def server(): - captures = [] - - class Handler(BaseHTTPRequestHandler): - def do_POST(self): - request = json.loads(self.rfile.read(int(self.headers['Content-Length']))) - captures.append((self.path, {key.lower(): value for key, value in self.headers.items()}, request)) - body = json.dumps(FIXTURE if self.path == '/v1/systemone' else dict(FIXTURE, answers={})).encode() - self.send_response(200) - self.send_header('Content-Type', 'application/json') - self.send_header('Content-Length', str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, *args): - pass - - http = HTTPServer(('127.0.0.1', 0), Handler) - # Bound waits keep a GIL regression from hanging the test process forever. - http.timeout = 2 - thread = threading.Thread(target=http.serve_forever, daemon=True) - thread.start() - yield f'http://127.0.0.1:{http.server_port}', captures - http.shutdown() - http.server_close() - thread.join(timeout=2) - - -def test_decide_all_types_and_raw_response(server): - base, captures = server - model = jev_decision('test-key', 'jev-latest', base + '/v1/systemone') - result = decide(model, {'message': 'Billed twice'}, QUESTIONS, maxRetries=0, timeout={'totalMs': 2000}) - assert result['answers']['is_urgent'] == {'type': 'boolean', 'probability': 1.0} - assert result['answers']['department']['choice'] == 'technical' - assert result['answers']['frustration']['score'] == 1.0 - assert result['answers']['frustration']['probabilities'] == {'0': 0, '1': 1.0, '2': 0} - assert result['usage'] == {'inputTokens': 392, 'outputTokens': 65, 'totalTokens': 457} - assert result['response']['body'] == FIXTURE - assert result['providerMetadata']['jev']['probabilitySource'] == 'native' - assert captures[0][0] == '/v1/systemone' - assert captures[0][1]['authorization'] == 'Bearer test-key' - assert captures[0][2]['questions']['is_urgent']['type'] == 'noul' - - -def test_invalid_answer_uses_python_error_class(server): - base, _ = server - model = jev_decision('test-key', 'jev-latest', base + '/invalid') - with pytest.raises(APICallError): - decide(model, 'text', QUESTIONS, maxRetries=0, timeout={'totalMs': 2000}) - - -@pytest.mark.parametrize('source', ['native', 'logit_scoring', 'model_estimate']) -def test_self_hosted_probability_source(server, source): - base, _ = server - model = jev_decision('test-key', 'jev-latest', base + '/v1/systemone', probability_source=source) - result = decide(model, 'text', QUESTIONS, maxRetries=0, timeout={'totalMs': 2000}) - assert result['providerMetadata']['jev']['probabilitySource'] == source - - -def test_unknown_probability_source(): - with pytest.raises(InvalidArgumentError): - jev_decision('test-key', 'jev-latest', probability_source='unknown') - - -def test_official_structured_contract_and_description(): - fixture = json.loads((Path(__file__).resolve().parents[3] / 'contract-tests/fixtures/decision-native.json').read_text()) - captures = [] - class Handler(BaseHTTPRequestHandler): - def do_POST(self): - captures.append(json.loads(self.rfile.read(int(self.headers['Content-Length'])))) - body = json.dumps(fixture['response']).encode() - self.send_response(200) - self.send_header('Content-Type', 'application/json') - self.send_header('Content-Length', str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *args): pass - http = HTTPServer(('127.0.0.1', 0), Handler) - thread = threading.Thread(target=http.serve_forever, daemon=True) - thread.start() - try: - model = jev_decision('test-key', 'jev-latest', f'http://127.0.0.1:{http.server_port}/v1/systemone') - assert describe_decision_model(model)['provider'] == 'jev' - assert not captures - request = fixture['request'] - result = decide(model, request['state'], request['questions'], maxRetries=0, timeout={'totalMs':2000}) - assert captures[0]['questions']['urgent']['criteria'] == request['questions']['urgent']['criteria'] - assert captures[0]['questions']['severity']['criteria'] == request['questions']['severity']['criteria'] - assert result['answers']['severity'] == {'type': 'score', 'score': 0.25, 'probabilities': {'0': 0.75, '1': 0.25}} - assert result['rounding'] == {'probabilityDecimals': 2, 'scoreDecimals': 2} - finally: - http.shutdown() - http.server_close() - thread.join(timeout=2) - - -@pytest.mark.parametrize('as_bytes', [False, True]) -def test_openai_images_native_values_and_score_descriptions(as_bytes): - from aimux import create_provider - contract = json.loads((Path(__file__).resolve().parents[3] / - 'contract-tests/fixtures/decision-openai-full.json').read_text()) - if as_bytes: - # DecisionImage.data is a base64 string on the wire, as the AI SDK - # writes bytes (provider-utils uint8-utils.ts:12-23); bytes given to - # decide() are encoded to the same string. - openai = contract['options']['providerOptions']['openai'] - openai['images'] = [ - dict(image, data=base64.b64decode(image['data'])) - for image in openai['images'] - ] - captured = [] - - class Handler(BaseHTTPRequestHandler): - def do_POST(self): - captured.append(json.loads(self.rfile.read(int(self.headers['Content-Length'])))) - self.send_response(200) - self.send_header('Content-Type', 'application/json') - self.end_headers() - self.wfile.write(json.dumps(contract['response']).encode()) - - def log_message(self, *_): - pass - - http = HTTPServer(('127.0.0.1', 0), Handler) - worker = threading.Thread(target=http.serve_forever, daemon=True) - worker.start() - try: - model = create_provider('openai', 'test-key', base_url=f'http://127.0.0.1:{http.server_port}/v1').decision_model('gpt-6-luna') - result = decide(model, **contract['options']) - assert captured == [contract['request']] - assert result['answers']['choice'] == {'type': 'choice', 'choice': 'boolean_true', - 'probabilities': {'boolean_true': 0.75, 'text_true': 0.25}} - assert result['providerMetadata']['openai']['answers']['choice']['value'] is True - assert result['answers']['score'] == {'type': 'score', 'score': 0.75, 'probabilities': {'0': 0.25, '1': 0.75}} - finally: - http.shutdown() - worker.join() - http.server_close() diff --git a/bindings/python/tests/test_e2e.py b/bindings/python/tests/test_e2e.py deleted file mode 100644 index 833c469b6..000000000 --- a/bindings/python/tests/test_e2e.py +++ /dev/null @@ -1,544 +0,0 @@ -"""End-to-end provider tests with mock HTTP server. - -Verifies the FULL chain: Python → PyO3 → aimux-core → HTTP mock → -response parsing → typed result. - -The mock server runs in a separate process (multiprocessing). Native calls -release the GIL, so a server thread in this process works too -(test_gil.py relies on it). -""" - -import json -import os -import signal -import socket -import tempfile -import time -from http.server import HTTPServer, BaseHTTPRequestHandler -from multiprocessing import Process, Queue - -import pytest - -from aimux import deepseek, anthropic, generate_text, stream_text - - -# ── Mock server in a separate process ─────────────────────────────────────── - -def _find_free_port(): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(('127.0.0.1', 0)) - return s.getsockname()[1] - - -def _mock_server_proc(port, response_body, content_type, status=200): - """Mock HTTP server that always returns the same response.""" - - class Handler(BaseHTTPRequestHandler): - def do_POST(self): - body = self.rfile.read(int(self.headers.get('Content-Length', 0))) - # Store last body in the server for inspection - self.server.last_body = body # type: ignore - - resp = response_body.encode() if isinstance(response_body, str) else response_body - self.send_response(status) - self.send_header('content-type', content_type) - self.send_header('content-length', str(len(resp))) - self.end_headers() - self.wfile.write(resp) - - def do_GET(self): - resp = response_body.encode() if isinstance(response_body, str) else response_body - self.send_response(status) - self.send_header('content-type', content_type) - self.send_header('content-length', str(len(resp))) - self.end_headers() - self.wfile.write(resp) - - def log_message(self, *args): - pass - - server = HTTPServer(('127.0.0.1', port), Handler) - server.last_body = None # type: ignore - server.serve_forever() - - -class MockServer: - """Context manager that starts a mock HTTP server in a subprocess.""" - - def __init__(self, response_body, content_type='application/json', status=200): - self.port = _find_free_port() - self.url = f'http://127.0.0.1:{self.port}' - self.proc = Process( - target=_mock_server_proc, - args=(self.port, response_body, content_type, status), - ) - - def __enter__(self): - self.proc.start() - # Wait for server to be ready - for _ in range(50): - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.connect(('127.0.0.1', self.port)) - break - except ConnectionRefusedError: - time.sleep(0.05) - return self - - def __exit__(self, *args): - self.proc.terminate() - self.proc.join(timeout=2) - - -# ── Mock response data (real API shapes) ──────────────────────────────────── - -OPENAI_CHAT = json.dumps({ - "id": "chatcmpl-test", - "model": "gpt-4o", - "choices": [{ - "message": {"role": "assistant", "content": "Rust is a systems programming language."}, - "finish_reason": "stop", - }], - "usage": {"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18}, -}) - -OPENAI_STREAM = ( - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"content":"Hello"}}]}\n\n' - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"content":" world"}}]}\n\n' - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{},"finish_reason":"stop"}],' - '"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}\n\n' - 'data: [DONE]\n\n' -) - -ANTHROPIC_MSG = json.dumps({ - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": "claude-3-5-sonnet-20241022", - "content": [{"type": "text", "text": "Hello from Claude!"}], - "stop_reason": "end_turn", - "usage": {"input_tokens": 10, "output_tokens": 5}, -}) - -ANTHROPIC_STREAM = ( - 'event: message_start\ndata: {"type":"message_start","message":' - '{"id":"msg_1","model":"claude-3-5-sonnet-20241022",' - '"usage":{"input_tokens":10,"output_tokens":0}}}\n\n' - 'event: content_block_start\ndata: {"type":"content_block_start",' - '"index":0,"content_block":{"type":"text","text":""}}\n\n' - 'event: content_block_delta\ndata: {"type":"content_block_delta",' - '"index":0,"delta":{"type":"text_delta","text":"Hello"}}\n\n' - 'event: content_block_delta\ndata: {"type":"content_block_delta",' - '"index":0,"delta":{"type":"text_delta","text":" from Claude"}}\n\n' - 'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n' - 'event: message_delta\ndata: {"type":"message_delta",' - '"delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}\n\n' - 'event: message_stop\ndata: {"type":"message_stop"}\n\n' -) - - -# ── Tests ─────────────────────────────────────────────────────────────────── - -class TestOpenAIE2E: - - def test_generate_text(self): - with MockServer(OPENAI_CHAT) as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - result = generate_text(model, "What is Rust?") - assert result["text"] == "Rust is a systems programming language." - assert result["usage"] is not None - - def test_stream_text(self): - with MockServer(OPENAI_STREAM, content_type='text/event-stream') as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - parts = list(stream_text(model, "Say hello")) - assert len(parts) > 0 - text = "".join( - p["text"] for p in parts if p["type"] == "text-delta" - ) - assert text == "Hello world" - - def test_response_timestamp_is_to_iso_string(self): - """The response timestamp is a Date (provider - language-model-v4-response-metadata.ts:10), built as - ``new Date(created * 1000)``; its JSON is ``toISOString()``.""" - body = json.dumps({**json.loads(OPENAI_CHAT), "created": 1741269019}) - with MockServer(body) as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - result = generate_text(model, "What is Rust?") - assert result["response"]["timestamp"] == "2025-03-06T13:50:19.000Z" - - def test_generate_text_with_options(self): - with MockServer(OPENAI_CHAT) as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - generate_text(model, "Hello", {"maxOutputTokens": 100, "temperature": 0.5}) - # Options should reach the HTTP request (tested implicitly — if they - # don't serialize, the Rust side would error) - - -class TestAnthropicE2E: - - def test_generate_text(self): - with MockServer(ANTHROPIC_MSG) as mock: - model = anthropic("test-key", "claude-3-5-sonnet-20241022", mock.url) - result = generate_text(model, "Hello") - assert result["text"] == "Hello from Claude!" - assert result["usage"] is not None - - def test_stream_text(self): - with MockServer(ANTHROPIC_STREAM, content_type='text/event-stream') as mock: - model = anthropic("test-key", "claude-3-5-sonnet-20241022", mock.url) - parts = list(stream_text(model, "Hello")) - assert len(parts) > 0 - text = "".join( - p["text"] for p in parts if p["type"] == "text-delta" - ) - assert text == "Hello from Claude" - - -# ── Mock server that records the request body ─────────────────────────────── - - -def _recording_server_proc(port, response_body, content_type, q): - """Mock server that records each request body into the queue.""" - - class Handler(BaseHTTPRequestHandler): - def do_POST(self): - body = self.rfile.read(int(self.headers.get('Content-Length', 0))) - try: - q.put(json.loads(body)) - except Exception: - q.put(None) - resp = response_body.encode() if isinstance(response_body, str) else response_body - self.send_response(200) - self.send_header('content-type', content_type) - self.send_header('content-length', str(len(resp))) - self.end_headers() - self.wfile.write(resp) - - def log_message(self, *args): - pass - - server = HTTPServer(('127.0.0.1', port), Handler) - server.serve_forever() - - -class RecordingMockServer: - """Mock server that returns a fixed response and records the request body.""" - - def __init__(self, response_body, content_type='application/json'): - self.port = _find_free_port() - self.url = f'http://127.0.0.1:{self.port}' - self.q = Queue() - self.proc = Process( - target=_recording_server_proc, - args=(self.port, response_body, content_type, self.q), - ) - - def __enter__(self): - self.proc.start() - for _ in range(50): - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.connect(('127.0.0.1', self.port)) - break - except ConnectionRefusedError: - time.sleep(0.05) - return self - - def __exit__(self, *args): - self.proc.terminate() - self.proc.join(timeout=2) - - def received_body(self, timeout=2.0): - return self.q.get(timeout=timeout) - - -# ── Mock server that returns a different response per request (FIFO) ──────── - - -def _seq_server_proc(port, responses, content_type, q): - """Mock server that returns different responses for each request (FIFO). - - Like the recording server, every request body is pushed to the queue so the - caller can inspect what reached the provider. The response for the Nth - request is ``responses[min(N, len(responses)-1)]`` (the last response is - reused for any extra requests). - """ - call_idx = [0] - - class Handler(BaseHTTPRequestHandler): - def do_POST(self): - body = self.rfile.read(int(self.headers.get('Content-Length', 0))) - try: - q.put(json.loads(body)) - except Exception: - q.put(None) - idx = call_idx[0] - call_idx[0] += 1 - resp = responses[min(idx, len(responses) - 1)] - resp_bytes = resp.encode() if isinstance(resp, str) else resp - self.send_response(200) - self.send_header('content-type', content_type) - self.send_header('content-length', str(len(resp_bytes))) - self.end_headers() - self.wfile.write(resp_bytes) - - def log_message(self, *args): - pass - - server = HTTPServer(('127.0.0.1', port), Handler) - server.serve_forever() - - -class SequencedMockServer: - """Mock server that returns a different response for each request (FIFO). - - The response list is consumed in order (the last entry is reused for any - extra requests beyond the list length). Each request body is recorded into - the queue, so callers can assert on what reached the provider — useful for - full tool-call round-trips where the server must return a tool-call - response first and a final text response second. - """ - - def __init__(self, responses, content_type='application/json'): - self.port = _find_free_port() - self.url = f'http://127.0.0.1:{self.port}' - self.q = Queue() - self.proc = Process( - target=_seq_server_proc, - args=(self.port, responses, content_type, self.q), - ) - - def __enter__(self): - self.proc.start() - for _ in range(50): - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.connect(('127.0.0.1', self.port)) - break - except ConnectionRefusedError: - time.sleep(0.05) - return self - - def __exit__(self, *args): - self.proc.terminate() - self.proc.join(timeout=2) - - def received_body(self, timeout=2.0): - return self.q.get(timeout=timeout) - - -# ── Tool-call parsing & structured content ────────────────────────────────── - -OPENAI_TOOL_CALL = json.dumps({ - "id": "chatcmpl-tc", - "model": "gpt-4o", - "choices": [{ - "message": { - "role": "assistant", - "content": None, - "tool_calls": [{ - "id": "call_abc", - "type": "function", - "function": {"name": "get_weather", "arguments": '{"location":"Tokyo"}'}, - }], - }, - "finish_reason": "tool_calls", - }], - "usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}, -}) - - -class TestStructuredContent: - - def test_integral_tool_input_numbers_are_integers(self): - """The AI SDK parses tool input with JSON.parse (ai - generate-text/parse-tool-call.ts:256; provider-utils - secure-json-parse.ts:31): `1e2` and `3.0` are the integers 100 and 3, - and JSON.stringify writes them so.""" - response = OPENAI_TOOL_CALL.replace( - '{\\"location\\":\\"Tokyo\\"}', '{\\"location\\":\\"Tokyo\\",\\"days\\":3.0,\\"n\\":1e2,\\"t\\":1.5}' - ) - assert '\\"days\\":3.0' in response - with RecordingMockServer(response) as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - result = generate_text(model, "Weather?", {"tools": [{ - "type": "function", - "name": "get_weather", - "inputSchema": {"type": "object"}, - }]}) - tool_input = result["toolCalls"][0]["input"] - assert tool_input == {"location": "Tokyo", "days": 3, "n": 100, "t": 1.5} - assert type(tool_input["days"]) is int and type(tool_input["n"]) is int - assert type(tool_input["t"]) is float - - def test_generate_text_parses_tool_calls(self): - with RecordingMockServer(OPENAI_TOOL_CALL) as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - opts = { - "tools": [{ - "type": "function", - "name": "get_weather", - "description": "Get weather for a location", - "inputSchema": { - "type": "object", - "properties": {"location": {"type": "string"}}, - "required": ["location"], - }, - }], - } - result = generate_text(model, "What's the weather in Tokyo?", opts) - - # Convenience field: toolCalls extracted - assert len(result["toolCalls"]) == 1 - assert result["toolCalls"][0]["toolName"] == "get_weather" - assert result["toolCalls"][0]["toolCallId"] == "call_abc" - assert result["toolCalls"][0]["input"] == {"location": "Tokyo"} - - # Structured content: the final step's content holds the parsed - # tool call - assert isinstance(result["content"], list) - tc = next((c for c in result["content"] if c["type"] == "tool-call"), None) - assert tc is not None, "content must contain a tool-call part" - assert tc["toolName"] == "get_weather" - assert tc["toolCallId"] == "call_abc" - assert tc["input"] == {"location": "Tokyo"} - - def test_multi_role_messages_reach_provider(self): - with RecordingMockServer(OPENAI_CHAT) as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - prompt = [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "What is Rust?"}, - ] - result = generate_text(model, prompt) - - body = mock.received_body() - assert isinstance(body["messages"], list) - assert len(body["messages"]) == 2 - assert body["messages"][0]["role"] == "system" - assert body["messages"][0]["content"] == "You are a helpful assistant." - assert body["messages"][1]["role"] == "user" - assert body["messages"][1]["content"] == "What is Rust?" - assert result["text"] - - def test_tool_choice_reaches_provider(self): - with RecordingMockServer(OPENAI_TOOL_CALL) as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - opts = { - "tools": [{ - "type": "function", - "name": "get_weather", - "inputSchema": { - "type": "object", - "properties": {"location": {"type": "string"}}, - }, - }], - "toolChoice": "required", - } - generate_text(model, "Hello", opts) - - body = mock.received_body() - assert body["tool_choice"] == "required" - - def test_stream_text_parses_tool_call_parts(self): - """Stream text should surface tool-call/tool-input-delta parts (not just text-delta/finish).""" - sse_body = ( - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_xyz","type":"function","function":{"name":"get_weather","arguments":""}}]}}]}\n\n' - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"location\\":\\"Tokyo\\"}"}}]}}]}\n\n' - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}\n\n' - 'data: [DONE]\n\n' - ) - with MockServer(sse_body, content_type='text/event-stream') as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - opts = { - "tools": [{ - "type": "function", - "name": "get_weather", - "inputSchema": { - "type": "object", - "properties": {"location": {"type": "string"}}, - }, - }], - } - parts = list(stream_text(model, "What's the weather in Tokyo?", opts)) - assert len(parts) > 0 - - # Stream must contain a tool-related StreamPart (not just text-delta/finish) - has_tool_part = any( - p["type"] in ("tool-call", "tool-input-delta", "tool-input-start") - for p in parts - ) - assert has_tool_part, f"expected a tool stream part; got: {parts}" - - # If a complete ToolCall part is present, verify its fields - tool_call_parts = [p for p in parts if p["type"] == "tool-call"] - if tool_call_parts: - assert tool_call_parts[0]["toolName"] == "get_weather" - - def test_tool_call_round_trip(self): - """Full tool-call round trip: two generate_text calls with a ToolResult - back-filled between them. - - First call → model requests a tool call (OPENAI_TOOL_CALL). - User executes the tool and sends the result back. - Second call → model returns final text (OPENAI_CHAT). - - The messages use the framework's user-facing content-part shape - (``tool-call`` / ``tool-result`` parts). The OpenAI converter turns - these into the provider-facing body (assistant ``tool_calls`` array + - ``tool`` role message with ``tool_call_id``), which is what we assert - on for the second request. - """ - # The mock answers request #1 with a tool call and request #2 with text. - with SequencedMockServer([OPENAI_TOOL_CALL, OPENAI_CHAT]) as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - opts = { - "tools": [{ - "type": "function", - "name": "get_weather", - "description": "Get weather for a location", - "inputSchema": { - "type": "object", - "properties": {"location": {"type": "string"}}, - "required": ["location"], - }, - }], - } - - # Step 1: first call — the model requests a tool call. - result = generate_text(model, "What's the weather in Tokyo?", opts) - assert len(result["toolCalls"]) == 1 - assert result["toolCalls"][0]["toolName"] == "get_weather" - assert result["toolCalls"][0]["toolCallId"] == "call_abc" - - # Step 2: back-fill the ToolResult and re-send the conversation. - # Roles: user → assistant(tool_call) → tool(result). - messages = [ - {"role": "user", "content": "What's the weather in Tokyo?"}, - {"role": "assistant", "content": [ - {"type": "tool-call", "toolCallId": "call_abc", - "toolName": "get_weather", "input": {"location": "Tokyo"}}, - ]}, - {"role": "tool", "content": [ - {"type": "tool-result", "toolCallId": "call_abc", - "toolName": "get_weather", - "output": {"type": "json", - "value": {"temperature": 22, "condition": "sunny"}}}, - ]}, - ] - - # Step 3: second call — same tools, model returns final text. - result2 = generate_text(model, messages, opts) - assert result2["text"] == "Rust is a systems programming language." - - # Step 4: the second request body must carry the full round trip. - mock.received_body() # first request (tool-call request) — drain - body2 = mock.received_body() - msgs = body2["messages"] - assert isinstance(msgs, list) - assert len(msgs) == 3, f"expected 3 messages, got {len(msgs)}" - last = msgs[-1] - assert last["role"] == "tool", f"expected last role 'tool', got {last.get('role')}" - assert last["tool_call_id"] == "call_abc" diff --git a/bindings/python/tests/test_error.py b/bindings/python/tests/test_error.py deleted file mode 100644 index cb1ebd0ce..000000000 --- a/bindings/python/tests/test_error.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Error contract tests for the Python binding. - -Every AiMuxError failure is an ``AimuxError`` subclass; the binding's own failures -are Python builtins (``ValueError`` / ``RuntimeError``, never an ``AimuxError``). -Payload attributes belong to the exception class that carries them and are -absent on unrelated classes. Optional ``APICallError`` values use ``None``. - -No outbound network: registry lookups fail before any request, and the API-call -case talks to a mock server on 127.0.0.1. -""" - -import json -import socket -import time -from http.server import BaseHTTPRequestHandler, HTTPServer -from multiprocessing import Process - -import pytest - -# Attributes the core only ever fills on ApiCall. -_API_CALL_ONLY = ( - "retry_ms", - "provider_code", - "provider_message", - "response_body", - "url", - "request_body_values", - "response_headers", - "data", -) - -_ERROR_BODY = json.dumps( - {"error": {"message": "slow down", "type": "rate_limit_exceeded"}} -) - - -def _free_port(): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - -def _rate_limited_server(port, retry_after_ms="1500"): - """Always answers 429 with a provider error body plus retry/request headers.""" - - class Handler(BaseHTTPRequestHandler): - def do_POST(self): - self.rfile.read(int(self.headers.get("Content-Length", 0))) - resp = _ERROR_BODY.encode() - self.send_response(429) - self.send_header("content-type", "application/json") - self.send_header("content-length", str(len(resp))) - self.send_header("x-request-id", "req_abc123") - self.send_header("retry-after-ms", retry_after_ms) - self.end_headers() - self.wfile.write(resp) - - def log_message(self, *args): - pass - - HTTPServer(("127.0.0.1", port), Handler).serve_forever() - - -def _wait_for_port(port): - for _ in range(50): - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.connect(("127.0.0.1", port)) - break - except ConnectionRefusedError: - time.sleep(0.05) - - -def test_no_such_provider_carries_only_its_payload_and_no_json(): - from aimux import AimuxError, NoSuchProviderError, provider - - with pytest.raises(NoSuchProviderError) as excinfo: - provider("definitely-not-a-provider", "sk-test-fake-key", "some-model") - err = excinfo.value - - assert type(err) is NoSuchProviderError - assert isinstance(err, AimuxError) - # The registry payload is an attribute, not something to dig out of JSON. - assert err.provider_id == "definitely-not-a-provider" - for attr in ("code", "status", "retryable", *_API_CALL_ONLY): - assert not hasattr(err, attr), f"{attr} must not exist on {type(err).__name__}" - # Every payload field is a typed attribute; there is no JSON companion. - assert not hasattr(err, "error_value") - - -def test_api_call_error_carries_every_field_the_response_produced(): - from aimux import APICallError, generate_text, openai - - port = _free_port() - proc = Process(target=_rate_limited_server, args=(port,)) - proc.start() - try: - _wait_for_port(port) - - model = openai("test-key", "gpt-4o", f"http://127.0.0.1:{port}") - # 429 is retryable; disable retries to observe the bare call failure. - with pytest.raises(APICallError) as excinfo: - generate_text(model, "hi", {"maxRetries": 0}) - err = excinfo.value - - assert type(err) is APICallError - assert err.status == 429 - assert err.retry_ms == 1500 - assert err.retryable is True - assert err.provider_code == "rate_limit_exceeded" - # No distinct request-id field: the raw header evidence carries it. - assert err.response_headers["x-request-id"] == "req_abc123" - assert err.response_headers["retry-after-ms"] == "1500" - assert f"127.0.0.1:{port}" in err.url - assert err.request_body_values["model"] == "gpt-4o" - assert err.response_body == _ERROR_BODY - # The provider's text on its own; str(e) is the composed form. - assert err.provider_message == "slow down" - assert "slow down" in str(err) - assert str(err) != err.provider_message - finally: - proc.terminate() - proc.join(timeout=2) - - -def test_retry_error_carries_reason_and_typed_attempt_history(): - from aimux import APICallError, RetryError, generate_text, openai - - port = _free_port() - # retry-after-ms: 1 keeps the honored retry delay negligible. - proc = Process(target=_rate_limited_server, args=(port, "1")) - proc.start() - try: - _wait_for_port(port) - - model = openai("test-key", "gpt-4o", f"http://127.0.0.1:{port}") - with pytest.raises(RetryError) as excinfo: - generate_text(model, "hi", {"maxRetries": 1}) - err = excinfo.value - - assert type(err) is RetryError - assert err.reason == "maxRetriesExceeded" - # Attempt history, oldest first; each entry is a full typed exception. - assert len(err.errors) == 2 - assert err.last_error is err.errors[-1] - for attempt in err.errors: - assert type(attempt) is APICallError - assert attempt.status == 429 - assert attempt.provider_code == "rate_limit_exceeded" - assert "Failed after 2 attempts" in str(err) - finally: - proc.terminate() - proc.join(timeout=2) - - -def test_malformed_wire_json_is_a_value_error_not_a_core_error(): - from aimux import AimuxError, openai - - # The native method: the wrapper's generate_text() JSON-encodes a str - # prompt, so malformed wire text can only reach the binding this way. - model = openai("test-key", "gpt-4o", "http://127.0.0.1:9") - with pytest.raises(ValueError, match="prompt_json") as excinfo: - model.generate_text("{not json") - - assert not isinstance(excinfo.value, AimuxError) - - -def test_missing_api_key_is_load_api_key_error(monkeypatch): - """No key passed and the env var unset: a typed LoadAPIKeyError at call time, not InvalidArgument.""" - from aimux import AimuxError, InvalidArgumentError, LoadAPIKeyError, generate_text, provider - - monkeypatch.delenv("GROQ_API_KEY", raising=False) - model = provider("groq", None, "some-model") - with pytest.raises(LoadAPIKeyError) as excinfo: - generate_text(model, "hi", {"maxRetries": 0}) - err = excinfo.value - - assert type(err) is LoadAPIKeyError - assert isinstance(err, AimuxError) - assert not isinstance(err, InvalidArgumentError) - assert err.env_var == "GROQ_API_KEY" - assert err.description == "Groq" - assert "GROQ_API_KEY" in str(err) - # Pre-HTTP: the ApiCall-only attributes do not exist. - for attr in ("status", "retryable", *_API_CALL_ONLY): - assert not hasattr(err, attr), f"{attr} must not exist on LoadAPIKeyError" - - -def test_explicit_empty_api_key_is_not_a_load_error(): - """The AI SDK sends an explicit empty key verbatim; only None consults the env.""" - from aimux import provider - - assert provider("groq", "", "some-model") is not None - - -def test_missing_setting_is_load_setting_error_at_call_time(monkeypatch): - """A required setting unset in the environment fails the call, typed.""" - from aimux import AimuxError, LoadSettingError, azure, generate_text - - monkeypatch.delenv("AZURE_RESOURCE_NAME", raising=False) - model = azure("test-key", "", "some-deployment") - with pytest.raises(LoadSettingError) as excinfo: - generate_text(model, "hi", {"maxRetries": 0}) - err = excinfo.value - - assert type(err) is LoadSettingError - assert isinstance(err, AimuxError) - assert err.env_var == "AZURE_RESOURCE_NAME" - assert err.setting_name == "resourceName" - for attr in ("status", "retryable", *_API_CALL_ONLY): - assert not hasattr(err, attr) diff --git a/bindings/python/tests/test_gil.py b/bindings/python/tests/test_gil.py deleted file mode 100644 index 979b90b4f..000000000 --- a/bindings/python/tests/test_gil.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Blocking native calls release the GIL. - -The mock server here runs in a thread of the test process, so it can only -answer while the calling thread waits inside a native call if that call -released the GIL. Each call carries a total timeout: if the GIL were held, -the server could not answer and the call would fail with a timeout instead -of hanging the suite. -""" - -import json -import threading -import time -from http.server import BaseHTTPRequestHandler, HTTPServer - -import pytest - -from aimux import deepseek, generate_text, openai_embedding - -CHAT = json.dumps({ - "id": "chatcmpl-gil", - "model": "deepseek-chat", - "choices": [{ - "message": {"role": "assistant", "content": "served by a Python thread"}, - "finish_reason": "stop", - }], - "usage": {"prompt_tokens": 1, "completion_tokens": 5, "total_tokens": 6}, -}) - -EMBEDDING = json.dumps({ - "data": [{"embedding": [0.5, 0.25], "index": 0}], - "model": "text-embedding-3-small", - "usage": {"prompt_tokens": 1, "total_tokens": 1}, -}) - -LIMITS = {"maxRetries": 0, "timeout": {"totalMs": 10000}} - - -class _ThreadServer: - """HTTP server on a daemon thread of this process, answering slowly.""" - - def __init__(self, body): - progress = self.progress = [] - - class Handler(BaseHTTPRequestHandler): - def do_POST(self): - self.rfile.read(int(self.headers.get("Content-Length", 0))) - # Keep the native call waiting while this thread keeps running. - for _ in range(10): - progress.append(time.monotonic()) - time.sleep(0.02) - payload = body.encode() - self.send_response(200) - self.send_header("content-type", "application/json") - self.send_header("content-length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) - - def log_message(self, *args): - pass - - self.server = HTTPServer(("127.0.0.1", 0), Handler) - self.url = "http://127.0.0.1:%d" % self.server.server_address[1] - self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) - - def __enter__(self): - self.thread.start() - return self - - def __exit__(self, *args): - self.server.shutdown() - self.server.server_close() - self.thread.join(timeout=5) - - -def test_generate_text_lets_other_python_threads_run(): - with _ThreadServer(CHAT) as server: - # A chat-completions provider: openai() uses the Responses API. - model = deepseek("test-key", "deepseek-chat", server.url) - started = time.monotonic() - result = generate_text(model, "hi", LIMITS) - assert result["text"] == "served by a Python thread" - # The server thread ran while generate_text was blocked. - assert len(server.progress) == 10 - assert all(t >= started for t in server.progress) - - -def test_embed_lets_other_python_threads_run(): - with _ThreadServer(EMBEDDING) as server: - embedder = openai_embedding("test-key", "text-embedding-3-small", base_url=server.url) - opts = dict(LIMITS, values=[]) - result = json.loads(embedder.embed(json.dumps(["hello"]), json.dumps(opts))) - assert result["embeddings"][0] == pytest.approx([0.5, 0.25]) - assert len(server.progress) == 10 diff --git a/bindings/python/tests/test_gil_release.py b/bindings/python/tests/test_gil_release.py new file mode 100644 index 000000000..dc61f77df --- /dev/null +++ b/bindings/python/tests/test_gil_release.py @@ -0,0 +1,118 @@ +"""RFC-0039 §9.8: heartbeat during *each* blocking native entry. + +Run with an installed wheel: python -m pytest tests/test_gil_release.py -v. +Each entry runs in a disposable process, so a held-GIL deadlock has a deadline. +HTTP/WS fixtures run in independent processes; FIFO recording exercises real +constructor I/O, flush barriers and writer joins without mocking native code. + +ENTRY_POINTS is the API-specific inventory. A migrated binding can supply +an alternative table using AIMUX_GIL_ENTRY_TABLE=module:attribute. Keep the same +case names/categories; each value is a context manager yielding a zero-argument +blocking call. Missing entries fail instead of silently losing coverage. +""" + +import importlib +import multiprocessing +import os +from pathlib import Path +import subprocess +import sys +import threading +import time + +import pytest + +DELAY = 0.5 +TIMEOUT = 25 + + +def _heartbeat(call): + """Exclude edge scheduling and require progress throughout the wait.""" + stop = threading.Event() + ready = threading.Event() + ticks = [] + + def ticker(): + ready.set() + while not stop.wait(0.005): + ticks.append(time.monotonic()) + + thread = threading.Thread(target=ticker, daemon=True) + thread.start() + assert ready.wait(2), "heartbeat did not start" + try: + start = time.monotonic() + result = call() + end = time.monotonic() + finally: + stop.set() + thread.join(timeout=2) + elapsed = end - start + assert elapsed >= DELAY * 0.7, "mock did not make this entry block" + interior = [t for t in ticks if start + 0.03 < t < end - 0.03] + assert len(interior) >= 10, "Python heartbeat stalled (GIL held)" + gaps = [b - a for a, b in zip([start] + interior, interior + [end])] + assert max(gaps) < DELAY * 0.6, "Python heartbeat stopped during native wait" + return result + + +# The binding uses shared ops handles. Binary push is immediate bounded +# admission, so it has separate ResourceLimit tests instead of a blocking case. +from gil_ops import ENTRY_POINTS + + +def _entry_table(): + target = os.environ.get("AIMUX_GIL_ENTRY_TABLE") + if not target: + return ENTRY_POINTS + module, attribute = target.split(":", 1) + table = getattr(importlib.import_module(module), attribute) + assert set(table) >= set(ENTRY_POINTS), "migrated table omits blocking entries" + for name, (category, _) in ENTRY_POINTS.items(): + assert table[name][0] == category, "migrated category changed: " + name + return table + + +@pytest.mark.parametrize("name", sorted(_entry_table())) +def test_blocking_entry_releases_gil(name): + env = dict(os.environ, NO_PROXY="127.0.0.1,localhost", no_proxy="127.0.0.1,localhost") + try: + process = subprocess.run( + [sys.executable, str(Path(__file__).resolve()), "--entry", name], + env=env, capture_output=True, text=True, timeout=TIMEOUT, + ) + except subprocess.TimeoutExpired: + pytest.fail(name + ": child exceeded deadline (native wait or teardown deadlock)") + if process.returncode == 77: + pytest.skip(process.stdout.strip()) + assert process.returncode == 0, process.stdout + process.stderr + + +def test_heartbeat_detects_a_stalled_ticker(): + # A deliberately held GIL proves boundary ticks cannot give a false pass. + def hold_gil(): + previous = sys.getswitchinterval() + try: + sys.setswitchinterval(2) + until = time.monotonic() + DELAY + while time.monotonic() < until: + pass + finally: + sys.setswitchinterval(previous) + + with pytest.raises(AssertionError, match="heartbeat"): + _heartbeat(hold_gil) + + +def test_heartbeat_accepts_a_released_gil(): + _heartbeat(lambda: time.sleep(DELAY)) + + +if __name__ == "__main__" and sys.argv[1:2] == ["--entry"]: + multiprocessing.set_start_method("spawn", force=True) + try: + with _entry_table()[sys.argv[2]][1]() as blocking_call: + _heartbeat(blocking_call) + except pytest.skip.Exception as skip: + print(str(skip)) + sys.exit(77) diff --git a/bindings/python/tests/test_model_specs_smoke.py b/bindings/python/tests/test_model_specs_smoke.py deleted file mode 100644 index 3fb97261a..000000000 --- a/bindings/python/tests/test_model_specs_smoke.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Offline smoke test for the ``get_model_specs`` entry point (B2). - -Before the fix, ``__init__.py`` called ``_native_get_model_specs`` but never -imported that name from the native module, so ``aimux.get_model_specs(...)`` -raised ``NameError`` at call time. The native fetch hits the network (anya2a -catalogue), so this test stubs the binding via ``unittest.mock`` — no real -HTTP is issued and the call path is exercised end-to-end. -""" - -from unittest.mock import patch - -import aimux - - -def test_get_model_specs_is_exposed(): - """The public entry point is present and callable.""" - assert hasattr(aimux, "get_model_specs") - assert callable(aimux.get_model_specs) - - -def test_get_model_specs_native_binding_imported(): - """B2 regression guard: the native binding is imported, so calling the - wrapper does not raise ``NameError``. The network fetch is stubbed.""" - assert hasattr(aimux, "_native_get_model_specs") - - with patch.object(aimux, "_native_get_model_specs", return_value='{"ok": true}') as mocked: - result = aimux.get_model_specs(None) - - mocked.assert_called_once_with(None) - assert result == {"ok": True} - - -def test_get_model_specs_forwards_source_url(): - """``source_url`` is forwarded to the native fetch (still offline).""" - with patch.object(aimux, "_native_get_model_specs", return_value="{}") as mocked: - aimux.get_model_specs("https://example.invalid/catalogue.json") - - mocked.assert_called_once_with("https://example.invalid/catalogue.json") diff --git a/bindings/python/tests/test_multimodal_e2e.py b/bindings/python/tests/test_multimodal_e2e.py deleted file mode 100644 index 0638ec202..000000000 --- a/bindings/python/tests/test_multimodal_e2e.py +++ /dev/null @@ -1,235 +0,0 @@ -"""End-to-end multimodal tests with a mock HTTP server. - -Verifies the FULL chain for each of the 8 multimodal features: -Python → PyO3 → aimux-core → HTTP mock → response parsing → typed result. - -The mock server runs in a separate process (multiprocessing) because PyO3's -block_on blocks the calling thread — a threading-based server would deadlock. -The MockServer helper is copied from test_e2e.py (test modules are not on the -import path). -""" - -import base64 -import json -import socket -import time -from http.server import HTTPServer, BaseHTTPRequestHandler -from multiprocessing import Process - -import pytest - -from aimux import ( - openai_embedding, - openai_speech, - openai_image, - openai_transcription, - cohere_reranking, - tavily_search, - openai_files, - google_video, -) - - -# ── Mock server in a separate process (copied from test_e2e.py) ────────────── - -def _find_free_port(): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(('127.0.0.1', 0)) - return s.getsockname()[1] - - -def _mock_server_proc(port, response_body, content_type, status=200): - """Mock HTTP server that always returns the same response. - - ``response_body`` may be ``str`` or ``bytes`` (raw binary, e.g. for speech). - Handles both POST and GET so any provider verb works. - """ - - class Handler(BaseHTTPRequestHandler): - def _respond(self): - resp = ( - response_body.encode() - if isinstance(response_body, str) - else response_body - ) - self.send_response(status) - self.send_header('content-type', content_type) - self.send_header('content-length', str(len(resp))) - self.end_headers() - self.wfile.write(resp) - - def do_POST(self): - # Drain the request body so the client can finish sending. - self.rfile.read(int(self.headers.get('Content-Length', 0))) - self._respond() - - def do_GET(self): - self._respond() - - def log_message(self, *args): - pass - - server = HTTPServer(('127.0.0.1', port), Handler) - server.serve_forever() - - -class MockServer: - """Context manager that starts a mock HTTP server in a subprocess.""" - - def __init__(self, response_body, content_type='application/json', status=200): - self.port = _find_free_port() - self.url = f'http://127.0.0.1:{self.port}' - self.proc = Process( - target=_mock_server_proc, - args=(self.port, response_body, content_type, status), - ) - - def __enter__(self): - self.proc.start() - # Wait for server to be ready - for _ in range(50): - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.connect(('127.0.0.1', self.port)) - break - except ConnectionRefusedError: - time.sleep(0.05) - return self - - def __exit__(self, *args): - self.proc.terminate() - self.proc.join(timeout=2) - - -# ── Mock response data (real provider shapes, verified in Go E2E tests) ────── - -EMBEDDING_RESP = json.dumps({ - "data": [{"embedding": [0.1, 0.2, 0.3], "index": 0}], - "model": "text-embedding-3-small", - "usage": {"prompt_tokens": 3, "total_tokens": 3}, -}) - -# Speech returns raw binary with content-type audio/mpeg (NOT JSON). -SPEECH_RESP = b"ID3\x04\x00fake-mp3-audio-payload" - -IMAGE_RESP = json.dumps({"data": [{"b64_json": "aW1hZ2Ux"}]}) - -TRANSCRIPTION_RESP = json.dumps({"text": "Hello world"}) - -RERANKING_RESP = json.dumps({ - "results": [ - {"index": 1, "relevance_score": 0.95}, - {"index": 0, "relevance_score": 0.3}, - ], -}) - -SEARCH_RESP = json.dumps({ - "results": [ - {"title": "Rust", "url": "https://rust-lang.org", "content": "Rust is..."}, - ], - "answer": "Rust is a systems language.", -}) - -FILES_RESP = json.dumps({ - "id": "file-abc", - "object": "file", - "bytes": 1024, - "created_at": 1234, - "filename": "test.pdf", - "purpose": "assistants", -}) - -# Google Video uses a multi-step async API; we only parse a canned result here. -VIDEO_RESULT_JSON = json.dumps({ - "videos": [ - {"type": "url", "url": "https://example.com/v.mp4", "mediaType": "video/mp4"}, - ], -}) - - -# ── Tests ─────────────────────────────────────────────────────────────────── - -class TestMultimodalE2E: - """Full-chain E2E tests for all 8 multimodal features.""" - - def test_embedding(self): - with MockServer(EMBEDDING_RESP) as mock: - embedder = openai_embedding( - "test-key", "text-embedding-3-small", base_url=mock.url - ) - result = json.loads(embedder.embed(json.dumps(["hello"]))) - assert len(result["embeddings"]) == 1 - assert len(result["embeddings"][0]) == 3 - assert result["embeddings"][0] == pytest.approx( - [0.1, 0.2, 0.3], abs=1e-5 - ) - - def test_speech(self): - # Mock returns raw binary with content-type audio/mpeg. - with MockServer(SPEECH_RESP, content_type='audio/mpeg') as mock: - speaker = openai_speech("test-key", "tts-1", base_url=mock.url) - opts = json.dumps({"text": "Hi", "voice": "alloy", "outputFormat": "mp3"}) - result = json.loads(speaker.generate(opts)) - # SpeechModelV4Result.audio is a Uint8Array upstream - # (speech-model-v4-result.ts:16): a base64 string in the JSON. - assert result["audio"] == base64.b64encode(SPEECH_RESP).decode("ascii") - - def test_image(self): - with MockServer(IMAGE_RESP) as mock: - imager = openai_image("test-key", "dall-e-3", base_url=mock.url) - # ImageCallOptions.providerOptions is a required (non-Option) field, - # so it must be present in the serialized options. - opts = json.dumps( - {"prompt": "otter", "n": 1, "providerOptions": {}} - ) - result = json.loads(imager.generate(opts)) - assert len(result["images"]) == 1 - assert result["images"][0] == "aW1hZ2Ux" - - def test_transcription(self): - with MockServer(TRANSCRIPTION_RESP) as mock: - transcriber = openai_transcription( - "test-key", "whisper-1", base_url=mock.url - ) - result = json.loads(transcriber.generate("dGVzdA==", "audio/mp3")) - assert result["text"] == "Hello world" - - def test_reranking(self): - with MockServer(RERANKING_RESP) as mock: - reranker = cohere_reranking( - "test-key", "rerank-v3.0", base_url=mock.url - ) - # RerankingDocuments is tagged by ``type``; the text-documents - # variant is {"type": "text", "values": [...]} (a bare array is rejected). - docs = json.dumps({"type": "text", "values": ["doc1", "doc2"]}) - result = json.loads(reranker.rerank("which?", docs)) - assert len(result["ranking"]) == 2 - assert result["ranking"][0]["relevanceScore"] == 0.95 - - def test_search(self): - with MockServer(SEARCH_RESP) as mock: - searcher = tavily_search("test-key", base_url=mock.url) - result = json.loads(searcher.search("What is Rust?")) - assert len(result["results"]) == 1 - assert result["results"][0]["title"] == "Rust" - assert result["answer"] == "Rust is a systems language." - - def test_files(self): - with MockServer(FILES_RESP) as mock: - files = openai_files("test-key", base_url=mock.url) - result = json.loads(files.upload_file("dGVzdA==", "application/pdf")) - assert result["providerReference"]["openai"] == "file-abc" - - def test_video_construction_and_result_parsing(self): - # Google Video uses a multi-step async API (POST predict → poll operation - # → fetch result); a single-response mock can't cover the full flow. - # Verify construction doesn't throw, then parse a canned result shape. - video = google_video( - "test-key", "veo-3.0", base_url="http://localhost:9999" - ) - assert video is not None - - result = json.loads(VIDEO_RESULT_JSON) - assert len(result["videos"]) == 1 - assert result["videos"][0]["url"] == "https://example.com/v.mp4" - assert result["videos"][0]["mediaType"] == "video/mp4" diff --git a/bindings/python/tests/test_ops_errors.py b/bindings/python/tests/test_ops_errors.py new file mode 100644 index 000000000..881e95a8f --- /dev/null +++ b/bindings/python/tests/test_ops_errors.py @@ -0,0 +1,92 @@ +"""Envelope-only tests run without a native extension or provider transport.""" +import importlib.util +from pathlib import Path +import unittest + +spec = importlib.util.spec_from_file_location( + 'ops_errors', Path(__file__).parents[1] / 'python/aimux/_errors.py', +) +e = importlib.util.module_from_spec(spec) +spec.loader.exec_module(e) + + +def envelope(payload, kind='aimux', code=1, **extra): + return dict(kind=kind, code=code, message='canonical message', error=payload, **extra) + + +API = dict(name='AI_APICallError', message='rate limited', statusCode=429, + responseHeaders={'retry-after-ms': '20'}, isRetryable=True) + + +class Errors(unittest.TestCase): + def test_every_aimux_name_has_distinct_subtype(self): + for name, cls in e._AIMUX_TYPES.items(): + with self.subTest(name=name): + payload = {'name': name} + if name == 'AI_RetryError': + payload['errors'] = [] + error = e.from_envelope(envelope(payload)) + self.assertIs(type(error), cls) + self.assertIsInstance(error, e.AimuxError) + + def test_all_payload_fields_and_envelope_survive(self): + value = envelope(API, retryAfterMs=20) + error = e.from_envelope(value) + self.assertIs(error.envelope, value) + self.assertIs(error.error, API) + self.assertEqual(error.code, 1) + self.assertEqual(error.kind, 'aimux') + self.assertEqual(error.status_code, 429) + self.assertTrue(error.is_retryable) + self.assertEqual(error.response_headers, {'retry-after-ms': '20'}) + self.assertEqual(error.retry_after_ms, 20) + self.assertEqual(str(error), 'canonical message') + + def test_history_retains_typed_attempts_without_inventing_top_hint(self): + value = envelope(dict(name='AI_RetryError', reason='maxRetriesExceeded', errors=[API, API])) + error = e.from_envelope(value) + self.assertIsInstance(error, e.RetryError) + self.assertIsNone(error.retry_after_ms) + self.assertEqual(error.reason, 'maxRetriesExceeded') + self.assertEqual(len(error.errors), 2) + self.assertIs(error.last_error, error.errors[-1]) + for attempt in error.errors: + self.assertIsInstance(attempt, e.APICallError) + self.assertEqual(attempt.response_headers['retry-after-ms'], '20') + self.assertFalse(hasattr(attempt, 'retry_after_ms')) + + def test_enveloped_attempts_retain_their_hints(self): + error = e.from_envelope(envelope(dict(name='AI_RetryError', errors=[envelope(API, retryAfterMs=20)]))) + self.assertEqual(error.last_error.retry_after_ms, 20) + self.assertIsNone(error.retry_after_ms) + + def test_repair_preserves_cause_and_original(self): + cause = dict(name='AI_JSONParseError', message='invalid JSON') + error = e.from_envelope(envelope(dict(name='AI_ToolCallRepairError', originalError=API, cause=cause))) + self.assertIsInstance(error.original_error, e.APICallError) + self.assertIsInstance(error.cause, e.JSONParseError) + self.assertIs(error.__cause__, error.cause) + self.assertIs(error.error['originalError'], API) + + def test_string_cause_is_preserved(self): + error = e.from_envelope(envelope(dict(name='AI_InvalidToolInputError', cause='invalid input'))) + self.assertEqual(error.cause, 'invalid input') + self.assertIsNone(error.__cause__) + + def test_domains_are_separate(self): + for domain, cls in [('recording', e.RecordingError), ('binding', e.BindingError), ('protocol', e.ProtocolError)]: + with self.subTest(domain=domain): + error = e.from_envelope(envelope(dict(name='InvalidState', detail={'x': [1]}), domain, 304)) + self.assertIs(type(error), cls) + self.assertNotIsInstance(error, e.AimuxError) + self.assertEqual(error.detail, {'x': [1]}) + + def test_unknown_aimux_payload_keeps_evidence(self): + payload = dict(name='FutureError', extra={'nested': '证据'}) + error = e.from_envelope(envelope(payload)) + self.assertIs(type(error), e.AimuxError) + self.assertIs(error.error, payload) + + +if __name__ == '__main__': + unittest.main() diff --git a/bindings/python/tests/test_ops_integration.py b/bindings/python/tests/test_ops_integration.py new file mode 100644 index 000000000..d0770e96a --- /dev/null +++ b/bindings/python/tests/test_ops_integration.py @@ -0,0 +1,233 @@ +"""Offline checks against the real assembled RFC-0039 runtime. + +Build/install a production wheel, then set AIMUX_REAL_OPS=1. The isolated +adapter runner deliberately does not enable these tests. +""" +import os +import unittest + +if os.environ.get('AIMUX_REAL_OPS') == '1': + import aimux + from aimux import ops + + +@unittest.skipUnless(os.environ.get('AIMUX_REAL_OPS') == '1', 'requires assembled shared ops runtime') +class RealOps(unittest.TestCase): + def test_manifest_pin_and_describe(self): + client = aimux.Client() + self.assertEqual(aimux.verify_contract()['opsVersion'], ops.OPS_VERSION) + self.assertEqual(aimux.manifest_digest(), aimux.EXPECTED_MANIFEST_DIGEST) + self.assertIsInstance(client.call(ops.OP_RUNTIME_DESCRIBE), dict) + + def test_unknown_op_is_protocol_error(self): + with self.assertRaises(aimux.ProtocolError) as error: + aimux.Client().call('not.a.registered.operation') + self.assertEqual(error.exception.code, 300) + + def test_unknown_handle_is_binding_error(self): + with self.assertRaises(aimux.BindingError) as error: + aimux.Client().call(ops.OP_MODEL_INFO, handle='18446744073709551615') + self.assertEqual(error.exception.code, 203) + + def test_drop_unknown_and_global_is_idempotent(self): + client = aimux.Client() + for handle in ('0', '18446744073709551615'): + for _ in range(2): + client.call(ops.OP_HANDLE_DROP, handle=handle) + + def provider(self, client=None): + client = client or aimux.Client() + return client.adopt(client.call(ops.OP_PROVIDER_CREATE, { + 'package': 'openai', 'settings': {'apiKey': 'offline-test-key'}, + })['handle']) + + def test_generated_pin_rejects_drift(self): + with self.assertRaises(ImportError): + aimux.verify_contract('outdated-generated-metadata') + + def test_all_model_kinds_round_trip_and_child_survives_provider(self): + with self.provider() as provider: + models = [provider.client.adopt(provider.call(ops.OP_PROVIDER_MODEL, { + 'method': method, 'modelId': 'offline-model', + })['handle']) for method in ( + 'languageModel', 'embeddingModel', 'imageModel', 'speechModel', + 'transcriptionModel', 'decision', + )] + try: + for model in models: + self.assertIsInstance(model.call(ops.OP_MODEL_INFO), dict) + finally: + for model in models: model.close() + + def test_clients_in_same_interpreter_share_handles(self): + with self.provider() as provider: + other = aimux.Client() + result = other.call(ops.OP_PROVIDER_MODEL, { + 'method': 'languageModel', 'modelId': 'offline-model', + }, handle=provider) + with other.adopt(result['handle']) as model: + self.assertIsInstance(model.call(ops.OP_MODEL_INFO), dict) + + def test_drop_is_idempotent_and_invalid_handle_does_not_leak_kind(self): + provider = self.provider() + model = provider.client.adopt(provider.call(ops.OP_PROVIDER_MODEL, { + 'method': 'languageModel', 'modelId': 'offline-model', + })['handle']) + provider.close() + model.close() + model.close() + with self.assertRaises(aimux.BindingError) as error: + model.call(ops.OP_MODEL_INFO) + self.assertEqual(error.exception.code, 203) + self.assertIsNone(error.exception.error.get('actual')) + + def test_gc_queues_handle_cleanup(self): + import gc + import time + client = aimux.Client() + provider = self.provider(client) + handle = provider.id + del provider + gc.collect() + # provider.model is provider-scoped and creates no child on invalid input. + for _ in range(100): + try: + result = client.call(ops.OP_PROVIDER_MODEL, {'method': 'languageModel', 'modelId': 'offline'}, handle=handle) + client.adopt(result['handle']).close() + except aimux.BindingError as error: + self.assertEqual(error.code, 203) + break + time.sleep(0.01) + else: + self.fail('GC cleanup did not remove provider') + + def test_control_dtos_reject_unknown_fields(self): + for op, params in [(ops.OP_RUNTIME_DESCRIBE, {'unknown': True}), + (ops.OP_PROVIDER_CREATE, {'package': 'openai', 'unknown': True})]: + with self.subTest(op=op), self.assertRaises(aimux.ProtocolError) as error: + aimux.Client().call(op, params) + self.assertEqual(error.exception.code, 301) + + def test_wrong_handle_kind_rejected_before_provider_call(self): + with self.provider() as provider: + with self.assertRaises(aimux.BindingError) as error: + provider.call(ops.OP_TEXT_GENERATE, {'prompt': 'hi'}) + self.assertEqual(error.exception.code, 203) + + def test_binary_push_uses_typed_service_and_enforces_byte_limit(self): + client = aimux.Client() + with self.assertRaises(aimux.BindingError) as error: + client.operation_push('18446744073709551615', b'\0\0') + self.assertEqual(error.exception.code, 203) + for payload in (b'x' * (1024 * 1024 + 1), memoryview(b'x' * (1024 * 1024 + 1))): + with self.assertRaises(aimux.ProtocolError) as error: + client.operation_push('0', payload) + self.assertEqual(error.exception.code, 305) + with self.assertRaises(aimux.ProtocolError) as error: + client.call(ops.OP_OPERATION_PUSH) + self.assertEqual(error.exception.code, 300) + + def test_secondary_abort_is_owner_typed(self): + with self.provider() as provider: + with self.assertRaises(aimux.BindingError) as error: + provider.call(ops.OP_PROVIDER_INVOKE, {'method': 'listModels', 'abort': provider.id}) + self.assertEqual(error.exception.code, 203) + + def test_stream_validation_error_precedes_acceptance(self): + with self.assertRaises(aimux.BindingError): + aimux.Client().stream(ops.OP_TEXT_STREAM, {'prompt': 'hi'}, handle='18446744073709551615') + + def test_recording_domain_comes_from_real_core(self): + import tempfile + from pathlib import Path + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'file' + path.write_text('blocks directory creation') + with self.assertRaises(aimux.RecordingError) as error: + aimux.Client().call(ops.OP_RECORDER_CREATE, { + 'backend': {'type': 'jsonl', 'directory': str(path / 'child')}, + 'capturePolicy': 'full', + }) + self.assertEqual(error.exception.kind, 'recording') + + def test_recorder_round_trip(self): + client = aimux.Client() + with client.adopt(client.call(ops.OP_RECORDER_CREATE, { + 'backend': {'type': 'ring', 'capacity': 4}, 'capturePolicy': 'full', + })['handle']) as recorder: + self.assertIsInstance(recorder.call(ops.OP_RECORDER_STATS), dict) + recorder.call(ops.OP_RECORDER_FLUSH) + self.assertIsInstance(recorder.call(ops.OP_RECORDER_EXPORT)['jsonl'], str) + + @unittest.skipUnless(hasattr(os, 'mkfifo'), 'requires POSIX FIFOs') + def test_real_constructor_io_releases_gil(self): + from gil_ops import recording + from test_ops_native import heartbeat + with recording('create') as invoke: + recorder = heartbeat(invoke) + self.assertIsInstance(recorder.id, str) + + def test_old_constructors_are_absent(self): + for name in ('openai', 'deepseek', 'anthropic', 'openai_embedding', 'DecisionModel', 'init_recording'): + self.assertFalse(hasattr(aimux, name), name) + + +@unittest.skipUnless(os.environ.get('AIMUX_REAL_OPS') == '1' and os.environ.get('AIMUX_HTTP_TESTS') == '1', + 'requires localhost HTTP/WS fixtures') +class RealProviderRoundTrips(unittest.TestCase): + def test_modalities(self): + from gil_ops import MODALITIES, modality + expected = {'embedding': 'embeddings', 'embedding_many': 'embeddings', 'speech': 'audio', + 'image': 'images', 'transcription': 'text', 'reranking': 'ranking', + 'search': 'results', 'upload_file': 'providerReference'} + for name in MODALITIES: + with self.subTest(name=name), modality(name) as invoke: + result = invoke() + self.assertIn(expected[name], result) + self.assertEqual(__import__('json').loads(__import__('json').dumps(result)), result) + + def test_text_and_streams(self): + from gil_ops import language + for op in (ops.OP_TEXT_GENERATE, ops.OP_TEXT_GENERATE_OBJECT, ops.OP_TEXT_GENERATE_OPEN_AI, ops.OP_TEXT_CONSUME, + ops.OP_TEXT_STREAM, ops.OP_TEXT_STREAM_OPEN_AI): + with self.subTest(op=op), language(op) as invoke: + result = invoke() + self.assertIsInstance(result, list if op in (ops.OP_TEXT_STREAM, ops.OP_TEXT_STREAM_OPEN_AI) else dict) + if op in (ops.OP_TEXT_STREAM, ops.OP_TEXT_STREAM_OPEN_AI): + # The Python bridge consumes End: success stops iteration, + # while terminal failures raise typed exceptions. + self.assertTrue(result) + self.assertTrue(all(frame['type'] == 'event' for frame in result)) + events = [frame['event'] for frame in result] + if op == ops.OP_TEXT_STREAM: + text = ''.join(event['text'] for event in events if event['type'] == 'text-delta') + else: + text = ''.join(choice['delta'].get('content', '') + for event in events for choice in event['choices']) + self.assertEqual(text, 'Hello world') + elif op == ops.OP_TEXT_GENERATE_OBJECT: + self.assertEqual(result['object'], {'answer': 'hello'}) + elif op == ops.OP_TEXT_GENERATE_OPEN_AI: + self.assertEqual(result['choices'][0]['message']['content'], + 'Rust is a systems programming language.') + else: + self.assertEqual(result['text'], 'Hello world' if op == ops.OP_TEXT_CONSUME + else 'Rust is a systems programming language.') + + def test_video_and_decision(self): + from gil_ops import video, decision + for factory in (video, decision): + with self.subTest(factory=factory.__name__), factory() as invoke: + self.assertIsInstance(invoke(), dict) + + def test_realtime_push_and_poll(self): + from gil_ops import model_at + from ops_fixtures import slow_ws + with slow_ws(0) as url, model_at(url, 'transcriptionModel', 'gpt-realtime-whisper') as model: + with model.client.adopt(model.call(ops.OP_TRANSCRIPTION_START)) as operation: + operation.push(b'\0\0') + part = operation.call(ops.OP_OPERATION_NEXT, {'timeoutMs': 5000}) + self.assertEqual(part['type'], 'part') + operation.call(ops.OP_OPERATION_INPUT_DONE) + operation.call(ops.OP_OPERATION_CANCEL) + self.assertIsInstance(operation.call(ops.OP_OPERATION_STATUS), dict) diff --git a/bindings/python/tests/test_ops_native.py b/bindings/python/tests/test_ops_native.py new file mode 100644 index 000000000..7d99efb31 --- /dev/null +++ b/bindings/python/tests/test_ops_native.py @@ -0,0 +1,365 @@ +"""Native boundary tests using the isolated dispatcher double. + +Run with run_native_contract.py. Real provider/ops integration is separate. +""" +import gc +import json +import os +import threading +import time +import unittest +import weakref + +if os.environ.get('AIMUX_OPS_DOUBLE') == '1': + import aimux + from aimux import ops + + +def heartbeat(operation): + ticks = [] + stop = threading.Event() + ready = threading.Event() + + def beat(): + ready.set() + while not stop.wait(0.005): + ticks.append(time.monotonic()) + + thread = threading.Thread(target=beat) + thread.start() + ready.wait() + started = time.monotonic() + try: + result = operation() + ended = time.monotonic() + finally: + stop.set() + thread.join(2) + assert ended - started >= 0.12, (started, ended) + # A tick well inside the wait proves concurrency without asserting CPU + # throughput on an oversubscribed build host. Exclude Python entry/exit. + assert any(started + 0.02 < tick < ended - 0.02 for tick in ticks), ticks + return result + + +@unittest.skipUnless(os.environ.get('AIMUX_OPS_DOUBLE') == '1', 'requires isolated native build') +class NativeBoundary(unittest.TestCase): + def setUp(self): + self.client = aimux.Client() + + def stream(self, **params): + return self.client.stream(ops.OP_TEXT_STREAM, params, handle='0') + + def stats(self): + return self.client.call('test.stats') + + def wait_inactive(self): + for _ in range(100): + if self.stats()['active'] == 0: + return + time.sleep(0.01) + self.fail('stream task leaked') + + @unittest.skipUnless(os.name == 'posix', 'POSIX GIL-holding control') + def test_heartbeat_rejects_a_gil_holding_native_call(self): + import ctypes + # PyDLL deliberately retains the GIL, unlike CDLL. This checks that + # the heartbeat harness detects a real native blocking regression. + sleep = ctypes.PyDLL(None).usleep + sleep.argtypes = [ctypes.c_uint] + sleep.restype = ctypes.c_int + with self.assertRaises(AssertionError): + heartbeat(lambda: sleep(160000)) + + def test_gil_heartbeat_every_blocking_call_family(self): + operations = [ + ops.OP_TEXT_GENERATE, ops.OP_TEXT_GENERATE_OBJECT, ops.OP_TEXT_CONSUME, ops.OP_TEXT_GENERATE_OPEN_AI, + ops.OP_EMBEDDING_EMBED, ops.OP_EMBEDDING_EMBED_MANY, ops.OP_IMAGE_GENERATE, ops.OP_SPEECH_GENERATE, + ops.OP_TRANSCRIPTION_GENERATE, ops.OP_RERANKING_RERANK, ops.OP_VIDEO_GENERATE, ops.OP_SEARCH_SEARCH, + ops.OP_PROVIDER_CREATE, ops.OP_PROVIDER_INVOKE, ops.OP_CATALOGUE_FETCH, ops.OP_RECORDER_CREATE, + ops.OP_TRANSCRIPTION_START, ops.OP_OPERATION_NEXT, ops.OP_RECORDER_FLUSH, ops.OP_FILES_UPLOAD, + ] + for op in operations: + with self.subTest(op=op): + result = heartbeat(lambda: self.client.call(op)) + if 'handle' in result: + self.client.adopt(result['handle']).close() + + def test_stream_construction_releases_gil(self): + stream = heartbeat(lambda: self.stream(acceptDelayMs=160)) + stream.close() + + def test_stream_poll_releases_gil(self): + with self.stream(delayMs=160) as stream: + self.assertEqual(heartbeat(lambda: next(stream))['seq'], '0') + + def test_join_releases_gil(self): + with self.stream(count=0, delayMs=160) as stream: + heartbeat(stream.join) + self.assertEqual(list(stream), []) + + def test_close_releases_gil_during_teardown(self): + stream = self.stream(count=100000, dropDelayMs=160) + time.sleep(0.04) + heartbeat(stream.close) + with self.assertRaises(aimux.RequestAbortedError): + next(stream) + + def test_full_queue_close_does_not_deadlock(self): + stream = self.stream(count=100000) + time.sleep(0.1) + started = time.monotonic() + stream.close() + self.assertLess(time.monotonic() - started, 1) + stream.close() + self.wait_inactive() + + def test_byte_budget_backpressure_is_cancellable(self): + stream = self.stream(count=100000, bytes=900000) + time.sleep(0.15) + stream.close() + self.wait_inactive() + + def test_single_event_limit_raises_protocol_error(self): + stream = self.stream(bytes=1024*1024) + try: + with self.assertRaises(aimux.ProtocolError) as error: + next(stream) + self.assertEqual(error.exception.code, 305) + finally: + stream.close() + + def test_success_is_the_only_normal_exhaustion(self): + stream = self.stream() + self.assertEqual([frame['seq'] for frame in stream], ['0', '1']) + with self.assertRaises(StopIteration): + next(stream) + stream.join() + + def test_late_cancel_preserves_success(self): + stream = self.stream(count=1) + stream.join() + stream.cancel() + self.assertEqual([frame['seq'] for frame in stream], ['0']) + stream.close() + self.assertEqual(list(stream), []) + + def test_success_does_not_cancel_a_shared_abort(self): + abort = aimux.Abort() + self.client.call('test.echo', {'delayMs': 0}, abort=abort) + self.assertFalse(abort.cancelled) + + def test_python_serialization_errors_use_binding_domain(self): + for params in [{'bytes': b'raw'}, {'bad': float('nan')}, {'bad': object()}]: + with self.subTest(params=params), self.assertRaises(aimux.BindingError) as error: + self.client.call('test.echo', params) + self.assertEqual(error.exception.kind, 'binding') + for arguments in [{'handle': 7}, {'op': 1}, {'abort': object()}, {'op': '\ud800'}]: + with self.subTest(arguments=arguments), self.assertRaises(aimux.BindingError): + self.client.call(**dict({'op': 'test.echo'}, **arguments)) + + def test_full_queue_keeps_terminal_error(self): + stream = self.stream(count=64, outcome='error') + stream.join() + delivered = 0 + try: + with self.assertRaises(aimux.APICallError): + while True: + next(stream) + delivered += 1 + self.assertEqual(delivered, 64) + finally: + stream.close() + + def test_recoverable_error_event_is_data(self): + with self.stream(count=1, recoverableError=True) as stream: + self.assertEqual(next(stream)['event']['type'], 'error') + self.assertEqual(list(stream), []) + + def test_error_terminal_preserves_subtype(self): + with self.stream(outcome='error', count=1) as stream: + self.assertEqual(next(stream)['seq'], '0') + for _ in range(2): + with self.assertRaises(aimux.APICallError) as error: + next(stream) + self.assertEqual(error.exception.status_code, 429) + + def test_cancelled_terminal_raises(self): + with self.stream(outcome='cancelled', count=0) as stream: + with self.assertRaises(aimux.RequestAbortedError): + next(stream) + + def test_missing_terminal_is_protocol_failure(self): + with self.stream(outcome='missing', count=0) as stream: + with self.assertRaises(aimux.ProtocolError) as error: + next(stream) + self.assertEqual(error.exception.code, 304) + + def test_acceptance_error_is_synchronous(self): + with self.assertRaises(aimux.ProtocolError): + self.stream(reject=True) + + def test_context_exit_cancels(self): + with self.stream(count=100000) as stream: + next(stream) + with self.assertRaises(aimux.RequestAbortedError): + next(stream) + self.wait_inactive() + + def test_explicit_abort_interrupts_call(self): + abort = aimux.Abort() + timer = threading.Timer(0.1, abort.cancel) + timer.start() + started = time.monotonic() + try: + with self.assertRaises(aimux.RequestAbortedError): + self.client.call(ops.OP_TEXT_GENERATE, {'delayMs': 10000}, abort=abort) + finally: + timer.join() + self.assertLess(time.monotonic() - started, 1) + self.assertTrue(abort.cancelled) + + def test_explicit_abort_interrupts_stream(self): + abort = aimux.Abort() + stream = self.client.stream(ops.OP_TEXT_STREAM, {'delayMs': 10000}, handle='0', abort=abort) + abort.cancel() + try: + with self.assertRaises(aimux.RequestAbortedError): + next(stream) + finally: + stream.close() + + def test_keyboard_interrupt_cancels_calls(self): + import _thread + timer = threading.Timer(0.1, _thread.interrupt_main) + timer.start() + try: + with self.assertRaises(KeyboardInterrupt): + self.client.call(ops.OP_TEXT_GENERATE, {'delayMs': 10000}) + finally: + timer.join() + + def test_keyboard_interrupt_cancels_poll(self): + import _thread + stream = self.stream(delayMs=10000) + timer = threading.Timer(0.1, _thread.interrupt_main) + timer.start() + try: + with self.assertRaises(KeyboardInterrupt): + next(stream) + finally: + timer.join() + stream.close() + self.wait_inactive() + + def test_concurrent_consumer_is_protocol_error(self): + stream = self.stream(delayMs=10000) + errors = [] + ready = threading.Event() + def consume(): + ready.set() + try: + next(stream) + except aimux.RequestAbortedError: + pass + except BaseException as error: + errors.append(error) + thread = threading.Thread(target=consume) + thread.start() + ready.wait() + time.sleep(0.05) + try: + with self.assertRaises(aimux.ProtocolError) as error: + next(stream) + self.assertEqual(error.exception.code, 303) + finally: + stream.cancel() + thread.join(2) + stream.close() + self.assertFalse(thread.is_alive()) + self.assertEqual(errors, []) + + def test_gc_cancels_unconsumed_stream(self): + stream = self.stream(count=100000) + ref = weakref.ref(stream) + del stream + gc.collect() + self.assertIsNone(ref()) + self.wait_inactive() + + def test_handle_context_and_gc_drop(self): + before = self.stats()['handles'] + result = self.client.call(ops.OP_PROVIDER_CREATE, {'delayMs': 0}) + with self.client.adopt(result['handle']) as provider: + self.assertEqual(self.stats()['handles'], before + 1) + self.assertEqual(self.stats()['handles'], before) + provider.close() + with self.assertRaises(aimux.BindingError) as error: + provider.call(ops.OP_MODEL_INFO) + self.assertEqual(error.exception.code, 203) + leaked = self.client.adopt(self.client.call(ops.OP_PROVIDER_CREATE, {'delayMs': 0})['handle']) + del leaked + gc.collect() + for _ in range(100): + if self.stats()['handles'] == before: + break + time.sleep(0.01) + self.assertEqual(self.stats()['handles'], before) + + def test_owner_is_shared_between_clients_in_an_interpreter(self): + first = self.client.call('test.echo', {'delayMs': 0})['owner'] + self.assertGreater(first, 0) + self.assertEqual(aimux.Client().call('test.echo', {'delayMs': 0})['owner'], first) + + def test_owner_cannot_be_forged_with_python_monkeypatch(self): + import _xxsubinterpreters + actual = self.client.call('test.echo', {'delayMs': 0})['owner'] + original = _xxsubinterpreters.get_current + _xxsubinterpreters.get_current = lambda: 99999999 + try: + self.assertEqual(aimux.Client().call('test.echo', {'delayMs': 0})['owner'], actual) + finally: + _xxsubinterpreters.get_current = original + + def test_wire_errors_use_binding_domain(self): + with self.assertRaises(aimux.BindingError) as error: + self.client._native.call('0', ops.OP_TEXT_GENERATE, '{bad') + self.assertEqual(error.exception.code, 202) + self.assertEqual(error.exception.argument, 'params_json') + for handle in ['-1', ' 1', '', '18446744073709551616', '1x']: + with self.subTest(handle=handle), self.assertRaises(aimux.ProtocolError): + self.client._native.call(handle, ops.OP_MODEL_INFO) + + def test_multimodal_wire_round_trip(self): + params = { + 'delayMs': 0, 'values': ['中文', '😀'], 'audio': {'type':'base64','value':'AAEC'}, + 'providerOptions': {'vendor': {'open': [None, True, 1.5]}}, + } + for op in [ops.OP_EMBEDDING_EMBED_MANY, ops.OP_IMAGE_GENERATE, ops.OP_SPEECH_GENERATE, ops.OP_TRANSCRIPTION_GENERATE, + ops.OP_RERANKING_RERANK, ops.OP_VIDEO_GENERATE, ops.OP_SEARCH_SEARCH, ops.OP_FILES_UPLOAD]: + with self.subTest(op=op): + self.assertEqual(self.client.call(op, params)['params'], params) + + def test_nested_errors_from_native_envelope(self): + api = dict(name='AI_APICallError', statusCode=429, message='slow', isRetryable=True) + payload = dict(name='AI_RetryError', reason='maxRetriesExceeded', errors=[api, api]) + with self.assertRaises(aimux.RetryError) as error: + self.client.call(ops.OP_TEXT_GENERATE, {'delayMs': 0, 'failure': payload}) + self.assertEqual(len(error.exception.errors), 2) + self.assertIsInstance(error.exception.last_error, aimux.APICallError) + payload = dict(name='AI_ToolCallRepairError', originalError=api, + cause={'name':'AI_JSONParseError','message':'bad'}) + with self.assertRaises(aimux.ToolCallRepairError) as error: + self.client.call(ops.OP_TEXT_GENERATE, {'delayMs': 0, 'failure': payload}) + self.assertIsInstance(error.exception.__cause__, aimux.JSONParseError) + + def test_manifest_checks(self): + self.assertEqual(aimux.verify_contract()['opsVersion'], ops.OPS_VERSION) + self.assertEqual(aimux.manifest_digest(), ops.MANIFEST_DIGEST) + with self.assertRaises(ImportError): + aimux.verify_contract('a different consumer digest') + aimux.verify_contract(aimux.manifest_digest()) + + +if __name__ == '__main__': + unittest.main() diff --git a/bindings/python/tests/test_ops_shared.py b/bindings/python/tests/test_ops_shared.py new file mode 100644 index 000000000..75a9c34a9 --- /dev/null +++ b/bindings/python/tests/test_ops_shared.py @@ -0,0 +1,85 @@ +"""Replay every shared vector through the production PyO3 call boundary. + +Only a scripted source is injected at the real Dispatcher's public test seam. +This suite runs in ordinary pytest/unittest discovery, including without the +optional provider integration environment flags. Release wheels have no seam. +""" +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + + +class SharedNativeVectors(unittest.TestCase): + @classmethod + def setUpClass(cls): + binding = Path(__file__).resolve().parents[1] + root = Path(os.environ.get('AIMUX_CONTRACT_VECTORS', binding.parents[1] / 'contract-tests/ops')) + cls.paths = sorted((root if root.name == 'scenarios' else root / 'scenarios').glob('*.json')) + if len(cls.paths) != 11: + raise AssertionError('all 11 shared stream scenarios must be replayed') + native_library = os.environ.get('AIMUX_PYTHON_CONTRACT_LIBRARY') + if not native_library: + command = [os.environ.get('AIMUX_BUILD_COMMAND', 'cargo'), 'build', '--manifest-path', + str(binding / 'Cargo.toml'), '--features', 'contract-tests', '--message-format=json'] + built = subprocess.run(command, env=dict(os.environ, PYO3_PYTHON=sys.executable), + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=1200) + artifacts = [json.loads(line) for line in built.stdout.splitlines() if line.startswith('{')] + if built.returncode: + diagnostics = ''.join(item['message'].get('rendered', '') for item in artifacts + if item.get('reason') == 'compiler-message') + raise AssertionError(built.stderr + '\n' + diagnostics) + libraries = [path for item in artifacts if item.get('reason') == 'compiler-artifact' + and item['target']['name'] == 'aimux' for path in item['filenames'] + if path.endswith(('.dylib', '.so', '.dll'))] + if len(libraries) != 1: + raise AssertionError('expected one native contract module: %r' % libraries) + native_library = libraries[0] + cls.folder = tempfile.TemporaryDirectory(prefix='aimux-python-vectors-') + cls.addClassCleanup(cls.folder.cleanup) + library = Path(cls.folder.name) / ('aimux.pyd' if sys.platform == 'win32' else 'aimux.so') + shutil.copyfile(native_library, library) + # The final component selects PyInit_aimux, while a distinct module key + # keeps the installed production module and its owner guard intact. + spec = importlib.util.spec_from_file_location('_aimux_contract.aimux', library) + cls.native = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.native) + cls.client = cls.native.Client() + + def test_shared_vectors(self): + for path in self.paths: + with self.subTest(scenario=path.stem): + case = json.loads(path.read_text()) + expected = case.pop('expected') + actual = json.loads(self.client.call('0', 'test.scenario', json.dumps(case))) + if expected['abortObserved'] is None: + actual['abortObserved'] = None + if actual['preflightError']: + actual['preflightError'] = self.error(actual['preflightError']) + for key in ('frames', 'consumerFrames'): + terminal = next((frame for frame in expected[key] if frame['type'] == 'end'), {}) + for frame in actual[key]: + if frame['type'] == 'end' and 'error' in frame: + if 'error' in terminal: + frame['error'] = self.error(frame['error']) + else: + del frame['error'] + self.assertEqual(actual, expected) + + def error(self, value): + self.assertIsInstance(value['message'], str) + self.assertIsInstance(value['error']['name'], str) + return {'kind': value['kind'], 'code': value['code'], 'error': {'name': value['error']['name']}} + + def test_owner_teardown_precedes_runtime_shutdown(self): + result = json.loads(self.client.call('0', 'test.teardown', '{}')) + self.assertEqual(result, {'released': True, 'runtimeAlive': True}) + + +if __name__ == '__main__': + unittest.main() diff --git a/bindings/python/tests/test_ops_wrapper.py b/bindings/python/tests/test_ops_wrapper.py new file mode 100644 index 000000000..e6cf67ac5 --- /dev/null +++ b/bindings/python/tests/test_ops_wrapper.py @@ -0,0 +1,156 @@ +"""Retained typed wire-schema regressions, independent of provider constructors.""" +import importlib.util +import os +import unittest + +HAS_TYPED = importlib.util.find_spec("pydantic") is not None and any( + os.environ.get(flag) == "1" for flag in ("AIMUX_REAL_OPS", "AIMUX_OPS_DOUBLE") +) +if HAS_TYPED: + from pydantic import TypeAdapter + from aimux.wrapper import ( + ContentPart, GenerateContent, GeneratedFile, StreamPart, TextContent, TextContentPart, + ) + +@unittest.skipUnless(HAS_TYPED, "requires installed binding and optional Pydantic") +class TestRequestOptions(unittest.TestCase): + + def test_unknown_request_options_are_rejected(self): + from aimux.wrapper import GenerateTextOptions + + for wire in ({"maxRetry": 0}, {"timeout": {"toolMs": 100}}, + {"timeout": {"totalMs": 100, "unknown": True}}): + with self.subTest(wire=wire), self.assertRaises(ValueError): + GenerateTextOptions.model_validate(wire) + + def test_python_names_and_provider_tool_flags_round_trip(self): + from aimux.wrapper import GenerateTextOptions, ProviderTool, TimeoutConfiguration + + for deferred in (None, False, True): + with self.subTest(deferred=deferred): + tool = ProviderTool(id="anthropic.code_execution_20250825", name="code_execution", + args={}, supports_deferred_results=deferred) + wire_tool = {"type": "provider", "id": tool.id, "name": tool.name, "args": {}} + if deferred is not None: + wire_tool["supportsDeferredResults"] = deferred + wire = {"maxRetries": 0, "timeout": {"totalMs": 100}, "tools": [wire_tool]} + options = GenerateTextOptions(max_retries=0, timeout=TimeoutConfiguration(total_ms=100), + tools=[tool]) + self.assertEqual(options.model_dump(mode="json", by_alias=True), wire) + self.assertEqual(GenerateTextOptions.model_validate(wire), options) + +@unittest.skipUnless(HAS_TYPED, "requires installed binding and optional Pydantic") +class TestGeneratedFiles(unittest.TestCase): + + def test_generated_file_is_base64_and_media_type(self): + """GeneratedFile is {base64, mediaType, providerMetadata?} (ai + generate-text/generated-file.ts:10-32); a text file part is + {type, file, providerMetadata?} (convert-language-model-content.ts:55-70); + the provider's LanguageModelV4File keeps {type, mediaType, data}.""" + file = GeneratedFile.model_validate({"base64": "aGk=", "mediaType": "text/plain"}) + assert file.model_dump(by_alias=True) == {"base64": "aGk=", "mediaType": "text/plain"} + assert GeneratedFile(base64=b"hi", media_type="text/plain").base64 == "aGk=" + assert file.to_bytes() == b"hi" + + for tag in ("file", "reasoning-file"): + wire = { + "type": tag, + "file": {"base64": "aGk=", "mediaType": "text/plain"}, + "providerMetadata": {"google": {"thoughtSignature": "sig"}}, + } + for adapter in (TypeAdapter(TextContent), TypeAdapter(StreamPart)): + assert adapter.dump_python(adapter.validate_python(wire), by_alias=True) == wire + + raw = { + "type": "file", + "data": {"type": "url", "url": "https://example.com/a.png"}, + "mediaType": "image/png", + } + adapter = TypeAdapter(GenerateContent) + assert adapter.dump_python(adapter.validate_python(raw), by_alias=True) == raw + +@unittest.skipUnless(HAS_TYPED, "requires installed binding and optional Pydantic") +class TestStandaloneTypeTags(unittest.TestCase): + + def test_standalone_tool_calls_and_sources_are_tagged(self): + """A result's toolCalls and sources are the AI SDK's TypedToolCall and + Source, tagged {type: 'tool-call'} (ai generate-text/tool-call.ts:8-14) + and {type: 'source', sourceType} (provider language-model-v4-source.ts:8, + :36); generate-text-result.ts:73, :78. A repair function's call is a + tagged LanguageModelV4ToolCall (language-model-v4-tool-call.ts:7).""" + from aimux.wrapper import GenerateTextResult, RawToolCall, ToolCall + + call = {"type": "tool-call", "toolCallId": "call-1", "toolName": "weather", "input": {"city": "SG"}} + url = {"type": "source", "sourceType": "url", "id": "s1", "url": "https://example.com"} + result = GenerateTextResult.model_validate( + {"text": "", "finishReason": "stop", "toolCalls": [call], "sources": [url]} + ) + dumped = result.model_dump(by_alias=True) + assert dumped["toolCalls"] == [call] + assert dumped["sources"] == [url] + assert RawToolCall(tool_call_id="c", tool_name="t", input="{}").model_dump(by_alias=True) == { + "type": "tool-call", "toolCallId": "c", "toolName": "t", "input": "{}", + } + + # JSON written before the tag still reads; another tag is rejected. + assert ToolCall.model_validate({"toolCallId": "c", "toolName": "t", "input": {}}).type == "tool-call" + with self.assertRaises(ValueError): + ToolCall.model_validate({"type": "source", "toolCallId": "c", "toolName": "t", "input": {}}) + +@unittest.skipUnless(HAS_TYPED, "requires installed binding and optional Pydantic") +class TestNullIsKeptApartFromMissing(unittest.TestCase): + + def test_unknown_fields_keep_null(self): + """``body?: unknown`` (provider language-model-v4-generate-result.ts:41, + :56) and ``inputSchemaInput?: unknown`` (provider-utils + types/tool-approval-request.ts:41): the AI SDK's JSON keeps ``null`` + and omits ``undefined``; so does the dump of a parsed model.""" + from aimux.wrapper import GenerateResponseMetadata, RequestInfo + + for wire in ({}, {"body": None}, {"body": {"a": None}}): + assert GenerateResponseMetadata.model_validate(wire).model_dump( + mode="json", by_alias=True) == wire + assert RequestInfo.model_validate(wire).model_dump( + mode="json", by_alias=True) == wire + approval = {"type": "tool-approval-request", "approvalId": "a1", + "toolCallId": "c1", "inputSchemaInput": None} + part = TypeAdapter(ContentPart).validate_python(approval) + assert part.model_dump(mode="json", by_alias=True) == approval + + def test_null_tool_input_is_sent_as_null(self): + """A tool call's ``input`` is a JSON value; ``null`` is sent back as + ``null`` instead of being dropped (which Rust rejects as missing).""" + part = TypeAdapter(ContentPart).validate_python( + {"type": "tool-call", "toolCallId": "c1", "toolName": "t", "input": None}) + assert part.model_dump(mode="json", by_alias=True)["input"] is None + assert TextContentPart(text="x").model_dump(mode="json", by_alias=True) == { + "type": "text", "text": "x"} + +@unittest.skipUnless(HAS_TYPED, "requires installed binding and optional Pydantic") +class TestStepsAndUsageAreCamelCase(unittest.TestCase): + + def test_usage_and_step_round_trip(self): + """``LanguageModelUsage`` (ai types/usage.ts:10-67) is camelCase with + an unknown count absent; a step is ``StepResult``'s own fields + (generate-text/step-result.ts:138-278), camelCase.""" + from aimux.wrapper import LanguageModelUsage, StepResult + + usage = {"inputTokens": 3, "inputTokenDetails": {"cacheReadTokens": 1}, + "outputTokens": 2, "outputTokenDetails": {}, "totalTokens": 5} + parsed = LanguageModelUsage.model_validate(usage) + assert parsed.input_token_details.cache_read_tokens == 1 + assert parsed.model_dump(mode="json", by_alias=True) == usage + + step = { + "callId": "c1", "stepNumber": 0, + "model": {"provider": "openai", "modelId": "gpt-4o"}, + "content": [], "finishReason": "stop", "usage": usage, + "performance": {"stepTimeMs": 1.0}, "warnings": [], "request": {}, + "response": {"id": "r1", "timestamp": "2026-01-01T00:00:00.000Z", + "modelId": "gpt-4o", "body": None, "messages": []}, + } + parsed_step = StepResult.model_validate(step) + assert parsed_step.model.model_id == "gpt-4o" + assert parsed_step.response.model_id == "gpt-4o" + assert parsed_step.model_dump(mode="json", by_alias=True) == step + diff --git a/bindings/python/tests/test_reasoning_signature.py b/bindings/python/tests/test_reasoning_signature.py deleted file mode 100644 index 9741a7905..000000000 --- a/bindings/python/tests/test_reasoning_signature.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Response-side reasoning signature visibility for the Python binding (#135). - -The Python binding constructs prompts as plain JSON dicts, so input-side -transparency is guaranteed by serde (asserted at the Rust level). What this -test proves is the *visibility* half of the round-trip: a thinking-block -response surfaces `signature` on the reasoning content of the result, so -extended-thinking multi-turn can echo it back. -""" - -import json - -from aimux import anthropic, generate_text - -from test_e2e import MockServer - -ANTHROPIC_THINKING = json.dumps({ - "id": "msg_py_rt", - "type": "message", - "role": "assistant", - "model": "claude-sonnet-4-20250514", - "content": [ - {"type": "thinking", "thinking": "pondering", "signature": "sig-py-resp-1"}, - {"type": "text", "text": "The answer."}, - ], - "stop_reason": "end_turn", - "usage": {"input_tokens": 10, "output_tokens": 5}, -}) - - -class TestReasoningSignatureVisibility: - - def test_result_carries_response_signature(self): - with MockServer(ANTHROPIC_THINKING) as mock: - model = anthropic("test-key", "claude-sonnet-4-20250514", mock.url) - result = generate_text(model, "Hello") - assert result["text"] == "The answer." - dumped = json.dumps(result) - assert "sig-py-resp-1" in dumped, ( - "expected the result to carry the thinking-block signature, " - f"got {dumped}" - ) diff --git a/bindings/python/tests/test_recording_smoke.py b/bindings/python/tests/test_recording_smoke.py deleted file mode 100644 index cf80e5d14..000000000 --- a/bindings/python/tests/test_recording_smoke.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Offline smoke test for the recording + mock-replay entry points (T5). - -These were registered on the native module but never re-exported from the -Python package. Pure offline: ``init_recording`` writes to disk and -``mock_replay`` needs valid recordings, so we only assert the entry points are -exposed and callable — we never invoke them. -""" - -import aimux - -_ENTRIES = [ - "init_recording", - "init_recording_ring", - "recording_stop", - "recording_flush", - "recording_try_flush", - "mock_replay", -] - - -def test_recording_entries_exposed(): - """Every recording/replay entry point is present and callable.""" - for name in _ENTRIES: - assert hasattr(aimux, name), f"missing entry point: {name}" - assert callable(getattr(aimux, name)), f"not callable: {name}" - - -def test_recording_entries_in_all(): - """Every recording/replay entry point is part of the public API.""" - for name in _ENTRIES: - assert name in aimux.__all__, f"missing from __all__: {name}" - - -def test_init_recording_ring_no_arg_uses_default(): - """Omitting cap uses the library default capacity and does not raise. - - The ring recorder is in-memory (no disk I/O), so invoking it here is safe; - ``recording_stop`` resets the global recorder afterward. - """ - aimux.init_recording_ring() - aimux.recording_stop() - - -def test_recorder_init_failure_raises_recording_error(tmp_path): - """A failed recorder initialization is a separate, typed error. - - A parent path that is a regular file fails during initialization with - ``Init``; no recorder is installed, so a checked flush remains a no-op. - """ - import pytest - - aimux.recording_stop() - aimux.recording_try_flush() # nothing recording: nothing to flush - - # Parent path is a regular file: init itself raises (code "Init"); the - # recorder is not silently degraded and discovered at the first flush. - blocker = tmp_path / "occupied" - blocker.write_text("x") - try: - with pytest.raises(aimux.RecordingError) as ei: - aimux.init_recording(str(blocker / "sub")) - assert ei.value.code == "Init" - # A recorder failure is not an AiMuxError failure: separate type. - assert not isinstance(ei.value, aimux.AimuxError) - aimux.recording_try_flush() # nothing installed: still a success - finally: - aimux.recording_stop() diff --git a/bindings/python/tests/test_session.py b/bindings/python/tests/test_session.py deleted file mode 100644 index 61fc1e630..000000000 --- a/bindings/python/tests/test_session.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Tests for RFC-0024 session grouping through the Python binding. - -Verifies: `session_id` in typed options reaches the core, calls are grouped in -the registered SessionStore, and the query API returns parsed results. Uses -the shared mock HTTP server — no real API calls. -""" - -import pytest - -from aimux import ( - deepseek, - init_session_store, - init_session_infer, - session_calls, - list_sessions, -) -from aimux.wrapper import GenerateTextOptions, generate_text - -from test_e2e import MockServer, OPENAI_CHAT - - -class TestSessionGrouping: - """Explicit session_id groups calls; query API returns them.""" - - def test_explicit_session_groups_calls(self): - init_session_store() - init_session_infer(False) - - with MockServer(OPENAI_CHAT) as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - - generate_text(model, "first", GenerateTextOptions(session_id="sess-1")) - generate_text(model, "second", GenerateTextOptions(session_id="sess-1")) - - calls = session_calls("sess-1") - assert len(calls) == 2 - assert [c["step"] for c in calls] == [0, 1] - assert calls[0]["callId"] != calls[1]["callId"] - assert calls[0]["recordedAt"].endswith("Z") - - # A call without session_id (inference off) is not grouped. - generate_text(model, "third") - sessions = list_sessions() - assert len(sessions) == 1 - assert sessions[0]["sessionId"] == "sess-1" - assert sessions[0]["source"] == "Explicit" - - # Unknown session → empty. - assert session_calls("nope") == [] - - # Separate explicit session. - generate_text(model, "other", GenerateTextOptions(session_id="sess-2")) - assert len(list_sessions()) == 2 - assert len(session_calls("sess-2")) == 1 - - def test_opt_in_inferer_groups_prefix_continuations(self): - init_session_store() - init_session_infer(True) - - with MockServer(OPENAI_CHAT) as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - - generate_text(model, "u1") - generate_text( - model, - [ - {"role": "user", "content": "u1"}, - {"role": "assistant", "content": "a1"}, - {"role": "user", "content": "u2"}, - ], - ) - - sessions = list_sessions() - autos = [s for s in sessions if s["sessionId"].startswith("auto-")] - assert len(autos) == 1, "prefix continuation stays in one auto session" - assert autos[0]["source"] == "Inferred" - assert len(autos[0]["calls"]) == 2 diff --git a/bindings/python/tests/test_trace.py b/bindings/python/tests/test_trace.py deleted file mode 100644 index c55759de9..000000000 --- a/bindings/python/tests/test_trace.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Tests for RFC-0015 cache probing through the Python binding. - -Verifies: `trace()` wraps a model, probed calls record fingerprints/verdicts, -and the query API returns parsed results. Uses the shared mock HTTP server — -no real API calls. -""" - -import json - -from aimux import deepseek -from aimux.wrapper import GenerateTextOptions, generate_text - -from test_e2e import MockServer, OPENAI_CHAT - - -class TestTraceProbe: - def test_trace_records_calls_and_query_api(self): - with MockServer(OPENAI_CHAT) as mock: - raw = deepseek("test-key", "gpt-4o", mock.url) - traced = raw.trace_audited(True) - - # > 4 KiB user message so block-aligned prefixes actually match. - big = "x" * 5000 - generate_text( - traced, - [{"role": "user", "content": big}], - GenerateTextOptions(session_id="sess-1"), - ) - generate_text( - traced, - [ - {"role": "user", "content": big}, - {"role": "assistant", "content": "a1"}, - {"role": "user", "content": "u2"}, - ], - GenerateTextOptions(session_id="sess-1"), - ) - - stats = json.loads(traced.trace_aggregate()) - assert len(stats) == 1 - assert stats[0]["requests"] == 2 - assert stats[0]["provider"] == "deepseek.chat" - assert "verdictCounts" in stats[0] - - chain = json.loads(traced.trace_session_chain("sess-1")) - assert len(chain["recordIds"]) == 2 - assert chain["prefixStability"] > 0.5 - - jsonl = traced.trace_export_jsonl() - lines = [l for l in jsonl.strip().split("\n") if l] - assert len(lines) == 2, "one TraceRecord per line" - first = json.loads(lines[0]) - assert first["fingerprint"]["bodyHash"] - assert first["sessionId"] == "sess-1" - - traced.trace_clear() - assert traced.trace_export_jsonl().strip() == "" - - def test_untraced_model_rejects_query_api(self): - with MockServer(OPENAI_CHAT) as mock: - raw = deepseek("test-key", "gpt-4o", mock.url) - try: - raw.trace_aggregate() - assert False, "untraced model must reject trace_aggregate" - except Exception as e: - assert "not traced" in str(e) - - def test_non_traced_model_generates_normally(self): - with MockServer(OPENAI_CHAT) as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - result = generate_text(model, "hello") - assert result.text diff --git a/bindings/python/tests/test_transcription_session.py b/bindings/python/tests/test_transcription_session.py deleted file mode 100644 index e1ae4f807..000000000 --- a/bindings/python/tests/test_transcription_session.py +++ /dev/null @@ -1,245 +0,0 @@ -"""Behavioral tests for the RFC-0028 transcription session API (Python binding). - -Covers the session surface (`start_transcription_session` / `push_audio` / -`next_part` / `close`) against a fake realtime WebSocket server: - -- `next_part(timeout_ms=...)` raises `APITimeoutError`, and the session stays - live afterwards. -- A server-sent realtime `error` event propagates verbatim as `APICallError`. -- A connect failure is delivered through the session channel as the first - `next_part` result (the session API never silently hangs). - -The fake WS server runs in a separate process (multiprocessing), like the -HTTP mocks in test_e2e — PyO3 blocks the calling thread, so a threading-based -server would deadlock. Handshake + frame codec are hand-rolled (no deps). -""" - -import base64 -import hashlib -import json -import socket -from multiprocessing import Process - -import pytest - -from aimux import ( - APICallError, - APITimeoutError, - openai_transcription, - start_transcription_session, -) - -WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" - - -# ── Minimal server-side WebSocket codec (frames < 126 bytes) ──────────────── - -def _text_frame(payload: str) -> bytes: - data = payload.encode() - assert len(data) < 126, "extend the codec for long payloads" - return bytes([0x81, len(data)]) + data - - -def _close_frame(code: int, reason: str) -> bytes: - data = code.to_bytes(2, "big") + reason.encode() - return bytes([0x88, len(data)]) + data - - -def _handshake(sock: socket.socket) -> None: - buf = b"" - while b"\r\n\r\n" not in buf: - chunk = sock.recv(4096) - if not chunk: - raise EOFError("client vanished mid-handshake") - buf += chunk - key = None - for line in buf.decode("latin-1").split("\r\n"): - if line.lower().startswith("sec-websocket-key:"): - key = line.split(":", 1)[1].strip() - if key is None: - raise ValueError("no Sec-WebSocket-Key in request") - accept = base64.b64encode( - hashlib.sha1((key + WS_GUID).encode()).digest() - ).decode() - sock.sendall( - ( - "HTTP/1.1 101 Switching Protocols\r\n" - "Upgrade: websocket\r\n" - "Connection: Upgrade\r\n" - f"Sec-WebSocket-Accept: {accept}\r\n" - "\r\n" - ).encode() - ) - - -def _ws_server_proc(port, script): - """Fake realtime server. `script` is a list of (delay_ms, action) tuples - (relative to handshake completion); action is ("event", json_text) or - ("close", code, reason). Client frames are masked and simply drained. - Readiness probes (bare TCP connects from the test process) are skipped. - """ - import time as _time - - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: - listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - listener.bind(("127.0.0.1", port)) - listener.listen(1) - listener.settimeout(10) - while True: - conn, _ = listener.accept() - with conn: - try: - _handshake(conn) - except (EOFError, ValueError, OSError): - # Readiness probe (closes without speaking WS) — skip. - continue - start = _time.monotonic() - for delay_ms, action in script: - target = start + delay_ms / 1000 - # Drain client frames (masked; content irrelevant). - while _time.monotonic() < target: - conn.settimeout(max(0.01, target - _time.monotonic())) - try: - if not conn.recv(65536): - return - except socket.timeout: - pass - if action[0] == "event": - conn.sendall(_text_frame(action[1])) - elif action[0] == "close": - conn.sendall(_close_frame(action[1], action[2])) - else: - raise ValueError(f"unknown action {action!r}") - # Hold the socket open after the script — the client drives - # the teardown — so a silent gap is never an accidental - # disconnect. - conn.settimeout(10) - try: - while conn.recv(65536): - pass - except OSError: - pass - return - - -def _find_free_port(): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - -class FakeRealtimeServer: - """Context manager: fake realtime WS server playing `script`.""" - - def __init__(self, script): - self.port = _find_free_port() - self.url = f"http://127.0.0.1:{self.port}" - self.proc = Process(target=_ws_server_proc, args=(self.port, script)) - - def __enter__(self): - self.proc.start() - # Wait for the listener to be ready (probe connects are skipped by - # the server), same gate as test_e2e.MockServer. - import time as _time - - for _ in range(100): - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.connect(("127.0.0.1", self.port)) - break - except ConnectionRefusedError: - _time.sleep(0.05) - return self - - def __exit__(self, *args): - self.proc.terminate() - self.proc.join(timeout=2) - if self.proc.is_alive(): - # SIGTERM missed the deadline — SIGKILL so tests never leak the - # fake-server process. - self.proc.kill() - - -SESSION_CREATED = '{"type":"session.created"}' -ERROR_EVENT = ( - '{"type":"error","error":' - '{"message":"insufficient quota for realtime transcription"}}' -) -DELTA_EVENT = ( - '{"type":"conversation.item.input_audio_transcription.delta",' - '"item_id":"i","delta":"he"}' -) - - -class TestTranscriptionSessionBehavior: - """Session API semantics through the PyO3 channel.""" - - def test_next_part_timeout_preserves_the_live_session(self): - # session.created at 50ms (no part mapping), delta at 900ms: the - # 250ms next_part window must time out in between and the session - # must still deliver the later delta. - with FakeRealtimeServer([(50, ("event", SESSION_CREATED)), - (900, ("event", DELTA_EVENT))]) as mock: - model = openai_transcription("k", "gpt-realtime-whisper", mock.url) - session = start_transcription_session(model, None) - try: - first = session.next_part(timeout_ms=3000) - assert first is not None - assert json.loads(first)["type"] == "stream-start" - - with pytest.raises(APITimeoutError) as ei: - session.next_part(timeout_ms=250) - # Timeout is a spent budget, not a provider-call transport - # failure, so it has no API-call payload. - assert "no transcription part within timeout" in str(ei.value) - assert not hasattr(ei.value, "status") - assert not hasattr(ei.value, "retryable") - - # The session stayed live across the timeout. - late = session.next_part(timeout_ms=5000) - assert late is not None - assert json.loads(late)["type"] == "transcript-delta" - finally: - session.close() - - def test_server_error_event_raises(self): - # Contract (issue #145, option A): in-stream errors RAISE from - # next_part as the typed hierarchy — matching the docstring, the FFI - # session (Err items), and the other six bindings. - with FakeRealtimeServer([(50, ("event", ERROR_EVENT))]) as mock: - model = openai_transcription("k", "gpt-realtime-whisper", mock.url) - session = start_transcription_session(model, None) - try: - first = session.next_part(timeout_ms=5000) - assert first is not None - assert json.loads(first)["type"] == "stream-start" - - with pytest.raises(APICallError) as ei: - session.next_part(timeout_ms=5000) - # The provider's own message, verbatim (the exception adds - # its "API call error: " prefix), not transport noise. - assert ei.value.args[0].endswith( - "insufficient quota for realtime transcription" - ) - # The retry verdict stays decidable on the typed error. - assert ei.value.retryable is False - - # The error terminated the stream: the channel ends normally. - assert session.next_part(timeout_ms=3000) is None - finally: - session.close() - - def test_connect_failure_surfaces_through_the_channel(self): - # Port 1 on loopback: connection refused immediately. - model = openai_transcription("k", "gpt-realtime-whisper", - "http://127.0.0.1:1") - session = start_transcription_session(model, None) - with pytest.raises(APICallError) as ei: - session.next_part(timeout_ms=5000) - err = ei.value - assert "websocket connect failed" in str(err) - # It is still an API-call error, but no HTTP response was observed. - assert err.status is None - assert err.retry_ms is None - # The session terminated with the connect error (channel closed). - assert session.next_part(timeout_ms=3000) is None diff --git a/bindings/python/tests/test_wrapper.py b/bindings/python/tests/test_wrapper.py deleted file mode 100644 index efb367885..000000000 --- a/bindings/python/tests/test_wrapper.py +++ /dev/null @@ -1,621 +0,0 @@ -"""Tests for the typed Pydantic wrapper (aimux.wrapper). - -Verifies the FULL chain through the typed layer: typed options / prompt models -→ JSON boundary → PyO3 → aimux-core → mock HTTP → typed result objects. - -Reuses the mock-server fixtures (MockServer / RecordingMockServer / -SequencedMockServer) and canned provider responses from test_e2e.py. -""" - -import json - -import pytest -from pydantic import TypeAdapter - -from aimux import deepseek, anthropic -from aimux.wrapper import ( - ContentPart, - GenerateContent, - GeneratedFile, - GenerateTextOptions, - GenerateTextResult, - ModelMessage, - StreamPart, - TextContent, - TextContentPart, - FunctionTool, - ProviderTool, - ToolChoiceTool, - generate_text, - stream_text, - parse_stream_part, - _opts_to_json, -) - -# Reuse the mock servers + canned responses from the e2e suite. -from test_e2e import ( - MockServer, - RecordingMockServer, - SequencedMockServer, - OPENAI_CHAT, - OPENAI_STREAM, - OPENAI_TOOL_CALL, - ANTHROPIC_MSG, -) - - -# ── generate_text: typed result ────────────────────────────────────────────── - -class TestGenerateText: - - def test_returns_typed_result_with_text(self): - with MockServer(OPENAI_CHAT) as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - result = generate_text(model, "What is Rust?") - - assert isinstance(result, GenerateTextResult) - assert result.text == "Rust is a systems programming language." - # Usage is parsed into a model, not left as a dict. - assert result.usage.input_tokens == 10 - assert result.usage.output_tokens == 8 - assert result.finish_reason == "stop" - assert len(result.steps) == 1 - - def test_parses_tool_calls(self): - with RecordingMockServer(OPENAI_TOOL_CALL) as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - opts = GenerateTextOptions( - tools=[ - FunctionTool( - name="get_weather", - description="Get weather for a location", - input_schema={ - "type": "object", - "properties": {"location": {"type": "string"}}, - "required": ["location"], - }, - ), - ], - ) - result = generate_text(model, "What's the weather in Tokyo?", opts) - - # Convenience field: tool_calls extracted as typed ToolCall objects. - assert isinstance(result.tool_calls, list) - assert len(result.tool_calls) == 1 - tc = result.tool_calls[0] - assert tc.tool_name == "get_weather" - assert tc.tool_call_id == "call_abc" - assert tc.input == {"location": "Tokyo"} - - # content carries the parsed tool call as a typed TextContent. - assert isinstance(result.content, list) - tool_call_parts = [c for c in result.content if c.type == "tool-call"] - assert tool_call_parts, "content must contain a tool-call part" - assert tool_call_parts[0].tool_name == "get_weather" - assert tool_call_parts[0].tool_call_id == "call_abc" - assert tool_call_parts[0].input == {"location": "Tokyo"} - - def test_content_is_text_variant(self): - with MockServer(OPENAI_CHAT) as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - result = generate_text(model, "What is Rust?") - - # content[0] is a typed Text content part. - first = result.content[0] - assert first.type == "text" - assert first.text == "Rust is a systems programming language." - - -# ── stream_text: typed-ish dicts ───────────────────────────────────────────── - -class TestStreamText: - - def test_yields_text_delta_dicts(self): - with MockServer(OPENAI_STREAM, content_type="text/event-stream") as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - parts = list(stream_text(model, "Say hello")) - - assert len(parts) > 0 - text = "".join(p["text"] for p in parts if p["type"] == "text-delta") - assert text == "Hello world" - - def test_stream_part_can_be_typed(self): - """A yielded dict round-trips through parse_stream_part into a model.""" - with MockServer(OPENAI_STREAM, content_type="text/event-stream") as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - first_delta = next( - p for p in stream_text(model, "Say hello") if p["type"] == "text-delta" - ) - - # The text stream's delta field is ``text`` (ai - # stream-text-result.ts:435-440); tool-input-delta keeps ``delta``. - assert set(first_delta) == {"type", "id", "text"} - sp = parse_stream_part(first_delta) - assert sp.type == "text-delta" - assert sp.text == "Hello" - # Round-trips back to the wire dict form. - assert sp.model_dump(exclude_none=True, by_alias=True) == first_delta - - # ── RFC-0016 M2: include_raw_chunks ───────────────────────────────────── - - def test_include_raw_chunks_emits_raw_parts(self): - """include_raw_chunks=True surfaces one Raw part per JSON SSE event, - before the parsed parts ([DONE] excluded).""" - with MockServer(OPENAI_STREAM, content_type="text/event-stream") as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - parts = list( - stream_text(model, "Say hello", - GenerateTextOptions(include_raw_chunks=True)) - ) - - raw_parts = [p for p in parts if p["type"] == "raw"] - # OPENAI_STREAM has 3 JSON events (2 content chunks + 1 usage chunk); - # the [DONE] sentinel emits no Raw. - assert len(raw_parts) == 3 - # Each Raw carries the parsed JSON payload of the SSE event. - assert ( - raw_parts[0]["rawValue"]["choices"][0]["delta"]["content"] - == "Hello" - ) - # Raw for the first event precedes its TextDelta. - first_raw_idx = parts.index(raw_parts[0]) - first_text_idx = next( - i for i, p in enumerate(parts) if p["type"] == "text-delta" - ) - assert first_raw_idx < first_text_idx - - def test_raw_chunks_off_by_default(self): - """Default options emit no Raw parts.""" - with MockServer(OPENAI_STREAM, content_type="text/event-stream") as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - parts = list(stream_text(model, "Say hello")) - - assert all(p["type"] != "raw" for p in parts) - - def test_include_raw_chunks_option_roundtrip(self): - """Pure model-layer round-trip of the include_raw_chunks option.""" - opts = GenerateTextOptions(include_raw_chunks=True) - dumped = _opts_to_json(opts) - assert '"includeRawChunks":true' in dumped - back = GenerateTextOptions.model_validate_json(dumped) - assert back.include_raw_chunks is True - - -# ── options: tools / tool_choice reach the provider ────────────────────────── - -class TestOptionsReachProvider: - - @pytest.mark.parametrize("supported", [None, True, False]) - def test_provider_tool_deferred_results_roundtrip(self, supported): - original = ProviderTool( - id="anthropic.code_execution_20250522", name="code_execution", args={}, - supports_deferred_results=supported, - ) - wire = json.loads(_opts_to_json(GenerateTextOptions(tools=[original])))["tools"][0] - assert "supports_deferred_results" not in wire - if supported is None: - assert "supportsDeferredResults" not in wire - else: - assert wire["supportsDeferredResults"] is supported - assert ProviderTool.model_validate(wire) == original - - def test_tools_and_tool_choice_reach_provider(self): - with RecordingMockServer(OPENAI_TOOL_CALL) as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - opts = GenerateTextOptions( - tools=[ - FunctionTool( - name="get_weather", - input_schema={ - "type": "object", - "properties": {"location": {"type": "string"}}, - }, - ), - ], - tool_choice="required", - ) - generate_text(model, "Hello", opts) - - body = mock.received_body() - - # OpenAI nests the function tool under a `function` key and renames - # `input_schema` → `parameters`. The typed model serialized correctly - # across the JSON boundary (proven separately) and the converter shaped - # it for the provider. - assert isinstance(body["tools"], list) - assert body["tools"][0]["type"] == "function" - assert body["tools"][0]["function"]["name"] == "get_weather" - assert body["tools"][0]["function"]["parameters"] == { - "type": "object", - "properties": {"location": {"type": "string"}}, - } - # tool_choice string variant passes through to the provider unchanged. - assert body["tool_choice"] == "required" - - def test_tool_choice_tool_variant_forces_tool_at_provider(self): - """The ``tool`` tool-choice variant forces the specific tool at the - provider (OpenAI renders it as ``{"type":"function","function":{...}}``).""" - with RecordingMockServer(OPENAI_TOOL_CALL) as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - opts = GenerateTextOptions( - tools=[FunctionTool(name="get_weather", input_schema={"type": "object"})], - tool_choice=ToolChoiceTool(tool_name="get_weather"), - ) - generate_text(model, "Hello", opts) - - body = mock.received_body() - - assert body["tool_choice"] == { - "type": "function", - "function": {"name": "get_weather"}, - } - - def test_tool_choice_serializes_camel_case_tool_name(self): - """At the wrapper→Rust JSON boundary, the ``tool`` tool-choice variant - must serialize as ``{"type":"tool","toolName":"..."}`` (camelCase), the - AI SDK shape Rust expects. This is a deterministic - unit test of the boundary, independent of provider conversion.""" - opts = GenerateTextOptions( - tools=[FunctionTool(name="get_weather", input_schema={"type": "object"})], - tool_choice=ToolChoiceTool(tool_name="get_weather"), - ) - wire = json.loads(_opts_to_json(opts)) - assert wire["toolChoice"] == {"type": "tool", "toolName": "get_weather"} - - # And it round-trips back into the typed model. - from aimux.wrapper import ToolChoiceTool as _TCT - back = _TCT.model_validate(wire["toolChoice"]) - assert back.tool_name == "get_weather" - - -# ── prompt: multi-role + multipart messages ────────────────────────────────── - -class TestPromptMessages: - - def test_multi_role_messages_reach_provider(self): - with RecordingMockServer(OPENAI_CHAT) as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - prompt = [ - ModelMessage(role="system", content="You are a helpful assistant."), - ModelMessage(role="user", content="What is Rust?"), - ] - result = generate_text(model, prompt) - - body = mock.received_body() - - msgs = body["messages"] - assert isinstance(msgs, list) - assert len(msgs) == 2 - assert msgs[0] == {"role": "system", "content": "You are a helpful assistant."} - assert msgs[1] == {"role": "user", "content": "What is Rust?"} - assert result.text # typed result still returned - - def test_multipart_content_reaches_provider(self): - """Typed multi-part content reaches the provider as a content-part - array. (Anthropic preserves the array form; OpenAI flattens text parts - to a string, so Anthropic is the cleaner provider to assert against.)""" - with RecordingMockServer(ANTHROPIC_MSG) as mock: - model = anthropic("test-key", "claude-3-5-sonnet-20241022", mock.url) - prompt = [ - ModelMessage( - role="user", - content=[TextContentPart(text="Hello, multimodal world.")], - ), - ] - generate_text(model, prompt) - - body = mock.received_body() - - msg = body["messages"][0] - assert msg["role"] == "user" - # Content is the multi-part array form (not flattened to a string). - assert isinstance(msg["content"], list) - assert msg["content"][0] == {"type": "text", "text": "Hello, multimodal world."} - - def test_image_bytes_are_base64_on_the_wire(self): - """ImagePart.image is DataContent upstream (provider-utils - types/content-part.ts:42): bytes are a base64 string in the JSON.""" - part = TypeAdapter(ContentPart).validate_python( - {"type": "image", "image": b"\x89PNG", "mediaType": "image/png"} - ) - assert part.model_dump(by_alias=True, exclude_none=True) == { - "type": "image", - "image": "iVBORw==", - "mediaType": "image/png", - } - with RecordingMockServer(ANTHROPIC_MSG) as mock: - model = anthropic("test-key", "claude-3-5-sonnet-20241022", mock.url) - generate_text(model, [ModelMessage(role="user", content=[part])]) - body = mock.received_body() - source = body["messages"][0]["content"][0]["source"] - assert source == {"type": "base64", "media_type": "image/png", "data": "iVBORw=="} - - - def test_file_parts_are_the_ai_sdk_file_part(self): - """FilePart.data is FileData | DataContent | URL | ProviderReference - upstream (provider-utils types/content-part.ts:75, file-data.ts:44-48); - there is no file-base64 / file-url / file-reference part.""" - adapter = TypeAdapter(ContentPart) - for wire in [ - {"type": "file", "data": {"type": "data", "data": "aGk="}, "mediaType": "text/plain"}, - {"type": "file", "data": {"type": "url", "url": "https://example.com/a.pdf"}, - "mediaType": "application/pdf"}, - {"type": "file", "data": {"type": "reference", "reference": {"anthropic": "file-abc"}}, - "mediaType": "application/pdf", "filename": "a.pdf"}, - {"type": "file", "data": {"type": "text", "text": "hello"}, "mediaType": "text/plain"}, - {"type": "file", "data": "aGk=", "mediaType": "text/plain"}, - {"type": "file", "data": {"anthropic": "file-abc"}, "mediaType": "application/pdf"}, - {"type": "reasoning-file", "data": "aGk=", "mediaType": "image/png"}, - {"type": "reasoning-file", "data": {"type": "url", "url": "https://example.com/a.png"}, - "mediaType": "image/png"}, - ]: - assert adapter.dump_python(adapter.validate_python(wire), by_alias=True) == wire - assert adapter.dump_python( - adapter.validate_python({"type": "file", "data": b"hi", "mediaType": "text/plain"}), - by_alias=True, - ) == {"type": "file", "data": "aGk=", "mediaType": "text/plain"} - for removed in ("file-base64", "file-url", "file-reference"): - with pytest.raises(ValueError): - adapter.validate_python( - {"type": removed, "data": "aGk=", "url": "https://example.com/a.pdf", - "mediaType": "application/pdf"} - ) - - # A data: URL's media type wins (ai prompt/file-part-data.ts:31-41); - # a bare reference is a reference (:133-138). - parts = [ - adapter.validate_python( - {"type": "file", "data": "data:image/png;base64,iVBORw==", "mediaType": "image"} - ), - adapter.validate_python( - {"type": "file", "data": {"anthropic": "file-abc"}, "mediaType": "application/pdf"} - ), - ] - with RecordingMockServer(ANTHROPIC_MSG) as mock: - model = anthropic("test-key", "claude-3-5-sonnet-20241022", mock.url) - generate_text(model, [ModelMessage(role="user", content=parts)]) - body = mock.received_body() - content = body["messages"][0]["content"] - assert content[0]["source"] == {"type": "base64", "media_type": "image/png", "data": "iVBORw=="} - assert content[1] == {"type": "document", "source": {"type": "file", "file_id": "file-abc"}} - - -# ── Generated files (#232 C46) ───────────────────────────────────────────── - -class TestGeneratedFiles: - - def test_generated_file_is_base64_and_media_type(self): - """GeneratedFile is {base64, mediaType, providerMetadata?} (ai - generate-text/generated-file.ts:10-32); a text file part is - {type, file, providerMetadata?} (convert-language-model-content.ts:55-70); - the provider's LanguageModelV4File keeps {type, mediaType, data}.""" - file = GeneratedFile.model_validate({"base64": "aGk=", "mediaType": "text/plain"}) - assert file.model_dump(by_alias=True) == {"base64": "aGk=", "mediaType": "text/plain"} - assert GeneratedFile(base64=b"hi", media_type="text/plain").base64 == "aGk=" - assert file.to_bytes() == b"hi" - - for tag in ("file", "reasoning-file"): - wire = { - "type": tag, - "file": {"base64": "aGk=", "mediaType": "text/plain"}, - "providerMetadata": {"google": {"thoughtSignature": "sig"}}, - } - for adapter in (TypeAdapter(TextContent), TypeAdapter(StreamPart)): - assert adapter.dump_python(adapter.validate_python(wire), by_alias=True) == wire - - raw = { - "type": "file", - "data": {"type": "url", "url": "https://example.com/a.png"}, - "mediaType": "image/png", - } - adapter = TypeAdapter(GenerateContent) - assert adapter.dump_python(adapter.validate_python(raw), by_alias=True) == raw - - -# ── Standalone type tags (#229 NP2-7) ─────────────────────────────────────── - -class TestStandaloneTypeTags: - - def test_standalone_tool_calls_and_sources_are_tagged(self): - """A result's toolCalls and sources are the AI SDK's TypedToolCall and - Source, tagged {type: 'tool-call'} (ai generate-text/tool-call.ts:8-14) - and {type: 'source', sourceType} (provider language-model-v4-source.ts:8, - :36); generate-text-result.ts:73, :78. A repair function's call is a - tagged LanguageModelV4ToolCall (language-model-v4-tool-call.ts:7).""" - from aimux import apply_tool_call_repair, tool_call_repair_context - from aimux.wrapper import GenerateTextResult, RawToolCall, ToolCall - - call = {"type": "tool-call", "toolCallId": "call-1", "toolName": "weather", "input": {"city": "SG"}} - url = {"type": "source", "sourceType": "url", "id": "s1", "url": "https://example.com"} - result = GenerateTextResult.model_validate( - {"text": "", "finishReason": "stop", "toolCalls": [call], "sources": [url]} - ) - dumped = result.model_dump(by_alias=True) - assert dumped["toolCalls"] == [call] - assert dumped["sources"] == [url] - assert RawToolCall(tool_call_id="c", tool_name="t", input="{}").model_dump(by_alias=True) == { - "type": "tool-call", "toolCallId": "c", "toolName": "t", "input": "{}", - } - - # JSON written before the tag still reads; another tag is rejected. - assert ToolCall.model_validate({"toolCallId": "c", "toolName": "t", "input": {}}).type == "tool-call" - with pytest.raises(ValueError): - ToolCall.model_validate({"type": "source", "toolCallId": "c", "toolName": "t", "input": {}}) - - # The native repair functions write the standalone call tagged. - invalid = json.dumps({ - "toolCallId": "call-1", "toolName": "weather", "input": {"town": "SG"}, - "dynamic": True, "invalid": True, - "error": {"name": "AI_InvalidToolInputError", "toolName": "weather", - "toolInput": '{"town":"SG"}', "cause": "missing city"}, - }) - opts = '{"tools":[{"type":"function","name":"weather","inputSchema":{"type":"object"}}]}' - out = json.loads(apply_tool_call_repair(invalid, opts, '{"type":"unchanged"}')) - assert out["type"] == "tool-call" and list(out)[0] == "type" - context = json.loads(tool_call_repair_context(invalid, '{"prompt":"weather?"}', opts)) - assert context["toolCall"]["type"] == "tool-call" - - -# ── Provider namespace order (#229 NP2-1) ──────────────────────────────────── - -class TestNamespaceOrder: - - def test_namespace_maps_keep_insertion_order(self): - """Provider options, provider metadata and provider references are JS - objects upstream (@ai-sdk/provider shared-v4-provider-options.ts:24, - shared-v4-provider-metadata.ts:24, shared-v4-provider-reference.ts:20), - so their keys keep insertion order, through Rust and back.""" - from aimux import apply_tool_call_repair - from aimux.wrapper import ToolCall - - metadata = '{"zeta":{"z":1,"a":2},"alpha":{},"mid":{}}' - invalid = ( - '{"toolCallId":"call-1","toolName":"weather","input":{"town":"SG"},' - '"dynamic":true,"invalid":true,"providerMetadata":' + metadata + ',' - '"error":{"name":"AI_InvalidToolInputError","toolName":"weather",' - '"toolInput":"{\\"town\\":\\"SG\\"}","cause":"missing city"}}' - ) - opts = '{"tools":[{"type":"function","name":"weather","inputSchema":{"type":"object"}}]}' - out = apply_tool_call_repair(invalid, opts, '{"type":"unchanged"}') - assert json.dumps(json.loads(out)["providerMetadata"], separators=(",", ":")) == metadata - - call = ToolCall.model_validate_json(out) - assert list(call.provider_metadata) == ["zeta", "alpha", "mid"] - assert '"providerMetadata":' + metadata in call.model_dump_json(by_alias=True) - - -# ── JSON null and undefined (#232 C34) ─────────────────────────────────────── - -class TestNullIsKeptApartFromMissing: - - def test_unknown_fields_keep_null(self): - """``body?: unknown`` (provider language-model-v4-generate-result.ts:41, - :56) and ``inputSchemaInput?: unknown`` (provider-utils - types/tool-approval-request.ts:41): the AI SDK's JSON keeps ``null`` - and omits ``undefined``; so does the dump of a parsed model.""" - from aimux.wrapper import GenerateResponseMetadata, RequestInfo - - for wire in ({}, {"body": None}, {"body": {"a": None}}): - assert GenerateResponseMetadata.model_validate(wire).model_dump( - mode="json", by_alias=True) == wire - assert RequestInfo.model_validate(wire).model_dump( - mode="json", by_alias=True) == wire - approval = {"type": "tool-approval-request", "approvalId": "a1", - "toolCallId": "c1", "inputSchemaInput": None} - part = TypeAdapter(ContentPart).validate_python(approval) - assert part.model_dump(mode="json", by_alias=True) == approval - - def test_null_tool_input_is_sent_as_null(self): - """A tool call's ``input`` is a JSON value; ``null`` is sent back as - ``null`` instead of being dropped (which Rust rejects as missing).""" - part = TypeAdapter(ContentPart).validate_python( - {"type": "tool-call", "toolCallId": "c1", "toolName": "t", "input": None}) - assert part.model_dump(mode="json", by_alias=True)["input"] is None - assert TextContentPart(text="x").model_dump(mode="json", by_alias=True) == { - "type": "text", "text": "x"} - - -class TestStepsAndUsageAreCamelCase: - - def test_usage_and_step_round_trip(self): - """``LanguageModelUsage`` (ai types/usage.ts:10-67) is camelCase with - an unknown count absent; a step is ``StepResult``'s own fields - (generate-text/step-result.ts:138-278), camelCase.""" - from aimux.wrapper import LanguageModelUsage, StepResult - - usage = {"inputTokens": 3, "inputTokenDetails": {"cacheReadTokens": 1}, - "outputTokens": 2, "outputTokenDetails": {}, "totalTokens": 5} - parsed = LanguageModelUsage.model_validate(usage) - assert parsed.input_token_details.cache_read_tokens == 1 - assert parsed.model_dump(mode="json", by_alias=True) == usage - - step = { - "callId": "c1", "stepNumber": 0, - "model": {"provider": "openai", "modelId": "gpt-4o"}, - "content": [], "finishReason": "stop", "usage": usage, - "performance": {"stepTimeMs": 1.0}, "warnings": [], "request": {}, - "response": {"id": "r1", "timestamp": "2026-01-01T00:00:00.000Z", - "modelId": "gpt-4o", "body": None, "messages": []}, - } - parsed_step = StepResult.model_validate(step) - assert parsed_step.model.model_id == "gpt-4o" - assert parsed_step.response.model_id == "gpt-4o" - assert parsed_step.model_dump(mode="json", by_alias=True) == step - - -# ── tool-call round trip with typed content parts ──────────────────────────── - -class TestToolCallRoundTrip: - - def test_round_trip_with_typed_content_parts(self): - """Full tool-call round trip using typed ModelMessage content parts. - - First call → model requests a tool call (OPENAI_TOOL_CALL). - The assistant/tool messages are built from typed content parts - (tool_call / tool_result), and a second call returns final text. - """ - opts = GenerateTextOptions( - tools=[ - FunctionTool( - name="get_weather", - description="Get weather for a location", - input_schema={ - "type": "object", - "properties": {"location": {"type": "string"}}, - "required": ["location"], - }, - ), - ], - ) - - with SequencedMockServer([OPENAI_TOOL_CALL, OPENAI_CHAT]) as mock: - model = deepseek("test-key", "gpt-4o", mock.url) - - # Step 1: model requests a tool call. - result = generate_text(model, "What's the weather in Tokyo?", opts) - assert len(result.tool_calls) == 1 - assert result.tool_calls[0].tool_name == "get_weather" - assert result.tool_calls[0].tool_call_id == "call_abc" - - # Step 2: back-fill the ToolResult via typed content parts. - messages = [ - ModelMessage(role="user", content="What's the weather in Tokyo?"), - ModelMessage( - role="assistant", - content=[ - { - "type": "tool-call", - "toolCallId": "call_abc", - "toolName": "get_weather", - "input": {"location": "Tokyo"}, - } - ], - ), - ModelMessage( - role="tool", - content=[ - { - "type": "tool-result", - "toolCallId": "call_abc", - "toolName": "get_weather", - "output": { - "type": "json", - "value": {"temperature": 22, "condition": "sunny"}, - }, - } - ], - ), - ] - - # Step 3: second call returns final text. - result2 = generate_text(model, messages, opts) - assert result2.text == "Rust is a systems programming language." - - # Step 4: the second request carries the full round trip. - mock.received_body() # drain first request - body2 = mock.received_body() - - msgs = body2["messages"] - assert len(msgs) == 3 - assert msgs[-1]["role"] == "tool" - assert msgs[-1]["tool_call_id"] == "call_abc" diff --git a/bindings/swift/Package.swift b/bindings/swift/Package.swift index 049bc4890..0f87d94bd 100644 --- a/bindings/swift/Package.swift +++ b/bindings/swift/Package.swift @@ -1,35 +1,23 @@ // swift-tools-version: 5.9 -// Package.swift — aimux Swift binding (C ABI path via aimux-ffi) - import PackageDescription +import Foundation +// Test-only C implementation: never included in the shipped Aimux product. +let testABI = ProcessInfo.processInfo.environment["AIMUX_SWIFT_TEST_ABI"] == "1" +var targets: [Target] = [ + .systemLibrary(name: "CAimuxFFI", path: "Sources/CAimuxFFI"), + .target(name: "Aimux", dependencies: ["CAimuxFFI"], path: ".", + exclude: ["Tests", "Sources/CAimuxFFI", "scripts", "README.md"], + sources: ["Sources/Aimux", "generated"], + linkerSettings: testABI ? [] : [.linkedLibrary("aimux_ffi")]), + .testTarget(name: "AimuxTests", dependencies: testABI ? ["Aimux", "CAimuxTestABI"] : ["Aimux"]), +] +if testABI { + targets.append(.target(name: "CAimuxTestABI", path: "Tests/CAimuxTestABI")) +} let package = Package( name: "Aimux", - platforms: [ - .iOS(.v15), - .macOS(.v12), - ], - products: [ - .library(name: "Aimux", targets: ["Aimux"]), - ], - targets: [ - // The C header from aimux-ffi is imported as a system module. - // In production, the prebuilt .xcframework provides the binary. - // For development, point `path` to the built library. - .systemLibrary( - name: "CAimuxFFI", - path: "Sources/CAimuxFFI", - pkgConfig: "aimux-ffi" - ), - .target( - name: "Aimux", - dependencies: ["CAimuxFFI"], - path: "Sources/Aimux" - ), - .testTarget( - name: "AimuxTests", - dependencies: ["Aimux"], - path: "Tests/AimuxTests" - ), - ] + platforms: [.iOS(.v15), .macOS(.v12)], + products: [.library(name: "Aimux", targets: ["Aimux"])], + targets: targets ) diff --git a/bindings/swift/README.md b/bindings/swift/README.md new file mode 100644 index 000000000..0934fce86 --- /dev/null +++ b/bindings/swift/README.md @@ -0,0 +1,95 @@ +# Aimux Swift + +The Swift package uses the versioned C ABI and its generated header. Link +`aimux_ffi`, or provide it in a packaged XCFramework. Generated `AimuxOps` +constants are part of the Swift module. + +`AimuxRuntime` addresses global scope; `AimuxHandle` owns a nonzero `UInt64` +handle. Native dispatch validates operation names, handle kinds and control +fields. `handle.id` provides a borrowed ID for secondary handle fields; encode +it as a decimal string and retain its owner until the call returns. `open` accepts the native handle record or the decimal string returned +by `transcription.start`. Existing Codable wire values remain available. + +```swift +let manifest = try await AimuxRuntime.manifest() +let provider = try await AimuxRuntime.open(AimuxOps.OP_PROVIDER_CREATE, params: .object([ + "package": .string("openai"), + "settings": .object(["apiKey": .string(apiKey)]), +])) +defer { provider.close() } +let model = try await provider.open(AimuxOps.OP_PROVIDER_MODEL, params: .object([ + "method": .string("languageModel"), "modelId": .string("gpt-4o-mini"), +])) +defer { model.close() } +let request: JSONValue = .object(["prompt": .string("Hello"), "options": .object([:])]) +let result = try await model.call(AimuxOps.OP_TEXT_GENERATE, params: request) +let stream = try await model.stream(AimuxOps.OP_TEXT_STREAM, params: request) +defer { stream.close() } +for try await frame in stream { + print(frame.seq, frame.event) +} +``` + +Decision models use `provider.model` with `method: "decision"`, then +`decision.decide` and `decision.capabilities`. Other modalities and pure tool +repair operations use the same call interface and manifest request types. + +Blocking native calls run on GCD workers. `close()` atomically ends admission, +signals active work, and queues native drop after admitted calls return. ARC +cleanup follows the same path. Adopting a raw ID with `init(owning:)` transfers +its unique ownership to the facade. + +Streams use `BoundedStream` with a blocking producer and one consumer. The +queue holds at most 256 frames / 8 MiB; each serialized frame is at most 1 MiB. +`StreamLimits` allows smaller budgets. Full queues block native callbacks. +Terminals have a separate slot; errors throw once after buffered events. +`AimuxError` retains domain, code, message, retry hint and unknown JSON fields. + +Task cancellation signals Abort. Exiting a loop releases its temporary iterator +and cancels production even while the sequence remains alive. Stored iterators +require `close()` when abandoned. Explicit close wakes full queues and readers. +Callback context is released once after the native stream call returns. +Callbacks copy and decode borrowed data synchronously and latch conversion +failures before signalling Abort. + +`push(Data)` and `nextPart(timeoutMs:)` use binary push/poll primitives. Poll +returns `.part`, `.ended` or `.timeout`; timeouts must be nonnegative. Cancelling +push/poll or closing an active Operation requests `operation.cancel` on a worker. + +## Tests + +Build the native library, then provide its directory for linking and loading: + +```sh +swift test --disable-sandbox \ + -Xlinker -L -Xlinker "$AIMUX_LIBRARY_DIR" \ + -Xlinker -rpath -Xlinker "$AIMUX_LIBRARY_DIR" +``` + +This runs the bounded queue tests, wire checks and real-library checks for +manifest, provider/model ownership, Decision, error terminals, rejection and +push/poll validation. These native checks require no provider credentials or +network. Successful provider requests and realtime audio require a configured +provider. Recorder, session-store and inference configuration remain process +wide. Transport replay and credential-resolver creation are reserved native +capabilities; business-type schemas and per-platform packaging verification +are separate from this package's tests. + +The synthetic C backend separately exercises callback/lifecycle races: + +```sh +AIMUX_SWIFT_TEST_ABI=1 swift test --disable-sandbox \ + -Xswiftc -strict-concurrency=complete -Xswiftc -warnings-as-errors +``` + +For a working compiler with unavailable SwiftPM, direct runners are provided: + +```sh +AIMUX_SWIFT_SDK=/path/to/MacOSX.sdk scripts/test-standalone.sh +AIMUX_SWIFT_SDK=/path/to/MacOSX.sdk \ + AIMUX_LIBRARY_DIR=/path/to/native/library scripts/test-native-standalone.sh +``` + +Direct runners use Swift 6 strict concurrency. iOS linking requires the iOS +native artifact and a full Xcode SDK; the macOS and synthetic checks do not +verify simulator linkage. diff --git a/bindings/swift/Sources/Aimux/Aimux.swift b/bindings/swift/Sources/Aimux/Aimux.swift index ad95af7ea..868182574 100644 --- a/bindings/swift/Sources/Aimux/Aimux.swift +++ b/bindings/swift/Sources/Aimux/Aimux.swift @@ -1,1241 +1,357 @@ -// Aimux.swift — Swift wrapper around the aimux-ffi C ABI. -// -// This is the C ABI path (§3.2). Swift calls the C functions from aimux-ffi -// and wraps them in a Swifty API with ARC-managed handles. - import CAimuxFFI import Foundation -// ───────────────────────────────────────────────────────────────────────────── -// C returned errors (aimux-error.h) — distinct from the Swift types below. -// -// Every fallible C function returns `aimux_error_t *` (`OpaquePointer?`): -// NULL = success (result in the trailing out-param), non-NULL = failure. The -// unified code is AiMuxError (1...20), RecordingError (100...105), or a C ABI -// failure (200...206). The three `expect*` decoders copy the relevant fields, release -// it with `aimux_error_free` (exactly once) and return the Swift error -// to throw. Errors are not handles: never `aimux_drop_handle` one. -// ───────────────────────────────────────────────────────────────────────────── +/// The full envelope, including unknown error names and payload fields. +public struct AimuxError: Error, Codable, Equatable, Sendable, LocalizedError { + public let kind: String + public let code: Int32 + public let message: String + public let error: JSONValue + public let retryAfterMs: UInt64? + public var errorDescription: String? { message } -/// Copy a caller-owned C string and free it (`aimux_free_string`). -private func takeCString(_ p: UnsafeMutablePointer?) -> String? { - guard let p else { return nil } - defer { aimux_free_string(p) } - return String(cString: p) + static func binding(_ message: String, code: Int32 = 206) -> AimuxError { + AimuxError(kind: "binding", code: code, message: message, + error: .object(["name": .string("CallbackFailure")]), retryAfterMs: nil) + } + static func state(_ message: String, code: Int32 = 304) -> AimuxError { + AimuxError(kind: "protocol", code: code, message: message, + error: .object(["name": .string(code == 303 ? "ConcurrentConsumer" : code == 302 ? "UnsupportedOpsVersion" : "InvalidState")]), + retryAfterMs: nil) + } } -/// The language invariant error: a binding/library contract broke. func invariant(_ message: String) -> DecodingError { .dataCorrupted(.init(codingPath: [], debugDescription: message)) } -/// Decode a returned error from a call that only exposes C ABI failures -/// (NULL / non-UTF-8 argument, dead handle, re-entrant call, ...) — a -/// binding/library invariant, never reachable from a correct binding. Reads -/// `aimux_error_message`, frees `e`, returns -/// `DecodingError.dataCorrupted("aimux ffi: : ")`. -func expectFfiError(_ e: OpaquePointer, context: String) -> any Error { - defer { aimux_error_free(e) } - let code = aimux_error_code(e) - guard (200...206).contains(code) else { - return invariant("aimux ffi: \(context): expected C ABI failure code, got \(code)") - } - let message = takeCString(aimux_error_message(e)) ?? "unknown failure" - return invariant("aimux ffi: \(context): \(message)") -} - -/// Decode a returned error from an `[AiMuxError]` call: 1...20 becomes -/// `AimuxError`; 200...206 is decoded by `expectFfiError`. Frees `e` once. -func expectAimuxError(_ e: OpaquePointer, context: String) -> any Error { - let code = aimux_error_code(e) - if (200...206).contains(code) { - return expectFfiError(e, context: context) +func takeString(_ pointer: UnsafeMutablePointer?) throws -> String { + guard let pointer else { throw invariant("Native success returned no JSON") } + defer { aimux_free_string(pointer) } + guard let string = String(validatingCString: pointer) else { + throw invariant("Native result is not UTF-8") } - defer { aimux_error_free(e) } - return AimuxError.fromC(e) - ?? invariant("aimux ffi: \(context): unknown aimux_error_code_t \(code)") + return string } -/// Decode a returned error from a `[RecordingError]` call: 100...105 becomes -/// `RecordingError`; 200...206 is a C ABI failure. Frees `e` once. -func expectRecordingError(_ e: OpaquePointer, context: String) -> any Error { - let code = aimux_error_code(e) - if (200...206).contains(code) { - return expectFfiError(e, context: context) - } - defer { aimux_error_free(e) } - return RecordingError.fromC(e) - ?? invariant("aimux ffi: \(context): unknown aimux_error_code_t \(code)") -} - -/// Reject a syntactically invalid raw JSON parameter before it crosses the -/// C ABI, so a malformed-wire-JSON C ABI failure is never produced by -/// this binding. Throws `DecodingError.dataCorrupted` naming `parameter`. -/// -/// Optional (`String?`) parameters follow the FFI's "empty means default" -/// rule: `nil` and blank strings pass. Required (`String`) parameters must -/// be valid JSON — an empty string is rejected. -func validateJson(_ json: String?, parameter: String) throws { - guard let json, !json.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - try validateJson(json, parameter: parameter) -} - -func validateJson(_ json: String, parameter: String) throws { +func takeError(_ pointer: OpaquePointer) -> any Error { + defer { aimux_error_free(pointer) } do { - _ = try JSONSerialization.jsonObject(with: Data(json.utf8), options: [.fragmentsAllowed]) - } catch { - throw DecodingError.dataCorrupted( - .init(codingPath: [], debugDescription: "\(parameter): invalid JSON: \(error.localizedDescription)") - ) - } -} - -/// Run an `[AiMuxError]` call writing an owned `char*` (JSON) result to -/// `char **out`: copies it into a Swift `String` and frees the C allocation, -/// or throws the decoded error. -func ffiStringCall( - context: String = #function, - _ body: (UnsafeMutablePointer?>) -> OpaquePointer? -) throws -> String { - var out: UnsafeMutablePointer? = nil - if let e = body(&out) { throw expectAimuxError(e, context: context) } - guard let out else { throw invariant("aimux ffi: \(context): success but no result written") } - defer { aimux_free_string(out) } - return String(cString: out) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Errors -// ───────────────────────────────────────────────────────────────────────────── - -/// Why a `.retry` failure stopped retrying. Raw values are the core's serde -/// camelCase wire names (`aimux_error_retry_reason`). -public enum RetryErrorReason: String, Equatable, Sendable { - /// Every permitted attempt failed with a retryable error. - case maxRetriesExceeded - /// A later attempt failed with an error not worth retrying. - case errorNotRetryable -} - -/// Structured aimux failure type (Swift `Error`). -/// -/// Maps 1:1 from the 16 core `AiMuxError` variants. Every HTTP-shaped failure -/// is `.apiCall` (`AIMUX_E_API_CALL`). Only aimux-core produces these: a -/// binding-local failure (raw JSON that does not parse, a typed value that -/// fails to encode, library output that fails to decode) surfaces as the -/// native `DecodingError` / `EncodingError`. Nothing a caller can pass in -/// traps: use-after-close throws `DecodingError.dataCorrupted` (deliberately -/// not `AimuxTranscriptionEndedError` — a truncated transcript must not read -/// as a complete one), an empty `router([])` throws C's zero-children failure. -/// -/// ```swift -/// do { -/// let result = try model.generateText(prompt: "\"hi\"") -/// } catch let e as AimuxError { -/// print(e.message, e.status, e.retryMs) -/// // Classification is the status field: 429 → rate limited (e.retryMs), -/// // 401 → auth, 404 → model not found. -/// if case .apiCall = e, e.status == 429 { /* back off */ } -/// // Whether a retry is worth it is `e.retryable`, never the status. -/// if e.retryable { /* retry */ } -/// } -/// ``` -public enum AimuxError: Error, LocalizedError, CustomStringConvertible, Equatable, Sendable { - case noOutputGenerated(message: String, status: Int, retryMs: Int64, retryable: Bool) - case jsonParse(message: String, status: Int, retryMs: Int64, retryable: Bool) - case invalidResponseData(message: String, status: Int, retryMs: Int64, retryable: Bool) - case invalidArgument(message: String, status: Int, retryMs: Int64, retryable: Bool) - case invalidPrompt(message: String, status: Int, retryMs: Int64, retryable: Bool) - case tokenExpired(message: String, status: Int, retryMs: Int64, retryable: Bool) - case unsupportedFunctionality(message: String, status: Int, retryMs: Int64, retryable: Bool) - case noSuchModel(message: String, status: Int, retryMs: Int64, retryable: Bool, modelId: String, modelType: String) - case noSuchProvider(message: String, status: Int, retryMs: Int64, retryable: Bool, providerId: String) - /// Every HTTP-shaped failure: read `status` to classify (401 auth, - /// 404 model, 429 rate limit). A `nil` status means no HTTP response was - /// ever observed — a missing API key, an error built without a request, or - /// a transport failure; read `retryable` to tell those apart, `status` - /// cannot. - case apiCall( - message: String, status: Int, retryMs: Int64, retryable: Bool, - providerCode: String? = nil, providerMessage: String? = nil, - responseBody: String? = nil, url: String? = nil, - requestBodyValues: JSONValue? = nil, responseHeaders: [String: String]? = nil, - providerData: JSONValue? = nil - ) - /// Retrying stopped (`AIMUX_E_RETRY`): `reason` says why, `errors` is the - /// per-attempt history (oldest first; `errors.last` is the final attempt, - /// each element any `AimuxError`, `.apiCall` with the full detail set - /// included), `message` the composed summary ("Failed after N attempts…"). - case retry(message: String, reason: RetryErrorReason, errors: [AimuxError]) - case timeout(message: String, status: Int, retryMs: Int64, retryable: Bool) - /// `message` is the abort payload verbatim ("request aborted" for signal - /// aborts). - case aborted(message: String, status: Int, retryMs: Int64, retryable: Bool) - /// The model called a tool that is not in the supplied tool set. - case noSuchTool(message: String, status: Int, retryMs: Int64, retryable: Bool, toolName: String, availableTools: [String]?) - /// The model produced tool arguments that fail to parse or validate. - case invalidToolInput(message: String, status: Int, retryMs: Int64, retryable: Bool, toolName: String, toolInput: String) - /// A `repairToolCall` hook itself failed; `originalError` is the error it - /// was repairing, as `AiMuxError` JSON tagged by `name` (the same encoding as - /// `ToolCall.error`). - case toolCallRepair(message: String, status: Int, retryMs: Int64, retryable: Bool, originalError: String) - /// No API key was passed and the fallback environment variable is unset - /// (`AIMUX_E_LOAD_API_KEY`, the AI SDK's `LoadAPIKeyError`). No request - /// was made; `envVar` names the variable that was consulted. - case loadApiKey(message: String, status: Int, retryMs: Int64, retryable: Bool, envVar: String) - /// A required provider setting was not passed and its fallback environment - /// variable is unset (`AIMUX_E_LOAD_SETTING`, `LoadSettingError`). - case loadSetting(message: String, status: Int, retryMs: Int64, retryable: Bool, envVar: String) - case other(message: String, status: Int, retryMs: Int64, retryable: Bool) - - // MARK: Accessors - - /// The C-derived payload. - private var payload: (message: String, status: Int, retryMs: Int64, retryable: Bool) { - switch self { - case .noOutputGenerated(let m, let s, let r, let t), - .jsonParse(let m, let s, let r, let t), - .invalidResponseData(let m, let s, let r, let t), - .invalidArgument(let m, let s, let r, let t), - .invalidPrompt(let m, let s, let r, let t), - .tokenExpired(let m, let s, let r, let t), - .unsupportedFunctionality(let m, let s, let r, let t), - .noSuchModel(let m, let s, let r, let t, _, _), - .noSuchProvider(let m, let s, let r, let t, _), - .apiCall(let m, let s, let r, let t, _, _, _, _, _, _, _), - .timeout(let m, let s, let r, let t), - .aborted(let m, let s, let r, let t), - .noSuchTool(let m, let s, let r, let t, _, _), - .invalidToolInput(let m, let s, let r, let t, _, _), - .toolCallRepair(let m, let s, let r, let t, _), - .loadApiKey(let m, let s, let r, let t, _), - .loadSetting(let m, let s, let r, let t, _), - .other(let m, let s, let r, let t): - return (m, s, r, t) - case .retry(let m, _, _): - // Retrying already stopped: no single HTTP payload, and by - // definition not retryable. - return (m, -1, -1, false) - } - } - - /// The C `aimux_error_code_t` (`aimux-error.h`). - public var code: Int32 { - let c: aimux_error_code_t - switch self { - case .noOutputGenerated: c = AIMUX_E_NO_OUTPUT_GENERATED - case .jsonParse: c = AIMUX_E_JSON_PARSE - case .invalidResponseData: c = AIMUX_E_INVALID_RESPONSE_DATA - case .invalidArgument: c = AIMUX_E_INVALID_ARGUMENT - case .invalidPrompt: c = AIMUX_E_INVALID_PROMPT - case .tokenExpired: c = AIMUX_E_TOKEN_EXPIRED - case .unsupportedFunctionality: c = AIMUX_E_UNSUPPORTED_FUNCTIONALITY - case .noSuchModel: c = AIMUX_E_NO_SUCH_MODEL - case .noSuchProvider: c = AIMUX_E_NO_SUCH_PROVIDER - case .apiCall: c = AIMUX_E_API_CALL - case .retry: c = AIMUX_E_RETRY - case .timeout: c = AIMUX_E_TIMEOUT - case .aborted: c = AIMUX_E_ABORTED - case .noSuchTool: c = AIMUX_E_NO_SUCH_TOOL - case .invalidToolInput: c = AIMUX_E_INVALID_TOOL_INPUT - case .toolCallRepair: c = AIMUX_E_TOOL_CALL_REPAIR - case .loadApiKey: c = AIMUX_E_LOAD_API_KEY - case .loadSetting: c = AIMUX_E_LOAD_SETTING - case .other: c = AIMUX_E_OTHER - } - return Int32(bitPattern: c.rawValue) - } - - /// Human-readable message (C `message` field). - public var message: String { payload.message } - - /// HTTP status code, or `nil` when not applicable (C reports `-1`). - public var status: Int? { - payload.status >= 0 ? payload.status : nil - } - - /// Rate-limit retry hint in milliseconds, or `nil` when not applicable - /// (C reports `-1`). `0` means retry immediately. - public var retryMs: Int64? { - payload.retryMs >= 0 ? payload.retryMs : nil - } - - /// Whether retrying may help — the `AiMuxError` verdict, carried across the C - /// ABI. Not derivable from `status`: a transport failure (request went - /// out, connection reset) and a missing API key (request never went out) - /// both report no status and disagree here. - public var retryable: Bool { payload.retryable } - - /// `.apiCall` only: the provider's own error code (e.g. `"insufficient_quota"`). - public var providerCode: String? { - if case .apiCall(_, _, _, _, let v, _, _, _, _, _, _) = self { return v } - return nil - } - - /// `.apiCall` only: the failure's own text without the composed prefix `message` carries (e.g. `"slow down"`). - public var providerMessage: String? { - if case .apiCall(_, _, _, _, _, let v, _, _, _, _, _) = self { return v } - return nil - } - - /// `.apiCall` only: the raw response body. - public var responseBody: String? { - if case .apiCall(_, _, _, _, _, _, let v, _, _, _, _) = self { return v } - return nil - } - - /// `.apiCall` only: the sanitized request URL. - public var url: String? { - if case .apiCall(_, _, _, _, _, _, _, let v, _, _, _) = self { return v } - return nil - } - - /// `.apiCall` only: the sanitized request body values (any JSON type). - public var requestBodyValues: JSONValue? { - if case .apiCall(_, _, _, _, _, _, _, _, let v, _, _) = self { return v } - return nil - } - - /// `.apiCall` only: the sanitized response headers (includes the - /// retry-after evidence `retryMs` is derived from). - public var responseHeaders: [String: String]? { - if case .apiCall(_, _, _, _, _, _, _, _, _, let v, _) = self { return v } - return nil - } - - /// `.apiCall` only: the provider's parsed error data (the AI SDK's - /// `APICallError.data`). - public var providerData: JSONValue? { - if case .apiCall(_, _, _, _, _, _, _, _, _, _, let v) = self { return v } - return nil - } - - /// `.retry` only: why retrying stopped. - public var retryReason: RetryErrorReason? { - if case .retry(_, let v, _) = self { return v } - return nil - } - - /// `.retry` only: the per-attempt history, oldest first. - public var retryErrors: [AimuxError]? { - if case .retry(_, _, let v) = self { return v } - return nil - } - - /// `.retry` only: the final attempt's error (`retryErrors` last element). - public var lastError: AimuxError? { retryErrors?.last } - - /// `.noSuchModel` only: the model id that was asked for. - public var modelId: String? { - if case .noSuchModel(_, _, _, _, let v, _) = self { return v } - return nil - } - - /// `.noSuchModel` only: the model type it was asked for as. - public var modelType: String? { - if case .noSuchModel(_, _, _, _, _, let v) = self { return v } - return nil - } - - /// `.noSuchProvider` only: the provider id that was asked for. - public var providerId: String? { - if case .noSuchProvider(_, _, _, _, let v) = self { return v } - return nil - } - - /// `.noSuchTool` / `.invalidToolInput` only: the tool name the model called. - public var toolName: String? { - switch self { - case .noSuchTool(_, _, _, _, let v, _), .invalidToolInput(_, _, _, _, let v, _): - return v - default: - return nil + let json = try takeString(aimux_error_json(pointer)) + let envelope = try JSONDecoder().decode(AimuxError.self, from: Data(json.utf8)) + guard envelope.code == aimux_error_code(pointer) else { + throw invariant("Error code does not match envelope") } - } - - /// `.noSuchTool` only: the available tool names, or `nil` when no tool set - /// was supplied. - public var availableTools: [String]? { - if case .noSuchTool(_, _, _, _, _, let v) = self { return v } - return nil - } - - /// `.invalidToolInput` only: the raw argument text the model produced. - public var toolInput: String? { - if case .invalidToolInput(_, _, _, _, _, let v) = self { return v } - return nil - } - - /// `.toolCallRepair` only: the original lookup/parse/validation error as - /// `AiMuxError` JSON tagged by `name` (the same encoding as `ToolCall.error`). - public var originalError: String? { - if case .toolCallRepair(_, _, _, _, let v) = self { return v } - return nil - } - - /// `.loadApiKey` / `.loadSetting` only: the environment variable that was - /// consulted (e.g. `"OPENAI_API_KEY"`). - public var envVar: String? { - switch self { - case .loadApiKey(_, _, _, _, let v), .loadSetting(_, _, _, _, let v): - return v - default: - return nil - } - } - - public var description: String { message } - - public var errorDescription: String? { - message - } - - // MARK: C mapping - - /// Map a borrowed `AiMuxError` view into the Swift enum. Reads - /// `code`/`message`/`retryable`/`status`/`retry_ms`, then only the payload - /// getters owned by that code (each owned string is copied and freed with - /// `aimux_free_string`). Returns `nil` for a code outside - /// `aimux_error_code_t` (header/library mismatch — the caller throws the - /// invariant error). Does not release the owner; `expectAimuxError` does. - static func fromC(_ h: OpaquePointer) -> AimuxError? { - let raw = aimux_error_code(h) - let code = aimux_error_code_t(UInt32(bitPattern: raw)) - let rawMsg = takeCString(aimux_error_message(h)) ?? "" - let retryable = aimux_error_retryable(h) != 0 - // status / retry_ms are API_CALL payload (-1 under any other code), - // which is exactly what the enum's Int/Int64 fields expect. - let status = Int(aimux_error_status(h)) - let retryMs = aimux_error_retry_ms(h) - let message = rawMsg.isEmpty ? "aimux: operation failed" : rawMsg + return envelope + } catch { return error } +} - switch code { - case AIMUX_E_NO_OUTPUT_GENERATED: - return .noOutputGenerated(message: message, status: status, retryMs: retryMs, retryable: retryable) - case AIMUX_E_JSON_PARSE: - return .jsonParse(message: message, status: status, retryMs: retryMs, retryable: retryable) - case AIMUX_E_INVALID_RESPONSE_DATA: - return .invalidResponseData(message: message, status: status, retryMs: retryMs, retryable: retryable) - case AIMUX_E_INVALID_ARGUMENT: - return .invalidArgument(message: message, status: status, retryMs: retryMs, retryable: retryable) - case AIMUX_E_INVALID_PROMPT: - return .invalidPrompt(message: message, status: status, retryMs: retryMs, retryable: retryable) - case AIMUX_E_TOKEN_EXPIRED: - // TokenExpired is definitionally an observed 401 (RFC-0018). - return .tokenExpired(message: message, status: status == -1 ? 401 : status, retryMs: retryMs, retryable: retryable) - case AIMUX_E_UNSUPPORTED_FUNCTIONALITY: - return .unsupportedFunctionality(message: message, status: status, retryMs: retryMs, retryable: retryable) - case AIMUX_E_NO_SUCH_MODEL: - return .noSuchModel(message: message, status: status, retryMs: retryMs, retryable: retryable, - modelId: takeCString(aimux_error_model_id(h)) ?? "", - modelType: takeCString(aimux_error_model_type(h)) ?? "") - case AIMUX_E_NO_SUCH_PROVIDER: - return .noSuchProvider(message: message, status: status, retryMs: retryMs, retryable: retryable, - providerId: takeCString(aimux_error_provider_id(h)) ?? "") - case AIMUX_E_API_CALL: - return .apiCall(message: message, status: status, retryMs: retryMs, retryable: retryable, - providerCode: takeCString(aimux_error_provider_code(h)), - providerMessage: takeCString(aimux_error_provider_message(h)), - responseBody: takeCString(aimux_error_response_body(h)), - url: takeCString(aimux_error_url(h)), - requestBodyValues: takeJSONValue(aimux_error_request_body_values(h)), - responseHeaders: takeHeaderMap(aimux_error_response_headers(h)), - providerData: takeJSONValue(aimux_error_provider_data(h))) - case AIMUX_E_RETRY: - let reason = takeCString(aimux_error_retry_reason(h)) - .flatMap(RetryErrorReason.init(rawValue:)) ?? .maxRetriesExceeded - var errors: [AimuxError] = [] - for i in 0..(_ body: @escaping @Sendable () throws -> T) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + do { continuation.resume(returning: try body()) } + catch { continuation.resume(throwing: error) } } - return .retry(message: message, reason: reason, errors: errors) - case AIMUX_E_TIMEOUT: - return .timeout(message: message, status: status, retryMs: retryMs, retryable: retryable) - case AIMUX_E_ABORTED: - return .aborted(message: message, status: status, retryMs: retryMs, retryable: retryable) - case AIMUX_E_NO_SUCH_TOOL: - // The accessor's JSON string array (or NULL) → [String]?; a decode - // failure would be an FFI contract break, treated as "absent". - let tools = takeCString(aimux_error_available_tools(h)) - .flatMap { try? JSONDecoder().decode([String].self, from: Data($0.utf8)) } - return .noSuchTool(message: message, status: status, retryMs: retryMs, retryable: retryable, - toolName: takeCString(aimux_error_tool_name(h)) ?? "", - availableTools: tools) - case AIMUX_E_INVALID_TOOL_INPUT: - return .invalidToolInput(message: message, status: status, retryMs: retryMs, retryable: retryable, - toolName: takeCString(aimux_error_tool_name(h)) ?? "", - toolInput: takeCString(aimux_error_tool_input(h)) ?? "") - case AIMUX_E_TOOL_CALL_REPAIR: - return .toolCallRepair(message: message, status: status, retryMs: retryMs, retryable: retryable, - originalError: takeCString(aimux_error_original_error(h)) ?? "") - case AIMUX_E_LOAD_API_KEY: - // The consulted environment variable rides the provider-code getter. - return .loadApiKey(message: message, status: status, retryMs: retryMs, retryable: retryable, - envVar: takeCString(aimux_error_provider_code(h)) ?? "") - case AIMUX_E_LOAD_SETTING: - return .loadSetting(message: message, status: status, retryMs: retryMs, retryable: retryable, - envVar: takeCString(aimux_error_provider_code(h)) ?? "") - case AIMUX_E_OTHER: - return .other(message: message, status: status, retryMs: retryMs, retryable: retryable) - default: - return nil } } - - /// Decode a getter-owned JSON string (any JSON type) into `JSONValue`, - /// freeing the C allocation. `nil` when the getter returned NULL. - private static func takeJSONValue(_ p: UnsafeMutablePointer?) -> JSONValue? { - guard let s = takeCString(p) else { return nil } - return try? JSONDecoder().decode(JSONValue.self, from: Data(s.utf8)) - } - - /// Decode a getter-owned JSON object of string→string pairs (the - /// response-headers shape), freeing the C allocation. - private static func takeHeaderMap(_ p: UnsafeMutablePointer?) -> [String: String]? { - guard let s = takeCString(p) else { return nil } - return try? JSONDecoder().decode([String: String].self, from: Data(s.utf8)) + static func drop(_ id: UInt64) { + guard id != 0 else { return } + DispatchQueue.global(qos: .utility).async { aimux_drop(id) } } } -// ───────────────────────────────────────────────────────────────────────────── -// RecordingError — mirrors aimux-core `recording::RecordingError`, a type -// unrelated to `AimuxError` (separate range in the unified C code enum). -// ───────────────────────────────────────────────────────────────────────────── - -/// Recorder failure reported by `Model.recordingTryFlush()`. Independent of -/// `AimuxError`: shares only `Error`. -public struct RecordingError: Error, LocalizedError, CustomStringConvertible, Equatable, Sendable { - /// Mirrors `aimux_error_code_t` (aimux-error.h) in declaration order. - /// Only `writerGone` / `flushTimeout` / `write` are reachable from a flush. - public enum Code: Equatable, Sendable { - case initFailed, openFile, spawn, writerGone, flushTimeout, write - } - - public let code: Code - public let message: String - - public init(code: Code, message: String) { - self.code = code - self.message = message - } - - public var description: String { "\(message) (recording, \(code))" } - public var errorDescription: String? { message } - - static func code(fromC rawCode: Int32) -> Code? { - switch aimux_error_code_t(UInt32(bitPattern: rawCode)) { - case AIMUX_E_RECORDING_INIT: return .initFailed - case AIMUX_E_RECORDING_OPEN_FILE: return .openFile - case AIMUX_E_RECORDING_SPAWN: return .spawn - case AIMUX_E_RECORDING_WRITER_GONE: return .writerGone - case AIMUX_E_RECORDING_FLUSH_TIMEOUT: return .flushTimeout - case AIMUX_E_RECORDING_WRITE: return .write - default: return nil - } - } - - /// Map a returned error through its code and message getters. Frees the - /// owned message string; `nil` for a code outside `aimux_error_code_t`. - /// `expectRecordingError` frees the returned error. - static func fromC(_ h: OpaquePointer) -> RecordingError? { - guard let code = code(fromC: aimux_error_code(h)) else { return nil } - let message = takeCString(aimux_error_message(h)) ?? "" - return RecordingError(code: code, message: message.isEmpty ? "aimux: recording error" : message) +private func nativeManifest() throws -> JSONValue { + let version = aimux_abi_version() + guard version >> 16 == AimuxOps.ABI_VERSION >> 16 else { + throw AimuxError.state("Unsupported ABI version \(version)") } + var output: UnsafeMutablePointer? + if let error = aimux_manifest_json(&output) { throw takeError(error) } + let value = try JSONDecoder().decode(JSONValue.self, from: Data(takeString(output).utf8)) + try validateManifest(value, version: version) + return value } -// ───────────────────────────────────────────────────────────────────────────── -// Model — ARC-managed wrapper around a C ABI handle. -// ───────────────────────────────────────────────────────────────────────────── - -/// A model instance backed by a Rust `Arc`. -/// -/// The C handle is automatically released when this object is deallocated. -public final class Model: @unchecked Sendable { - - // The opaque handle from aimux-ffi. 0 means invalid/freed. - private var handle: UInt64 - - /// File-private initializer — use `Model.openai()` etc. to create. - fileprivate init(handle: UInt64) { - self.handle = handle - } - - deinit { - if handle != 0 { - aimux_drop_handle(handle) - } - } - - /// Run a C constructor writing a handle to `uint64_t *out_handle`; throws - /// the returned error decoded by `expecting` (`expectAimuxError` for `[AiMuxError]` - /// constructors, `expectFfiError` for `[C ABI]` ones). - static func wrapHandle( - expecting decode: (OpaquePointer, String) -> any Error = expectAimuxError, - context: String = #function, - _ call: (UnsafeMutablePointer) -> OpaquePointer? - ) throws -> UInt64 { - var h: UInt64 = 0 - if let e = call(&h) { throw decode(e, context) } - return h - } - - // ── Provider constructors ────────────────────────────────────────────── - - /// Initialize the global logger (RFC-0014). - /// - /// Idempotent — safe to call any number of times from any thread; only the - /// first call has an effect. If the host already registered its own - /// `tracing` subscriber, this is a no-op (aimux never overrides a - /// consumer's logger). - /// - /// - Parameter level: "off" | "error" | "warn" | "info" | "debug" | - /// "trace"; empty defaults to "warn". The `AIMUX_LOG` / `AIMUX_LOG_LEVEL` - /// environment variables take precedence when set. Logs go to stderr. - public static func initLogging(level: String) { - // [C ABI]: a Swift String is always valid UTF-8, so this cannot fail. - if let e = aimux_init_logging(level.isEmpty ? "warn" : level) { - preconditionFailure("\(expectFfiError(e, context: "initLogging"))") - } - } - - /// Create an OpenAI model instance. - public static func openai(apiKey: String, modelId: String) throws -> Model { - let handle = try wrapHandle { aimux_openai_new(apiKey, modelId, $0) } - return Model(handle: handle) - } - - /// Create an Anthropic model instance. - public static func anthropic(apiKey: String, modelId: String) throws -> Model { - let handle = try wrapHandle { aimux_anthropic_new(apiKey, modelId, $0) } - return Model(handle: handle) - } - - /// Create an OpenAI model instance with a custom base URL. - /// - /// An empty `baseUrl` falls back to the provider's standard URL - /// (see `aimux_openai_new_with_base`). - public static func openai(apiKey: String, modelId: String, baseUrl: String) throws -> Model { - let handle = try wrapHandle { aimux_openai_new_with_base(apiKey, modelId, baseUrl, $0) } - return Model(handle: handle) - } - - /// Create an Anthropic model instance with a custom base URL. - public static func anthropic(apiKey: String, modelId: String, baseUrl: String) throws -> Model { - let handle = try wrapHandle { aimux_anthropic_new_with_base(apiKey, modelId, baseUrl, $0) } - return Model(handle: handle) - } - - /// Create a Cohere model instance. - public static func cohere(apiKey: String, modelId: String) throws -> Model { - let handle = try wrapHandle { aimux_cohere_new(apiKey, modelId, $0) } - return Model(handle: handle) - } - - /// Create a Cohere model instance with a custom base URL. - public static func cohere(apiKey: String, modelId: String, baseUrl: String) throws -> Model { - let handle = try wrapHandle { aimux_cohere_new_with_base(apiKey, modelId, baseUrl, $0) } - return Model(handle: handle) - } - - /// Create a Mistral model instance. - public static func mistral(apiKey: String, modelId: String) throws -> Model { - let handle = try wrapHandle { aimux_mistral_new(apiKey, modelId, $0) } - return Model(handle: handle) - } - - /// Create a Mistral model instance with a custom base URL. - public static func mistral(apiKey: String, modelId: String, baseUrl: String) throws -> Model { - let handle = try wrapHandle { aimux_mistral_new_with_base(apiKey, modelId, baseUrl, $0) } - return Model(handle: handle) - } - - /// Create an xAI model instance. - public static func xai(apiKey: String, modelId: String) throws -> Model { - let handle = try wrapHandle { aimux_xai_new(apiKey, modelId, $0) } - return Model(handle: handle) - } - - /// Create an xAI model instance with a custom base URL. - public static func xai(apiKey: String, modelId: String, baseUrl: String) throws -> Model { - let handle = try wrapHandle { aimux_xai_new_with_base(apiKey, modelId, baseUrl, $0) } - return Model(handle: handle) - } - - /// Create a Bedrock model instance (AWS SigV4 credentials). - public static func bedrock( - accessKeyId: String, secretAccessKey: String, region: String, modelId: String - ) throws -> Model { - let handle = try wrapHandle { - aimux_bedrock_new(accessKeyId, secretAccessKey, region, modelId, $0) - } - return Model(handle: handle) - } - - /// Create a Bedrock model instance with a custom base URL. - public static func bedrock( - accessKeyId: String, secretAccessKey: String, region: String, modelId: String, baseUrl: String - ) throws -> Model { - let handle = try wrapHandle { - aimux_bedrock_new_with_base(accessKeyId, secretAccessKey, region, modelId, baseUrl, $0) - } - return Model(handle: handle) - } - - /// Create a Vertex AI model instance (GCP bearer token). - public static func vertex( - accessToken: String, project: String, location: String, modelId: String - ) throws -> Model { - let handle = try wrapHandle { - aimux_vertex_new(accessToken, project, location, modelId, $0) - } - return Model(handle: handle) - } - - /// Create a Vertex AI model instance with a custom base URL. - public static func vertex( - accessToken: String, project: String, location: String, modelId: String, baseUrl: String - ) throws -> Model { - let handle = try wrapHandle { - aimux_vertex_new_with_base(accessToken, project, location, modelId, baseUrl, $0) - } - return Model(handle: handle) - } - - /// Create an Anthropic-on-AWS model instance (API key + region). - public static func anthropicAws(apiKey: String, region: String, modelId: String) throws -> Model { - let handle = try wrapHandle { aimux_anthropic_aws_new(apiKey, region, modelId, $0) } - return Model(handle: handle) - } - - /// Create an Anthropic-on-AWS model instance with a custom base URL. - public static func anthropicAws( - apiKey: String, region: String, modelId: String, baseUrl: String - ) throws -> Model { - let handle = try wrapHandle { - aimux_anthropic_aws_new_with_base(apiKey, region, modelId, baseUrl, $0) - } - return Model(handle: handle) - } - - /// Create an Azure OpenAI model instance (API key + resource name). - /// The deployment name is passed as `modelId`; `apiVersion` is optional. - public static func azure( - apiKey: String, resourceName: String, deployment: String, apiVersion: String? = nil - ) throws -> Model { - let handle = try wrapHandle { - aimux_azure_new(apiKey, resourceName, deployment, apiVersion, $0) - } - return Model(handle: handle) +func validateManifest(_ value: JSONValue, version: UInt32) throws { + guard value["opsVersion"] == .string(AimuxOps.OPS_VERSION), + value["abiVersion"] == .number(Double(version)), + value["digest"] == .string(AimuxOps.MANIFEST_DIGEST) else { + throw AimuxError.state("Incompatible manifest: opsVersion \(String(describing: value["opsVersion"])) (expected \(AimuxOps.OPS_VERSION)), digest \(String(describing: value["digest"])) (expected \(AimuxOps.MANIFEST_DIGEST))", code: 302) } +} - /// Create an Azure OpenAI model instance with a custom base URL. - public static func azureWithBase( - apiKey: String, baseUrl: String, deployment: String, apiVersion: String? = nil - ) throws -> Model { - let handle = try wrapHandle { - aimux_azure_new_with_base(apiKey, baseUrl, deployment, apiVersion, $0) - } - return Model(handle: handle) - } - - /// Create a model from the provider registry by name (RFC-0017 phase 4). - /// - /// - Parameters: - /// - name: Registry provider name (e.g. `"deepseek"`, `"groq"`). - /// - apiKey: API key, or `nil` to read the provider's env var from the - /// registry entry. - /// - modelId: Model id. - /// - configJson: Optional JSON object of `ProviderOptions` - /// (`{"baseUrl": "...", "headers": {...}, "organization": "...", - /// "project": "...", "params": {...}}`); `nil` for defaults. Any - /// other key (`maxRetries` is a per-call option) is rejected as - /// `.invalidArgument`. - public static func provider( - name: String, apiKey: String? = nil, modelId: String, configJson: String? = nil - ) throws -> Model { - try validateJson(configJson, parameter: "configJson") - let handle = try wrapHandle { - aimux_provider_new(name, apiKey, modelId, configJson, $0) - } - return Model(handle: handle) - } - - // ── Recording + mock replay (RFC-0023) ───────────────────────────────── - - /// Start recording: complete `Recording` entries are written as JSONL to - /// `{dir}/recordings.jsonl` (the directory is auto-created). - /// - /// Recording is opt-in; calling again (with a different dir) replaces the - /// active recorder. Like `initLogging`, this controls a global recorder and - /// is not tied to a specific model instance. - /// - /// - Parameter dir: Directory that will hold `recordings.jsonl`. - /// - Throws: `RecordingError` (`.initFailed` — dir could not be created, - /// `.openFile`, `.spawn`) when the recorder cannot be constructed. On - /// failure the previous recorder (if any) stays in place. - public static func initRecording(dir: String) throws { - if let e = aimux_init_recording(dir) { throw expectRecordingError(e, context: "initRecording") } - } +private enum RuntimeMetadata { + // Swift's static initialization serializes this immutable compatibility check. + static let manifest: Result = Result { try nativeManifest() } +} - /// Start in-memory bounded recording (ring recorder with FIFO eviction). - /// - /// Keeps only the most recent `cap` recordings in memory, evicting the - /// oldest first. Use this when recordings don't need to be persisted to - /// disk. - /// - /// - Parameter cap: Ring capacity. Pass `nil` (the default) to use the - /// library default capacity (FFI `aimux_init_recording_ring_default`); - /// `nil` cannot fail. - /// - Throws: `AimuxError.invalidArgument` for `cap: 0` — aimux-core - /// decides, so a Swift caller sees exactly what a C caller sees - /// (`AIMUX_E_INVALID_ARGUMENT`, "cap: must be > 0"). - public static func initRecordingRing(cap: UInt64? = nil) throws { - guard let cap else { return aimux_init_recording_ring_default() } - if let e = aimux_init_recording_ring(cap) { - throw expectAimuxError(e, context: "initRecordingRing") - } +func verifyRuntime() throws { + guard aimux_abi_version() >> 16 == AimuxOps.ABI_VERSION >> 16 else { + throw AimuxError.state("Unsupported ABI version") } + _ = try RuntimeMetadata.manifest.get() +} - /// Stop recording: the global recorder becomes `None`. - public static func recordingStop() { - aimux_recording_stop() - } +func nativeCall(_ id: UInt64, _ op: String, _ params: String, _ abort: UInt64 = 0) throws -> String { + try verifyRuntime() + guard !op.utf8.contains(0) else { throw invariant("Operation contains NUL") } + var output: UnsafeMutablePointer? + if let error = aimux_call(id, op, params, abort, &output) { throw takeError(error) } + return try takeString(output) +} - /// Flush the global recorder, blocking until the JSONL is on disk. - /// - /// No-op for the in-memory ring recorder. - public static func recordingFlush() { - aimux_recording_flush() +func handleID(_ json: String) throws -> UInt64 { + let value = try JSONDecoder().decode(JSONValue.self, from: Data(json.utf8)) + // Resource factories return a handle record; transcription.start returns + // the Operation ID directly. Both native results encode IDs as strings. + let decimal = value.stringValue ?? value["handle"]?.stringValue + guard let decimal, let id = UInt64(decimal), id != 0, String(id) == decimal else { + throw invariant("Expected a nonzero decimal handle string") } + return id +} - /// Checked flush: like `recordingFlush()` but throws `RecordingError` - /// (`.writerGone` / `.flushTimeout` / `.write`) when the JSONL cannot be - /// confirmed on disk. Returns normally when nothing is recording. The - /// legacy `recordingFlush()` stays and never reports. - public static func recordingTryFlush() throws { - if let e = aimux_recording_try_flush() { throw expectRecordingError(e, context: "recordingTryFlush") } +// All mutable fields and abort/drop races are protected by this lock. Abort +// itself is the ABI's nonblocking signal, safe to invoke while holding it. +final class Cancellation: @unchecked Sendable { + private let lock = NSLock() + private var id: UInt64 = 0 + private var cancelled = false + private var finished = false + private let operation: HandleState? + private weak var channel: StreamChannel? + init(operation: HandleState? = nil) { self.operation = operation } + + func attach(_ channel: StreamChannel) { + lock.lock() + self.channel = channel + let stop = cancelled + lock.unlock() + if stop { channel.close() } + } + func install(_ id: UInt64) { + lock.lock() + self.id = id + if cancelled { aimux_abort(id) } + lock.unlock() + } + func cancel() { request(closeConsumer: true) } + func abortForCallbackFailure() { request(closeConsumer: false) } + private func request(closeConsumer: Bool) { + lock.lock() + let first = !cancelled && !finished + if first { cancelled = true; if id != 0 { aimux_abort(id) } } + let channel = self.channel + lock.unlock() + if closeConsumer { channel?.close() } + if first { operation?.cancelOperation() } + } + func finish() { + lock.lock() + finished = true + let value = id + id = 0 + lock.unlock() + Blocking.drop(value) + } + var isCancelled: Bool { + lock.lock(); defer { lock.unlock() } + return cancelled } +} - /// Create a mock replay model from recorded JSONL (one `Recording` per - /// line). - /// - /// The returned model answers `generateText` / `streamText` from the - /// recorded data without sending any real provider request. - /// - /// - Parameter recordingsJsonl: The recorded JSONL content. - /// - Returns: A `Model` backed by the mock replay handle. - public static func mockReplay(recordingsJsonl: String) throws -> Model { - for line in recordingsJsonl.split(whereSeparator: \.isNewline) - where !line.allSatisfy(\.isWhitespace) { - try validateJson(String(line), parameter: "recordingsJsonl") +// Admission pins the native registry entry until C returns. Close takes away +// admission atomically, cancels admitted work, and queues drop after the last +// pin, never joining on the caller's actor or under this lock. +final class HandleState: @unchecked Sendable { + private let lock = NSLock() + private var id: UInt64 + private var closed = false + private var users = 0 + private var cancellations: [UUID: Cancellation] = [:] + init(_ id: UInt64) { self.id = id } + + var identifier: UInt64? { + lock.lock(); defer { lock.unlock() } + return closed ? nil : id + } + + func begin(_ cancellation: Cancellation) throws -> (UInt64, UUID) { + lock.lock(); defer { lock.unlock() } + guard !closed else { throw AimuxError.state("Handle is closed") } + let ticket = UUID() + cancellations[ticket] = cancellation + users += 1 + return (id, ticket) + } + func end(_ ticket: UUID?) { + lock.lock() + if let ticket { cancellations.removeValue(forKey: ticket) } + users -= 1 + let drop = closed && users == 0 ? id : 0 + if drop != 0 { id = 0 } + lock.unlock() + Blocking.drop(drop) + } + func close() { + lock.lock() + guard !closed else { lock.unlock(); return } + closed = true + let active = Array(cancellations.values) + let drop = users == 0 ? id : 0 + if drop != 0 { id = 0 } + lock.unlock() + for cancellation in active { cancellation.cancel() } + Blocking.drop(drop) + } + func cancelOperation() { + lock.lock() + guard id != 0 else { lock.unlock(); return } + let value = id + users += 1 + lock.unlock() + DispatchQueue.global(qos: .utility).async { + _ = try? nativeCall(value, AimuxOps.OP_OPERATION_CANCEL, "{}") + self.end(nil) } - let handle = try wrapHandle { aimux_mock_replay_new(recordingsJsonl, $0) } - return Model(handle: handle) } +} - // ── Composite models (RFC-0021 / RFC-0022) ────────────────────────────── - - /// Create a RouterModel (RFC-0021) over the given child models. The - /// returned model routes each call to one child and falls back across the - /// rest on error (per `configJson`). - /// - /// - Parameters: - /// - models: child models (must be non-empty — an empty array throws, - /// it does not trap: the array is as likely to come from a `.filter` - /// as from a literal, so C's zero-children failure surfaces instead). - /// - configJson: optional config: `{"router": "rule"|"weighted", - /// "weights": [...], "fallback": "on_error"|"none", "providerName", - /// "modelId"}`. - /// - Returns: a new RouterModel wrapping the children. - public static func router( - _ models: [Model], - configJson: String? = nil - ) throws -> Model { - try validateJson(configJson, parameter: "configJson") - let handles = models.map { $0.handle } - let handle = try handles.withUnsafeBufferPointer { buf -> UInt64 in - try wrapHandle { out in - aimux_router_new(buf.baseAddress, buf.count, configJson, out) - } - } - return Model(handle: handle) - } +public enum PollResult: Equatable, Sendable { + case part(JSONValue) + case ended + case timeout +} - /// Create a MoaModel (RFC-0022) over reference models + one aggregator. - /// References fan out in parallel, then the aggregator synthesizes a final - /// answer. - /// - /// - Parameters: - /// - references: reference models (may be empty — runs aggregator only). - /// - aggregator: the aggregator model. - /// - configJson: optional MoaConfig. - /// - Returns: a new MoaModel. - public static func moa( - references: [Model], - aggregator: Model, - configJson: String? = nil - ) throws -> Model { - try validateJson(configJson, parameter: "configJson") - let agg = aggregator.handle - // Empty references: pass a NULL base address + 0 length. - let handle: UInt64 - if references.isEmpty { - handle = try wrapHandle { out in - aimux_moa_new(nil, 0, agg, configJson, out) - } - } else { - let refHandles = references.map { $0.handle } - handle = try refHandles.withUnsafeBufferPointer { buf -> UInt64 in - try wrapHandle { out in - aimux_moa_new(buf.baseAddress, buf.count, agg, configJson, out) +/// Owns one nonzero UInt64 C handle. Close is idempotent and safe to race +/// with admission. Use the runtime's manifest for supported operations/kinds. +public final class AimuxHandle: Sendable { + let state: HandleState + public init(owning id: UInt64) throws { + guard id != 0 else { throw AimuxError.state("Zero is global scope, not an owned handle") } + state = HandleState(id) + } + deinit { state.close() } + public func close() { state.close() } + /// Use a decimal string of this ID in secondary handle fields. The ID is + /// borrowed; it becomes unavailable after close and does not transfer ownership. + public var id: UInt64? { state.identifier } + + public func call(_ op: String, params: JSONValue = .object([:])) async throws -> JSONValue { + try await invoke(state, op, params) + } + public func stream(_ op: String, params: JSONValue = .object([:]), + limits: StreamLimits = .default) async throws -> AimuxStream { + try await startStream(state, op, params, limits) + } + public func open(_ op: String, params: JSONValue = .object([:])) async throws -> AimuxHandle { + let value = try await call(op, params: params) + return try checkedOwnedHandle(value) + } + public func push(_ data: Data) async throws { + guard data.count <= 1_048_576 else { throw resourceLimit("Audio chunk exceeds 1 MiB") } + let cancellation = Cancellation(operation: state) + let (id, ticket) = try state.begin(cancellation) + defer { state.end(ticket); cancellation.finish() } + try await withTaskCancellationHandler { + do { + try Task.checkCancellation() + try await Blocking.run { + try verifyRuntime() + try data.withUnsafeBytes { bytes in + if let error = aimux_operation_push(id, bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count) { + throw takeError(error) + } + } } + try Task.checkCancellation() + if cancellation.isCancelled { throw CancellationError() } + } catch { + if cancellation.isCancelled { throw CancellationError() } + throw error } - } - return Model(handle: handle) - } - - /// Replace the default transport with one routed by this proxy configuration - /// (M6, RFC-0016). Requests started afterwards use it; calling it again - /// replaces the previous configuration. - /// - /// `configJSON` shape: `{ "http_url", "https_url", "all_url", "no_proxy" }` - /// (all fields optional; omitting all is equivalent to relying on the - /// `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` env vars). - /// - /// - Parameter configJSON: ProxyConfig JSON. - /// - Throws: `AimuxError` (`.invalidArgument`) for a wrong-shape config. - public static func initProxy(_ configJSON: String) throws { - try validateJson(configJSON, parameter: "configJSON") - if let e = aimux_init_proxy(configJSON) { throw expectAimuxError(e, context: "initProxy") } - } - - // ── Provider handles (RFC-0027) ────────────────────────────────────────── - - /// Create a **provider handle** for a registry-backed provider (RFC-0027). - /// - /// Unlike `provider()` (which binds to a single modelId), this returns a - /// `ProviderHandle` that supports `listModels()` and `model()`. - public static func createProvider( - name: String, apiKey: String? = nil, configJson: String? = nil - ) throws -> ProviderHandle { - try validateJson(configJson, parameter: "configJson") - let handle = try wrapHandle { - aimux_provider_handle_new(name, apiKey, configJson, $0) - } - return ProviderHandle(handle: handle) - } - - // ── Generation ───────────────────────────────────────────────────────── - - /// Generate text (non-streaming). - /// - /// - Parameters: - /// - prompt: A prompt string or messages array (serialized as JSON). - /// - options: Optional GenerateTextOptions (serialized as JSON). - /// - Returns: The JSON-serialized GenerateTextResult. - public func generateText(prompt: String, options: String? = nil) throws -> String { - try validateJson(prompt, parameter: "prompt") - try validateJson(options, parameter: "options") - return try ffiStringCall { aimux_generate_text(handle, prompt, options, $0) } - } - - /// Generate a structured JSON object (M12, RFC-0016). - /// - /// Same signature as `generateText`; returns a JSON-serialized - /// `GenerateObjectResult`. Pass `"output": {"type": "object", "schema": ...}` - /// via `options` to describe the object; the model's text must parse as - /// JSON and match the schema. - /// - /// - Parameters: - /// - prompt: A prompt string or messages array (serialized as JSON). - /// - options: Optional GenerateTextOptions (serialized as JSON). - /// - Returns: The JSON-serialized GenerateObjectResult. - public func generateObject(prompt: String, options: String? = nil) throws -> String { - try validateJson(prompt, parameter: "prompt") - try validateJson(options, parameter: "options") - return try ffiStringCall { aimux_generate_object(handle, prompt, options, $0) } - } - - /// Consume a stream to completion and return the aggregated result - /// (M11, RFC-0016). Synchronous (blocks until the stream finishes). - /// - /// Same signature as `generateText`; returns a JSON-serialized - /// `GenerateTextResult`. - /// - /// - Parameters: - /// - prompt: A prompt string or messages array (serialized as JSON). - /// - options: Optional GenerateTextOptions (serialized as JSON). - /// - Returns: The JSON-serialized GenerateTextResult. - public func consumeStreamText(prompt: String, options: String? = nil) throws -> String { - try validateJson(prompt, parameter: "prompt") - try validateJson(options, parameter: "options") - return try ffiStringCall { aimux_consume_stream_text(handle, prompt, options, $0) } - } - - /// Generate text (non-streaming) with OpenAI Chat Completions output. - /// - /// Same as `generateText`, but returns a serialized ChatCompletion - /// (OpenAI "chat.completion" object). Works with any provider (RFC-0026). - /// - /// - Parameters: - /// - prompt: A prompt string or messages array (serialized as JSON). - /// - options: Optional GenerateTextOptions (serialized as JSON). - /// - Returns: The JSON-serialized ChatCompletion. - public func generateTextAsOpenAI(prompt: String, options: String? = nil) throws -> String { - try validateJson(prompt, parameter: "prompt") - try validateJson(options, parameter: "options") - return try ffiStringCall { aimux_generate_text_as_openai(handle, prompt, options, $0) } - } - - /// Convert a serialized GenerateTextResult into a serialized ChatCompletion — - /// the conversion half of `generateTextAsOpenAI`. - /// - /// For repairing on the host (RFC-0035): patch the native result, then - /// convert it, so the completion's tool calls are the repaired ones. This - /// model only supplies the fallback model id; nothing is generated. - public func generateTextResultAsOpenAI(_ result: String) throws -> String { - try validateJson(result, parameter: "result") - return try ffiStringCall { aimux_generate_text_result_as_openai(handle, result, $0) } - } - - /// Stream text from the model. - /// - /// The C ABI returns NULL after `onDone`, or a returned error on failure - /// (no `onDone`, no C `onError` callback). Failures are surfaced via - /// `onError` (`AimuxError` / `DecodingError`). - /// - /// - Parameters: - /// - prompt: A prompt string (serialized as JSON). - /// - options: Optional GenerateTextOptions (serialized as JSON). - /// - onPart: Called for each TextStreamPart (JSON string). - /// - onDone: Called when the stream completes normally. - /// - onError: Called on stream failure with the decoded error unchanged - /// (`AimuxError`, or `DecodingError.dataCorrupted` for unparseable - /// `prompt` / `options` JSON, rejected before the C call). - public func streamText( - prompt: String, - options: String? = nil, - onPart: @escaping (String) -> Void, - onDone: @escaping () -> Void, - onError: @escaping (any Error) -> Void - ) { - stream(aimux_stream_text, prompt: prompt, options: options, - onPart: onPart, onDone: onDone, onError: onError) - } - - /// Stream text from the model with OpenAI Chat Completions output. - /// - /// Same as `streamText`, but each `onPart` receives a serialized - /// ChatCompletionChunk (OpenAI "chat.completion.chunk" object). Works with - /// any provider (RFC-0026). Stream options (`include_usage`, - /// `include_reasoning`) are passed via `options` → - /// `providerOptions.openai.stream_options`. - public func streamTextAsOpenAI( - prompt: String, - options: String? = nil, - onPart: @escaping (String) -> Void, - onDone: @escaping () -> Void, - onError: @escaping (any Error) -> Void - ) { - stream(aimux_stream_text_as_openai, prompt: prompt, options: options, - onPart: onPart, onDone: onDone, onError: onError) - } - - /// C `aimux_stream_text` / `aimux_stream_text_as_openai` signature. - private typealias StreamFn = ( - UInt64, UnsafePointer?, UnsafePointer?, - (@convention(c) (UnsafePointer?, UnsafeMutableRawPointer?) -> Void)?, - (@convention(c) (UnsafeMutableRawPointer?) -> Void)?, - UnsafeMutableRawPointer? - ) -> OpaquePointer? - - /// Shared body of the two closure-based stream methods (they differ only - /// by C symbol). - private func stream( - _ fn: StreamFn, - prompt: String, - options: String?, - onPart: @escaping (String) -> Void, - onDone: @escaping () -> Void, - onError: @escaping (any Error) -> Void - ) { - let context = StreamContext(onPart: onPart, onDone: onDone) - let unmanaged = Unmanaged.passRetained(context) - defer { unmanaged.release() } - - do { - try validateJson(prompt, parameter: "prompt") - try validateJson(options, parameter: "options") - if let e = fn(handle, prompt, options, - aimuxStreamOnPart, aimuxStreamOnDone, - unmanaged.toOpaque()) { - throw expectAimuxError(e, context: "streamText") + } onCancel: { cancellation.cancel() } + } + public func nextPart(timeoutMs: Int64) async throws -> PollResult { + let cancellation = Cancellation(operation: state) + let (id, ticket) = try state.begin(cancellation) + defer { state.end(ticket); cancellation.finish() } + return try await withTaskCancellationHandler { + do { + try Task.checkCancellation() + let result: PollResult = try await Blocking.run { + try verifyRuntime() + var output: UnsafeMutablePointer? + var status: Int32 = 0 + if let error = aimux_operation_next_part(id, timeoutMs, &output, &status) { throw takeError(error) } + switch status { + case 1: return .part(try JSONDecoder().decode(JSONValue.self, from: Data(takeString(output).utf8))) + case 2, 3: + if let output { aimux_free_string(output); throw invariant("Unexpected poll output") } + return status == 2 ? .ended : .timeout + default: + if let output { aimux_free_string(output) } + throw invariant("Unknown poll state \(status)") + } + } + try Task.checkCancellation() + if cancellation.isCancelled { throw CancellationError() } + return result + } catch { + if cancellation.isCancelled { throw CancellationError() } + throw error } - } catch { - // AimuxError / DecodingError (bad raw JSON, C ABI invariant) - // pass through unchanged. - onError(error) - } - } - - /// Stream text as an AsyncSequence of TextStreamPart JSON strings. - /// - /// Usage: - /// ```swift - /// for try await part in model.streamTextAsync(prompt: "...") { - /// print(part) - /// } - /// ``` - public func streamTextAsync( - prompt: String, options: String? = nil - ) -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - self.streamText( - prompt: prompt, options: options, - onPart: { continuation.yield($0) }, - onDone: { continuation.finish() }, - onError: { continuation.finish(throwing: $0) } - ) - } - } - - /// Stream text with OpenAI Chat Completions output as an AsyncSequence of - /// ChatCompletionChunk JSON strings (RFC-0026). - public func streamTextAsOpenAIAsync( - prompt: String, options: String? = nil - ) -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - self.streamTextAsOpenAI( - prompt: prompt, options: options, - onPart: { continuation.yield($0) }, - onDone: { continuation.finish() }, - onError: { continuation.finish(throwing: $0) } - ) - } + } onCancel: { cancellation.cancel() } } } -// ───────────────────────────────────────────────────────────────────────────── -// ProviderHandle (RFC-0027) — provider handle for listModels / model -// ───────────────────────────────────────────────────────────────────────────── - -/// A provider handle — created by `Model.createProvider`, supports `listModels()` -/// (runtime discovery) and `model()` (build a model from a discovered id). -public final class ProviderHandle: @unchecked Sendable { - - private var handle: UInt64 - - fileprivate init(handle: UInt64) { - self.handle = handle - } - - deinit { - if handle != 0 { - aimux_drop_handle(handle) - } - } - - /// List models available on this provider (runtime discovery + anya2a spec). - /// Returns a JSON array of ResolvedModel. - public func listModels() throws -> String { - return try ffiStringCall { aimux_provider_list_models(handle, $0) } - } - - /// Build a native decision model with this provider's connection configuration. - public func decisionModel(_ modelId: String) throws -> DecisionModel { - let h = try Model.wrapHandle { aimux_provider_decision_model(handle, modelId, $0) } - return DecisionModel(handle: h) - } - - /// Build a language model from a discovered model id. - public func model(_ modelId: String) throws -> Model { - let h = try Model.wrapHandle { aimux_provider_model(handle, modelId, $0) } - return Model(handle: h) - } +func ownedHandle(_ value: JSONValue) throws -> AimuxHandle { + let json = String(decoding: try JSONEncoder().encode(value), as: UTF8.self) + return try AimuxHandle(owning: handleID(json)) } -// ───────────────────────────────────────────────────────────────────────────── -// Model specs (RFC-0027) — get_model_specs -// ───────────────────────────────────────────────────────────────────────────── - -/// Fetch the community model catalogue (anya2a). Returns a JSON-serialized -/// Catalogue string. Thin fetch — no caching. -/// -/// - Parameter sourceUrl: Optional URL override (nil = default endpoint). -public func getModelSpecs(sourceUrl: String? = nil) throws -> String { - try ffiStringCall { aimux_get_model_specs(sourceUrl, $0) } +func checkedOwnedHandle(_ value: JSONValue) throws -> AimuxHandle { + let handle = try ownedHandle(value) + if Task.isCancelled { handle.close(); throw CancellationError() } + return handle } -// ───────────────────────────────────────────────────────────────────────────── -// Stream context + C trampolines -// ───────────────────────────────────────────────────────────────────────────── - -/// Holds the Swift closures for stream callbacks. -/// Passed through the C ABI `stream_ctx` void* (not thread-local). -private final class StreamContext { - let onPart: (String) -> Void - let onDone: () -> Void - - init(onPart: @escaping (String) -> Void, onDone: @escaping () -> Void) { - self.onPart = onPart - self.onDone = onDone +/// Global scope (G). Every blocking primitive runs on a background worker. +public enum AimuxRuntime { + public static func manifest() async throws -> JSONValue { + try await Blocking.run { try nativeManifest() } } -} - -/// C-compatible trampoline: `on_part(const char *json, void *stream_ctx)`. -private func aimuxStreamOnPart( - _ jsonPtr: UnsafePointer?, - _ ctx: UnsafeMutableRawPointer? -) { - guard let ctx else { return } - let context = Unmanaged.fromOpaque(ctx).takeUnretainedValue() - if let jsonPtr { - context.onPart(String(cString: jsonPtr)) + public static func call(_ op: String, params: JSONValue = .object([:])) async throws -> JSONValue { + try await invoke(nil, op, params) + } + public static func open(_ op: String, params: JSONValue = .object([:])) async throws -> AimuxHandle { + try checkedOwnedHandle(await call(op, params: params)) } } -/// C-compatible trampoline: `on_done(void *stream_ctx)`. -private func aimuxStreamOnDone(_ ctx: UnsafeMutableRawPointer?) { - guard let ctx else { return } - Unmanaged.fromOpaque(ctx).takeUnretainedValue().onDone() -} - -// ───────────────────────────────────────────────────────────────────────────── -// Convenience: JSON helpers -// ───────────────────────────────────────────────────────────────────────────── - -public extension Model { - /// Generate text with a simple string prompt, returning a parsed result. - /// - /// - Parameters: - /// - prompt: A plain text prompt. - /// - options: Optional options dictionary. Must hold only JSON-legal - /// values; anything else throws `DecodingError.dataCorrupted`. - /// - Returns: Parsed GenerateTextResult as a dictionary. - func generate(prompt: String, options: [String: Any]? = nil) throws -> [String: Any] { - // Both force-unwraps are JSON UTF-8 bytes → String, which cannot fail. - let promptJson = String(data: try JSONEncoder().encode(prompt), encoding: .utf8)! - let optsJson = try options.map { opts -> String in - // `data(withJSONObject:)` raises an *uncatchable* ObjC exception - // (not a Swift error) on a non-JSON value, e.g. a Date; the type - // is `[String: Any]`, so only this check keeps that unreachable. - guard JSONSerialization.isValidJSONObject(opts) else { - throw DecodingError.dataCorrupted(.init(codingPath: [], - debugDescription: "options: not a JSON object")) +func invoke(_ state: HandleState?, _ op: String, _ params: JSONValue) async throws -> JSONValue { + let json = String(decoding: try JSONEncoder().encode(params), as: UTF8.self) + let cancellation = Cancellation(operation: op.hasPrefix("operation.") ? state : nil) + let admission = try state?.begin(cancellation) + defer { if let admission { state?.end(admission.1) }; cancellation.finish() } + return try await withTaskCancellationHandler { + try Task.checkCancellation() + let result: JSONValue + do { + result = try await Blocking.run { + try verifyRuntime() + let abort = try handleID(nativeCall(0, AimuxOps.OP_ABORT_CREATE, "{}")) + cancellation.install(abort) + let result = try nativeCall(admission?.0 ?? 0, op, json, abort) + return try JSONDecoder().decode(JSONValue.self, from: Data(result.utf8)) } - return String(data: try JSONSerialization.data(withJSONObject: opts), encoding: .utf8)! - } - let resultJson = try generateText(prompt: promptJson, options: optsJson) - guard let json = try JSONSerialization.jsonObject(with: Data(resultJson.utf8)) as? [String: Any] else { - throw DecodingError.typeMismatch([String: Any].self, - .init(codingPath: [], debugDescription: "generate_text result is not a JSON object")) + } catch { + try Task.checkCancellation() + throw error } - return json - } + return result + } onCancel: { cancellation.cancel() } } diff --git a/bindings/swift/Sources/Aimux/BoundedStream.swift b/bindings/swift/Sources/Aimux/BoundedStream.swift new file mode 100644 index 000000000..d80b8befa --- /dev/null +++ b/bindings/swift/Sources/Aimux/BoundedStream.swift @@ -0,0 +1,259 @@ +import Foundation + +/// A single-consumer bridge from synchronous callbacks to async iteration. +/// +/// Call `producer.yield` only on a background thread: it blocks while either +/// queue budget is full. `sizeOf` must return the payload's actual byte size. +/// Keep the producer in the callback context until the native call returns. +/// Task cancellation, `cancel()`, or releasing the iterator aborts the producer +/// once. An early `for await` exit releases its iterator even if this sequence +/// is retained. Retaining an iterator explicitly requires explicit cancellation. +public struct BoundedStream: AsyncSequence, Sendable { + public enum Failure: Error, Equatable { + case elementTooLarge + case invalidElementSize + case multipleConsumers + } + + public enum YieldResult: Sendable, Equatable { + case accepted + case terminated + } + + public struct Producer: Sendable { + fileprivate let state: State + + /// Blocks the callback thread until space is available or the stream + /// terminates. Oversized payloads finish the stream with a Failure. + @discardableResult + public func yield(_ element: Element) -> YieldResult { + state.yield(element) + } + + /// Terminal state has its own slot, so this never waits for capacity. + /// The first finish wins; its error follows all accepted elements once. + public func finish(throwing error: (any Error)? = nil) { + state.finish(error) + } + } + + public struct AsyncIterator: AsyncIteratorProtocol { + fileprivate let lease: Lease + + public func cancel() { if lease.admitted { lease.state.cancel() } } + + public mutating func next() async throws -> Element? { + // Keep the lease alive throughout suspension, including cancellation. + defer { withExtendedLifetime(lease) {} } + // Qualified: inside AsyncIterator, Failure names the protocol associated type. + guard lease.admitted else { throw BoundedStream.Failure.multipleConsumers } + return try await withTaskCancellationHandler { + try await lease.state.next() + } onCancel: { [state = lease.state] in + state.cancel() + } + } + } + + private let state: State + public var producer: Producer { Producer(state: state) } + + public init( + capacity: Int = 256, + byteCapacity: Int = 8 * 1024 * 1024, + maxElementBytes: Int = 1024 * 1024, + sizeOf: @escaping @Sendable (Element) -> Int, + abort: @escaping @Sendable () -> Void + ) { + precondition((1...256).contains(capacity)) + precondition((1...(8 * 1024 * 1024)).contains(byteCapacity)) + precondition((1...(1024 * 1024)).contains(maxElementBytes)) + state = State(capacity: capacity, byteCapacity: byteCapacity, + maxElementBytes: maxElementBytes, sizeOf: sizeOf, abort: abort) + } + + public func makeAsyncIterator() -> AsyncIterator { + AsyncIterator(lease: Lease(state: state)) + } + + /// Discards undelivered elements and wakes both sides. Subsequent next is nil. + public func cancel() { state.cancel() } + + var snapshot: (items: Int, bytes: Int, highItems: Int, highBytes: Int) { state.snapshot } + + fileprivate final class Lease { + let state: State + let admitted: Bool + + init(state: State) { + self.state = state + admitted = state.admitConsumer() + } + + deinit { + if admitted { state.cancel() } + } + } + + // Every mutable field is protected by condition. Continuations and abort + // callbacks run outside that lock, permitting reentrant native termination. + fileprivate final class State: @unchecked Sendable { + private struct Slot { + let element: Element + let bytes: Int + } + + private enum Terminal { + case open + case finished((any Error)?) + case cancelled + case consumed + } + + private let condition = NSCondition() + private var slots: [Slot?] + private var head = 0 + private var count = 0 + private var bytes = 0 + private var highItems = 0 + private var highBytes = 0 + + var snapshot: (items: Int, bytes: Int, highItems: Int, highBytes: Int) { + condition.lock(); defer { condition.unlock() } + return (count, bytes, highItems, highBytes) + } + private var terminal = Terminal.open + private var consumerAdmitted = false + private var waiter: CheckedContinuation? + private let byteCapacity: Int + private let maxElementBytes: Int + private let sizeOf: @Sendable (Element) -> Int + private let abort: @Sendable () -> Void + + init(capacity: Int, byteCapacity: Int, maxElementBytes: Int, + sizeOf: @escaping @Sendable (Element) -> Int, + abort: @escaping @Sendable () -> Void) { + slots = Array(repeating: nil, count: capacity) + self.byteCapacity = byteCapacity + self.maxElementBytes = maxElementBytes + self.sizeOf = sizeOf + self.abort = abort + } + + func admitConsumer() -> Bool { + condition.lock() + defer { condition.unlock() } + guard !consumerAdmitted else { return false } + consumerAdmitted = true + return true + } + + func yield(_ element: Element) -> YieldResult { + let size = sizeOf(element) + condition.lock() + while case .open = terminal { + guard size >= 0, size <= maxElementBytes, size <= byteCapacity else { + condition.unlock() + finish(size < 0 ? Failure.invalidElementSize : Failure.elementTooLarge) + return .terminated + } + if let waiting = waiter { + waiter = nil + condition.unlock() + waiting.resume(returning: element) + return .accepted + } + if count < slots.count && size <= byteCapacity - bytes { + slots[(head + count) % slots.count] = Slot(element: element, bytes: size) + count += 1 + bytes += size + highItems = Swift.max(highItems, count) + highBytes = Swift.max(highBytes, bytes) + condition.unlock() + return .accepted + } + condition.wait() + } + condition.unlock() + return .terminated + } + + func finish(_ error: (any Error)?) { + condition.lock() + guard case .open = terminal else { + condition.unlock() + return + } + terminal = .finished(error) + let waiting = waiter + waiter = nil + if waiting != nil { terminal = .consumed } + condition.broadcast() + condition.unlock() + if let waiting { + if let error { waiting.resume(throwing: error) } + else { waiting.resume(returning: nil) } + } + } + + func cancel() { + condition.lock() + switch terminal { + case .cancelled, .consumed: + condition.unlock() + return + case .open, .finished: + break + } + // The consumer is abandoning any buffered elements and terminal. + let previousTerminal = terminal + let discarded = slots + terminal = .cancelled + slots = Array(repeating: nil, count: slots.count) + count = 0 + bytes = 0 + let waiting = waiter + waiter = nil + condition.broadcast() + condition.unlock() + waiting?.resume(returning: nil) + abort() + // Payload and error destructors may themselves call native code. + withExtendedLifetime((discarded, previousTerminal)) {} + } + + func next() async throws -> Element? { + try await withCheckedThrowingContinuation { continuation in + condition.lock() + if count > 0, let slot = slots[head] { + slots[head] = nil + head = (head + 1) % slots.count + count -= 1 + bytes -= slot.bytes + condition.broadcast() + condition.unlock() + continuation.resume(returning: slot.element) + return + } + switch terminal { + case .open: + guard waiter == nil else { + condition.unlock() + continuation.resume(throwing: Failure.multipleConsumers) + return + } + waiter = continuation + condition.unlock() + case .finished(let error): + terminal = .consumed + condition.unlock() + if let error { continuation.resume(throwing: error) } + else { continuation.resume(returning: nil) } + case .cancelled, .consumed: + condition.unlock() + continuation.resume(returning: nil) + } + } + } + } +} diff --git a/bindings/swift/Sources/Aimux/DecisionModel.swift b/bindings/swift/Sources/Aimux/DecisionModel.swift deleted file mode 100644 index 729f15cda..000000000 --- a/bindings/swift/Sources/Aimux/DecisionModel.swift +++ /dev/null @@ -1,50 +0,0 @@ -import CAimuxFFI -import Foundation - -/// A DecisionModelV4 model using the core JSON contract: DecideOptions in, DecideResult out. -public final class DecisionModel: @unchecked Sendable { - private let lock = NSLock() - private var handle: UInt64 - - init(handle: UInt64) { self.handle = handle } - deinit { close() } - - /// Endpoint is a complete POST URL; nil uses the official API. - public static func jev(apiKey: String, modelId: String, endpoint: String? = nil, - probabilitySource: String? = nil) throws -> DecisionModel { - let handle = try Model.wrapHandle { - aimux_jev_decision_new_with_probability_source(apiKey, modelId, endpoint, probabilitySource, $0) - } - return DecisionModel(handle: handle) - } - - /// Optional abort handle uses the existing C ABI abort signal lifecycle. - public func decide(options: String, abortHandle: UInt64 = 0) throws -> String { - try validateJson(options, parameter: "options") - let handle = try snapshot() - return try ffiStringCall { aimux_decide_with_abort(handle, options, abortHandle, $0) } - } - - /// The model's DecisionModelV4 properties as JSON - /// (`{specificationVersion, provider, modelId, supportedQuestionTypes}`), without HTTP. - public func describe() throws -> String { - let handle = try snapshot() - return try ffiStringCall { aimux_decision_capabilities(handle, $0) } - } - - private func snapshot() throws -> UInt64 { - lock.lock() - defer { lock.unlock() } - guard handle != 0 else { throw invariant("DecisionModel is closed") } - return handle - } - - /// Idempotent; a request already inside Rust may finish after close. - public func close() { - lock.lock() - let value = handle - handle = 0 - lock.unlock() - if value != 0 { aimux_drop_handle(value) } - } -} diff --git a/bindings/swift/Sources/Aimux/Multimodal.swift b/bindings/swift/Sources/Aimux/Multimodal.swift index 1d6bd7f65..54184536e 100644 --- a/bindings/swift/Sources/Aimux/Multimodal.swift +++ b/bindings/swift/Sources/Aimux/Multimodal.swift @@ -1,500 +1,5 @@ -// Multimodal.swift — Swift wrapper around the aimux-ffi multimodal C ABI. -// -// Mirrors the Go binding (bindings/go/multimodal.go): eight modality model -// types (Embedding, Speech, Transcription, Image, Video, Reranking, Search, -// Files), each wrapping an opaque C handle. Handles are ARC-managed and -// released via `aimux_drop_handle` on deallocation, just like `Model` in -// Aimux.swift. Cross-boundary data uses JSON strings (base64 for binary), -// matching the C ABI wire format. -// -// The instance methods form the *raw* layer (JSON-string in, JSON-string out, -// consistent with `Model.generateText`). The Codable types below let callers -// decode those JSON strings into typed Swift values. - -import CAimuxFFI import Foundation -// ───────────────────────────────────────────────────────────────────────────── -// EmbeddingModel -// ───────────────────────────────────────────────────────────────────────────── - -/// A model instance backed by a Rust `Arc`. -/// -/// The C handle is automatically released when this object is deallocated. -public final class EmbeddingModel: @unchecked Sendable { - - // The opaque handle from aimux-ffi. 0 means invalid/freed. - private var handle: UInt64 - - private init(handle: UInt64) { - self.handle = handle - } - - deinit { - if handle != 0 { - aimux_drop_handle(handle) - } - } - - // ── Provider constructors ────────────────────────────────────────────── - - /// Create an OpenAI embedding model instance (e.g. text-embedding-3-small). - public static func openai(apiKey: String, modelId: String) throws -> EmbeddingModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_openai_embedding_new(apiKey, modelId, $0) } - return EmbeddingModel(handle: handle) - } - - /// Create an OpenAI embedding model instance with a custom base URL. - public static func openai(apiKey: String, modelId: String, baseUrl: String) throws -> EmbeddingModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_openai_embedding_new_with_base(apiKey, modelId, baseUrl, $0) } - return EmbeddingModel(handle: handle) - } - - /// Create a Cohere embedding model instance (e.g. embed-english-v3.0). - public static func cohere(apiKey: String, modelId: String) throws -> EmbeddingModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_cohere_embedding_new(apiKey, modelId, $0) } - return EmbeddingModel(handle: handle) - } - - /// Create a Cohere embedding model instance with a custom base URL. - public static func cohere(apiKey: String, modelId: String, baseUrl: String) throws -> EmbeddingModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_cohere_embedding_new_with_base(apiKey, modelId, baseUrl, $0) } - return EmbeddingModel(handle: handle) - } - - /// Create a Google embedding model instance (e.g. gemini-embedding-001). - public static func google(apiKey: String, modelId: String) throws -> EmbeddingModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_google_embedding_new(apiKey, modelId, $0) } - return EmbeddingModel(handle: handle) - } - - /// Create a Google embedding model instance with a custom base URL. - public static func google(apiKey: String, modelId: String, baseUrl: String) throws -> EmbeddingModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_google_embedding_new_with_base(apiKey, modelId, baseUrl, $0) } - return EmbeddingModel(handle: handle) - } - - // ── Generation ───────────────────────────────────────────────────────── - - /// Generate embeddings for the given text values. - /// - /// - Parameters: - /// - values: A JSON array of strings to embed (e.g. `["hello","world"]`). - /// - options: Optional EmbeddingCallOptions serialized as JSON. - /// - Returns: The JSON-serialized EmbeddingResult. - public func embed(values: String, options: String? = nil) throws -> String { - try validateJson(values, parameter: "values") - try validateJson(options, parameter: "options") - let h = handle - return try ffiStringCall { - aimux_embed(h, values, options, $0) - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// SpeechModel (TTS) -// ───────────────────────────────────────────────────────────────────────────── - -/// A model instance backed by a Rust `Arc`. -/// -/// The C handle is automatically released when this object is deallocated. -public final class SpeechModel: @unchecked Sendable { - - // The opaque handle from aimux-ffi. 0 means invalid/freed. - private var handle: UInt64 - - private init(handle: UInt64) { - self.handle = handle - } - - deinit { - if handle != 0 { - aimux_drop_handle(handle) - } - } - - // ── Provider constructors ────────────────────────────────────────────── - - /// Create an OpenAI speech (TTS) model instance. - public static func openai(apiKey: String, modelId: String) throws -> SpeechModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_openai_speech_new(apiKey, modelId, $0) } - return SpeechModel(handle: handle) - } - - /// Create an OpenAI speech model instance with a custom base URL. - public static func openai(apiKey: String, modelId: String, baseUrl: String) throws -> SpeechModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_openai_speech_new_with_base(apiKey, modelId, baseUrl, $0) } - return SpeechModel(handle: handle) - } - - // ── Generation ───────────────────────────────────────────────────────── - - /// Generate speech audio from the given options. - /// - /// - Parameter options: SpeechCallOptions serialized as JSON (required — carries the input). - /// - Returns: The JSON-serialized SpeechResult. - public func generate(options: String) throws -> String { - try validateJson(options, parameter: "options") - let h = handle - return try ffiStringCall { - aimux_speech_generate(h, options, $0) - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// TranscriptionModel (STT) -// ───────────────────────────────────────────────────────────────────────────── - -/// A model instance backed by a Rust `Arc`. -/// -/// The C handle is automatically released when this object is deallocated. -public final class TranscriptionModel: @unchecked Sendable { - - // The opaque handle from aimux-ffi. 0 means invalid/freed. - private var handle: UInt64 - - private init(handle: UInt64) { - self.handle = handle - } - - deinit { - if handle != 0 { - aimux_drop_handle(handle) - } - } - - // ── Provider constructors ────────────────────────────────────────────── - - /// Create an OpenAI transcription (STT) model instance. - public static func openai(apiKey: String, modelId: String) throws -> TranscriptionModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_openai_transcription_new(apiKey, modelId, $0) } - return TranscriptionModel(handle: handle) - } - - /// Create an OpenAI transcription model instance with a custom base URL. - public static func openai(apiKey: String, modelId: String, baseUrl: String) throws -> TranscriptionModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_openai_transcription_new_with_base(apiKey, modelId, baseUrl, $0) } - return TranscriptionModel(handle: handle) - } - - // ── Generation ───────────────────────────────────────────────────────── - - /// Transcribe audio (base64-encoded) to text. - /// - /// - Parameters: - /// - audioBase64: Base64-encoded audio bytes. - /// - mediaType: Media type of the audio (e.g. `audio/wav`). - /// - options: Optional TranscriptionCallOptions serialized as JSON. - /// - Returns: The JSON-serialized TranscriptionResult. - public func generate(audioBase64: String, mediaType: String, options: String? = nil) throws -> String { - try validateJson(options, parameter: "options") - let h = handle - return try ffiStringCall { - aimux_transcription_generate(h, audioBase64, mediaType, options, $0) - } - } - - /// Start a streaming transcription session (RFC-0028) on this model. - /// Requires a model that supports `do_stream` (realtime models). - /// - /// - Parameters: - /// - options: Optional session options JSON - /// (`{"inputAudioFormat": {...}, "providerOptions", "headers", - /// "includeRawChunks"}`). - /// - abortHandle: Optional abort handle (`aimux_abort_signal_new`); - /// firing it aborts the session. - public func startStream(options: String? = nil, abortHandle: UInt64 = 0) throws -> TranscriptionSession { - try validateJson(options, parameter: "options") - let h = handle - let session = try Model.wrapHandle { out in - aimux_transcription_session_new(h, abortHandle, options, out) - } - return TranscriptionSession(handle: session) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// ImageModel -// ───────────────────────────────────────────────────────────────────────────── - -/// A model instance backed by a Rust `Arc`. -/// -/// The C handle is automatically released when this object is deallocated. -public final class ImageModel: @unchecked Sendable { - - // The opaque handle from aimux-ffi. 0 means invalid/freed. - private var handle: UInt64 - - private init(handle: UInt64) { - self.handle = handle - } - - deinit { - if handle != 0 { - aimux_drop_handle(handle) - } - } - - // ── Provider constructors ────────────────────────────────────────────── - - /// Create an OpenAI image model instance (e.g. dall-e-3). - public static func openai(apiKey: String, modelId: String) throws -> ImageModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_openai_image_new(apiKey, modelId, $0) } - return ImageModel(handle: handle) - } - - /// Create an OpenAI image model instance with a custom base URL. - public static func openai(apiKey: String, modelId: String, baseUrl: String) throws -> ImageModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_openai_image_new_with_base(apiKey, modelId, baseUrl, $0) } - return ImageModel(handle: handle) - } - - /// Create a Google image model instance (e.g. gemini-2.5-flash-image). - public static func google(apiKey: String, modelId: String) throws -> ImageModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_google_image_new(apiKey, modelId, $0) } - return ImageModel(handle: handle) - } - - /// Create a Google image model instance with a custom base URL. - public static func google(apiKey: String, modelId: String, baseUrl: String) throws -> ImageModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_google_image_new_with_base(apiKey, modelId, baseUrl, $0) } - return ImageModel(handle: handle) - } - - // ── Generation ───────────────────────────────────────────────────────── - - /// Generate images from the given options. - /// - /// - Parameter options: ImageCallOptions serialized as JSON (required — carries the input). - /// - Returns: The JSON-serialized ImageResult. - public func generate(options: String) throws -> String { - try validateJson(options, parameter: "options") - let h = handle - return try ffiStringCall { - aimux_image_generate(h, options, $0) - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// VideoModel -// ───────────────────────────────────────────────────────────────────────────── - -/// A model instance backed by a Rust `Arc`. -/// -/// The C handle is automatically released when this object is deallocated. -public final class VideoModel: @unchecked Sendable { - - // The opaque handle from aimux-ffi. 0 means invalid/freed. - private var handle: UInt64 - - private init(handle: UInt64) { - self.handle = handle - } - - deinit { - if handle != 0 { - aimux_drop_handle(handle) - } - } - - // ── Provider constructors ────────────────────────────────────────────── - - /// Create a Google video model instance (e.g. veo-3.0). - public static func google(apiKey: String, modelId: String) throws -> VideoModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_google_video_new(apiKey, modelId, $0) } - return VideoModel(handle: handle) - } - - /// Create a Google video model instance with a custom base URL. - public static func google(apiKey: String, modelId: String, baseUrl: String) throws -> VideoModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_google_video_new_with_base(apiKey, modelId, baseUrl, $0) } - return VideoModel(handle: handle) - } - - // ── Generation ───────────────────────────────────────────────────────── - - /// Generate videos from the given options. - /// - /// - Parameter options: VideoCallOptions serialized as JSON (required — carries the input). - /// - Returns: The JSON-serialized VideoResult. - public func generate(options: String) throws -> String { - try validateJson(options, parameter: "options") - let h = handle - return try ffiStringCall { - aimux_video_generate(h, options, $0) - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// RerankingModel -// ───────────────────────────────────────────────────────────────────────────── - -/// A model instance backed by a Rust `Arc`. -/// -/// The C handle is automatically released when this object is deallocated. -public final class RerankingModel: @unchecked Sendable { - - // The opaque handle from aimux-ffi. 0 means invalid/freed. - private var handle: UInt64 - - private init(handle: UInt64) { - self.handle = handle - } - - deinit { - if handle != 0 { - aimux_drop_handle(handle) - } - } - - // ── Provider constructors ────────────────────────────────────────────── - - /// Create a Cohere reranking model instance (e.g. rerank-v3.0). - public static func cohere(apiKey: String, modelId: String) throws -> RerankingModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_cohere_reranking_new(apiKey, modelId, $0) } - return RerankingModel(handle: handle) - } - - /// Create a Cohere reranking model instance with a custom base URL. - public static func cohere(apiKey: String, modelId: String, baseUrl: String) throws -> RerankingModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_cohere_reranking_new_with_base(apiKey, modelId, baseUrl, $0) } - return RerankingModel(handle: handle) - } - - // ── Generation ───────────────────────────────────────────────────────── - - /// Rerank documents by relevance to a query. - /// - /// - Parameter options: RerankingCallOptions serialized as JSON (required — carries the input). - /// - Returns: The JSON-serialized RerankingResult. - public func rerank(options: String) throws -> String { - try validateJson(options, parameter: "options") - let h = handle - return try ffiStringCall { - aimux_rerank(h, options, $0) - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// SearchModel -// ───────────────────────────────────────────────────────────────────────────── - -/// A model instance backed by a Rust `Arc`. -/// -/// The C handle is automatically released when this object is deallocated. -public final class SearchModel: @unchecked Sendable { - - // The opaque handle from aimux-ffi. 0 means invalid/freed. - private var handle: UInt64 - - private init(handle: UInt64) { - self.handle = handle - } - - deinit { - if handle != 0 { - aimux_drop_handle(handle) - } - } - - // ── Provider constructors ────────────────────────────────────────────── - - /// Create a Tavily search model instance. Tavily uses a fixed endpoint, so - /// no model ID is needed (the C ABI's `model_id` argument is ignored). - public static func tavily(apiKey: String) throws -> SearchModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_tavily_search_new(apiKey, "", $0) } - return SearchModel(handle: handle) - } - - /// Create a Tavily search model instance with a custom base URL (useful for - /// testing against a mock server). - public static func tavily(apiKey: String, baseUrl: String) throws -> SearchModel { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_tavily_search_new_with_base(apiKey, "", baseUrl, $0) } - return SearchModel(handle: handle) - } - - // ── Generation ───────────────────────────────────────────────────────── - - /// Perform a web search. - /// - /// - Parameter options: SearchCallOptions serialized as JSON (required — carries the input). - /// - Returns: The JSON-serialized SearchResult. - public func search(options: String) throws -> String { - try validateJson(options, parameter: "options") - let h = handle - return try ffiStringCall { - aimux_search(h, options, $0) - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Files -// ───────────────────────────────────────────────────────────────────────────── - -/// A file-upload manager backed by a Rust `Arc`. -/// -/// The C handle is automatically released when this object is deallocated. -public final class Files: @unchecked Sendable { - - // The opaque handle from aimux-ffi. 0 means invalid/freed. - private var handle: UInt64 - - private init(handle: UInt64) { - self.handle = handle - } - - deinit { - if handle != 0 { - aimux_drop_handle(handle) - } - } - - // ── Provider constructors ────────────────────────────────────────────── - - /// Create an OpenAI files manager instance. No model ID is required. - public static func openai(apiKey: String) throws -> Files { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_openai_files_new(apiKey, $0) } - return Files(handle: handle) - } - - /// Create an OpenAI files manager instance with a custom base URL. - public static func openai(apiKey: String, baseUrl: String) throws -> Files { - let handle = try Model.wrapHandle(expecting: expectFfiError) { aimux_openai_files_new_with_base(apiKey, baseUrl, $0) } - return Files(handle: handle) - } - - // ── Upload ───────────────────────────────────────────────────────────── - - /// Upload a file (base64-encoded) to the provider. - /// - /// - Parameters: - /// - dataBase64: Base64-encoded file bytes. - /// - mediaType: Media type of the file (e.g. `application/pdf`). - /// - options: Optional UploadFileCallOptions serialized as JSON. - /// - Returns: The JSON-serialized UploadFileResult. - public func upload(dataBase64: String, mediaType: String, options: String? = nil) throws -> String { - try validateJson(options, parameter: "options") - let h = handle - return try ffiStringCall { - aimux_file_upload(h, dataBase64, mediaType, options, $0) - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Multimodal result types (Codable) -// -// These decode the JSON of the generated `bindings/node/src/types/*Result.ts` -// types: camelCase fields (so the structs synthesize their `Codable`), -// optional fields absent rather than null, `warnings` typed as `[Warning]`. -// ───────────────────────────────────────────────────────────────────────────── - // MARK: - Embedding /// Token usage for an embedding call. @@ -741,125 +246,3 @@ public struct UploadFileResult: Codable, Equatable { public var providerMetadata: ProviderMetadata? public var warnings: [Warning] } - - -// ───────────────────────────────────────────────────────────────────────────── -// TranscriptionSession (RFC-0028 streaming) -// ───────────────────────────────────────────────────────────────────────────── - -/// Thrown by `TranscriptionSession.nextPart` when the stream ended normally -/// (a `finish` part was delivered earlier). -/// -/// A session the caller `close()`d does NOT throw this — it throws the Swift -/// boundary projection (`DecodingError.dataCorrupted`, "transcription session -/// is closed"). Keeping the two apart matters: a pump loop that `break`s on -/// this error must not report a transcript its own `defer { close() }` -/// truncated as a complete one. -public struct AimuxTranscriptionEndedError: Error {} - -/// Error for `TranscriptionSession.nextPart`: no part arrived within the -/// timeout. The session stays live — call again. -public struct AimuxTranscriptionTimeoutError: Error {} - -/// A live streaming-transcription session (RFC-0028): push audio chunks, -/// mark end-of-audio, then pull transcription parts (JSON -/// `TranscriptionStreamPart`s). -public final class TranscriptionSession: @unchecked Sendable { - - // The opaque session handle from aimux-ffi. 0 means invalid/freed. - private var handle: UInt64 - - // Internal: sessions are created via `TranscriptionModel.startStream`. - init(handle: UInt64) { - self.handle = handle - } - - deinit { - if handle != 0 { - aimux_transcription_session_drop(handle) - } - } - - // The handle read + nil-check must stay on one thread with the call; - // sessions are single-threaded (like Model). Using a closed session is a - // C ABI failure, thrown (never trapped — a trap here is uncatchable - // SIGTRAP in a host app) and deliberately NOT the same type as a clean - // end of stream: a pump loop that breaks on AimuxTranscriptionEndedError - // must not read "transcript complete" from a transcript its own - // `defer { close() }` truncated. - private func withHandle(_ body: (UInt64) throws -> T) throws -> T { - let h = handle - guard h != 0 else { - throw invariant("aimux: transcription session is closed") - } - return try body(h) - } - - /// Push one binary audio chunk. Blocks while the internal channel is full - /// (backpressure propagation). - /// - /// Throws the Swift boundary projection (`DecodingError.dataCorrupted`, - /// "transcription session is closed") if the session was closed — never - /// `AimuxTranscriptionEndedError`, which means the stream ended on its own. - public func pushAudio(_ audio: [UInt8]) throws { - try withHandle { h in - let e = audio.withUnsafeBufferPointer { buf -> OpaquePointer? in - aimux_transcription_push_audio(h, buf.baseAddress, audio.count) - } - if let e { throw expectAimuxError(e, context: "pushAudio") } - } - } - - /// Signal end-of-audio (idempotent). - /// - /// Throws the Swift boundary projection (`DecodingError.dataCorrupted`, - /// "transcription session is closed") if the session was closed — the same - /// signal as the other two methods, matching Go's `ErrClosed`, which its - /// `InputDone` also returns rather than swallowing. - public func inputDone() throws { - try withHandle { h in - if let e = aimux_transcription_input_done(h) { throw expectFfiError(e, context: "inputDone") } - } - } - - /// Pull the next transcription part (JSON `TranscriptionStreamPart`). - /// - /// Throws `AimuxTranscriptionEndedError` when the stream finished - /// normally, the Swift boundary projection (`DecodingError.dataCorrupted`) - /// when the session was closed — the two are deliberately distinct, so a - /// pump loop that breaks on "ended" cannot read a transcript its own - /// `close()` truncated as complete — and `AimuxTranscriptionTimeoutError` - /// when no part arrived in time (retryable, not an error at the C ABI — - /// nothing is decoded). - /// `timeoutMs`: >0 wait at most; 0 immediate poll; negative = wait - /// indefinitely. - public func nextPart(timeoutMs: Int64) throws -> String { - try withHandle { h in - var part: UnsafeMutablePointer? = nil - var state: Int32 = 0 - if let e = aimux_transcription_next_part(h, timeoutMs, &part, &state) { - throw expectAimuxError(e, context: "nextPart") - } - switch state { - case Int32(AIMUX_TRANSCRIPTION_NEXT_PART_PART.rawValue): - guard let part else { throw invariant("aimux ffi: nextPart: PART state but no part written") } - defer { aimux_free_string(part) } - return String(cString: part) - case Int32(AIMUX_TRANSCRIPTION_NEXT_PART_ENDED.rawValue): - throw AimuxTranscriptionEndedError() - case Int32(AIMUX_TRANSCRIPTION_NEXT_PART_TIMEOUT.rawValue): - throw AimuxTranscriptionTimeoutError() - default: - throw invariant("aimux ffi: nextPart: unknown aimux_transcription_next_part_state_t \(state)") - } - } - } - - /// Terminate and release the session (aborts the driver; idempotent). - public func close() { - if handle != 0 { - aimux_transcription_session_drop(handle) - handle = 0 - } - } -} diff --git a/bindings/swift/Sources/Aimux/Stream.swift b/bindings/swift/Sources/Aimux/Stream.swift new file mode 100644 index 000000000..fa3bac934 --- /dev/null +++ b/bindings/swift/Sources/Aimux/Stream.swift @@ -0,0 +1,244 @@ +import CAimuxFFI +import Foundation + +public struct StreamLimits: Sendable { + public let items: Int + public let bytes: Int + public let eventBytes: Int + public static let `default` = StreamLimits(items: 256, bytes: 8 * 1_048_576, eventBytes: 1_048_576) + private init(items: Int, bytes: Int, eventBytes: Int) { + self.items = items; self.bytes = bytes; self.eventBytes = eventBytes + } + /// Limits may be lowered, never disabled or raised above protocol budgets. + public init(maxItems: Int, maxBytes: Int, maxEventBytes: Int) throws { + guard (1...256).contains(maxItems), (1...8 * 1_048_576).contains(maxBytes), + (1...min(maxBytes, 1_048_576)).contains(maxEventBytes) else { + throw resourceLimit("Invalid stream limits") + } + self.init(items: maxItems, bytes: maxBytes, eventBytes: maxEventBytes) + } +} + +func resourceLimit(_ message: String) -> AimuxError { + AimuxError(kind: "protocol", code: 305, message: message, + error: .object(["name": .string("ResourceLimit")]), retryAfterMs: nil) +} + +public struct StreamEvent: Equatable, Sendable { + public let seq: String + public let event: JSONValue +} + +private enum Completion { + case success + case failure(any Error) +} + +struct BufferedEvent: Sendable { + let value: StreamEvent + let bytes: Int +} + +/// The callback bridge uses the shared bounded queue for all delivery. +final class StreamChannel: @unchecked Sendable { + let stream: BoundedStream + private let lock = NSLock() + private var releases = 0 + + init(limits: StreamLimits, cancellation: Cancellation) { + stream = BoundedStream(capacity: limits.items, byteCapacity: limits.bytes, + maxElementBytes: limits.eventBytes, sizeOf: { $0.bytes }, + abort: { cancellation.cancel() }) + } + func send(_ event: StreamEvent, size: Int) { + stream.producer.yield(BufferedEvent(value: event, bytes: size)) + } + fileprivate func finish(_ completion: Completion) { + switch completion { + case .success: stream.producer.finish() + case .failure(let error): stream.producer.finish(throwing: error) + } + } + func close() { stream.cancel() } + func contextReleased() { + lock.lock(); releases += 1; lock.unlock() + } + var snapshot: (items: Int, bytes: Int, highItems: Int, highBytes: Int, releases: Int) { + let queue = stream.snapshot + lock.lock(); defer { lock.unlock() } + return (queue.items, queue.bytes, queue.highItems, queue.highBytes, releases) + } +} + +/// Single-consumer, lossless stream. An iterator owns its lifetime: leaving a +/// for-await loop releases it and aborts even when the sequence is retained. +/// If retaining an iterator yourself, close it explicitly when abandoning it. +public final class AimuxStream: Sendable, AsyncSequence { + public typealias Element = StreamEvent + let channel: StreamChannel + init(_ channel: StreamChannel) { self.channel = channel } + deinit { channel.close() } + public func close() { channel.close() } + public func makeAsyncIterator() -> Iterator { + Iterator(self) + } + public final class Iterator: AsyncIteratorProtocol { + private let sequence: AimuxStream + private let iterator: BoundedStream.AsyncIterator + fileprivate init(_ sequence: AimuxStream) { + self.sequence = sequence + iterator = sequence.channel.stream.makeAsyncIterator() + } + public func close() { iterator.cancel() } + public func next() async throws -> StreamEvent? { + do { + try Task.checkCancellation() + var reader = iterator + let event = try await reader.next()?.value + try Task.checkCancellation() + return event + } catch BoundedStream.Failure.multipleConsumers { + throw AimuxError.state("Stream already has a consumer", code: 303) + } catch { + if Task.isCancelled { close(); throw CancellationError() } + throw error + } + } + } +} + +// The sole continuation is removed before resuming. After acceptance this +// object retains no sequence, so native production cannot keep a consumer alive. +private final class StreamReady: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var sequence: AimuxStream? + init(_ sequence: AimuxStream, _ continuation: CheckedContinuation) { + self.sequence = sequence; self.continuation = continuation + } + func accept() { + lock.lock() + let continuation = self.continuation, sequence = self.sequence + self.continuation = nil; self.sequence = nil + lock.unlock() + if let continuation, let sequence { continuation.resume(returning: sequence) } + } + func fail(_ error: any Error) { + lock.lock() + let continuation = self.continuation + self.continuation = nil; self.sequence = nil + lock.unlock() + continuation?.resume(throwing: error) + } +} + +// C callbacks are serialized by aimux_stream. This state is confined to that +// native calling worker; only the synchronized channel/ready/cancellation cross +// threads. No Sendable assertion is needed for the callback context itself. +private final class StreamBridge { + let channel: StreamChannel + let ready: StreamReady + let cancellation: Cancellation + let limits: StreamLimits + private var completion: Completion? + private var failure: (any Error)? + private var expectedSeq: UInt64 = 0 + + init(_ channel: StreamChannel, _ ready: StreamReady, _ cancellation: Cancellation, _ limits: StreamLimits) { + self.channel = channel; self.ready = ready; self.cancellation = cancellation; self.limits = limits + } + deinit { channel.contextReleased() } + + func receive(_ pointer: UnsafePointer?) { + guard failure == nil else { return } + do { + guard completion == nil else { throw invariant("Frame after terminal") } + guard let pointer else { throw invariant("Null frame") } + // Inspect at most the per-event budget before copying or decoding. + let length = strnlen(pointer, limits.eventBytes + 1) + guard length <= limits.eventBytes else { throw resourceLimit("Event exceeds stream budget") } + let data = Data(bytes: pointer, count: length) + struct Frame: Decodable { + let type: String + let seq: String + let event: JSONValue? + let outcome: String? + let error: AimuxError? + } + let frame = try JSONDecoder().decode(Frame.self, from: data) + guard frame.seq == String(expectedSeq) else { throw invariant("Invalid stream sequence") } + guard expectedSeq < UInt64.max else { throw invariant("Stream sequence exhausted") } + expectedSeq += 1 + switch frame.type { + case "event": + guard let event = frame.event else { throw invariant("Missing event payload") } + ready.accept() + channel.send(StreamEvent(seq: frame.seq, event: event), size: length) + case "end": + switch frame.outcome { + case "success": + guard frame.error == nil else { throw invariant("Success terminal has an error") } + completion = .success + case "error": + guard let error = frame.error else { throw invariant("Missing terminal error") } + completion = .failure(error) + case "cancelled": completion = .failure(CancellationError()) + default: throw invariant("Unknown terminal outcome") + } + ready.accept() + default: throw invariant("Unknown stream frame") + } + } catch { + // First-failure latch. Never throw across the C callback boundary. + failure = (error as? AimuxError) ?? AimuxError.binding(String(describing: error)) + cancellation.abortForCallbackFailure() + } + } + func complete(_ nativeError: (any Error)?) { + let result: Completion + if let failure { result = .failure(failure) } + else if let nativeError { result = .failure(nativeError) } + else { result = completion ?? .failure(AimuxError.binding("Native stream returned without an end frame")) } + switch result { + case .success: ready.accept() + case .failure(let error): ready.fail(error) + } + channel.finish(result) + } +} + +private func streamCallback(_ json: UnsafePointer?, _ context: UnsafeMutableRawPointer?) { + guard let context else { return } + Unmanaged.fromOpaque(context).takeUnretainedValue().receive(json) +} + +func startStream(_ state: HandleState, _ op: String, _ params: JSONValue, _ limits: StreamLimits) async throws -> AimuxStream { + guard !op.utf8.contains(0) else { throw invariant("Operation contains NUL") } + let json = String(decoding: try JSONEncoder().encode(params), as: UTF8.self) + let cancellation = Cancellation() + let (id, ticket) = try state.begin(cancellation) + let channel = StreamChannel(limits: limits, cancellation: cancellation) + cancellation.attach(channel) + let stream = try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let ready = StreamReady(AimuxStream(channel), continuation) + DispatchQueue.global(qos: .utility).async { + defer { state.end(ticket); cancellation.finish() } + let bridge = StreamBridge(channel, ready, cancellation, limits) + let retained = Unmanaged.passRetained(bridge) + // Native guarantees no callback after return. This is the only + // retained-context release on every accepted and rejected path. + defer { retained.release() } + do { + try verifyRuntime() + let abort = try handleID(nativeCall(0, AimuxOps.OP_ABORT_CREATE, "{}")) + cancellation.install(abort) + let error = aimux_stream(id, op, json, abort, streamCallback, retained.toOpaque()) + bridge.complete(error.map(takeError)) + } catch { bridge.complete(error) } + } + } + } onCancel: { channel.close() } + try Task.checkCancellation() + return stream +} diff --git a/bindings/swift/Sources/Aimux/ToolCallRepair.swift b/bindings/swift/Sources/Aimux/ToolCallRepair.swift index 7826ed74a..31a7755d3 100644 --- a/bindings/swift/Sources/Aimux/ToolCallRepair.swift +++ b/bindings/swift/Sources/Aimux/ToolCallRepair.swift @@ -1,26 +1,5 @@ -// ToolCallRepair.swift — host-side tool-call repair (RFC-0035). -// -// aimux-core is single-call: an unparseable tool call never fails generation, -// it comes back as a `ToolCall` with `invalid: true` and `error`. Repair is -// therefore post-processing done here, after the generation call returns: the -// binding asks the library for the AI SDK `repairToolCall` argument, hands it -// to the user's closure, and feeds the answer back into a pure native function -// that re-validates and patches. Only JSON crosses the boundary — nothing is -// registered with the C ABI and no native call is in flight while the closure -// runs, so the closure may itself call back into aimux. -// -// OpenAI-shaped output has no `invalid` / `error` to repair from, so -// `generateTextAsOpenAI` repairs the native result and converts it -// (`aimux_generate_text_result_as_openai`); `streamTextAsOpenAI` does not -// reflect repair (RFC-0035 §4). - -import CAimuxFFI import Foundation -// ───────────────────────────────────────────────────────────────────────────── -// Public types -// ───────────────────────────────────────────────────────────────────────────── - /// A tool call as the provider produced it (`aimux_core::RawToolCall`). /// /// Field-for-field the same as ``ToolCall``, except `input` is the model's raw @@ -73,8 +52,7 @@ public struct RawToolCall: Codable, Equatable { } } -/// The argument handed to ``GenerateTextOptions/repairToolCall``. Mirrors the -/// AI SDK `repairToolCall` argument. +/// Data consumed by the pure tool repair operations. public struct ToolCallRepairContext: Codable, Equatable { /// The failing call, with the provider's raw argument text. public var toolCall: RawToolCall @@ -98,195 +76,3 @@ public struct ToolCallRepairContext: Codable, Equatable { self.tools = tools; self.messages = messages; self.instructions = instructions } } - -/// A one-shot repair function: return a corrected ``RawToolCall``, `nil` to -/// leave the call invalid as it is, or throw to fail the repair. -public typealias RepairToolCall = (ToolCallRepairContext) throws -> RawToolCall? - -/// Carries the repair closure inside ``GenerateTextOptions`` without costing it -/// its synthesized `Codable` / `Equatable`. Two option values never differ by -/// their repair function: a closure has no equality worth comparing, and the -/// function is host-side only — it is never serialized into the options JSON. -struct RepairToolCallBox: Equatable { - let run: RepairToolCall - static func == (_: RepairToolCallBox, _: RepairToolCallBox) -> Bool { true } -} - -// ───────────────────────────────────────────────────────────────────────────── -// The three native functions (pure, no handle, no I/O) -// ───────────────────────────────────────────────────────────────────────────── - -/// Build the repair argument for one invalid tool call. -/// -/// - Parameters: -/// - toolCall: One `GenerateTextResult.toolCalls` entry with `"invalid": true`. -/// - prompt: The prompt JSON the call was generated with. -/// - options: The options JSON the call was generated with. -/// - Returns: The serialized ``ToolCallRepairContext``, or the JSON literal -/// `"null"` when the options carried no tool set — such a call is never -/// repaired (AI SDK rule), and that is a success, not a failure. -public func toolCallRepairContext( - toolCall: String, prompt: String, options: String? = nil -) throws -> String { - try validateJson(toolCall, parameter: "toolCall") - try validateJson(prompt, parameter: "prompt") - try validateJson(options, parameter: "options") - return try ffiStringCall { aimux_tool_call_repair_context(toolCall, prompt, options, $0) } -} - -/// Resolve one invalid tool call against a repair reply, returning the -/// serialized ``ToolCall`` — valid, or invalid carrying a nested -/// `ToolCallRepair` error. -/// -/// Throws `AimuxError.invalidArgument` when the options carry no tool set (for -/// such a call ``toolCallRepairContext(toolCall:prompt:options:)`` already -/// answered `null`) or when `toolCall` is not an invalid call. -public func applyToolCallRepair( - toolCall: String, options: String?, reply: String -) throws -> String { - try validateJson(toolCall, parameter: "toolCall") - try validateJson(options, parameter: "options") - try validateJson(reply, parameter: "reply") - return try ffiStringCall { aimux_apply_tool_call_repair(toolCall, options, reply, $0) } -} - -/// Apply a repair reply to a serialized `GenerateTextResult`. Both `toolCalls` -/// and the matching `responseMessages` tool-call part are rewritten — the -/// transcript must keep pointing at the same entry, or the next turn replays -/// the unrepaired arguments. -public func applyToolCallRepairToResult( - result: String, options: String?, toolCallId: String, reply: String -) throws -> String { - try validateJson(result, parameter: "result") - try validateJson(options, parameter: "options") - try validateJson(reply, parameter: "reply") - return try ffiStringCall { - aimux_apply_tool_call_repair_to_result(result, options, toolCallId, reply, $0) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// The host loop — one helper for results, one for stream parts -// ───────────────────────────────────────────────────────────────────────────── - -/// Repair every invalid tool call in a serialized result document and return -/// the patched JSON (the input unchanged when no repair function is set). -/// -/// Handles a `GenerateTextResult`, generated or consumed from a stream -/// (top-level `toolCalls`); a `GenerateObjectResult` has none. Calls are -/// visited in document order and each gets at most one repair attempt, as -/// in the AI SDK. -func repairedResultJson( - _ resultJson: String, promptJson: String, optsJson: String?, - options: GenerateTextOptions? -) throws -> String { - guard let repair = options?.repairToolCall else { return resultJson } - guard let document = (try? JSONSerialization.jsonObject(with: Data(resultJson.utf8))) - as? [String: Any] else { return resultJson } - guard let calls = document["toolCalls"] as? [[String: Any]] else { return resultJson } - - var patched = resultJson - for call in calls where call["invalid"] as? Bool == true { - guard let id = call["toolCallId"] as? String, let callJson = jsonString(call) else { continue } - let contextJson = try toolCallRepairContext( - toolCall: callJson, prompt: promptJson, options: optsJson - ) - guard let context = try decodeRepairContext(contextJson) else { continue } - patched = try applyToolCallRepairToResult( - result: patched, options: optsJson, toolCallId: id, - reply: repairReply(context, repair) - ) - } - return patched -} - -/// Repair a `{"type":"tool-call", …}` stream part, returning the replacement -/// part JSON. -/// -/// Any other part — `tool-input-delta` above all — is returned untouched and -/// immediately: the AI SDK forwards argument deltas verbatim even when a repair -/// function is configured. -func repairedStreamPartJson( - _ partJson: String, promptJson: String, optsJson: String?, - options: GenerateTextOptions? -) throws -> String { - guard let repair = options?.repairToolCall, - let part = (try? JSONSerialization.jsonObject(with: Data(partJson.utf8))) as? [String: Any], - part["type"] as? String == "tool-call", - part["invalid"] as? Bool == true, - let callJson = jsonString(part) - else { return partJson } - - // The two library calls are pure — no runtime, no `ffi_block_on` — so they - // are safe right here on the callback thread. Only the user's closure has - // to be moved off it. - let contextJson = try toolCallRepairContext( - toolCall: callJson, prompt: promptJson, options: optsJson - ) - guard let context = try decodeRepairContext(contextJson) else { return partJson } - let reply = offCallbackThread { repairReply(context, repair) } - let repairedJson = try applyToolCallRepair( - toolCall: callJson, options: optsJson, reply: reply - ) - guard var repaired = try JSONSerialization.jsonObject(with: Data(repairedJson.utf8)) as? [String: Any] - else { return partJson } - repaired["type"] = "tool-call" - return jsonString(repaired) ?? partJson -} - -/// Run `body` on another thread and block until it answers. -/// -/// A dedicated `Thread`, not `DispatchQueue.sync`: GCD runs a `sync` block on -/// the calling thread when it can, which would put the hook right back on the -/// guarded callback thread. -/// -/// Invariant: the FFI re-entrancy guard is **thread-local**. A stream -/// `on_part` callback runs on the very thread that entered -/// `aimux_stream_text`, still inside that call's `ffi_block_on`, so any aimux -/// entry point invoked on it comes straight back as -/// `AIMUX_E_FFI_REENTRANT_CALL` (204). A repair function is expected to ask a -/// model to fix the arguments, so it must not run there. -/// -/// Ordering is unaffected: the callback thread waits here, so the repaired -/// part is still delivered in its place in the stream, before the next one. -private func offCallbackThread(_ body: @escaping () -> T) -> T { - var answer: T? - let done = DispatchSemaphore(value: 0) - let worker = Thread { - answer = body() - done.signal() - } - worker.start() - done.wait() - // `body` is non-throwing and always assigns before signalling. - return answer! -} - -/// Decode a repair context, or `nil` for the JSON literal `null` the library -/// writes for a call made without a tool set (skip it — never repairable). -private func decodeRepairContext(_ json: String) throws -> ToolCallRepairContext? { - guard json.trimmingCharacters(in: .whitespacesAndNewlines) != "null" else { return nil } - return try JSONDecoder().decode(ToolCallRepairContext.self, from: Data(json.utf8)) -} - -/// Run the user's repair function and phrase its outcome as a reply: -/// a replacement call, `nil` (leave the call as it is), or a throw (the call -/// stays invalid, carrying a `ToolCallRepair` error with this message as cause). -private func repairReply(_ context: ToolCallRepairContext, _ repair: RepairToolCall) -> String { - do { - guard let replacement = try repair(context) else { return #"{"type":"unchanged"}"# } - let encoded = try JSONEncoder().encode(replacement) - return #"{"type":"repaired","toolCall":"# + String(decoding: encoded, as: UTF8.self) + "}" - } catch { - let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error) - return jsonString(["type": "failed", "message": message]) - ?? #"{"type":"failed","message":"repair function failed"}"# - } -} - -/// Serialize a JSONSerialization-compatible value back to a JSON string. -private func jsonString(_ object: Any) -> String? { - guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.fragmentsAllowed]) - else { return nil } - return String(data: data, encoding: .utf8) -} diff --git a/bindings/swift/Sources/Aimux/Types.swift b/bindings/swift/Sources/Aimux/Types.swift index f1f5e26c2..953164ba4 100644 --- a/bindings/swift/Sources/Aimux/Types.swift +++ b/bindings/swift/Sources/Aimux/Types.swift @@ -1,22 +1,5 @@ -// Types.swift — typed Codable wrapper layer over the aimux-ffi C ABI. -// -// The raw API in `Aimux.swift` exchanges JSON strings across the Swift↔C -// boundary (`generateText(prompt:options:) -> String`, `streamText` yields -// JSON-string parts). This file adds a thin, *typed* layer on top: inputs and -// outputs are `Codable` Swift types decoding and encoding exactly the JSON of -// `bindings/node/src/types/*.ts` (the ts-rs types generated from the Rust -// serde definitions, which equal the AI SDK JSON). The raw API is left -// untouched; the typed methods live in a `Model` extension and delegate to it. -// -// Wire conventions mirrored here: -// • field names are camelCase, so structs synthesize their `Codable` -// • optional fields are absent, never `null` -// • unions are internally tagged: `type` (kebab-case values); `Source` uses -// `sourceType`, `Role`-keyed messages use `role` -// • `ToolChoice` is mixed: "auto"|"none"|"required" or {"type":"tool","toolName":…} -// • `ModelPrompt` / `MessageContent` / `FileBytes` are untagged (string | array) - -import CAimuxFFI +// Codable values for the canonical AI SDK wire contract. + import Foundation // MARK: - JSONValue (arbitrary JSON, for `input`/`output`/`raw`/`providerMetadata`/…) @@ -1413,40 +1396,6 @@ public struct GenerateTextOptions: Codable, Equatable { public var includeRawChunks: Bool? public var sessionId: String? - /// One-shot host-side repair for tool calls the model got wrong - /// (RFC-0035), mirroring the AI SDK's `repairToolCall`. - /// - /// Runs after generation, on every tool call the engine marked invalid, in - /// document order and at most once per call. Return a corrected - /// ``RawToolCall`` (it is re-validated: still invalid means the original - /// call stays invalid, carrying the new parse or validation error, not a - /// `ToolCallRepair` error), `nil` to leave the - /// call untouched with its original error, or throw to fail the repair - /// with the thrown error's message as cause. A call made without a tool set - /// is never repaired and the function is not invoked for it. - /// - /// The function runs outside any native call, so it may itself call back - /// into aimux — asking a model to fix the arguments is the usual reason to - /// set it. It is host-side only and never appears in the options JSON. - /// - /// Applies to `generateText`, `generateObject`, `consumeStreamText`, - /// `streamText` and `generateTextAsOpenAI` (which repairs the native result - /// before converting it). `streamTextAsOpenAI` does not reflect repair: its - /// argument deltas are the provider's text, as in the AI SDK. - /// - /// While streaming, the stream waits for the function, and an abort does - /// not take effect until it returns. If a repair fails at the boundary - /// (not in the function — a throw is a `ToolCallRepair` error on the - /// call), `onError` is told and the stream continues without that part. - public var repairToolCall: RepairToolCall? { - get { repairToolCallBox?.run } - set { repairToolCallBox = newValue.map(RepairToolCallBox.init(run:)) } - } - - /// Not in `CodingKeys`, so it needs its own default for the synthesized - /// `init(from:)` — and stays out of the options JSON in both directions. - var repairToolCallBox: RepairToolCallBox? = nil - enum CodingKeys: String, CodingKey { case maxOutputTokens, temperature, stopSequences, topP, topK, presencePenalty case frequencyPenalty, output, seed, tools, toolChoice, headers @@ -1464,8 +1413,7 @@ public struct GenerateTextOptions: Codable, Equatable { maxRetries: UInt32? = nil, timeout: TimeoutConfiguration? = nil, includeRawChunks: Bool? = nil, - sessionId: String? = nil, - repairToolCall: RepairToolCall? = nil) { + sessionId: String? = nil) { self.maxOutputTokens = maxOutputTokens; self.temperature = temperature self.stopSequences = stopSequences; self.topP = topP; self.topK = topK self.presencePenalty = presencePenalty; self.frequencyPenalty = frequencyPenalty @@ -1475,7 +1423,6 @@ public struct GenerateTextOptions: Codable, Equatable { self.maxRetries = maxRetries; self.timeout = timeout self.includeRawChunks = includeRawChunks self.sessionId = sessionId - self.repairToolCallBox = repairToolCall.map(RepairToolCallBox.init(run:)) } } @@ -1873,224 +1820,3 @@ public struct CompletionTokensDetails: Codable, Equatable { self.reasoningTokens = reasoningTokens } } - -// MARK: - Typed wrapper methods (extra layer over the raw C-ABI API) - -public extension Model { - - /// Generate text (non-streaming) with typed inputs/outputs. - /// - /// - Parameters: - /// - prompt: A `ModelPrompt` — a plain string (`.text`) or a message list - /// (`.messages`), serialized to the JSON shape the FFI expects. - /// - options: Optional `GenerateTextOptions`. - /// - Returns: A decoded `GenerateTextResult`. - func generateText( - prompt: ModelPrompt, - options: GenerateTextOptions? = nil - ) throws -> GenerateTextResult { - let promptJson = try AimuxCodable.jsonString(for: prompt) - let optsJson = try options.map { try AimuxCodable.jsonString(for: $0) } - let resultJson = try repairedResultJson( - generateText(prompt: promptJson, options: optsJson), - promptJson: promptJson, optsJson: optsJson, options: options - ) - return try JSONDecoder().decode(GenerateTextResult.self, from: Data(resultJson.utf8)) - } - - /// Generate a structured JSON object with typed inputs/outputs (M12, RFC-0016). - /// - /// Same signature as the typed `generateText`; returns a decoded - /// `GenerateObjectResult`. Pass `output: {"type": "object", "schema": ...}` via - /// `options` to describe the object; the model's text must parse as JSON - /// and match the schema. - /// - /// - Parameters: - /// - prompt: A `ModelPrompt` — a plain string (`.text`) or a message list - /// (`.messages`), serialized to the JSON shape the FFI expects. - /// - options: Optional `GenerateTextOptions`. - /// - Returns: A decoded `GenerateObjectResult`. - func generateObject( - prompt: ModelPrompt, - options: GenerateTextOptions? = nil - ) throws -> GenerateObjectResult { - let promptJson = try AimuxCodable.jsonString(for: prompt) - let optsJson = try options.map { try AimuxCodable.jsonString(for: $0) } - let resultJson = try repairedResultJson( - generateObject(prompt: promptJson, options: optsJson), - promptJson: promptJson, optsJson: optsJson, options: options - ) - return try JSONDecoder().decode(GenerateObjectResult.self, from: Data(resultJson.utf8)) - } - - /// Consume a stream to completion and return the aggregated typed result - /// (M11, RFC-0016). Synchronous (blocks until the stream finishes). - /// - /// - Parameters: - /// - prompt: A `ModelPrompt` — a plain string (`.text`) or a message list - /// (`.messages`), serialized to the JSON shape the FFI expects. - /// - options: Optional `GenerateTextOptions`. - /// - Returns: A decoded `GenerateTextResult`. - func consumeStreamText( - prompt: ModelPrompt, - options: GenerateTextOptions? = nil - ) throws -> GenerateTextResult { - let promptJson = try AimuxCodable.jsonString(for: prompt) - let optsJson = try options.map { try AimuxCodable.jsonString(for: $0) } - let resultJson = try repairedResultJson( - consumeStreamText(prompt: promptJson, options: optsJson), - promptJson: promptJson, optsJson: optsJson, options: options - ) - return try JSONDecoder().decode(GenerateTextResult.self, from: Data(resultJson.utf8)) - } - - /// Stream text with typed `TextStreamPart`s. - /// - /// Each raw JSON-string part is decoded into a `TextStreamPart` before being - /// passed to `onPart`. A part that fails to decode is reported via - /// `onError` (the stream otherwise continues until done/error). - func streamText( - prompt: ModelPrompt, - options: GenerateTextOptions? = nil, - onPart: @escaping (TextStreamPart) -> Void, - onDone: @escaping () -> Void, - onError: @escaping (any Error) -> Void - ) { - let promptJson: String - let optsJson: String? - do { - promptJson = try AimuxCodable.jsonString(for: prompt) - optsJson = try options.map { try AimuxCodable.jsonString(for: $0) } - } catch { - onError(error) // EncodingError from JSONEncoder - return - } - streamText(prompt: promptJson, options: optsJson, - onPart: { json in - do { - // An invalid tool-call part is replaced by its - // repaired form; every other part — argument deltas - // above all — passes through untouched. - let partJson = try repairedStreamPartJson( - json, promptJson: promptJson, optsJson: optsJson, options: options - ) - try onPart(JSONDecoder().decode(TextStreamPart.self, from: Data(partJson.utf8))) - } catch { - onError(error) // DecodingError from JSONDecoder, AimuxError from repair - } - }, - onDone: onDone, - onError: onError) - } - - /// Stream text as an `AsyncSequence` of typed `TextStreamPart`s. - /// - /// The stream finishes on normal completion and throws `AimuxError` - /// (AiMuxError failure, C codes preserved via `fromC`) or the native - /// `EncodingError` / `DecodingError` when typed (de)serialization fails. - func streamTextAsync( - prompt: ModelPrompt, - options: GenerateTextOptions? = nil - ) -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - self.streamText( - prompt: prompt, options: options, - onPart: { continuation.yield($0) }, - onDone: { continuation.finish() }, - onError: { continuation.finish(throwing: $0) } - ) - } - } - - // MARK: OpenAI-compatible output (RFC-0026) - - /// Generate text (non-streaming) with OpenAI Chat Completions output. - /// - /// - Parameters: - /// - prompt: A `ModelPrompt` — a plain string (`.text`) or a message list - /// (`.messages`), serialized to the JSON shape the FFI expects. - /// - options: Optional `GenerateTextOptions`. - /// - Returns: A decoded `ChatCompletion`. - func generateTextAsOpenAI( - prompt: ModelPrompt, - options: GenerateTextOptions? = nil - ) throws -> ChatCompletion { - let promptJson = try AimuxCodable.jsonString(for: prompt) - let optsJson = try options.map { try AimuxCodable.jsonString(for: $0) } - let completionJson: String - if options?.repairToolCall == nil { - completionJson = try generateTextAsOpenAI(prompt: promptJson, options: optsJson) - } else { - // A ChatCompletion has no invalid marker: repair the native result, - // then convert it. - let resultJson = try repairedResultJson( - generateText(prompt: promptJson, options: optsJson), - promptJson: promptJson, optsJson: optsJson, options: options - ) - completionJson = try generateTextResultAsOpenAI(resultJson) - } - return try JSONDecoder().decode(ChatCompletion.self, from: Data(completionJson.utf8)) - } - - /// Stream text with OpenAI Chat Completions output, yielding typed - /// `ChatCompletionChunk`s. - /// - /// Each raw JSON-string chunk is decoded into a `ChatCompletionChunk` - /// before being passed to `onPart`. A chunk that fails to decode is - /// reported via `onError` (the stream otherwise continues until - /// done/error). Stream options (`include_usage`, `include_reasoning`) are - /// passed via `options.providerOptions.openai.stream_options`. - func streamTextAsOpenAI( - prompt: ModelPrompt, - options: GenerateTextOptions? = nil, - onPart: @escaping (ChatCompletionChunk) -> Void, - onDone: @escaping () -> Void, - onError: @escaping (any Error) -> Void - ) { - let promptJson: String - let optsJson: String? - do { - promptJson = try AimuxCodable.jsonString(for: prompt) - optsJson = try options.map { try AimuxCodable.jsonString(for: $0) } - } catch { - onError(error) // EncodingError from JSONEncoder - return - } - streamTextAsOpenAI(prompt: promptJson, options: optsJson, - onPart: { json in - do { - try onPart(JSONDecoder().decode(ChatCompletionChunk.self, from: Data(json.utf8))) - } catch { - onError(error) // DecodingError from JSONDecoder - } - }, - onDone: onDone, - onError: onError) - } - - /// Stream text with OpenAI Chat Completions output as an `AsyncSequence` - /// of typed `ChatCompletionChunk`s (RFC-0026). - func streamTextAsOpenAIAsync( - prompt: ModelPrompt, - options: GenerateTextOptions? = nil - ) -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - self.streamTextAsOpenAI( - prompt: prompt, options: options, - onPart: { continuation.yield($0) }, - onDone: { continuation.finish() }, - onError: { continuation.finish(throwing: $0) } - ) - } - } -} - -/// JSON (de)serialization helpers shared by the typed wrapper. -fileprivate enum AimuxCodable { - /// Encode an `Encodable` value to a JSON string (the wire is camelCase, so - /// the default key strategy applies). - static func jsonString(for value: T) throws -> String { - let data = try JSONEncoder().encode(value) - return String(data: data, encoding: .utf8) ?? "" - } -} diff --git a/bindings/swift/Sources/CAimuxFFI/module.modulemap b/bindings/swift/Sources/CAimuxFFI/module.modulemap index fcdd4fc78..2ebc086bb 100644 --- a/bindings/swift/Sources/CAimuxFFI/module.modulemap +++ b/bindings/swift/Sources/CAimuxFFI/module.modulemap @@ -1,5 +1,4 @@ module CAimuxFFI { - header "../../../../aimux-ffi/aimux-ffi.h" - link "aimux_ffi" + header "../../../../aimux-ffi/include/aimux.h" export * } diff --git a/bindings/swift/Tests/AimuxTests/AimuxTests.swift b/bindings/swift/Tests/AimuxTests/AimuxTests.swift deleted file mode 100644 index 166765c38..000000000 --- a/bindings/swift/Tests/AimuxTests/AimuxTests.swift +++ /dev/null @@ -1,752 +0,0 @@ -import XCTest -@testable import Aimux -import CAimuxFFI - -final class AimuxTests: XCTestCase { - - func testModelCreation() throws { - // Even with a fake key, the provider should construct - // (key is validated on first API call, not construction). - let model = try Model.openai(apiKey: "sk-test-fake-key", modelId: "gpt-4o-mini") - XCTAssertNotNil(model) - } - - func testAnthropicModelCreation() throws { - let model = try Model.anthropic( - apiKey: "sk-ant-test-fake-key", - modelId: "claude-3-5-sonnet-20241022" - ) - XCTAssertNotNil(model) - } - - func testProviderModelCreation() throws { - // Registry-backed construction: deepseek is in the provider registry - // (key is validated on first API call, not construction). - let model = try Model.provider( - name: "deepseek", apiKey: "sk-test-fake-key", modelId: "deepseek-chat" - ) - XCTAssertNotNil(model) - } - - func testInvalidPromptThrows() throws { - let model = try Model.openai(apiKey: "sk-test-fake-key", modelId: "gpt-4o-mini") - XCTAssertThrowsError(try model.generateText(prompt: "{invalid json}")) - } - - func testNoSuchProviderCarriesProviderId() { - XCTAssertThrowsError(try Model.provider( - name: "no-such-provider", apiKey: "sk-test-fake-key", modelId: "whatever" - )) { error in - guard let e = error as? AimuxError else { - return XCTFail("expected AimuxError, got \(error)") - } - guard case .noSuchProvider = e else { - return XCTFail("expected .noSuchProvider, got \(e)") - } - XCTAssertEqual(e.providerId, "no-such-provider") - } - } - - /// `expectAimuxError` reads code 10, copies the per-code payload into the - /// matching case, leaves unrelated payload accessors `nil`, and frees the - /// returned error. - /// Driven through the real FFI: an unknown provider fails offline. - func testExpectAimuxReadsReturnedError() throws { - var h: UInt64 = 0 - guard let e = aimux_provider_new("no-such-provider", "k", "m", nil, &h) else { - return XCTFail("expected a returned error") - } - XCTAssertEqual(h, 0) - XCTAssertEqual(aimux_error_code(e), Int32(AIMUX_E_NO_SUCH_PROVIDER.rawValue)) - guard let err = expectAimuxError(e, context: "test") as? AimuxError else { // frees `e` - return XCTFail("expected AimuxError") - } - guard case .noSuchProvider = err else { - return XCTFail("expected .noSuchProvider, got \(err)") - } - XCTAssertFalse(err.message.isEmpty) - XCTAssertEqual(err.providerId, "no-such-provider") - XCTAssertNil(err.status) - XCTAssertNil(err.retryMs) - XCTAssertFalse(err.retryable) - XCTAssertNil(err.providerCode) - XCTAssertNil(err.modelId) - } - - /// NULL owners answer nothing (NULL-safe getters, aimux-error.h). - func testNullOwnerGettersAreSafe() { - XCTAssertEqual(aimux_error_code(nil), 0) - XCTAssertNil(aimux_error_message(nil)) - XCTAssertNil(aimux_error_message(nil)) - aimux_error_free(nil) - } - - // MARK: C ABI failure codes - - /// A NULL required string argument is an invariant a correct binding never - /// triggers: code 200 has a non-empty message, and the - /// decoder throws `DecodingError` (never an `AimuxError`). - func testNullPointerArgIsDecodingError() throws { - var h: UInt64 = 0 - guard let e = aimux_openai_new(nil, "gpt-4o", &h) else { - return XCTFail("expected a returned error") - } - XCTAssertEqual(h, 0) - XCTAssertEqual(aimux_error_code(e), 200) - let raw = aimux_error_message(e) - XCTAssertNotNil(raw) - XCTAssertFalse(String(cString: raw!).isEmpty) - aimux_free_string(raw) - let error = expectAimuxError(e, context: "test") // frees `e` - XCTAssertFalse(error is AimuxError) - guard case DecodingError.dataCorrupted(let ctx) = error else { - return XCTFail("expected DecodingError.dataCorrupted, got \(error)") - } - XCTAssertTrue(ctx.debugDescription.hasPrefix("aimux ffi: "), ctx.debugDescription) - XCTAssertTrue(ctx.debugDescription.contains("api_key"), ctx.debugDescription) - } - - /// A dead / never-issued handle reaching the FFI (the wrappers guard this - /// locally with `precondition`) is likewise the invariant `DecodingError`, - /// whichever decoder the call site uses. - func testInvalidHandleIsDecodingError() { - var out: UnsafeMutablePointer? = nil - guard let e = aimux_generate_text(0x7FFF_FFFF_FFFF_FFFF, "\"hi\"", nil, &out) else { - return XCTFail("expected a returned error") - } - XCTAssertNil(out) - XCTAssertEqual(aimux_error_code(e), 203) - let error = expectAimuxError(e, context: "test") - XCTAssertFalse(error is AimuxError) - XCTAssertTrue(error is DecodingError, "expected DecodingError, got \(error)") - - guard let e2 = aimux_transcription_input_done(0x7FFF_FFFF_FFFF_FFFF) else { - return XCTFail("expected a returned error") - } - XCTAssertEqual(aimux_error_code(e2), 203) - XCTAssertTrue(expectRecordingError(e2, context: "test") is DecodingError) - } - - /// Malformed raw JSON is rejected by the binding before the C call: - /// `DecodingError.dataCorrupted` naming the parameter, not an `AimuxError`. - func testMalformedJsonIsRejectedBeforeFfi() throws { - let model = try Model.openai(apiKey: "sk-test-fake-key", modelId: "gpt-4o-mini") - XCTAssertThrowsError(try model.generateText(prompt: "{not json")) { error in - XCTAssertFalse(error is AimuxError) - guard case DecodingError.dataCorrupted(let ctx) = error else { - return XCTFail("expected DecodingError.dataCorrupted, got \(error)") - } - XCTAssertTrue(ctx.debugDescription.contains("prompt"), ctx.debugDescription) - } - XCTAssertThrowsError(try model.generateText(prompt: "\"hi\"", options: "{not json")) { error in - guard case DecodingError.dataCorrupted(let ctx) = error else { - return XCTFail("expected DecodingError.dataCorrupted, got \(error)") - } - XCTAssertTrue(ctx.debugDescription.contains("options"), ctx.debugDescription) - } - // Valid JSON fragments pass the pre-check (they reach the C ABI). - XCTAssertNoThrow(try validateJson("\"hi\"", parameter: "prompt")) - XCTAssertNoThrow(try validateJson(nil, parameter: "options")) - // Optional params: blank means default (FFI rule); required: rejected. - XCTAssertNoThrow(try validateJson(Optional(" "), parameter: "options")) - XCTAssertThrowsError(try validateJson("", parameter: "prompt")) - } - - /// Every raw-JSON entry pre-validates: multimodal `embed(values:)`, - /// composite `router(configJson:)` and `mockReplay` reject malformed JSON - /// with `DecodingError` before any C call (constructors are infallible - /// offline, so this runs without a network). - func testRawJsonEntriesRejectMalformedJsonBeforeFfi() throws { - let embedder = try EmbeddingModel.openai(apiKey: "sk-test-fake-key", modelId: "text-embedding-3-small") - XCTAssertThrowsError(try embedder.embed(values: "{not json")) { error in - guard case DecodingError.dataCorrupted(let ctx) = error else { - return XCTFail("expected DecodingError.dataCorrupted, got \(error)") - } - XCTAssertTrue(ctx.debugDescription.contains("values"), ctx.debugDescription) - } - let child = try Model.openai(apiKey: "sk-test-fake-key", modelId: "gpt-4o-mini") - XCTAssertThrowsError(try Model.router([child], configJson: "{not json")) { error in - guard case DecodingError.dataCorrupted(let ctx) = error else { - return XCTFail("expected DecodingError.dataCorrupted, got \(error)") - } - XCTAssertTrue(ctx.debugDescription.contains("configJson"), ctx.debugDescription) - } - XCTAssertThrowsError(try Model.mockReplay(recordingsJsonl: "{}\n{not json\n")) { error in - guard case DecodingError.dataCorrupted(let ctx) = error else { - return XCTFail("expected DecodingError.dataCorrupted, got \(error)") - } - XCTAssertTrue(ctx.debugDescription.contains("recordingsJsonl"), ctx.debugDescription) - } - } - - /// `code` returns the mapped C constant; there is no binding-local case - /// without one. - func testCodeAccessor() { - XCTAssertEqual(AimuxError.timeout(message: "t", status: -1, retryMs: -1, retryable: true).code, - Int32(AIMUX_E_TIMEOUT.rawValue)) - } - - /// 19 / 20: a missing API key / provider setting have their own codes and - /// carry the consulted environment variable. - func testLoadErrorCodes() { - XCTAssertEqual(AIMUX_E_NO_OUTPUT_GENERATED.rawValue, 18) - XCTAssertEqual(AimuxError.noOutputGenerated(message: "no output", status: -1, - retryMs: -1, retryable: false).code, 18) - XCTAssertEqual(AIMUX_E_LOAD_API_KEY.rawValue, 19) - XCTAssertEqual(AIMUX_E_LOAD_SETTING.rawValue, 20) - let key = AimuxError.loadApiKey(message: "no key", status: -1, retryMs: -1, retryable: false, - envVar: "OPENAI_API_KEY") - XCTAssertEqual(key.code, Int32(AIMUX_E_LOAD_API_KEY.rawValue)) - XCTAssertEqual(key.envVar, "OPENAI_API_KEY") - XCTAssertNil(key.status) - let setting = AimuxError.loadSetting(message: "no region", status: -1, retryMs: -1, retryable: false, - envVar: "AWS_REGION") - XCTAssertEqual(setting.code, Int32(AIMUX_E_LOAD_SETTING.rawValue)) - XCTAssertEqual(setting.envVar, "AWS_REGION") - } - - /// HTTP-shaped failures all arrive as `.apiCall`; the classification is the - /// `status` field (there are no per-status cases any more). - func testApiCallClassifiesByStatus() { - let rateLimited = AimuxError.apiCall( - message: "API call error: HTTP 429: slow down", - status: 429, retryMs: 1500, retryable: true - ) - XCTAssertEqual(rateLimited.status, 429) - XCTAssertEqual(rateLimited.retryMs, 1500) - XCTAssertTrue(rateLimited.retryable) - - let auth = AimuxError.apiCall( - message: "API call error: HTTP 401: invalid api key", - status: 401, retryMs: -1, retryable: false - ) - XCTAssertEqual(auth.status, 401) - XCTAssertNil(auth.retryMs) - XCTAssertFalse(auth.retryable) - - // Transport failure: no response, so no status. - let transport = AimuxError.apiCall( - message: "API call error: connection reset", - status: -1, retryMs: -1, retryable: true - ) - XCTAssertNil(transport.status) - } - - /// `retryable` is not derivable from `status`: both of these report no - /// status and disagree about whether a retry is worth attempting. - func testRetryableIsNotDerivableFromStatus() { - let transport = AimuxError.apiCall( - message: "API call error: connection reset", - status: -1, retryMs: -1, retryable: true - ) - let missingKey = AimuxError.apiCall( - message: "API call error: missing api key", - status: -1, retryMs: -1, retryable: false - ) - XCTAssertNil(transport.status) - XCTAssertNil(missingKey.status) - XCTAssertTrue(transport.retryable) - XCTAssertFalse(missingKey.retryable) - } - - /// End to end: a 401 from the provider surfaces as `.apiCall` with the - /// observed status, not a dedicated auth case. - func testE2EAuthFailureIsApiCall() throws { - let server = MockHTTPServer(response: .json( - ["error": ["message": "invalid api key", "type": "invalid_request_error"]], - status: 401 - )) - try server.start() - defer { server.stop() } - - let model = try Model.openai( - apiKey: "bad-key", modelId: "gpt-4o", baseUrl: server.baseURL - ) - XCTAssertThrowsError(try model.generateText(prompt: jsonEncodeString("hi"))) { error in - guard let e = error as? AimuxError else { - return XCTFail("expected AimuxError, got \(error)") - } - guard case .apiCall = e else { - return XCTFail("expected .apiCall, got \(e)") - } - XCTAssertEqual(e.status, 401) - XCTAssertTrue(e.message.contains("401"), "got \(e.message)") - XCTAssertFalse(e.retryable) - XCTAssertEqual(e.responseBody?.contains("invalid api key"), true, "got \(String(describing: e.responseBody))") - } - } - - func testStreamTextReturnsAsyncSequence() throws { - let model = try Model.openai(apiKey: "sk-test-fake-key", modelId: "gpt-4o-mini") - let stream = model.streamTextAsync(prompt: "\"hello\"") - // AsyncStream is created — we don't consume it (would need network). - XCTAssertNotNil(stream) - } - - // MARK: - Recording ring (T10) - - /// `cap: 0` is decided by aimux-core, not by a local trap: the C layer - /// classifies it as `AIMUX_E_INVALID_ARGUMENT`, so Swift throws - /// `AimuxError.invalidArgument` — catchable, identical to what a C caller - /// sees. - func testInitRecordingRingZeroCapThrowsInvalidArgument() { - XCTAssertThrowsError(try Model.initRecordingRing(cap: 0)) { error in - guard let e = error as? AimuxError else { - return XCTFail("expected AimuxError, got \(error)") - } - guard case .invalidArgument = e else { - return XCTFail("expected .invalidArgument, got \(e)") - } - } - // A rejected cap must not have replaced the global recorder. - Model.recordingStop() - } - - /// A positive cap constructs the ring recorder. - func testInitRecordingRingAcceptsPositiveCap() throws { - try Model.initRecordingRing(cap: 8) - // Reset global recorder state so this doesn't leak into other tests. - Model.recordingStop() - } - - /// Omitting cap uses the library default capacity (FFI - /// aimux_init_recording_ring_default) and must not throw. - func testInitRecordingRingDefaultNoArg() throws { - try Model.initRecordingRing() - // Reset global recorder state so this doesn't leak into other tests. - Model.recordingStop() - } - - // MARK: - recordingTryFlush - - /// Nothing recording: the checked flush is a no-op that must not throw. - func testRecordingTryFlushNoRecorder() { - Model.recordingStop() - XCTAssertNoThrow(try Model.recordingTryFlush()) - } - - func testRecordingCodeOutsideRustEnumIsRejected() { - XCTAssertNil(RecordingError.code(fromC: 999)) - } - - /// Unwritable dir (parent path is a regular file): init fails with - /// `RecordingError.initFailed` (not an `AimuxError`), nothing is - /// installed, and the checked flush afterwards succeeds. - func testRecordingInitFailedUnwritableDir() throws { - let blocker = FileManager.default.temporaryDirectory - .appendingPathComponent("aimux-swift-blocker-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: blocker, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: blocker) } - let occupied = blocker.appendingPathComponent("occupied") - try Data("x".utf8).write(to: occupied) - - defer { Model.recordingStop() } - XCTAssertThrowsError( - try Model.initRecording(dir: occupied.appendingPathComponent("sub").path) - ) { error in - XCTAssertNil(error as? AimuxError, "recording errors are not AimuxError") - guard let e = error as? RecordingError else { - return XCTFail("expected RecordingError, got \(error)") - } - XCTAssertEqual(e.code, .initFailed) - XCTAssertFalse(e.message.isEmpty) - } - XCTAssertNoThrow(try Model.recordingTryFlush()) - } - - // MARK: - base_url constructors (no network: just construction) - - func testOpenAIWithBaseUrlConstructs() throws { - // A base URL pointing at a non-listening port still constructs the - // provider (the URL is only contacted on the first API call). - let model = try Model.openai( - apiKey: "test-key", modelId: "gpt-4o", - baseUrl: "http://127.0.0.1:1" - ) - XCTAssertNotNil(model) - } - - func testAnthropicWithBaseUrlConstructs() throws { - let model = try Model.anthropic( - apiKey: "test-key", modelId: "claude-3-5-sonnet-20241022", - baseUrl: "http://127.0.0.1:1" - ) - XCTAssertNotNil(model) - } - - // MARK: - E2E via mock HTTP server (full chain: Swift → FFI → reqwest → mock) - - /// Plain OpenAI generate_text: base_url routes reqwest to the mock server. - func testE2EOpenAIGenerateTextViaBaseUrl() throws { - let server = MockHTTPServer(response: .json(openaiPlainResponse)) - try server.start() - defer { server.stop() } - - let model = try chatModel(server.baseURL) - let resultJson = try model.generateText(prompt: jsonEncodeString("What is Rust?")) - let r = parseJSON(resultJson) - XCTAssertEqual(r["text"] as? String, "Rust is a systems programming language.") - XCTAssertNotNil(r["usage"]) - XCTAssertEqual(server.lastRequestPath, "/chat/completions") - } - - /// Tool-call parsing: mock returns tool_calls; verify convenience field - /// `toolCalls` and the structured `content` `tool-call` item. - func testE2EOpenAIToolCallParsing() throws { - let server = MockHTTPServer(response: .json(openaiToolCallResponse)) - try server.start() - defer { server.stop() } - - let model = try chatModel(server.baseURL) - let opts = jsonEncode([ - "tools": [[ - "type": "function", - "name": "get_weather", - "description": "Get weather for a location", - "inputSchema": [ - "type": "object", - "properties": ["location": ["type": "string"]], - "required": ["location"], - ], - ]], - ]) - let r = parseJSON(try model.generateText( - prompt: jsonEncodeString("What's the weather in Tokyo?"), options: opts - )) - - // Convenience field: toolCalls extracted - let toolCalls = (r["toolCalls"] as? [[String: Any]]) ?? [] - XCTAssertEqual(toolCalls.count, 1) - XCTAssertEqual(toolCalls[0]["toolName"] as? String, "get_weather") - XCTAssertEqual(toolCalls[0]["toolCallId"] as? String, "call_abc") - let input = toolCalls[0]["input"] as? [String: Any] - XCTAssertEqual(input?["location"] as? String, "Tokyo") - - // Structured content: content contains a tool-call item - let content = (r["content"] as? [[String: Any]]) ?? [] - let tc = content.first(where: { $0["type"] as? String == "tool-call" }) - XCTAssertNotNil(tc, "content should contain a tool-call item") - XCTAssertEqual(tc?["toolName"] as? String, "get_weather") - XCTAssertEqual(tc?["toolCallId"] as? String, "call_abc") - } - - /// Multi-role messages: a system+user array reaches the provider verbatim. - func testE2EOpenAIMultiRoleMessages() throws { - let server = MockHTTPServer(response: .json(openaiPlainResponse)) - try server.start() - defer { server.stop() } - - let model = try chatModel(server.baseURL) - let prompt = jsonEncode([ - ["role": "system", "content": "You are a helpful assistant."], - ["role": "user", "content": "What is Rust?"], - ]) - let r = parseJSON(try model.generateText(prompt: prompt)) - XCTAssertEqual(r["text"] as? String, "Rust is a systems programming language.") - - // The full multi-role message sequence reaches the provider request body. - let reqBody = parseJSON(server.lastRequestBody) - let msgs = (reqBody["messages"] as? [[String: Any]]) ?? [] - XCTAssertEqual(msgs.count, 2) - XCTAssertEqual(msgs[0]["role"] as? String, "system") - XCTAssertEqual(msgs[0]["content"] as? String, "You are a helpful assistant.") - XCTAssertEqual(msgs[1]["role"] as? String, "user") - XCTAssertEqual(msgs[1]["content"] as? String, "What is Rust?") - } - - /// ToolChoice: passing `toolChoice: "required"` reaches the provider body. - func testE2EOpenAIToolChoiceRequired() throws { - let server = MockHTTPServer(response: .json(openaiToolCallResponse)) - try server.start() - defer { server.stop() } - - let model = try chatModel(server.baseURL) - let opts = jsonEncode([ - "tools": [[ - "type": "function", - "name": "get_weather", - "inputSchema": [ - "type": "object", - "properties": ["location": ["type": "string"]], - ], - ]], - "toolChoice": "required", - ]) - // Must not throw. - _ = try model.generateText(prompt: jsonEncodeString("Hello"), options: opts) - - // tool_choice reaches the provider request body as "required". - let reqBody = parseJSON(server.lastRequestBody) - XCTAssertEqual(reqBody["tool_choice"] as? String, "required") - } - - /// Streaming text: SSE deltas reassemble into the full text. - func testE2EOpenAIStreamText() throws { - let server = MockHTTPServer(response: .sse(sse(openaiStreamTextEvents))) - try server.start() - defer { server.stop() } - - let model = try chatModel(server.baseURL) - var parts: [String] = [] - var streamErr: (any Error)? - model.streamText( - prompt: jsonEncodeString("Say hello"), - onPart: { parts.append($0) }, - onDone: {}, - onError: { streamErr = $0 } - ) - XCTAssertNil(streamErr) - XCTAssertFalse(parts.isEmpty) - - let parsed = parts.map { parseJSON($0) } - let text = parsed - .compactMap { $0["type"] as? String == "text-delta" ? $0["text"] as? String : nil } - .joined() - XCTAssertEqual(text, "Hello world") - } - - /// Streaming tool call: the stream emits a tool-related part - /// (`tool-call` / `tool-input-start` / `tool-input-delta`). - func testE2EOpenAIStreamToolCall() throws { - let server = MockHTTPServer(response: .sse(sse(openaiStreamToolEvents))) - try server.start() - defer { server.stop() } - - let model = try chatModel(server.baseURL) - let opts = jsonEncode([ - "tools": [[ - "type": "function", - "name": "get_weather", - "inputSchema": [ - "type": "object", - "properties": ["location": ["type": "string"]], - ], - ]], - ]) - var parts: [String] = [] - var streamErr: (any Error)? - model.streamText( - prompt: jsonEncodeString("What's the weather?"), - options: opts, - onPart: { parts.append($0) }, - onDone: {}, - onError: { streamErr = $0 } - ) - XCTAssertNil(streamErr) - XCTAssertFalse(parts.isEmpty) - - let parsed = parts.map { parseJSON($0) } - let hasToolPart = parsed.contains { - ["tool-call", "tool-input-delta", "tool-input-start"].contains($0["type"] as? String ?? "") - } - XCTAssertTrue(hasToolPart, "stream should contain a tool-related part") - - if let toolCall = parsed.first(where: { $0["type"] as? String == "tool-call" }) { - XCTAssertEqual(toolCall["toolName"] as? String, "get_weather") - } - } - - /// Anthropic generate_text via base_url (provider interchangeability). - func testE2EAnthropicGenerateTextViaBaseUrl() throws { - let server = MockHTTPServer(response: .json([ - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": "claude-3-5-sonnet-20241022", - "content": [["type": "text", "text": "Hello from Claude!"]], - "stop_reason": "end_turn", - "usage": ["input_tokens": 10, "output_tokens": 5], - ])) - try server.start() - defer { server.stop() } - - let model = try Model.anthropic( - apiKey: "test-key", modelId: "claude-3-5-sonnet-20241022", - baseUrl: server.baseURL + "/v1" - ) - let r = parseJSON(try model.generateText(prompt: jsonEncodeString("Hello"))) - XCTAssertEqual(r["text"] as? String, "Hello from Claude!") - XCTAssertNotNil(r["usage"]) - XCTAssertEqual(server.lastRequestPath, "/v1/messages") - } - - /// Full tool-call round trip: the first `generateText` returns a tool call; - /// the caller back-fills a `ToolResult` and calls `generateText` again, - /// which returns the final text. Verifies the two-request exchange - /// end-to-end (Swift → FFI → reqwest → mock) and that the second request - /// body carries the tool-result message. - /// - /// The replayed messages use the content-part shape (`tool-call` / - /// `tool-result` with a typed `output`), which is what `generateText` - /// deserializes and the OpenAI converter maps to the wire `tool_calls` / - /// `tool` format. - func testE2EToolCallRoundTrip() throws { - let server = MockHTTPServer(responses: [ - .json(openaiToolCallResponse), - .json(roundTripFinalResponse), - ]) - try server.start() - defer { server.stop() } - - let model = try chatModel(server.baseURL) - let opts = jsonEncode([ - "tools": [[ - "type": "function", - "name": "get_weather", - "description": "Get weather for a location", - "inputSchema": [ - "type": "object", - "properties": ["location": ["type": "string"]], - "required": ["location"], - ], - ]], - ]) - - // 1) First call: the model asks to call get_weather. - let r1 = parseJSON(try model.generateText( - prompt: jsonEncodeString("What's the weather in Tokyo?"), options: opts - )) - let toolCalls = (r1["toolCalls"] as? [[String: Any]]) ?? [] - XCTAssertEqual(toolCalls.count, 1) - XCTAssertEqual(toolCalls[0]["toolName"] as? String, "get_weather") - XCTAssertEqual(toolCalls[0]["toolCallId"] as? String, "call_abc") - - // 2) Second call: replay the conversation with the ToolResult filled in. - let secondPrompt = jsonEncode([ - ["role": "user", "content": "What's the weather in Tokyo?"], - ["role": "assistant", "content": [[ - "type": "tool-call", - "toolCallId": "call_abc", - "toolName": "get_weather", - "input": ["location": "Tokyo"], - ]]], - ["role": "tool", "content": [[ - "type": "tool-result", - "toolCallId": "call_abc", - "toolName": "get_weather", - "output": ["type": "json", "value": ["temperature": 22, "condition": "sunny"]], - ]]], - ]) - let r2 = parseJSON(try model.generateText(prompt: secondPrompt, options: opts)) - XCTAssertEqual(r2["text"] as? String, "The weather in Tokyo is sunny.") - - // 3) The second request body carries all three messages; the last is - // the tool result. - let reqBody = parseJSON(server.lastRequestBody) - let msgs = (reqBody["messages"] as? [[String: Any]]) ?? [] - XCTAssertEqual(msgs.count, 3) - XCTAssertEqual(msgs.last?["role"] as? String, "tool") - } -} - -// MARK: - Mock response fixtures (same shape as Node e2e.test.ts / Rust e2e_test.rs) - -private let openaiPlainResponse: [String: Any] = [ - "id": "chatcmpl-test", - "model": "gpt-4o", - "choices": [[ - "message": ["role": "assistant", "content": "Rust is a systems programming language."], - "finish_reason": "stop", - ]], - "usage": ["prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18], -] - -private let openaiToolCallResponse: [String: Any] = [ - "id": "chatcmpl-tc", - "model": "gpt-4o", - "choices": [[ - "message": [ - "role": "assistant", - "content": NSNull(), - "tool_calls": [[ - "id": "call_abc", - "type": "function", - "function": ["name": "get_weather", "arguments": "{\"location\":\"Tokyo\"}"], - ]], - ], - "finish_reason": "tool_calls", - ]], - "usage": ["prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30], -] - -/// Final text response for the tool-call round trip (second request). -private let roundTripFinalResponse: [String: Any] = [ - "id": "chatcmpl-test", - "model": "gpt-4o", - "choices": [[ - "message": ["role": "assistant", "content": "The weather in Tokyo is sunny."], - "finish_reason": "stop", - ]], - "usage": ["prompt_tokens": 30, "completion_tokens": 8, "total_tokens": 38], -] - -/// OpenAI SSE text deltas → "Hello world". -private let openaiStreamTextEvents: [[String: Any]] = [ - ["id": "1", "model": "gpt-4o", "choices": [["delta": ["content": "Hello"]]]], - ["id": "1", "model": "gpt-4o", "choices": [["delta": ["content": " world"]]]], - [ - "id": "1", "model": "gpt-4o", - "choices": [["delta": [String: Any](), "finish_reason": "stop"]], - "usage": ["prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7], - ], -] - -/// OpenAI SSE tool-call deltas (incremental argument JSON). -private let openaiStreamToolEvents: [[String: Any]] = [ - [ - "id": "1", "model": "gpt-4o", - "choices": [["delta": [ - "role": "assistant", - "tool_calls": [[ - "index": 0, "id": "call_xyz", "type": "function", - "function": ["name": "get_weather", "arguments": ""], - ]], - ]]], - ], - [ - "id": "1", "model": "gpt-4o", - "choices": [["delta": [ - "tool_calls": [[ - "index": 0, - "function": ["arguments": "{\"location\":\"Tokyo\"}"], - ]], - ]]], - ], - [ - "id": "1", "model": "gpt-4o", - "choices": [["delta": [String: Any](), "finish_reason": "tool_calls"]], - "usage": ["prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7], - ], -] - -// MARK: - JSON helpers - -/// JSON-encode a bare string prompt (`"text"`) as the FFI expects. -private func jsonEncodeString(_ s: String) -> String { - let data = try! JSONEncoder().encode(s) - return String(data: data, encoding: .utf8)! -} - -/// JSON-encode an array or dictionary object. -private func jsonEncode(_ object: Any) -> String { - let data = try! JSONSerialization.data(withJSONObject: object) - return String(data: data, encoding: .utf8)! -} - -/// Build an SSE body from a list of `data:` JSON events, terminated by [DONE]. -private func sse(_ events: [[String: Any]]) -> String { - let body = events.map { ev -> String in - let data = try! JSONSerialization.data(withJSONObject: ev) - return "data: " + (String(data: data, encoding: .utf8) ?? "") - }.joined(separator: "\n\n") - return body + "\n\n" + "data: [DONE]\n\n" -} - -/// Parse a JSON object string into `[String: Any]` (empty on failure). -private func parseJSON(_ s: String) -> [String: Any] { - guard let data = s.data(using: .utf8), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return [:] - } - return obj -} diff --git a/bindings/swift/Tests/AimuxTests/BoundedStreamTests.swift b/bindings/swift/Tests/AimuxTests/BoundedStreamTests.swift new file mode 100644 index 000000000..08224966f --- /dev/null +++ b/bindings/swift/Tests/AimuxTests/BoundedStreamTests.swift @@ -0,0 +1,337 @@ +import Foundation +import XCTest +@testable import Aimux + +final class BoundedStreamTests: XCTestCase { + private enum TestError: Error, Equatable { case failed } + + private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + func increment() { lock.lock(); value += 1; lock.unlock() } + var count: Int { lock.lock(); defer { lock.unlock() }; return value } + } + + private func stream(capacity: Int = 1, abort: @escaping @Sendable () -> Void = {}) -> BoundedStream { + BoundedStream(capacity: capacity, sizeOf: { _ in 1 }, abort: abort) + } + + func testFIFOIsLosslessAcrossManyWraps() async throws { + let aborts = Counter() + let stream = stream(capacity: 3, abort: { aborts.increment() }) + let producer = stream.producer + let done = expectation(description: "producer finished") + DispatchQueue.global().async { + for value in 0..<2000 { + XCTAssertEqual(producer.yield(value), .accepted) + } + producer.finish() + done.fulfill() + } + var values: [Int] = [] + for try await value in stream { values.append(value) } + await fulfillment(of: [done], timeout: 5) + XCTAssertEqual(values, Array(0..<2000)) + XCTAssertEqual(aborts.count, 0) + } + + func testFullQueueBlocksUntilConsumerMakesSpace() async throws { + let stream = stream() + let producer = stream.producer + XCTAssertEqual(producer.yield(1), .accepted) + let entered = DispatchSemaphore(value: 0) + let returned = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + entered.signal() + XCTAssertEqual(producer.yield(2), .accepted) + returned.signal() + } + XCTAssertEqual(entered.wait(timeout: .now() + 2), .success) + XCTAssertEqual(returned.wait(timeout: .now() + 0.05), .timedOut) + var iterator = stream.makeAsyncIterator() + let first = try await iterator.next() + XCTAssertEqual(first, 1) + XCTAssertEqual(returned.wait(timeout: .now() + 2), .success) + producer.finish() + let second = try await iterator.next() + let end = try await iterator.next() + XCTAssertEqual(second, 2) + XCTAssertNil(end) + } + + func testTerminalErrorFollowsBufferOnceAndFirstFinishWins() async throws { + let aborts = Counter() + let stream = stream(capacity: 2, abort: { aborts.increment() }) + let producer = stream.producer + producer.yield(1) + producer.yield(2) + producer.finish(throwing: TestError.failed) + producer.finish() + XCTAssertEqual(producer.yield(3), .terminated) + var iterator = stream.makeAsyncIterator() + let first = try await iterator.next() + let second = try await iterator.next() + XCTAssertEqual(first, 1) + XCTAssertEqual(second, 2) + do { + _ = try await iterator.next() + XCTFail("missing terminal failure") + } catch { XCTAssertEqual(error as? TestError, .failed) } + let end = try await iterator.next() + XCTAssertNil(end) + stream.cancel() + XCTAssertEqual(aborts.count, 0) + } + + func testFinishWakesBlockedProducerWithoutDroppingAcceptedElement() async throws { + let stream = stream() + let producer = stream.producer + producer.yield(1) + let entered = DispatchSemaphore(value: 0) + let returned = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + entered.signal() + XCTAssertEqual(producer.yield(2), .terminated) + returned.signal() + } + XCTAssertEqual(entered.wait(timeout: .now() + 2), .success) + XCTAssertEqual(returned.wait(timeout: .now() + 0.05), .timedOut) + producer.finish(throwing: TestError.failed) + XCTAssertEqual(returned.wait(timeout: .now() + 2), .success) + var iterator = stream.makeAsyncIterator() + let first = try await iterator.next() + XCTAssertEqual(first, 1) + do { _ = try await iterator.next(); XCTFail("missing error") } + catch { XCTAssertEqual(error as? TestError, .failed) } + } + + func testCancelWithFullQueueReleasesProducerAndAbortsOnce() async throws { + let aborts = Counter() + let stream = stream(abort: { aborts.increment() }) + let producer = stream.producer + producer.yield(1) + let entered = DispatchSemaphore(value: 0) + let returned = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + entered.signal() + XCTAssertEqual(producer.yield(2), .terminated) + returned.signal() + } + XCTAssertEqual(entered.wait(timeout: .now() + 2), .success) + XCTAssertEqual(returned.wait(timeout: .now() + 0.05), .timedOut) + DispatchQueue.concurrentPerform(iterations: 20) { _ in stream.cancel() } + XCTAssertEqual(returned.wait(timeout: .now() + 2), .success) + producer.finish() + var iterator = stream.makeAsyncIterator() + let end = try await iterator.next() + XCTAssertNil(end) + XCTAssertEqual(aborts.count, 1) + } + + func testTaskCancellationWhileWaitingWakesConsumer() async throws { + let aborts = Counter() + let stream = stream(abort: { aborts.increment() }) + let started = expectation(description: "consumer started") + let task = Task { + var iterator = stream.makeAsyncIterator() + started.fulfill() + let end = try await iterator.next() + XCTAssertNil(end) + } + await fulfillment(of: [started], timeout: 2) + task.cancel() + try await task.value + XCTAssertEqual(aborts.count, 1) + XCTAssertEqual(stream.producer.yield(1), .terminated) + } + + func testAlreadyCancelledTaskDoesNotConsumeBufferedElement() async throws { + let aborts = Counter() + let stream = stream(abort: { aborts.increment() }) + stream.producer.yield(1) + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + var iterator = stream.makeAsyncIterator() + let end = try await iterator.next() + XCTAssertNil(end) + } + try await task.value + XCTAssertEqual(aborts.count, 1) + } + + func testEarlyForAwaitExitAbortsWithStreamStillRetained() async throws { + let aborts = Counter() + let stream = stream(abort: { aborts.increment() }) + let producer = stream.producer + let done = expectation(description: "producer unblocked after break") + DispatchQueue.global().async { + var value = 0 + while producer.yield(value) == .accepted { value += 1 } + done.fulfill() + } + for try await value in stream { + XCTAssertEqual(value, 0) + break + } + await fulfillment(of: [done], timeout: 2) + XCTAssertEqual(aborts.count, 1) + XCTAssertEqual(producer.yield(99), .terminated) + withExtendedLifetime(stream) {} + } + + func testTaskCancellationDuringLoopBodyWithFullQueue() async throws { + let aborts = Counter() + let stream = stream(abort: { aborts.increment() }) + let producer = stream.producer + let consuming = expectation(description: "consumer in loop body") + let full = expectation(description: "queue refilled") + let returned = DispatchSemaphore(value: 0) + let task = Task { + for try await _ in stream { + consuming.fulfill() + try await Task.sleep(nanoseconds: 10_000_000_000) + } + } + DispatchQueue.global().async { + XCTAssertEqual(producer.yield(1), .accepted) + XCTAssertEqual(producer.yield(2), .accepted) + full.fulfill() + XCTAssertEqual(producer.yield(3), .terminated) + returned.signal() + } + await fulfillment(of: [consuming, full], timeout: 2) + XCTAssertEqual(returned.wait(timeout: .now() + 0.05), .timedOut) + task.cancel() + do { try await task.value; XCTFail("missing cancellation") } + catch { XCTAssertTrue(error is CancellationError) } + XCTAssertEqual(returned.wait(timeout: .now() + 2), .success) + XCTAssertEqual(aborts.count, 1) + } + + func testFinishResumesWaitingConsumer() async throws { + for fail in [false, true] { + let aborts = Counter() + let stream = stream(abort: { aborts.increment() }) + let started = expectation(description: "consumer started") + let task = Task { + var iterator = stream.makeAsyncIterator() + started.fulfill() + do { + let end = try await iterator.next() + XCTAssertNil(end) + XCTAssertFalse(fail) + } catch { + XCTAssertTrue(fail) + XCTAssertEqual(error as? TestError, .failed) + } + let end = try await iterator.next() + XCTAssertNil(end) + } + await fulfillment(of: [started], timeout: 2) + stream.producer.finish(throwing: fail ? TestError.failed : nil) + try await task.value + XCTAssertEqual(aborts.count, 0) + } + } + + func testByteBudgetBlocksBeforeCountBudget() async throws { + let stream = BoundedStream(capacity: 4, byteCapacity: 5, + maxElementBytes: 5, sizeOf: { $0.utf8.count }, abort: {}) + let producer = stream.producer + producer.yield("abc") + let returned = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + XCTAssertEqual(producer.yield("def"), .accepted) + returned.signal() + } + XCTAssertEqual(returned.wait(timeout: .now() + 0.05), .timedOut) + var iterator = stream.makeAsyncIterator() + let first = try await iterator.next() + XCTAssertEqual(first, "abc") + XCTAssertEqual(returned.wait(timeout: .now() + 2), .success) + producer.finish() + let second = try await iterator.next() + XCTAssertEqual(second, "def") + let end = try await iterator.next() + XCTAssertNil(end) + } + + func testOversizedElementFailsAfterAcceptedBuffer() async throws { + let stream = BoundedStream(byteCapacity: 8, maxElementBytes: 3, + sizeOf: { $0.utf8.count }, abort: {}) + let producer = stream.producer + XCTAssertEqual(producer.yield("ok"), .accepted) + XCTAssertEqual(producer.yield("large"), .terminated) + var iterator = stream.makeAsyncIterator() + let first = try await iterator.next() + XCTAssertEqual(first, "ok") + do { _ = try await iterator.next(); XCTFail("missing size failure") } + catch { XCTAssertEqual(error as? BoundedStream.Failure, .elementTooLarge) } + let end = try await iterator.next() + XCTAssertNil(end) + } + + func testInvalidSizeFailsAndCannotWaitForeverOnByteBudget() async throws { + for size in [-1, 3] { + let stream = BoundedStream(byteCapacity: 2, maxElementBytes: 4, + sizeOf: { _ in size }, abort: {}) + XCTAssertEqual(stream.producer.yield(1), .terminated) + var iterator = stream.makeAsyncIterator() + do { _ = try await iterator.next(); XCTFail("missing size failure") } + catch { + XCTAssertEqual(error as? BoundedStream.Failure, + size < 0 ? .invalidElementSize : .elementTooLarge) + } + } + } + + func testSecondIteratorCannotCancelFirstIterator() async throws { + let aborts = Counter() + let stream = stream(abort: { aborts.increment() }) + var first = stream.makeAsyncIterator() + do { + var second = stream.makeAsyncIterator() + second.cancel() + do { _ = try await second.next(); XCTFail("second consumer accepted") } + catch { XCTAssertEqual(error as? BoundedStream.Failure, .multipleConsumers) } + } + XCTAssertEqual(aborts.count, 0) + stream.producer.yield(1) + stream.producer.finish() + let value = try await first.next() + let end = try await first.next() + XCTAssertEqual(value, 1) + XCTAssertNil(end) + } + + func testAbortCanReenterProducerFinish() async throws { + // The callback runs outside the condition lock, like native abort which + // may synchronously produce a final callback. + final class Context: @unchecked Sendable { + var producer: BoundedStream.Producer? + } + let context = Context() + let aborts = Counter() + let stream = stream(abort: { + aborts.increment() + context.producer?.finish() + }) + context.producer = stream.producer + stream.cancel() + stream.cancel() + XCTAssertEqual(aborts.count, 1) + context.producer = nil + } + + func testOptionalElementIsNotMistakenForEnd() async throws { + let stream = BoundedStream(sizeOf: { _ in 1 }, abort: {}) + stream.producer.yield(nil) + stream.producer.finish() + var iterator = stream.makeAsyncIterator() + let value = try await iterator.next() + XCTAssertNotNil(value as Any?) + XCTAssertNil(value!) + let end = try await iterator.next() + XCTAssertNil(end as Any?) + } +} diff --git a/bindings/swift/Tests/AimuxTests/ContractTests.swift b/bindings/swift/Tests/AimuxTests/ContractTests.swift new file mode 100644 index 000000000..431e260b9 --- /dev/null +++ b/bindings/swift/Tests/AimuxTests/ContractTests.swift @@ -0,0 +1,378 @@ +import Foundation +@testable import Aimux +#if canImport(CAimuxTestABI) +import CAimuxTestABI +#endif +#if !AIMUX_STANDALONE +import Testing +#endif + +struct CheckFailure: Error, CustomStringConvertible { + let description: String +} +func require(_ condition: Bool, _ message: String = #function, + file: String = #fileID, line: Int = #line) throws { + if !condition { throw CheckFailure(description: "\(file):\(line): \(message)") } +} +#if canImport(CAimuxTestABI) +func eventually(_ predicate: @escaping @Sendable () -> Bool) async throws { + for _ in 0..<2000 { + if predicate() { return } + try await Task.sleep(nanoseconds: 1_000_000) + } + throw CheckFailure(description: "Timed out waiting for native cleanup/state") +} +func newHandle() throws -> (AimuxHandle, UInt64) { + let id = aimux_test_new_handle() + return (try AimuxHandle(owning: id), id) +} +func smallLimits() throws -> StreamLimits { + try StreamLimits(maxItems: 2, maxBytes: 256, maxEventBytes: 256) +} + +#if !AIMUX_STANDALONE +@Suite(.serialized) +#endif +struct ContractTests { +#if !AIMUX_STANDALONE + @Test +#endif + static func manifestRequiresExactVersionAndDigest() throws { + let valid: [String: JSONValue] = [ + "abiVersion": .number(Double(AimuxOps.ABI_VERSION)), + "opsVersion": .string(AimuxOps.OPS_VERSION), + "digest": .string(AimuxOps.MANIFEST_DIGEST), + ] + try validateManifest(.object(valid), version: AimuxOps.ABI_VERSION) + for (field, value) in [("opsVersion", JSONValue.string("1.1")), + ("digest", .string("wrong-digest")), ("digest", .null)] { + var invalid = valid + invalid[field] = value + do { + try validateManifest(.object(invalid), version: AimuxOps.ABI_VERSION) + throw CheckFailure(description: "Accepted incompatible manifest") + } catch let error as AimuxError { + try require(error.code == 302) + let message = error.message + try require(message.contains(AimuxOps.OPS_VERSION)) + try require(message.contains(AimuxOps.MANIFEST_DIGEST)) + try require(message.contains(String(describing: value))) + } + } + } +#if !AIMUX_STANDALONE + @Test +#endif + static func manifestAndHandles() async throws { + let manifest = try await AimuxRuntime.manifest() + guard case .object(let fields) = manifest else { throw CheckFailure(description: "Missing manifest") } + try require(fields["opsVersion"] == .string(AimuxOps.OPS_VERSION)) + let handle = try await AimuxRuntime.open(AimuxOps.OP_PROVIDER_CREATE) + try require(try await handle.call("echo", params: .string("你好 🌍")) == .string("你好 🌍")) + handle.close() + do { _ = try await handle.call("echo"); throw CheckFailure(description: "Closed handle admitted") } + catch let error as AimuxError { try require(error.code == 304) } + try await eventually { aimux_test_strings() == 0 && aimux_test_errors() == 0 } + } +#if !AIMUX_STANDALONE + @Test +#endif + static func versionMismatch() async throws { + aimux_test_version(2 << 16) + defer { aimux_test_version(AimuxOps.ABI_VERSION) } + do { _ = try await AimuxRuntime.manifest(); throw CheckFailure(description: "Accepted wrong ABI") } + catch let error as AimuxError { try require(error.code == 304) } + } +#if !AIMUX_STANDALONE + @Test +#endif + @MainActor static func mainActorHeartbeat() async throws { + let mainCalls = aimux_test_main_calls() + let request = Task { try await AimuxRuntime.call("slow") } + try await eventually { aimux_test_active() == 1 } + let start = Date() + try await Task.sleep(nanoseconds: 5_000_000) + try require(Date().timeIntervalSince(start) < 0.1, "Main actor heartbeat was blocked") + _ = try await request.value + try require(aimux_test_main_calls() == mainCalls, "Native blocking work ran on main thread") + } +#if !AIMUX_STANDALONE + @Test +#endif + static func boundedLosslessStream() async throws { + let (handle, _) = try newHandle() + defer { handle.close() } + let stream = try await handle.stream("many", limits: smallLimits()) + try await eventually { stream.channel.snapshot.items == 2 } + var count = 0 + for try await event in stream { + try require(event.seq == String(count), "Lost or reordered event") + guard case .object(let payload) = event.event else { throw CheckFailure(description: "Wrong payload") } + try require(payload["text"] == .string("你好 🌍")) + count += 1 + } + try require(count == 10000, "Stream dropped events") + try require(stream.channel.snapshot.highItems <= 2) + try require(stream.channel.snapshot.highBytes <= 256) + try await eventually { stream.channel.snapshot.releases == 1 } + } +#if !AIMUX_STANDALONE + @Test +#endif + static func byteBudgetAndTerminalFullQueue() async throws { + let (handle, _) = try newHandle() + defer { handle.close() } + let limits = try StreamLimits(maxItems: 256, maxBytes: 128, maxEventBytes: 128) + let stream = try await handle.stream("four", limits: limits) + try await eventually { stream.channel.snapshot.items == 1 } + var count = 0 + for try await _ in stream { count += 1 } + try require(count == 4 && stream.channel.snapshot.highItems == 1) + try require(stream.channel.snapshot.highBytes <= 128) + let terminalLimits = try StreamLimits(maxItems: 1, maxBytes: 1024, maxEventBytes: 1024) + let terminal = try await handle.stream("oneError", limits: terminalLimits) + try await eventually { aimux_test_active() == 0 && terminal.channel.snapshot.releases == 1 } + try require(terminal.channel.snapshot.items == 1, "Terminal used data queue capacity") + let iterator = terminal.makeAsyncIterator() + try require(try await iterator.next()?.seq == "0") + do { _ = try await iterator.next(); throw CheckFailure(description: "Terminal lost behind full queue") } + catch let error as AimuxError { try require(error.code == 1) } + try require(try await iterator.next() == nil) + } +#if !AIMUX_STANDALONE + @Test +#endif + static func cancelBeforeAcceptanceAndWhileWaiting() async throws { + let (handle, _) = try newHandle() + defer { handle.close() } + let starting = Task { try await handle.stream("connecting") } + try await eventually { aimux_test_active() == 1 } + starting.cancel() + do { _ = try await starting.value; throw CheckFailure(description: "Startup ignored cancel") } + catch is CancellationError {} + let stream = try await handle.stream("idle") + let consumer = Task { + for try await _ in stream {} + } + try await eventually { stream.channel.snapshot.items == 0 } + consumer.cancel() + do { try await consumer.value; throw CheckFailure(description: "Waiting reader ignored cancel") } + catch is CancellationError {} + try await eventually { stream.channel.snapshot.releases == 1 && aimux_test_active() == 0 } + } +#if !AIMUX_STANDALONE + @Test +#endif + static func earlyLoopExit() async throws { + let (handle, _) = try newHandle() + defer { handle.close() } + let stream = try await handle.stream("many", limits: smallLimits()) + let before = aimux_test_aborts() + for try await _ in stream { break } + // Keep the sequence alive: the temporary iterator must cancel on break. + try await eventually { stream.channel.snapshot.releases == 1 && aimux_test_active() == 0 } + try require(aimux_test_aborts() > before) + try require(stream.channel.snapshot.items == 0) + } +#if !AIMUX_STANDALONE + @Test +#endif + static func cancelFullQueue() async throws { + let (handle, _) = try newHandle() + defer { handle.close() } + let stream = try await handle.stream("many", limits: smallLimits()) + try await eventually { stream.channel.snapshot.items == 2 } + let consumer = Task { + let iterator = stream.makeAsyncIterator() + do { try await Task.sleep(nanoseconds: 10_000_000_000) } catch {} + return try await iterator.next() + } + consumer.cancel() + do { _ = try await consumer.value; throw CheckFailure(description: "Cancellation ignored") } + catch is CancellationError {} + try await eventually { stream.channel.snapshot.releases == 1 && aimux_test_active() == 0 } + try require(stream.channel.snapshot.items == 0) + } +#if !AIMUX_STANDALONE + @Test +#endif + static func closeFullQueue() async throws { + let (handle, id) = try newHandle() + let stream = try await handle.stream("many", limits: smallLimits()) + try await eventually { stream.channel.snapshot.items == 2 } + DispatchQueue.concurrentPerform(iterations: 100) { _ in handle.close() } + try await eventually { stream.channel.snapshot.releases == 1 && aimux_test_drops(id) == 1 } + try require(aimux_test_active() == 0) + try require(stream.channel.snapshot.items == 0) + } +#if !AIMUX_STANDALONE + @Test +#endif + static func concurrentCloseAndAdmission() async throws { + let (handle, id) = try newHandle() + let request = Task { try await handle.call("slow") } + try await eventually { aimux_test_active() == 1 } + DispatchQueue.concurrentPerform(iterations: 100) { _ in handle.close() } + try require(aimux_test_drops(id) == 0, "Dropped an admitted native call") + _ = try await request.value + try await eventually { aimux_test_drops(id) == 1 } + do { _ = try await handle.call("echo"); throw CheckFailure(description: "Admission after close") } + catch let error as AimuxError { try require(error.code == 304) } + handle.close() + try require(aimux_test_drops(id) == 1) + } +#if !AIMUX_STANDALONE + @Test +#endif + static func taskCancellationAbortsCall() async throws { + let request = Task { try await AimuxRuntime.call("wait") } + try await eventually { aimux_test_active() == 1 } + request.cancel() + do { _ = try await request.value; throw CheckFailure(description: "Call ignored cancel") } + catch is CancellationError {} + try await eventually { aimux_test_active() == 0 } + } +#if !AIMUX_STANDALONE + @Test +#endif + static func unknownErrorPreserved() async throws { + do { _ = try await AimuxRuntime.call("error"); throw CheckFailure(description: "Missing error") } + catch let error as AimuxError { + try require(error.kind == "aimux" && error.code == 1 && error.retryAfterMs == 123) + try require(error.message == "未来错误 🌍") + let expected: JSONValue = .object([ + "name": .string("AI_FutureError"), + "opaque": .object(["newField": .array([.bool(true), .null, .number(7)])]), + ]) + try require(error.error == expected) + try require(try JSONDecoder().decode(AimuxError.self, from: JSONEncoder().encode(error)) == error) + } + try require(aimux_test_errors() == 0 && aimux_test_strings() == 0) + } +#if !AIMUX_STANDALONE + @Test +#endif + static func rejectedStream() async throws { + let (handle, _) = try newHandle() + defer { handle.close() } + let emitted = aimux_test_emitted() + do { _ = try await handle.stream("reject"); throw CheckFailure(description: "Expected startup failure") } + catch let error as AimuxError { try require(error.error != .null) } + try require(aimux_test_emitted() == emitted) + try await eventually { aimux_test_active() == 0 && aimux_test_errors() == 0 } + } +#if !AIMUX_STANDALONE + @Test +#endif + static func terminalFailureOnce() async throws { + let (handle, _) = try newHandle() + defer { handle.close() } + let stream = try await handle.stream("terminalError") + let iterator = stream.makeAsyncIterator() + do { _ = try await iterator.next(); throw CheckFailure(description: "Expected terminal error") } + catch let error as AimuxError { try require(error.message == "未来错误 🌍") } + try require(try await iterator.next() == nil) + try await eventually { stream.channel.snapshot.releases == 1 } + } +#if !AIMUX_STANDALONE + @Test +#endif + static func callbackFailureLatch() async throws { + let (handle, _) = try newHandle() + defer { handle.close() } + let stream = try await handle.stream("malformed") + let iterator = stream.makeAsyncIterator() + try require(try await iterator.next()?.seq == "0") + do { _ = try await iterator.next(); throw CheckFailure(description: "Malformed callback ignored") } + catch let error as AimuxError { try require(error.kind == "binding" && error.code == 206) } + try require(try await iterator.next() == nil) + try await eventually { stream.channel.snapshot.releases == 1 } + } +#if !AIMUX_STANDALONE + @Test +#endif + static func missingTerminalFails() async throws { + let (handle, _) = try newHandle() + defer { handle.close() } + let stream = try await handle.stream("missingEnd") + do { + for try await _ in stream {} + throw CheckFailure(description: "EOF reported success") + } catch let error as AimuxError { try require(error.code == 206) } + try await eventually { stream.channel.snapshot.releases == 1 } + } +#if !AIMUX_STANDALONE + @Test +#endif + static func oversizedFrameAndLimits() async throws { + do { _ = try StreamLimits(maxItems: 257, maxBytes: 100, maxEventBytes: 100); throw CheckFailure(description: "Unbounded limit") } + catch let error as AimuxError { try require(error.code == 305) } + let (handle, _) = try newHandle() + defer { handle.close() } + do { _ = try await handle.stream("oversized"); throw CheckFailure(description: "Oversized frame accepted") } + catch let error as AimuxError { try require(error.code == 305) } + try await eventually { aimux_test_active() == 0 } + } +#if !AIMUX_STANDALONE + @Test +#endif + static func secondConsumerRejected() async throws { + let (handle, _) = try newHandle() + defer { handle.close() } + let stream = try await handle.stream("many", limits: smallLimits()) + let first = stream.makeAsyncIterator() + let second = stream.makeAsyncIterator() + do { _ = try await second.next(); throw CheckFailure(description: "Multiple readers admitted") } + catch let error as AimuxError { try require(error.code == 303) } + try require(try await first.next()?.seq == "0") + first.close() + try await eventually { stream.channel.snapshot.releases == 1 } + } +#if !AIMUX_STANDALONE + @Test +#endif + static func operationPrimitives() async throws { + let (handle, _) = try newHandle() + defer { handle.close() } + try await handle.push(Data()) + try await handle.push(Data([1, 2, 3])) + try require(try await handle.nextPart(timeoutMs: 0) == .timeout) + try require(try await handle.nextPart(timeoutMs: 1) == .ended) + try require(try await handle.nextPart(timeoutMs: 2) == .part(.object(["text": .string("hello")]))) + do { try await handle.push(Data(count: 1_048_577)); throw CheckFailure(description: "Oversized chunk accepted") } + catch let error as AimuxError { try require(error.code == 305) } + try require(aimux_test_strings() == 0) + } +#if !AIMUX_STANDALONE + @Test +#endif + static func cancelBlockedPushAndPoll() async throws { + let (pushHandle, pushID) = try newHandle() + let push = Task { try await pushHandle.push(Data(count: 8)) } + try await eventually { aimux_test_active() == 1 } + push.cancel() + do { try await push.value; throw CheckFailure(description: "Push ignored cancel") } + catch is CancellationError {} + pushHandle.close() + try await eventually { aimux_test_drops(pushID) == 1 } + let (pollHandle, pollID) = try newHandle() + let poll = Task { try await pollHandle.nextPart(timeoutMs: -1) } + try await eventually { aimux_test_active() == 1 } + pollHandle.close() + do { _ = try await poll.value; throw CheckFailure(description: "Closed poll reported success") } + catch is CancellationError {} + try await eventually { aimux_test_drops(pollID) == 1 } + } +#if !AIMUX_STANDALONE + @Test +#endif + static func wireValues() throws { + let original: JSONValue = .object(["unknown": .null, "bool": .bool(true), "text": .string("你好 🌍")]) + try require(try JSONDecoder().decode(JSONValue.self, from: JSONEncoder().encode(original)) == original) + let part = try JSONDecoder().decode(TextStreamPart.self, from: Data(#"{"type":"text-delta","id":"x","text":"hi"}"#.utf8)) + try require(try JSONDecoder().decode(TextStreamPart.self, from: JSONEncoder().encode(part)) == part) + let options = GenerateTextOptions(maxOutputTokens: 12, providerOptions: ["future": ["opaque": .null]]) + try require(try JSONDecoder().decode(GenerateTextOptions.self, from: JSONEncoder().encode(options)) == options) + } +} +#endif diff --git a/bindings/swift/Tests/AimuxTests/DecisionTests.swift b/bindings/swift/Tests/AimuxTests/DecisionTests.swift deleted file mode 100644 index dbae93d74..000000000 --- a/bindings/swift/Tests/AimuxTests/DecisionTests.swift +++ /dev/null @@ -1,122 +0,0 @@ -import XCTest -import Foundation -@testable import Aimux - -final class DecisionTests: XCTestCase { - func testProviderDecisionModelOwnsItsHandle() throws { - var provider: ProviderHandle? = try Model.createProvider(name: "openai", apiKey: "test-key") - let model = try provider!.decisionModel("gpt-6-luna") - defer { model.close() } - provider = nil // ProviderHandle uses ARC; the decision model owns its handle. - let description = try JSONSerialization.jsonObject(with: Data(model.describe().utf8)) as! [String: Any] - XCTAssertEqual(description["specificationVersion"] as? String, "v4") - XCTAssertEqual(description["provider"] as? String, "openai") - XCTAssertEqual(description["modelId"] as? String, "gpt-6-luna") - XCTAssertNil(provider) - } - - func testOfficialStructuredContractAndDescription() throws { - var root = URL(fileURLWithPath: #filePath) - for _ in 0..<5 { root.deleteLastPathComponent() } - let fixture = try JSONSerialization.jsonObject(with: Data(contentsOf: - root.appendingPathComponent("contract-tests/fixtures/decision-native.json"))) as! [String: Any] - func encode(_ value: Any) throws -> String { - String(data: try JSONSerialization.data(withJSONObject: value), encoding: .utf8)! - } - func decode(_ value: String) throws -> [String: Any] { - try JSONSerialization.jsonObject(with: Data(value.utf8)) as! [String: Any] - } - let response = MockResponse.json(fixture["response"]!) - let server = MockHTTPServer(response: response) - try server.start() - defer { server.stop() } - let model = try DecisionModel.jev(apiKey: "test-key", modelId: "jev-latest", - endpoint: server.baseURL + "/v1/systemone") - defer { model.close() } - let description = try decode(model.describe()) - XCTAssertEqual(description["provider"] as? String, "jev") - XCTAssertEqual((description["supportedQuestionTypes"] as? [String])?.count, 3) - XCTAssertEqual(server.lastRequestBody, "") - let request = fixture["request"] as! [String: Any] - let result = try decode(model.decide(options: encode(request))) - let answers = result["answers"] as! [String: Any] - XCTAssertEqual((answers["urgent"] as? [String: Any])?["probability"] as? Double, 0.9) - XCTAssertEqual((result["rounding"] as? [String: Any])?["scoreDecimals"] as? Int, 2) - XCTAssertEqual(result["usage"] as? NSDictionary, - ["inputTokens": 50, "outputTokens": 10, "totalTokens": 60] as NSDictionary) - let questions = request["questions"] as! [String: Any] - let actual = try decode(server.lastRequestBody)["questions"] as! [String: Any] - XCTAssertEqual(server.lastRequestPath, "/v1/systemone") - XCTAssertEqual((actual["urgent"] as! [String: Any])["criteria"] as? NSDictionary, - (questions["urgent"] as! [String: Any])["criteria"] as? NSDictionary) - XCTAssertEqual((actual["severity"] as! [String: Any])["criteria"] as? NSArray, - (questions["severity"] as! [String: Any])["criteria"] as? NSArray) - XCTAssertThrowsError(try model.decide(options: "{")) - model.close() - XCTAssertThrowsError(try model.describe()) - XCTAssertThrowsError(try DecisionModel.jev(apiKey: "test", modelId: "jev-latest", probabilitySource: "unknown")) - } - - func testOpenAIMediaAndTypedValues() throws { - var root = URL(fileURLWithPath: #filePath) - for _ in 0..<5 { root.deleteLastPathComponent() } - let fixtureData = try Data(contentsOf: - root.appendingPathComponent("contract-tests/fixtures/decision-openai-full.json")) - let fixture = try JSONSerialization.jsonObject(with: fixtureData) as! [String: Any] - func encode(_ value: Any) throws -> String { - String(data: try JSONSerialization.data(withJSONObject: value), encoding: .utf8)! - } - let server = MockHTTPServer(response: MockResponse.json(fixture["response"]!)) - try server.start() - defer { server.stop() } - let provider = try Model.createProvider(name: "openai", apiKey: "test-key", - configJson: encode(["base_url": server.baseURL + "/v1"])) - let model = try provider.decisionModel("gpt-6-luna") - defer { model.close() } - // The questions and criteria are maps whose order is the request's - // order, and JSONSerialization keeps no key order: send the fixture's - // own text for the options. - let options = rawObject(String(decoding: fixtureData, as: UTF8.self), key: "options") - let result = try JSONSerialization.jsonObject(with: Data(model.decide(options: options).utf8)) as! [String: Any] - let actual = try JSONSerialization.jsonObject(with: Data(server.lastRequestBody.utf8)) as! NSDictionary - XCTAssertEqual(actual, fixture["request"] as! NSDictionary) - XCTAssertEqual(server.lastRequestPath, "/v1/decisions") - let answers = result["answers"] as! [String: Any] - XCTAssertEqual((answers["choice"] as! [String: Any])["choice"] as? String, "boolean_true") - let metadata = (result["providerMetadata"] as! [String: Any])["openai"] as! [String: Any] - let choice = (metadata["answers"] as! [String: Any])["choice"] as! [String: Any] - XCTAssertEqual(choice["value"] as? Bool, true) - XCTAssertEqual((answers["score"] as! [String: Any])["score"] as? Double, 0.75) - } -} - -/// The raw text of the top-level `key` object in the JSON `text`. -private func rawObject(_ text: String, key: String) -> String { - let marker = text.range(of: "\"\(key)\":")! - let start = text[marker.upperBound...].firstIndex(of: "{")! - var depth = 0 - var inString = false - var escaped = false - var index = start - while index < text.endIndex { - let c = text[index] - if inString { - if escaped { - escaped = false - } else if c == "\\" { - escaped = true - } else if c == "\"" { - inString = false - } - } else if c == "\"" { - inString = true - } else if c == "{" { - depth += 1 - } else if c == "}" { - depth -= 1 - if depth == 0 { return String(text[start...index]) } - } - index = text.index(after: index) - } - fatalError("no balanced object for \(key)") -} diff --git a/bindings/swift/Tests/AimuxTests/MockHTTPServer.swift b/bindings/swift/Tests/AimuxTests/MockHTTPServer.swift deleted file mode 100644 index abbce9213..000000000 --- a/bindings/swift/Tests/AimuxTests/MockHTTPServer.swift +++ /dev/null @@ -1,298 +0,0 @@ -// MockHTTPServer.swift — a tiny in-process HTTP/1.1 server for e2e tests. -// -// The aimux-ffi layer drives HTTP via Rust's `reqwest`, which does NOT go -// through `URLSession`. `URLProtocol`-based mocking therefore cannot -// intercept it. Instead we stand up a real local TCP socket that the FFI -// connects to, mirroring Node's `node:http` `createServer` pattern. -// -// The server listens on 127.0.0.1 with an OS-assigned port, accepts one -// request per connection, records the request body/path for assertions, and -// returns a pre-configured response (JSON or SSE). It uses `Connection: -// close` so each FFI call maps to exactly one connection. - -import Foundation -@testable import Aimux -#if canImport(Glibc) -import Glibc -#elseif canImport(Darwin) -import Darwin -#endif - -/// A model that speaks the OpenAI chat-completions wire format at `baseURL`: -/// the registry's OpenAI-compatible preset (`Model.openai` talks to the -/// Responses API). -func chatModel(_ baseURL: String) throws -> Model { - try Model.provider( - name: "deepseek", apiKey: "test-key", modelId: "gpt-4o", - configJson: #"{"baseUrl":"\#(baseURL)"}"# - ) -} - -/// A preset HTTP response returned by `MockHTTPServer`. -struct MockResponse { - let status: Int - let contentType: String - let body: Data - - static func json(_ object: Any, status: Int = 200) -> MockResponse { - let data = try! JSONSerialization.data(withJSONObject: object) - return MockResponse(status: status, contentType: "application/json", body: data) - } - - static func sse(_ body: String) -> MockResponse { - return MockResponse( - status: 200, - contentType: "text/event-stream", - body: body.data(using: .utf8) ?? Data() - ) - } -} - -/// Minimal POSIX-socket HTTP server for tests. -final class MockHTTPServer { - - /// The single response view (first of the queue). Kept for backward - /// compatibility with callers that constructed the server with a single - /// `response`. The queue (`responses`) is the source of truth at serve time. - var response: MockResponse - - /// Responses returned in FIFO order, one per accepted request. Once - /// exhausted, the last entry repeats so late/extra requests still get a - /// well-defined answer instead of crashing. - private var responses: [MockResponse] - /// Number of requests served so far; indexes into `responses`. - private var requestCount: Int = 0 - - private var listenFd: Int32 = -1 - private let queue = DispatchQueue(label: "aimux.mock.http.server") - private let lock = NSLock() - private var stopped = false - - /// The most recently received request body (UTF-8). - private(set) var lastRequestBody: String = "" - /// The most recently received request path (e.g. "/chat/completions"). - private(set) var lastRequestPath: String = "" - - private var assignedPort: Int = 0 - /// Base URL the FFI should point at, e.g. `http://127.0.0.1:54321`. - var baseURL: String { "http://127.0.0.1:\(assignedPort)" } - - /// Single-response constructor: every request returns the same response - /// (backward compatible with existing tests). - init(response: MockResponse) { - self.responses = [response] - self.response = response - } - - /// Queue constructor: responses are returned in order, one per request. - /// After the queue is exhausted the last response repeats. An empty queue - /// falls back to a generic error response. - init(responses: [MockResponse]) { - if responses.isEmpty { - self.responses = [.json(["error": "no responses"])] - } else { - self.responses = responses - } - self.response = self.responses[0] - } - - /// Bind to 127.0.0.1 on an OS-assigned port and start accepting. - func start() throws { -#if canImport(Glibc) - // Glibc imports SOCK_STREAM as an enum; Darwin imports it as Int32. - listenFd = socket(AF_INET, Int32(SOCK_STREAM.rawValue), 0) -#else - listenFd = socket(AF_INET, SOCK_STREAM, 0) -#endif - guard listenFd >= 0 else { - throw NSError(domain: "MockHTTPServer", code: 1, userInfo: [NSLocalizedDescriptionKey: "socket() failed"]) - } - - var yes: Int32 = 1 - _ = setsockopt( - listenFd, SOL_SOCKET, SO_REUSEADDR, - &yes, socklen_t(MemoryLayout.size) - ) - - var addr = sockaddr_in() - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = 0 // 0 -> OS chooses a free port - addr.sin_addr.s_addr = inet_addr("127.0.0.1") - - let bindRC = withUnsafePointer(to: &addr) { ptr -> Int32 in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in - bind(listenFd, sa, socklen_t(MemoryLayout.size)) - } - } - guard bindRC == 0 else { - close(listenFd); listenFd = -1 - throw NSError(domain: "MockHTTPServer", code: 2, userInfo: [NSLocalizedDescriptionKey: "bind() failed"]) - } - - guard listen(listenFd, 16) == 0 else { - close(listenFd); listenFd = -1 - throw NSError(domain: "MockHTTPServer", code: 3, userInfo: [NSLocalizedDescriptionKey: "listen() failed"]) - } - - // Retrieve the OS-assigned port. - var actual = sockaddr_in() - var len = socklen_t(MemoryLayout.size) - withUnsafeMutablePointer(to: &actual) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in - _ = getsockname(listenFd, sa, &len) - } - } - assignedPort = Int(UInt16(bigEndian: actual.sin_port)) - - queue.async { [weak self] in self?.acceptLoop() } - } - - /// Stop accepting and close the listening socket. - func stop() { - lock.lock() - stopped = true - let fd = listenFd - listenFd = -1 - lock.unlock() - if fd >= 0 { close(fd) } - } - - deinit { stop() } - - // MARK: - internals - - private func acceptLoop() { - while true { - lock.lock() - if stopped { lock.unlock(); return } - let fd = listenFd - lock.unlock() - if fd < 0 { return } - - // Poll with a timeout so we can notice `stopped` promptly. - var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) - let pr = poll(&pfd, 1, 200) - if pr <= 0 { continue } - if (pfd.revents & Int16(POLLIN)) == 0 { continue } - - let clientFd = accept(fd, nil, nil) - if clientFd < 0 { continue } - handle(clientFd) - close(clientFd) - } - } - - /// Read one full HTTP request, record it, and send the preset response. - private func handle(_ fd: Int32) { - let (headerString, bodyData) = readRequest(fd) - - // Optional Expect: 100-continue handshake (reqwest/hyper may emit it). - if headerString.lowercased().contains("expect: 100-continue") { - _ = sendAll(fd, Data("HTTP/1.1 100 Continue\r\n\r\n".utf8)) - } - - // Record the request. - let path = headerString - .components(separatedBy: "\r\n") - .first - .map { $0.components(separatedBy: " ").dropFirst().first ?? "" } ?? "" - let bodyStr = String(data: bodyData, encoding: .utf8) ?? "" - - lock.lock() - lastRequestBody = bodyStr - lastRequestPath = path - lock.unlock() - - sendResponse(fd) - } - - /// Read until headers complete (\r\n\r\n), then the Content-Length body. - private func readRequest(_ fd: Int32) -> (String, Data) { - let marker = Data([0x0D, 0x0A, 0x0D, 0x0A]) // \r\n\r\n - var received = Data() - let cap = 8192 - let buf = UnsafeMutablePointer.allocate(capacity: cap) - defer { buf.deallocate() } - - var headerEnd: Int = -1 - // Read until the header terminator is found. - while headerEnd < 0 { - let n = recv(fd, UnsafeMutableRawPointer(buf), cap, 0) - if n <= 0 { break } - received.append(buf, count: n) - if let r = received.range(of: marker) { headerEnd = r.upperBound } - } - if headerEnd < 0 { - return ("", Data()) - } - - let headerData = received.subdata(in: 0.. Int { - var sent = 0 - data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in - var remaining = raw.count - var ptr = raw.baseAddress - while remaining > 0 { - let n = write(fd, ptr, remaining) - if n <= 0 { break } - sent += n - remaining -= n - ptr = ptr?.advanced(by: n) - } - } - return sent - } -} diff --git a/bindings/swift/Tests/AimuxTests/MultimodalTests.swift b/bindings/swift/Tests/AimuxTests/MultimodalTests.swift deleted file mode 100644 index 862124842..000000000 --- a/bindings/swift/Tests/AimuxTests/MultimodalTests.swift +++ /dev/null @@ -1,293 +0,0 @@ -// MultimodalTests.swift — E2E tests for the multimodal bindings. -// -// Mirrors bindings/go/multimodal_withbase_test.go (the gold reference). Each -// test stands up a MockHTTPServer with a canned *provider* JSON response, -// constructs a model via the provider factory with `baseUrl` pointing at the -// mock, calls the method, and parses the returned JSON-string result to assert -// fields. -// -// Important: the methods return the aimux *canonical* wire shape produced by -// the FFI, NOT the raw provider response. The FFI converts provider shapes, -// e.g. OpenAI `data[].b64_json` → `images` (base64 strings), an OpenAI file -// object → `providerReference.openai`, raw audio bytes → `audio` (a byte -// array). The canned responses below are the provider shapes (what the mock -// returns); the assertions decode the result into the typed result structs -// (what the FFI returns). -// -// Image, video and file call options carry `"providerOptions":{}` where the -// generated options type requires it (`ImageCallOptions`, `VideoCallOptions`). - -import XCTest -@testable import Aimux - -final class MultimodalTests: XCTestCase { - - // MARK: - Embedding - - /// OpenAI embedding: mock returns `data[].embedding`; the FFI exposes it as - /// `embeddings` (an array of float arrays). The first embedding has 3 dims. - func testE2E_EmbeddingViaMock() throws { - let server = MockHTTPServer(response: jsonResponse( - #"{"data":[{"embedding":[0.1,0.2,0.3],"index":0}],"model":"text-embedding-3-small","usage":{"prompt_tokens":3,"total_tokens":3}}"# - )) - try server.start() - defer { server.stop() } - - let model = try EmbeddingModel.openai( - apiKey: "test", modelId: "text-embedding-3-small", baseUrl: server.baseURL - ) - let r = try decode(EmbeddingResult.self, try model.embed(values: jsonEncode(["hello"]))) - - // `embeddings` is [[float]]; verify one embedding vector of dimension 3. - XCTAssertEqual(r.embeddings.count, 1) - XCTAssertEqual(r.embeddings.first?.count, 3, "first embedding should have 3 dimensions") - } - - // MARK: - Image - - /// OpenAI image: mock returns `data[].b64_json`; the FFI exposes it as - /// `images` (base64 strings). - func testE2E_ImageViaMock() throws { - let server = MockHTTPServer(response: jsonResponse( - #"{"data":[{"b64_json":"aW1hZ2Ux"}]}"# - )) - try server.start() - defer { server.stop() } - - let model = try ImageModel.openai( - apiKey: "test", modelId: "dall-e-3", baseUrl: server.baseURL - ) - let opts = jsonEncode([ - "prompt": "otter", - "n": 1, - "providerOptions": [String: Any](), - ]) - let r = try decode(ImageResult.self, try model.generate(options: opts)) - - XCTAssertEqual(r.images, .base64(["aW1hZ2Ux"])) - } - - // MARK: - Transcription - - /// OpenAI transcription: mock returns `{"text":"..."}`; the FFI passes the - /// text through as `text`. - func testE2E_TranscriptionViaMock() throws { - let server = MockHTTPServer(response: jsonResponse( - #"{"text":"Hello world"}"# - )) - try server.start() - defer { server.stop() } - - let model = try TranscriptionModel.openai( - apiKey: "test", modelId: "whisper-1", baseUrl: server.baseURL - ) - let r = try decode(TranscriptionResult.self, - try model.generate(audioBase64: "dGVzdA==", mediaType: "audio/mp3")) - - XCTAssertEqual(r.text, "Hello world") - } - - // MARK: - Reranking - - /// Cohere reranking: mock returns `results[]`; the FFI exposes it as - /// `ranking[]` with `index` and `relevanceScore`. - func testE2E_RerankingViaMock() throws { - let server = MockHTTPServer(response: jsonResponse( - #"{"results":[{"index":1,"relevance_score":0.95},{"index":0,"relevance_score":0.3}]}"# - )) - try server.start() - defer { server.stop() } - - let model = try RerankingModel.cohere( - apiKey: "test", modelId: "rerank-v3.0", baseUrl: server.baseURL - ) - let opts = jsonEncode([ - "query": "which?", - "documents": ["type": "text", "values": ["doc1", "doc2"]], - "topN": 2, - ]) - let r = try decode(RerankingResult.self, try model.rerank(options: opts)) - - XCTAssertEqual(r.ranking.count, 2) - XCTAssertEqual(r.ranking[0].index, 1) - XCTAssertEqual(r.ranking[0].relevanceScore, 0.95) - } - - // MARK: - Search - - /// Tavily search: mock returns `results[]` + `answer`; the FFI exposes them - /// as `results[]` (title/url/content) and `answer`. - func testE2E_SearchViaMock() throws { - let server = MockHTTPServer(response: jsonResponse( - #"{"results":[{"title":"Rust","url":"https://rust-lang.org","content":"Rust is..."}],"answer":"Rust is a systems language."}"# - )) - try server.start() - defer { server.stop() } - - let model = try SearchModel.tavily(apiKey: "test", baseUrl: server.baseURL) - let opts = jsonEncode([ - "query": "What is Rust?", - ]) - let r = try decode(SearchResult.self, try model.search(options: opts)) - - XCTAssertEqual(r.results.count, 1) - XCTAssertEqual(r.results[0].title, "Rust") - XCTAssertEqual(r.answer, "Rust is a systems language.") - } - - // MARK: - Files - - /// OpenAI files: mock returns an OpenAI file object; the FFI exposes the - /// file id as `providerReference.openai`. - func testE2E_FilesViaMock() throws { - let server = MockHTTPServer(response: jsonResponse( - #"{"id":"file-abc","object":"file","bytes":1024,"created_at":1234,"filename":"test.pdf","purpose":"assistants"}"# - )) - try server.start() - defer { server.stop() } - - let files = try Files.openai(apiKey: "test", baseUrl: server.baseURL) - let r = try decode(UploadFileResult.self, - try files.upload(dataBase64: "dGVzdA==", mediaType: "application/pdf")) - - XCTAssertEqual(r.providerReference["openai"], "file-abc") - } - - // MARK: - Speech (TTS) - - /// OpenAI speech (TTS): the mock returns a raw binary body with - /// `Content-Type: audio/mpeg`. Unlike a URLProtocol mock, this POSIX-socket - /// MockHTTPServer supports arbitrary content-types via direct `MockResponse` - /// construction, so we exercise the full round trip (Swift → FFI → reqwest → - /// mock). The FFI returns the bytes as `audio`, a base64 string - /// (`SpeechModelV4Result.audio`, speech-model-v4-result.ts:16). - func testE2E_SpeechViaMock() throws { - // A non-empty binary payload (the literal bytes of a base64-looking - // string, matching bindings/go/multimodal_withbase_test.go). - let audioBody = Data("SGVsbG8gd29ybGQ=".utf8) - let server = MockHTTPServer(response: MockResponse( - status: 200, contentType: "audio/mpeg", body: audioBody - )) - try server.start() - defer { server.stop() } - - let model = try SpeechModel.openai( - apiKey: "test", modelId: "tts-1", baseUrl: server.baseURL - ) - let opts = jsonEncode([ - "text": "Hi", - "voice": "alloy", - "outputFormat": "mp3", - ]) - let r = try decode(SpeechResult.self, try model.generate(options: opts)) - - XCTAssertEqual(r.audio, .base64(audioBody.base64EncodedString())) - XCTAssertEqual(try JSONEncoder().encode(FileBytes.binary([0, 1, 2, 250, 255])), - Data(#""AAEC+v8=""#.utf8)) - XCTAssertEqual(Base64Bytes.decode("AAEC-v8"), [0, 1, 2, 250, 255]) - XCTAssertThrowsError(try JSONDecoder().decode(FileBytes.self, from: Data("[1,2]".utf8))) - } - - // MARK: - Video (construction + result parsing only) - - /// Google video uses a multi-step async API (POST predict → poll operation → - /// fetch result) that a single-response mock server can't cover. We verify - /// construction with a base URL (pointing at a non-listening port, never - /// contacted) and decode a canned VideoResult JSON, checking the `url` - /// variant's `url`/`mediaType` — mirroring the Go test. - func testE2E_VideoConstructionAndParse() throws { - let model = try VideoModel.google( - apiKey: "test", modelId: "veo-3.0", baseUrl: "http://localhost:9999" - ) - XCTAssertNotNil(model) - - let r = try decode(VideoResult.self, """ - {"videos":[{"type":"url","url":"https://example.com/v.mp4","mediaType":"video/mp4"}], - "warnings":[],"response":{}} - """) - XCTAssertEqual(r.videos, [.url(url: "https://example.com/v.mp4", mediaType: "video/mp4")]) - } - - // MARK: - TranscriptionSession use-after-close - - // A live session needs a realtime websocket provider, which no mock here - // can stand up. `close()` is defined as `handle = 0`, so a session built - // on handle 0 *is* a closed session — and the drop is skipped, so nothing - // is freed twice. These tests pin the contract that matters: after close, - // every method throws and none of them traps (a trap is SIGTRAP, which - // XCTest cannot catch — if this regresses, the test run dies here). - - /// All three methods on a closed session throw a C ABI failure — - /// catchable (Go `ErrClosed` / Dart `StateError` / Kotlin - /// `IllegalStateException` are the peers) and deliberately NOT - /// `AimuxTranscriptionEndedError`: a pump loop that breaks on "ended" - /// must not read a transcript truncated by its own `close()` as complete. - func testClosedSessionMethodsThrowFfiErrorNotEnded() { - let session = TranscriptionSession(handle: 0) - - for (name, call) in [ - ("pushAudio", { try session.pushAudio([0x00, 0x01]) }), - ("inputDone", { try session.inputDone() }), - ("nextPart", { _ = try session.nextPart(timeoutMs: 0) }), - ] as [(String, () throws -> Void)] { - XCTAssertThrowsError(try call(), name) { error in - XCTAssertFalse( - error is AimuxTranscriptionEndedError, - "\(name): a closed session must not look like a clean end of stream" - ) - XCTAssertTrue( - error is DecodingError, - "\(name): expected the Swift boundary projection, got \(error)" - ) - } - } - } - - /// The `defer { session.close() }` shape: close, then one more call. This - /// used to trap the host process; it must be an ordinary catch. - func testCloseThenCallIsCatchable() { - let session = TranscriptionSession(handle: 0) - session.close() - session.close() // idempotent - - var caught = false - do { - try session.inputDone() - } catch is AimuxTranscriptionEndedError { - XCTFail("a closed session must not be reported as a clean end of stream") - } catch is DecodingError { - caught = true - } catch { - XCTFail("expected the Swift boundary projection, got \(error)") - } - XCTAssertTrue(caught, "use-after-close must be catchable, not a trap") - } - - // MARK: - router([]) - - /// An empty child list throws C's zero-children failure instead of - /// trapping — the array may well come from a `.filter`, and the function - /// already has a throwing channel. - func testRouterWithNoChildrenThrows() { - XCTAssertThrowsError(try Model.router([])) - } -} - -// MARK: - Helpers - -/// Build a JSON `MockResponse` from a raw JSON string. This lets the canned -/// responses mirror the Go test fixtures verbatim (no double-serialization). -private func jsonResponse(_ raw: String) -> MockResponse { - MockResponse(status: 200, contentType: "application/json", body: Data(raw.utf8)) -} - -/// JSON-encode an array or dictionary object. -private func jsonEncode(_ object: Any) -> String { - let data = try! JSONSerialization.data(withJSONObject: object) - return String(data: data, encoding: .utf8)! -} - -/// Decode a JSON result string into its typed result struct. -private func decode(_ type: T.Type, _ json: String) throws -> T { - try JSONDecoder().decode(type, from: Data(json.utf8)) -} diff --git a/bindings/swift/Tests/AimuxTests/NativeSmokeTests.swift b/bindings/swift/Tests/AimuxTests/NativeSmokeTests.swift new file mode 100644 index 000000000..d886e5481 --- /dev/null +++ b/bindings/swift/Tests/AimuxTests/NativeSmokeTests.swift @@ -0,0 +1,121 @@ +#if !canImport(CAimuxTestABI) +import Foundation +@testable import Aimux +#if !AIMUX_STANDALONE +import Testing +#endif + +struct NativeSmokeTests { +#if !AIMUX_STANDALONE + @Test +#endif + static func manifest() async throws { + let manifest = try await AimuxRuntime.manifest() + try require(manifest["ops"]?.arrayValue?.contains { $0["name"] == .string(AimuxOps.OP_DECISION_DECIDE) } == true) + } +#if !AIMUX_STANDALONE + @Test +#endif + static func unknownOpEnvelope() async throws { + do { + _ = try await AimuxRuntime.call("swift.unknown.op") + throw CheckFailure(description: "Unknown op accepted") + } catch let error as AimuxError { + try require(error.kind == "protocol" && error.code == 300) + try require(error.error["name"] == .string("InvalidOp")) + } + } + + private static func provider(_ package: String) async throws -> AimuxHandle { + try await AimuxRuntime.open(AimuxOps.OP_PROVIDER_CREATE, params: .object([ + "package": .string(package), "settings": .object(["apiKey": .string("test-key")]), + ])) + } +#if !AIMUX_STANDALONE + @Test +#endif + static func providerAndModel() async throws { + let provider = try await provider("openai") + defer { provider.close() } + let model = try await provider.open(AimuxOps.OP_PROVIDER_MODEL, params: .object([ + "method": .string("languageModel"), "modelId": .string("test-model"), + ])) + defer { model.close() } + // Models retain native ownership after their provider facade closes. + provider.close() + let info = try await model.call(AimuxOps.OP_MODEL_INFO) + try require(info["modelId"] == .string("test-model")) + model.close() + model.close() + do { _ = try await model.call(AimuxOps.OP_MODEL_INFO); throw CheckFailure(description: "Closed handle admitted") } + catch let error as AimuxError { try require(error.code == 304) } + } +#if !AIMUX_STANDALONE + @Test +#endif + static func secondaryHandleReference() async throws { + let provider = try await provider("openai") + defer { provider.close() } + guard let id = provider.id else { throw CheckFailure(description: "Missing provider ID") } + let registry = try await AimuxRuntime.open(AimuxOps.OP_REGISTRY_CREATE, params: .object([ + "namespace": .string("swift"), + "entries": .array([.object(["key": .string("test"), "provider": .string(String(id))])]), + ])) + defer { registry.close() } + provider.close() + try require(provider.id == nil) + let retained = try await registry.open(AimuxOps.OP_REGISTRY_PROVIDER, params: .object(["key": .string("test")])) + defer { retained.close() } + let model = try await retained.open(AimuxOps.OP_PROVIDER_MODEL, params: .object([ + "method": .string("languageModel"), "modelId": .string("retained-model"), + ])) + defer { model.close() } + try require(try await model.call(AimuxOps.OP_MODEL_INFO)["modelId"] == .string("retained-model")) + } +#if !AIMUX_STANDALONE + @Test +#endif + static func decision() async throws { + let provider = try await provider("jev") + defer { provider.close() } + let model = try await provider.open(AimuxOps.OP_PROVIDER_MODEL, params: .object([ + "method": .string("decision"), "modelId": .string("test-decision"), + ])) + defer { model.close() } + let capabilities = try await model.call(AimuxOps.OP_DECISION_CAPABILITIES) + try require(capabilities.objectValue != nil) + } +#if !AIMUX_STANDALONE + @Test +#endif + static func terminalErrorAndRejectedStream() async throws { + let provider = try await provider("openai") + defer { provider.close() } + let model = try await provider.open(AimuxOps.OP_PROVIDER_MODEL, params: .object([ + "method": .string("languageModel"), "modelId": .string("test-model"), + ])) + defer { model.close() } + let stream = try await model.stream(AimuxOps.OP_TEXT_STREAM, params: .object(["prompt": .array([])])) + let iterator = stream.makeAsyncIterator() + do { _ = try await iterator.next(); throw CheckFailure(description: "Empty prompt succeeded") } + catch let error as AimuxError { try require(error.kind == "aimux" && error.code == 6) } + try require(try await iterator.next() == nil) + do { _ = try await provider.stream(AimuxOps.OP_TEXT_STREAM); throw CheckFailure(description: "Wrong handle kind accepted") } + catch let error as AimuxError { try require(error.code == 203) } + } +#if !AIMUX_STANDALONE + @Test +#endif + static func primitiveValidation() async throws { + let abort = try await AimuxRuntime.open(AimuxOps.OP_ABORT_CREATE) + defer { abort.close() } + _ = try await abort.call(AimuxOps.OP_ABORT_SIGNAL) + do { try await abort.push(Data()); throw CheckFailure(description: "Push accepted an Abort") } + catch let error as AimuxError { try require(error.code == 203) } + do { _ = try await abort.nextPart(timeoutMs: 0); throw CheckFailure(description: "Poll accepted an Abort") } + catch let error as AimuxError { try require(error.code == 203) } + do { _ = try await abort.nextPart(timeoutMs: -1); throw CheckFailure(description: "Negative timeout accepted") } + catch let error as AimuxError { try require(error.code == 301) } + } +} +#endif diff --git a/bindings/swift/Tests/AimuxTests/WireTests.swift b/bindings/swift/Tests/AimuxTests/WireTests.swift new file mode 100644 index 000000000..72ad68af5 --- /dev/null +++ b/bindings/swift/Tests/AimuxTests/WireTests.swift @@ -0,0 +1,234 @@ +import Foundation +@testable import Aimux +#if !AIMUX_STANDALONE +import Testing +#endif + +// Existing wire checks retained through the ABI switch. +struct WireTests { +#if !AIMUX_STANDALONE + @Test +#endif + static func handleResults() throws { + try require(try handleID(#"{"handle":"18446744073709551615","kind":"Provider"}"#) == UInt64.max) + try require(try handleID(#""9007199254740993""#) == 9_007_199_254_740_993) + for json in [#"{"handle":1}"#, #""0""#, #""01""#, #""18446744073709551616""#] { + do { _ = try handleID(json); throw CheckFailure(description: "Invalid handle accepted") } + catch is DecodingError {} + } + } + +#if !AIMUX_STANDALONE + @Test +#endif + static func testToolCallProviderMetadataRoundTrip() throws { + let original = ToolCall( + toolCallId: "call_1", + toolName: "get_weather", + input: jv(#"{"location":"Tokyo"}"#), + providerMetadata: ["openai": ["itemId": .string("item_1")]] + ) + + let encoded = try JSONEncoder().encode(original) + let wire = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] + let metadata = wire?["providerMetadata"] as? [String: Any] + try require(metadata?["openai"] != nil) + + let decoded = try JSONDecoder().decode(ToolCall.self, from: encoded) + try assertEqual(decoded.providerMetadata, original.providerMetadata) + } +#if !AIMUX_STANDALONE + @Test +#endif + static func testUnknownFieldsKeepNullApartFromMissing() throws { + let missing = try JSONDecoder().decode(ResponseInfo.self, from: Data("{}".utf8)) + try assertNil(missing.body) + let null = try JSONDecoder().decode(ResponseInfo.self, from: Data(#"{"body":null}"#.utf8)) + try assertEqual(null.body, .null) + try assertEqual(String(decoding: try JSONEncoder().encode(null), as: UTF8.self), #"{"body":null}"#) + let request = try JSONDecoder().decode(RequestInfo.self, from: Data(#"{"body":null}"#.utf8)) + try assertEqual(request.body, .null) + } +#if !AIMUX_STANDALONE + @Test +#endif + static func testGeneratedFilesAreBase64AndMediaType() throws { + let file = GeneratedFile(base64: "aGk=", mediaType: "text/plain") + let decoded = try JSONDecoder().decode(GeneratedFile.self, from: Data(#"{"base64":"aGk=","mediaType":"text/plain"}"#.utf8)) + try assertEqual(decoded, file) + try assertEqual(file.bytes(), [104, 105]) + + let output = GeneratedFileOutput(file: file, providerMetadata: ["google": ["thoughtSignature": .string("sig")]]) + let wire = #"{"type":"file","file":{"base64":"aGk=","mediaType":"text/plain"},"providerMetadata":{"google":{"thoughtSignature":"sig"}}}"# + try assertEqual(try JSONDecoder().decode(TextStreamPart.self, from: Data(wire.utf8)), .file(output)) + try assertEqual(try JSONDecoder().decode(TextContent.self, from: Data(wire.utf8)), .file(output)) + + let raw = #"{"type":"file","data":{"type":"url","url":"https://example.com/a.png"},"mediaType":"image/png"}"# + try assertEqual(try JSONDecoder().decode(RawGenerateContent.self, from: Data(raw.utf8)), + .file(LanguageModelFile(data: .url(url: "https://example.com/a.png"), mediaType: "image/png"))) + } +#if !AIMUX_STANDALONE + @Test +#endif + static func testTextStreamToolPartsAndUsageAreCamelCase() throws { + let cases: [(String, TextStreamPart)] = [ + (#"{"type":"tool-result","toolCallId":"srv-1","toolName":"web_search","input":{"q":"rust"},"output":"ok","providerExecuted":true}"#, + .toolResult(TypedToolResult(toolCallId: "srv-1", toolName: "web_search", input: .object(["q": .string("rust")]), + output: .string("ok"), providerExecuted: true, providerMetadata: nil, + dynamic: nil, preliminary: nil))), + (#"{"type":"tool-output-denied","toolCallId":"c","toolName":"t"}"#, + .toolOutputDenied(ToolOutputDenied(toolCallId: "c", toolName: "t", providerExecuted: nil, dynamic: nil))), + (#"{"type":"finish","finishReason":"stop","totalUsage":{"inputTokens":3,"inputTokenDetails":{"cacheReadTokens":1},"outputTokens":2,"outputTokenDetails":{},"totalTokens":5}}"#, + .finish(finishReason: .stop, rawFinishReason: nil, + totalUsage: LanguageModelUsage(inputTokens: 3, inputTokenDetails: InputTokenDetails(cacheReadTokens: 1), + outputTokens: 2, totalTokens: 5))), + ] + for (wire, part) in cases { + try assertEqual(try JSONDecoder().decode(TextStreamPart.self, from: Data(wire.utf8)), part) + let encoded = try JSONSerialization.jsonObject(with: JSONEncoder().encode(part)) as? NSDictionary + try assertEqual(encoded, try JSONSerialization.jsonObject(with: Data(wire.utf8)) as? NSDictionary) + } + } +#if !AIMUX_STANDALONE + @Test +#endif + static func testTextStreamDeltasAreText() throws { + let cases: [(String, TextStreamPart)] = [ + (#"{"type":"text-delta","id":"t1","text":"Hello"}"#, .textDelta(id: "t1", text: "Hello", providerMetadata: nil)), + (#"{"type":"reasoning-delta","id":"t1","text":"Hmm"}"#, .reasoningDelta(id: "t1", text: "Hmm", providerMetadata: nil)), + (#"{"type":"tool-input-delta","id":"t1","delta":"{"}"#, .toolInputDelta(id: "t1", delta: "{", providerMetadata: nil)), + ] + for (wire, part) in cases { + try assertEqual(try JSONDecoder().decode(TextStreamPart.self, from: Data(wire.utf8)), part) + let encoded = try JSONSerialization.jsonObject(with: JSONEncoder().encode(part)) as? NSDictionary + try assertEqual(encoded, try JSONSerialization.jsonObject(with: Data(wire.utf8)) as? NSDictionary) + } + try assertThrows(try JSONDecoder().decode(TextStreamPart.self, from: Data(#"{"type":"text-delta","id":"t1","delta":"Hello"}"#.utf8))) + } +#if !AIMUX_STANDALONE + @Test +#endif + static func testFilePartsAreTheAiSdkFilePart() throws { + let cases: [(String, ContentPart)] = [ + (#"{"type":"file","data":{"type":"url","url":"https://example.com/a.pdf"},"mediaType":"application/pdf"}"#, + .file(data: .tagged(.url(url: "https://example.com/a.pdf")), mediaType: "application/pdf")), + (#"{"type":"file","data":{"type":"data","data":"aGk="},"mediaType":"text/plain"}"#, + .file(data: .tagged(.data(data: .base64("aGk="))), mediaType: "text/plain")), + (#"{"type":"file","data":"aGk=","mediaType":"text/plain"}"#, + .file(data: .bare("aGk="), mediaType: "text/plain")), + (#"{"type":"file","data":{"openai":"file-abc"},"mediaType":"application/pdf"}"#, + .file(data: .reference(["openai": "file-abc"]), mediaType: "application/pdf")), + (#"{"type":"file","data":{"type":"reference","reference":{"openai":"file-abc"}},"mediaType":"application/pdf"}"#, + .file(data: .tagged(.reference(reference: ["openai": "file-abc"])), mediaType: "application/pdf")), + (#"{"type":"reasoning-file","data":"aGk=","mediaType":"image/png"}"#, + .reasoningFile(data: .bare("aGk="), mediaType: "image/png")), + ] + for (wire, part) in cases { + let decoded = try JSONDecoder().decode(ContentPart.self, from: Data(wire.utf8)) + try assertEqual(decoded, part) + try assertEqual(try JSONDecoder().decode(ContentPart.self, from: try JSONEncoder().encode(part)), part) + } + for removed in ["file-base64", "file-url", "file-reference"] { + let wire = #"{"type":"\#(removed)","data":"aGk=","url":"https://example.com/a.pdf","mediaType":"application/pdf"}"# + try assertThrows(try JSONDecoder().decode(ContentPart.self, from: Data(wire.utf8))) + } + } +#if !AIMUX_STANDALONE + @Test +#endif + static func testToolChoiceCodableWire() throws { + let pairs: [(ToolChoice, String)] = [ + (ToolChoice.auto, "\"auto\""), + (ToolChoice.none, "\"none\""), + (ToolChoice.required, "\"required\""), + ] + for (choice, wire) in pairs { + let data = try JSONEncoder().encode(choice) + try assertEqual(String(data: data, encoding: .utf8), wire) + try assertEqual(try JSONDecoder().decode(ToolChoice.self, from: data), choice) + } + + let tool = ToolChoice.tool(toolName: "get_weather") + let data = try JSONEncoder().encode(tool) + let obj = parseJSON(String(data: data, encoding: .utf8) ?? "") + try assertEqual(obj["type"] as? String, "tool") + try assertEqual(obj["toolName"] as? String, "get_weather") + try assertEqual(try JSONDecoder().decode(ToolChoice.self, from: data), tool) + } +#if !AIMUX_STANDALONE + @Test +#endif + static func testGenerateTextResultRoundTrip() throws { + let json = """ + { + "content": [ + {"type":"text","text":"hello"}, + {"type":"tool-call","toolCallId":"call_1","toolName":"get_weather","input":{"location":"Tokyo"}} + ], + "text": "hello", + "reasoning": [{"type":"reasoning","text":"hmm"}], + "reasoningText": "hmm", + "files": [], + "sources": [{"type":"source","sourceType":"url","id":"s1","url":"https://example.com"}], + "toolCalls": [ + {"type":"tool-call","toolCallId":"call_1","toolName":"get_weather","input":{"location":"Tokyo"}} + ], + "toolResults": [], + "finishReason": "tool-calls", + "rawFinishReason": "tool_calls", + "usage": {"inputTokens":3,"inputTokenDetails":{},"outputTokens":2,"outputTokenDetails":{},"totalTokens":5}, + "totalUsage": {"inputTokens":3,"inputTokenDetails":{},"outputTokens":2,"outputTokenDetails":{},"totalTokens":5}, + "warnings": [{"type":"other","message":"careful"}], + "request": {}, + "response": {"id":"resp_1","timestamp":"2026-01-01T00:00:00.000Z","modelId":"gpt-4o","messages":[]}, + "responseMessages": [ + {"role":"assistant","content":[ + {"type":"tool-call","toolCallId":"call_1","toolName":"get_weather","input":{"location":"Tokyo"}} + ]}, + {"role":"tool","content":[ + {"type":"tool-result","toolCallId":"call_1","toolName":"get_weather", + "output":{"type":"json","value":{"temp":22}}} + ]} + ], + "steps": [{"callId":"c1","stepNumber":0}] + } + """ + let original = try JSONDecoder().decode(GenerateTextResult.self, + from: json.data(using: .utf8)!) + try assertEqual(original.text, "hello") + try assertEqual(original.toolCalls[0].toolName, "get_weather") + try assertEqual(original.finishReason, .toolCalls) + try assertEqual(original.rawFinishReason, "tool_calls") + try assertEqual(original.usage.totalTokens, 5) + try assertEqual(original.reasoningText, "hmm") + try assertNil(original.output) + try assertEqual(original.response.id, "resp_1") + try assertEqual(original.response.modelId, "gpt-4o") + try assertEqual(original.warnings, [.other(message: "careful")]) + try assertEqual(original.reasoning, [.reasoning(ReasoningOutput(text: "hmm"))]) + guard case .parts(let toolContent) = original.responseMessages[1].content, + case .toolResult(_, _, let output, _) = toolContent[0] else { + throw CheckFailure(description: "expected a tool-result part, got \(original.responseMessages[1].content)") + } + try assertEqual(output, .json(.object(["temp": .number(22)]))) + + // Encode → decode yields an equal value. + let reencoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(GenerateTextResult.self, from: reencoded) + try assertEqual(original, decoded) + } +} +private func assertEqual(_ lhs: T, _ rhs: T) throws { + try require(lhs == rhs, "Values differ: \(lhs) versus \(rhs)") +} +private func assertNil(_ value: T?) throws { try require(value == nil) } +private func assertThrows(_ body: @autoclosure () throws -> T) throws { + do { _ = try body() } catch { return } + throw CheckFailure(description: "Expected decoding failure") +} +private func parseJSON(_ value: String) -> [String: Any] { + (try? JSONSerialization.jsonObject(with: Data(value.utf8)) as? [String: Any]) ?? [:] +} +private func jv(_ value: String) -> JSONValue { + try! JSONDecoder().decode(JSONValue.self, from: Data(value.utf8)) +} diff --git a/bindings/swift/Tests/AimuxTests/WrapperTests.swift b/bindings/swift/Tests/AimuxTests/WrapperTests.swift deleted file mode 100644 index d36eb74b0..000000000 --- a/bindings/swift/Tests/AimuxTests/WrapperTests.swift +++ /dev/null @@ -1,595 +0,0 @@ -// WrapperTests.swift — tests for the typed Codable wrapper layer (Types.swift). -// -// Exercises the `Model` extension methods that take `ModelPrompt` / -// `GenerateTextOptions` and return `GenerateTextResult` / `TextStreamPart`, -// verifying the JSON↔Codable boundary is handled correctly end-to-end -// (Swift typed API → FFI → reqwest → MockHTTPServer). The raw string-based -// API is left untouched; these tests use only the typed layer. - -import XCTest -@testable import Aimux - -final class WrapperTests: XCTestCase { - - // MARK: - generateText: plain text result - - /// A plain string prompt returns a `GenerateTextResult` with `.text`, - /// `.content` (a `text` content part), a unified `.stop` finish reason, - /// and token usage decoded from the mock. - func testTypedGenerateTextPlainText() throws { - let server = MockHTTPServer(response: .json(openaiPlainResponse)) - try server.start() - defer { server.stop() } - - let model = try chatModel(server.baseURL) - let result = try model.generateText(prompt: .text("What is Rust?")) - - XCTAssertEqual(result.text, "Rust is a systems programming language.") - XCTAssertEqual(result.finishReason, .stop) - XCTAssertEqual(result.usage.inputTokens, 10) - XCTAssertEqual(result.usage.outputTokens, 8) - XCTAssertEqual(result.steps.count, 1) - - // content carries a `text` content part. - let textContent = result.content.compactMap { part -> String? in - part["type"]?.stringValue == "text" ? part["text"]?.stringValue : nil - } - XCTAssertEqual(textContent, ["Rust is a systems programming language."]) - - XCTAssertEqual(server.lastRequestPath, "/chat/completions") - } - - // MARK: - generateText: tool calls - - func testProviderToolDeferredResultsRoundTrip() throws { - for supported in [nil, true, false] as [Bool?] { - let original = ProviderTool(id: "anthropic.code_execution_20250522", name: "code_execution", - args: [:], supportsDeferredResults: supported) - let encoded = try JSONEncoder().encode(original) - let wire = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) - XCTAssertEqual(wire.keys.contains("supportsDeferredResults"), supported != nil) - XCTAssertEqual(try JSONDecoder().decode(ProviderTool.self, from: encoded), original) - - let tool = Tool.provider(original) - let toolEncoded = try JSONEncoder().encode(tool) - let toolWire = try XCTUnwrap(JSONSerialization.jsonObject(with: toolEncoded) as? [String: Any]) - XCTAssertEqual(toolWire.keys.contains("supportsDeferredResults"), supported != nil) - XCTAssertEqual(try JSONDecoder().decode(Tool.self, from: toolEncoded), tool) - } - } - - func testToolCallProviderMetadataRoundTrip() throws { - let original = ToolCall( - toolCallId: "call_1", - toolName: "get_weather", - input: jv(#"{"location":"Tokyo"}"#), - providerMetadata: ["openai": ["itemId": .string("item_1")]] - ) - - let encoded = try JSONEncoder().encode(original) - let wire = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] - let metadata = wire?["providerMetadata"] as? [String: Any] - XCTAssertNotNil(metadata?["openai"]) - - let decoded = try JSONDecoder().decode(ToolCall.self, from: encoded) - XCTAssertEqual(decoded.providerMetadata, original.providerMetadata) - } - - /// Passing a typed `tools` option yields a typed `ToolCall` in the result: - /// `.toolCalls[0].toolName`, `.toolCallId`, and the `ToolCall` variant - /// of `.content`. - func testTypedGenerateTextToolCall() throws { - let server = MockHTTPServer(response: .json(openaiToolCallResponse)) - try server.start() - defer { server.stop() } - - let model = try chatModel(server.baseURL) - let options = GenerateTextOptions(tools: [ - .function(FunctionTool( - name: "get_weather", - inputSchema: jv(#"{"type":"object","properties":{"location":{"type":"string"}},"required":["location"]}"#), - description: "Get weather for a location" - )) - ]) - let result = try model.generateText( - prompt: .text("What's the weather in Tokyo?"), options: options - ) - - // Convenience tool_calls array (decoded from the top-level field). - XCTAssertEqual(result.toolCalls.count, 1) - XCTAssertEqual(result.toolCalls[0].toolName, "get_weather") - XCTAssertEqual(result.toolCalls[0].toolCallId, "call_abc") - XCTAssertEqual(result.toolCalls[0].input["location"]?.stringValue, "Tokyo") - - // content contains a tool-call item with the parsed input. - let toolContents = result.content.filter { $0["type"]?.stringValue == "tool-call" } - XCTAssertEqual(toolContents.count, 1) - XCTAssertEqual(toolContents[0]["toolName"]?.stringValue, "get_weather") - XCTAssertEqual(toolContents[0]["toolCallId"]?.stringValue, "call_abc") - XCTAssertEqual(toolContents[0]["input"]?["location"]?.stringValue, "Tokyo") - XCTAssertEqual(result.finishReason, .toolCalls) - } - - // MARK: - generateText: multi-role messages - - /// A `.messages` prompt with system + user roles reaches the provider - /// request body verbatim. - func testTypedGenerateTextMultiRoleMessages() throws { - let server = MockHTTPServer(response: .json(openaiPlainResponse)) - try server.start() - defer { server.stop() } - - let model = try chatModel(server.baseURL) - let result = try model.generateText(prompt: .messages([ - .system("You are a helpful assistant."), - .user("What is Rust?"), - ])) - XCTAssertEqual(result.text, "Rust is a systems programming language.") - - // The full multi-role message sequence reaches the provider request. - let reqBody = parseJSON(server.lastRequestBody) - let msgs = (reqBody["messages"] as? [[String: Any]]) ?? [] - XCTAssertEqual(msgs.count, 2) - XCTAssertEqual(msgs[0]["role"] as? String, "system") - XCTAssertEqual(msgs[0]["content"] as? String, "You are a helpful assistant.") - XCTAssertEqual(msgs[1]["role"] as? String, "user") - XCTAssertEqual(msgs[1]["content"] as? String, "What is Rust?") - } - - // MARK: - generateText: tool_choice - - /// A typed `toolChoice: .required` reaches the provider request body as - /// the bare string `"required"`. - func testTypedGenerateTextToolChoiceRequired() throws { - let server = MockHTTPServer(response: .json(openaiToolCallResponse)) - try server.start() - defer { server.stop() } - - let model = try chatModel(server.baseURL) - let options = GenerateTextOptions( - tools: [.function(FunctionTool( - name: "get_weather", - inputSchema: jv(#"{"type":"object","properties":{"location":{"type":"string"}}}"#) - ))], - toolChoice: .required - ) - // Must not throw. - _ = try model.generateText(prompt: .text("Hello"), options: options) - - let reqBody = parseJSON(server.lastRequestBody) - XCTAssertEqual(reqBody["tool_choice"] as? String, "required") - } - - /// `ToolChoice` encodes to the aimux wire shape and round-trips through - /// `JSONEncoder`/`JSONDecoder`: bare strings for the unit variants, and - /// `{"type":"tool","toolName":...}` (camelCase `toolName`) for the named - /// tool. (The provider later remaps the named form to its own wire shape, - /// so we validate the wrapper's encoding directly here.) - /// `body?: unknown` (provider language-model-v4-generate-result.ts:41, :56) - /// and `inputSchemaInput?: unknown` (provider-utils - /// types/tool-approval-request.ts:41): an explicit null is kept apart from - /// a missing value. - func testUnknownFieldsKeepNullApartFromMissing() throws { - let missing = try JSONDecoder().decode(ResponseInfo.self, from: Data("{}".utf8)) - XCTAssertNil(missing.body) - let null = try JSONDecoder().decode(ResponseInfo.self, from: Data(#"{"body":null}"#.utf8)) - XCTAssertEqual(null.body, .null) - XCTAssertEqual(String(decoding: try JSONEncoder().encode(null), as: UTF8.self), #"{"body":null}"#) - let request = try JSONDecoder().decode(RequestInfo.self, from: Data(#"{"body":null}"#.utf8)) - XCTAssertEqual(request.body, .null) - } - - /// GeneratedFile is {base64, mediaType, providerMetadata?} (ai - /// generate-text/generated-file.ts:10-32); a text file part is - /// {type, file, providerMetadata?} (convert-language-model-content.ts:55-70); - /// the provider's LanguageModelV4File keeps {type, data, mediaType}. - func testGeneratedFilesAreBase64AndMediaType() throws { - let file = GeneratedFile(base64: "aGk=", mediaType: "text/plain") - let decoded = try JSONDecoder().decode(GeneratedFile.self, from: Data(#"{"base64":"aGk=","mediaType":"text/plain"}"#.utf8)) - XCTAssertEqual(decoded, file) - XCTAssertEqual(file.bytes(), [104, 105]) - - let output = GeneratedFileOutput(file: file, providerMetadata: ["google": ["thoughtSignature": .string("sig")]]) - let wire = #"{"type":"file","file":{"base64":"aGk=","mediaType":"text/plain"},"providerMetadata":{"google":{"thoughtSignature":"sig"}}}"# - XCTAssertEqual(try JSONDecoder().decode(TextStreamPart.self, from: Data(wire.utf8)), .file(output)) - XCTAssertEqual(try JSONDecoder().decode(TextContent.self, from: Data(wire.utf8)), .file(output)) - - let raw = #"{"type":"file","data":{"type":"url","url":"https://example.com/a.png"},"mediaType":"image/png"}"# - XCTAssertEqual(try JSONDecoder().decode(RawGenerateContent.self, from: Data(raw.utf8)), - .file(LanguageModelFile(data: .url(url: "https://example.com/a.png"), mediaType: "image/png"))) - } - - /// The text stream's tool parts are the AI SDK's TypedToolResult and - /// TypedToolOutputDenied, camelCase (ai generate-text/tool-result.ts:10-42, - /// tool-output-denied.ts:7-15), and a finish part's totalUsage is its - /// LanguageModelUsage (types/usage.ts:10-67): camelCase counts, an unknown - /// count absent. - func testTextStreamToolPartsAndUsageAreCamelCase() throws { - let cases: [(String, TextStreamPart)] = [ - (#"{"type":"tool-result","toolCallId":"srv-1","toolName":"web_search","input":{"q":"rust"},"output":"ok","providerExecuted":true}"#, - .toolResult(TypedToolResult(toolCallId: "srv-1", toolName: "web_search", input: .object(["q": .string("rust")]), - output: .string("ok"), providerExecuted: true, providerMetadata: nil, - dynamic: nil, preliminary: nil))), - (#"{"type":"tool-output-denied","toolCallId":"c","toolName":"t"}"#, - .toolOutputDenied(ToolOutputDenied(toolCallId: "c", toolName: "t", providerExecuted: nil, dynamic: nil))), - (#"{"type":"finish","finishReason":"stop","totalUsage":{"inputTokens":3,"inputTokenDetails":{"cacheReadTokens":1},"outputTokens":2,"outputTokenDetails":{},"totalTokens":5}}"#, - .finish(finishReason: .stop, rawFinishReason: nil, - totalUsage: LanguageModelUsage(inputTokens: 3, inputTokenDetails: InputTokenDetails(cacheReadTokens: 1), - outputTokens: 2, totalTokens: 5))), - ] - for (wire, part) in cases { - XCTAssertEqual(try JSONDecoder().decode(TextStreamPart.self, from: Data(wire.utf8)), part) - let encoded = try JSONSerialization.jsonObject(with: JSONEncoder().encode(part)) as? NSDictionary - XCTAssertEqual(encoded, try JSONSerialization.jsonObject(with: Data(wire.utf8)) as? NSDictionary) - } - } - - /// The text stream's text-delta and reasoning-delta carry `text` (ai - /// generate-text/stream-text-result.ts:435-440, :466-471); a - /// tool-input-delta keeps `delta` (:496-501). - func testTextStreamDeltasAreText() throws { - let cases: [(String, TextStreamPart)] = [ - (#"{"type":"text-delta","id":"t1","text":"Hello"}"#, .textDelta(id: "t1", text: "Hello", providerMetadata: nil)), - (#"{"type":"reasoning-delta","id":"t1","text":"Hmm"}"#, .reasoningDelta(id: "t1", text: "Hmm", providerMetadata: nil)), - (#"{"type":"tool-input-delta","id":"t1","delta":"{"}"#, .toolInputDelta(id: "t1", delta: "{", providerMetadata: nil)), - ] - for (wire, part) in cases { - XCTAssertEqual(try JSONDecoder().decode(TextStreamPart.self, from: Data(wire.utf8)), part) - let encoded = try JSONSerialization.jsonObject(with: JSONEncoder().encode(part)) as? NSDictionary - XCTAssertEqual(encoded, try JSONSerialization.jsonObject(with: Data(wire.utf8)) as? NSDictionary) - } - XCTAssertThrowsError(try JSONDecoder().decode(TextStreamPart.self, from: Data(#"{"type":"text-delta","id":"t1","delta":"Hello"}"#.utf8))) - } - - /// A result's toolCalls and sources are the AI SDK's TypedToolCall and - /// Source, tagged {type: 'tool-call'} (ai generate-text/tool-call.ts:8-14) - /// and {type: 'source', sourceType} (provider language-model-v4-source.ts:8, - /// :36); generate-text-result.ts:73, :78. A repair function's call is a - /// tagged LanguageModelV4ToolCall (language-model-v4-tool-call.ts:7). - func testStandaloneToolCallsAndSourcesAreTagged() throws { - func object(_ data: Data) throws -> NSDictionary? { - try JSONSerialization.jsonObject(with: data) as? NSDictionary - } - let call = #"{"type":"tool-call","toolCallId":"call-1","toolName":"weather","input":{"city":"SG"}}"# - let decoded = try JSONDecoder().decode(ToolCall.self, from: Data(call.utf8)) - XCTAssertEqual(try object(JSONEncoder().encode(decoded)), try object(Data(call.utf8))) - let raw = RawToolCall(toolCallId: "c", toolName: "t", input: "{}") - XCTAssertEqual(try object(JSONEncoder().encode(raw)), - try object(Data(#"{"type":"tool-call","toolCallId":"c","toolName":"t","input":"{}"}"#.utf8))) - let url = #"{"type":"source","sourceType":"url","id":"s1","url":"https://example.com"}"# - let source = try JSONDecoder().decode(Source.self, from: Data(url.utf8)) - XCTAssertEqual(try object(JSONEncoder().encode(source)), try object(Data(url.utf8))) - // In a stream part the union's tag and the source's agree. - let part = try JSONDecoder().decode(TextStreamPart.self, from: Data(url.utf8)) - XCTAssertEqual(try object(JSONEncoder().encode(part)), try object(Data(url.utf8))) - - // JSON written before the tag still reads; another tag is rejected. - XCTAssertNoThrow(try JSONDecoder().decode(ToolCall.self, from: Data(#"{"toolCallId":"c","toolName":"t","input":{}}"#.utf8))) - XCTAssertThrowsError(try JSONDecoder().decode( - ToolCall.self, from: Data(#"{"type":"source","toolCallId":"c","toolName":"t","input":{}}"#.utf8))) - - // The native repair function writes the standalone call tagged. - let invalid = #"{"toolCallId":"call-1","toolName":"weather","input":{"town":"SG"},"dynamic":true,"invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"weather","toolInput":"{\"town\":\"SG\"}","cause":"missing city"}}"# - let options = #"{"tools":[{"type":"function","name":"weather","inputSchema":{"type":"object"}}]}"# - let repaired = try applyToolCallRepair(toolCall: invalid, options: options, reply: #"{"type":"unchanged"}"#) - XCTAssertTrue(repaired.hasPrefix(#"{"type":"tool-call","toolCallId":"call-1""#), repaired) - } - - /// FilePart.data is FileData | DataContent | URL | ProviderReference - /// (provider-utils types/content-part.ts:75, file-data.ts:44-48); - /// ReasoningFilePart.data is tagged data or a URL, or a string (:164). - /// There is no file-base64 / file-url / file-reference part. - func testFilePartsAreTheAiSdkFilePart() throws { - let cases: [(String, ContentPart)] = [ - (#"{"type":"file","data":{"type":"url","url":"https://example.com/a.pdf"},"mediaType":"application/pdf"}"#, - .file(data: .tagged(.url(url: "https://example.com/a.pdf")), mediaType: "application/pdf")), - (#"{"type":"file","data":{"type":"data","data":"aGk="},"mediaType":"text/plain"}"#, - .file(data: .tagged(.data(data: .base64("aGk="))), mediaType: "text/plain")), - (#"{"type":"file","data":"aGk=","mediaType":"text/plain"}"#, - .file(data: .bare("aGk="), mediaType: "text/plain")), - (#"{"type":"file","data":{"openai":"file-abc"},"mediaType":"application/pdf"}"#, - .file(data: .reference(["openai": "file-abc"]), mediaType: "application/pdf")), - (#"{"type":"file","data":{"type":"reference","reference":{"openai":"file-abc"}},"mediaType":"application/pdf"}"#, - .file(data: .tagged(.reference(reference: ["openai": "file-abc"])), mediaType: "application/pdf")), - (#"{"type":"reasoning-file","data":"aGk=","mediaType":"image/png"}"#, - .reasoningFile(data: .bare("aGk="), mediaType: "image/png")), - ] - for (wire, part) in cases { - let decoded = try JSONDecoder().decode(ContentPart.self, from: Data(wire.utf8)) - XCTAssertEqual(decoded, part) - XCTAssertEqual(try JSONDecoder().decode(ContentPart.self, from: try JSONEncoder().encode(part)), part) - } - for removed in ["file-base64", "file-url", "file-reference"] { - let wire = #"{"type":"\#(removed)","data":"aGk=","url":"https://example.com/a.pdf","mediaType":"application/pdf"}"# - XCTAssertThrowsError(try JSONDecoder().decode(ContentPart.self, from: Data(wire.utf8))) - } - } - - func testToolChoiceCodableWire() throws { - let pairs: [(ToolChoice, String)] = [ - (ToolChoice.auto, "\"auto\""), - (ToolChoice.none, "\"none\""), - (ToolChoice.required, "\"required\""), - ] - for (choice, wire) in pairs { - let data = try JSONEncoder().encode(choice) - XCTAssertEqual(String(data: data, encoding: .utf8), wire) - XCTAssertEqual(try JSONDecoder().decode(ToolChoice.self, from: data), choice) - } - - let tool = ToolChoice.tool(toolName: "get_weather") - let data = try JSONEncoder().encode(tool) - let obj = parseJSON(String(data: data, encoding: .utf8) ?? "") - XCTAssertEqual(obj["type"] as? String, "tool") - XCTAssertEqual(obj["toolName"] as? String, "get_weather") - XCTAssertEqual(try JSONDecoder().decode(ToolChoice.self, from: data), tool) - } - - // MARK: - streamText: typed TextStreamPart - - /// Streaming text deltas are delivered as typed `TextStreamPart.textDelta` - /// values, reassembling into the full text. - func testTypedStreamTextYieldsStreamParts() throws { - let server = MockHTTPServer(response: .sse(sse(openaiStreamTextEvents))) - try server.start() - defer { server.stop() } - - let model = try chatModel(server.baseURL) - var parts: [TextStreamPart] = [] - var streamErr: (any Error)? - model.streamText( - prompt: .text("Say hello"), - onPart: { parts.append($0) }, - onDone: {}, - onError: { streamErr = $0 } - ) - XCTAssertNil(streamErr) - XCTAssertFalse(parts.isEmpty) - - // The text deltas reassemble to "Hello world". - let text = parts.reduce(into: "") { acc, part in - if case .textDelta(_, let text, _) = part { acc += text } - } - XCTAssertEqual(text, "Hello world") - - // Every emitted part decoded into a concrete TextStreamPart variant; - // the stream opens with start and start-step and closes with - // finish-step and finish. - XCTAssertTrue(parts.contains { if case .textDelta = $0 { true } else { false } }) - XCTAssertEqual(parts.first, .start) - if case .startStep = parts[1] {} else { XCTFail("expected startStep, got \(parts[1])") } - if case .finishStep = parts[parts.count - 2] {} else { XCTFail("expected finishStep") } - if case .finish(let reason, _, _) = parts[parts.count - 1] { - XCTAssertEqual(reason, .stop) - } else { - XCTFail("expected finish") - } - } - - /// Streaming tool-call fragments are delivered as typed tool-related - /// `TextStreamPart`s, including a final `.toolCall` carrying the tool name. - func testTypedStreamTextToolCall() throws { - let server = MockHTTPServer(response: .sse(sse(openaiStreamToolEvents))) - try server.start() - defer { server.stop() } - - let model = try chatModel(server.baseURL) - let options = GenerateTextOptions(tools: [ - .function(FunctionTool( - name: "get_weather", - inputSchema: jv(#"{"type":"object","properties":{"location":{"type":"string"}}}"#) - )) - ]) - var parts: [TextStreamPart] = [] - var streamErr: (any Error)? - model.streamText( - prompt: .text("What's the weather?"), - options: options, - onPart: { parts.append($0) }, - onDone: {}, - onError: { streamErr = $0 } - ) - XCTAssertNil(streamErr) - XCTAssertFalse(parts.isEmpty) - - // At least one tool-related part was emitted. - let hasToolPart = parts.contains { part in - switch part { - case .toolInputStart, .toolInputDelta, .toolInputEnd, .toolCall, .toolResult: - return true - default: return false - } - } - XCTAssertTrue(hasToolPart, "stream should contain a tool-related TextStreamPart") - - // The complete ToolCall part carries the tool name and structured input. - let toolCall = parts.compactMap { part -> (String, JSONValue)? in - if case .toolCall(let call) = part { return (call.toolName, call.input) } - return nil - }.first - XCTAssertEqual(toolCall?.0, "get_weather") - } - - /// The async-sequence typed wrapper yields the same typed parts as the - /// callback form. - func testTypedStreamTextAsyncYieldsStreamParts() async throws { - let server = MockHTTPServer(response: .sse(sse(openaiStreamTextEvents))) - try server.start() - defer { server.stop() } - - let model = try chatModel(server.baseURL) - let parts = try await collect(model.streamTextAsync(prompt: .text("Say hello"))) - XCTAssertFalse(parts.isEmpty) - let text = parts.reduce(into: "") { acc, part in - if case .textDelta(_, let text, _) = part { acc += text } - } - XCTAssertEqual(text, "Hello world") - } - - // MARK: - Codable round-trip - - /// A `GenerateTextResult` decoded from a representative JSON blob - /// round-trips through encode → decode losslessly. - func testGenerateTextResultRoundTrip() throws { - let json = """ - { - "content": [ - {"type":"text","text":"hello"}, - {"type":"tool-call","toolCallId":"call_1","toolName":"get_weather","input":{"location":"Tokyo"}} - ], - "text": "hello", - "reasoning": [{"type":"reasoning","text":"hmm"}], - "reasoningText": "hmm", - "files": [], - "sources": [{"type":"source","sourceType":"url","id":"s1","url":"https://example.com"}], - "toolCalls": [ - {"type":"tool-call","toolCallId":"call_1","toolName":"get_weather","input":{"location":"Tokyo"}} - ], - "toolResults": [], - "finishReason": "tool-calls", - "rawFinishReason": "tool_calls", - "usage": {"inputTokens":3,"inputTokenDetails":{},"outputTokens":2,"outputTokenDetails":{},"totalTokens":5}, - "totalUsage": {"inputTokens":3,"inputTokenDetails":{},"outputTokens":2,"outputTokenDetails":{},"totalTokens":5}, - "warnings": [{"type":"other","message":"careful"}], - "request": {}, - "response": {"id":"resp_1","timestamp":"2026-01-01T00:00:00.000Z","modelId":"gpt-4o","messages":[]}, - "responseMessages": [ - {"role":"assistant","content":[ - {"type":"tool-call","toolCallId":"call_1","toolName":"get_weather","input":{"location":"Tokyo"}} - ]}, - {"role":"tool","content":[ - {"type":"tool-result","toolCallId":"call_1","toolName":"get_weather", - "output":{"type":"json","value":{"temp":22}}} - ]} - ], - "steps": [{"callId":"c1","stepNumber":0}] - } - """ - let original = try JSONDecoder().decode(GenerateTextResult.self, - from: json.data(using: .utf8)!) - XCTAssertEqual(original.text, "hello") - XCTAssertEqual(original.toolCalls[0].toolName, "get_weather") - XCTAssertEqual(original.finishReason, .toolCalls) - XCTAssertEqual(original.rawFinishReason, "tool_calls") - XCTAssertEqual(original.usage.totalTokens, 5) - XCTAssertEqual(original.reasoningText, "hmm") - XCTAssertNil(original.output) - XCTAssertEqual(original.response.id, "resp_1") - XCTAssertEqual(original.response.modelId, "gpt-4o") - XCTAssertEqual(original.warnings, [.other(message: "careful")]) - XCTAssertEqual(original.reasoning, [.reasoning(ReasoningOutput(text: "hmm"))]) - guard case .parts(let toolContent) = original.responseMessages[1].content, - case .toolResult(_, _, let output, _) = toolContent[0] else { - return XCTFail("expected a tool-result part, got \(original.responseMessages[1].content)") - } - XCTAssertEqual(output, .json(.object(["temp": .number(22)]))) - - // Encode → decode yields an equal value. - let reencoded = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(GenerateTextResult.self, from: reencoded) - XCTAssertEqual(original, decoded) - } -} - -// MARK: - Helpers - -/// Drain an `AsyncThrowingStream` into an array (the mock stream completes -/// synchronously inside `streamText`, so this returns promptly). -private func collect(_ stream: AsyncThrowingStream) async throws -> [TextStreamPart] { - var parts: [TextStreamPart] = [] - for try await part in stream { parts.append(part) } - return parts -} - -/// Parse a JSON object string into `[String: Any]` (empty on failure). -private func parseJSON(_ s: String) -> [String: Any] { - guard let data = s.data(using: .utf8), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return [:] - } - return obj -} - -/// Build a `JSONValue` from a JSON string (round-trips through the type's -/// own Codable, so it exercises the same decode path as the wrapper). -private func jv(_ json: String) -> JSONValue { - try! JSONDecoder().decode(JSONValue.self, from: json.data(using: .utf8)!) -} - -/// Build an SSE body from a list of `data:` JSON events, terminated by [DONE]. -private func sse(_ events: [[String: Any]]) -> String { - let body = events.map { ev -> String in - let data = try! JSONSerialization.data(withJSONObject: ev) - return "data: " + (String(data: data, encoding: .utf8) ?? "") - }.joined(separator: "\n\n") - return body + "\n\n" + "data: [DONE]\n\n" -} - -// MARK: - Mock response fixtures (same shape as AimuxTests / Node / Rust e2e) - -private let openaiPlainResponse: [String: Any] = [ - "id": "chatcmpl-test", - "model": "gpt-4o", - "choices": [[ - "message": ["role": "assistant", "content": "Rust is a systems programming language."], - "finish_reason": "stop", - ]], - "usage": ["prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18], -] - -private let openaiToolCallResponse: [String: Any] = [ - "id": "chatcmpl-tc", - "model": "gpt-4o", - "choices": [[ - "message": [ - "role": "assistant", - "content": NSNull(), - "tool_calls": [[ - "id": "call_abc", - "type": "function", - "function": ["name": "get_weather", "arguments": "{\"location\":\"Tokyo\"}"], - ]], - ], - "finish_reason": "tool_calls", - ]], - "usage": ["prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30], -] - -/// OpenAI SSE text deltas → "Hello world". -private let openaiStreamTextEvents: [[String: Any]] = [ - ["id": "1", "model": "gpt-4o", "choices": [["delta": ["content": "Hello"]]]], - ["id": "1", "model": "gpt-4o", "choices": [["delta": ["content": " world"]]]], - [ - "id": "1", "model": "gpt-4o", - "choices": [["delta": [String: Any](), "finish_reason": "stop"]], - "usage": ["prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7], - ], -] - -/// OpenAI SSE tool-call deltas (incremental argument JSON). -private let openaiStreamToolEvents: [[String: Any]] = [ - [ - "id": "1", "model": "gpt-4o", - "choices": [["delta": [ - "role": "assistant", - "tool_calls": [[ - "index": 0, "id": "call_xyz", "type": "function", - "function": ["name": "get_weather", "arguments": ""], - ]], - ]]], - ], - [ - "id": "1", "model": "gpt-4o", - "choices": [["delta": [ - "tool_calls": [[ - "index": 0, - "function": ["arguments": "{\"location\":\"Tokyo\"}"], - ]], - ]]], - ], - [ - "id": "1", "model": "gpt-4o", - "choices": [["delta": [String: Any](), "finish_reason": "tool_calls"]], - "usage": ["prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7], - ], -] diff --git a/bindings/swift/Tests/CAimuxTestABI/include/TestABI.h b/bindings/swift/Tests/CAimuxTestABI/include/TestABI.h new file mode 100644 index 000000000..1e31415cb --- /dev/null +++ b/bindings/swift/Tests/CAimuxTestABI/include/TestABI.h @@ -0,0 +1,13 @@ +#ifndef AIMUX_TEST_ABI_H +#define AIMUX_TEST_ABI_H +#include +uint64_t aimux_test_new_handle(void); +int aimux_test_drops(uint64_t handle); +int aimux_test_active(void); +int aimux_test_strings(void); +int aimux_test_errors(void); +int aimux_test_aborts(void); +int aimux_test_emitted(void); +int aimux_test_main_calls(void); +void aimux_test_version(uint32_t version); +#endif diff --git a/bindings/swift/Tests/CAimuxTestABI/include/module.modulemap b/bindings/swift/Tests/CAimuxTestABI/include/module.modulemap new file mode 100644 index 000000000..b5e425236 --- /dev/null +++ b/bindings/swift/Tests/CAimuxTestABI/include/module.modulemap @@ -0,0 +1,4 @@ +module CAimuxTestABI { + header "TestABI.h" + export * +} diff --git a/bindings/swift/Tests/CAimuxTestABI/mock.c b/bindings/swift/Tests/CAimuxTestABI/mock.c new file mode 100644 index 000000000..90b0727fb --- /dev/null +++ b/bindings/swift/Tests/CAimuxTestABI/mock.c @@ -0,0 +1,122 @@ +/* Test-only ABI implementation. No provider behavior is claimed by these tests. */ +#include "TestABI.h" +#include "../../../../aimux-ffi/include/aimux.h" +#include +#include +#include +#include +#include +#include + +#define BASE UINT64_C(9007199254740992) +#define SLOTS 65536 +static atomic_uint next_id = 1; +static atomic_int cancelled[SLOTS], drops[SLOTS]; +static atomic_int active, strings, errors, aborts, emitted, main_calls; +static atomic_uint version = AIMUX_ABI_VERSION; +struct aimux_error { int32_t code; char *json; }; +static unsigned index_of(uint64_t id) { return id > BASE && id - BASE < SLOTS ? (unsigned)(id - BASE) : 0; } +static void background(void) { if (pthread_main_np()) atomic_fetch_add(&main_calls, 1); } +static char *owned(const char *s) { atomic_fetch_add(&strings, 1); return strdup(s); } +static aimux_error_t *failure(int code, const char *json) { + aimux_error_t *e = malloc(sizeof(*e)); e->code = code; e->json = strdup(json); + atomic_fetch_add(&errors, 1); return e; +} +static const char *unknown = "{\"kind\":\"aimux\",\"code\":1,\"message\":\"未来错误 🌍\",\"error\":{\"name\":\"AI_FutureError\",\"opaque\":{\"newField\":[true,null,7]}},\"retryAfterMs\":123}"; +uint64_t aimux_test_new_handle(void) { return BASE + atomic_fetch_add(&next_id, 1); } +int aimux_test_drops(uint64_t id) { return atomic_load(&drops[index_of(id)]); } +int aimux_test_active(void) { return atomic_load(&active); } +int aimux_test_strings(void) { return atomic_load(&strings); } +int aimux_test_errors(void) { return atomic_load(&errors); } +int aimux_test_aborts(void) { return atomic_load(&aborts); } +int aimux_test_emitted(void) { return atomic_load(&emitted); } +int aimux_test_main_calls(void) { return atomic_load(&main_calls); } +void aimux_test_version(uint32_t v) { atomic_store(&version, v); } +uint32_t aimux_abi_version(void) { return atomic_load(&version); } +aimux_error_t *aimux_manifest_json(char **out) { + char json[256]; + background(); + snprintf(json, sizeof(json), "{\"abiVersion\":%u,\"opsVersion\":\"" AIMUX_OPS_VERSION "\",\"digest\":\"" + AIMUX_MANIFEST_DIGEST "\",\"ops\":[]}", (unsigned)AIMUX_ABI_VERSION); + *out = owned(json); return NULL; +} +aimux_error_t *aimux_call(uint64_t id, const char *op, const char *params, uint64_t abort, char **out) { + background(); *out = NULL; + if (!strcmp(op, AIMUX_OP_ABORT_CREATE) || !strcmp(op, AIMUX_OP_PROVIDER_CREATE)) { + char value[128]; snprintf(value, sizeof(value), "{\"handle\":\"%llu\"}", (unsigned long long)aimux_test_new_handle()); + *out = owned(value); return NULL; + } + if (!strcmp(op, AIMUX_OP_OPERATION_CANCEL)) { + atomic_store(&cancelled[index_of(id)], 1); *out = owned("{}"); return NULL; + } + if (!strcmp(op, "error")) return failure(1, unknown); + if (!strcmp(op, "slow") || !strcmp(op, "wait")) { + atomic_fetch_add(&active, 1); + if (!strcmp(op, "slow")) usleep(150000); + else while (!atomic_load(&cancelled[index_of(abort)])) usleep(1000); + atomic_fetch_sub(&active, 1); + if (!strcmp(op, "wait")) return failure(1, unknown); + } + *out = owned(params); return NULL; +} +aimux_error_t *aimux_stream(uint64_t id, const char *op, const char *params, uint64_t abort, + aimux_event_cb cb, void *ctx) { + (void)id; (void)params; background(); + if (!strcmp(op, "reject")) return failure(1, unknown); + atomic_fetch_add(&active, 1); + unsigned seq = 0; + int malformed = !strcmp(op, "malformed"), missing = !strcmp(op, "missingEnd"); + int oversized = !strcmp(op, "oversized"), error = !strcmp(op, "terminalError") || !strcmp(op, "oneError"); + int count = (!strcmp(op, "many") || !strcmp(op, "wait")) ? 10000 : 4; + if (error) count = !strcmp(op, "oneError") ? 1 : 0; + if (!strcmp(op, "idle")) count = 1; + if (!strcmp(op, "connecting")) count = 0; + for (int i = 0; i < count && !atomic_load(&cancelled[index_of(abort)]); ++i) { + if (malformed && i == 1) { cb("invalid", ctx); break; } + if (oversized) { + char *big = malloc(1048578); memset(big, 'x', 1048577); big[1048577] = 0; + cb(big, ctx); free(big); break; + } + char frame[256]; snprintf(frame, sizeof(frame), "{\"type\":\"event\",\"seq\":\"%u\",\"event\":{\"type\":\"text-delta\",\"text\":\"你好 🌍\",\"n\":%d}}", seq++, i); + atomic_fetch_add(&emitted, 1); cb(frame, ctx); + } + if (!strcmp(op, "idle") || !strcmp(op, "connecting")) { + while (!atomic_load(&cancelled[index_of(abort)])) usleep(1000); + } + if (!missing) { + char end[1024]; + if (error) snprintf(end, sizeof(end), "{\"type\":\"end\",\"seq\":\"%u\",\"outcome\":\"error\",\"error\":%s}", seq, unknown); + else snprintf(end, sizeof(end), "{\"type\":\"end\",\"seq\":\"%u\",\"outcome\":\"%s\"}", seq, + atomic_load(&cancelled[index_of(abort)]) ? "cancelled" : "success"); + cb(end, ctx); + } + atomic_fetch_sub(&active, 1); return NULL; +} +aimux_error_t *aimux_operation_push(uint64_t id, const uint8_t *data, size_t length) { + (void)data; background(); + if (length == 8) { + atomic_fetch_add(&active, 1); + while (!atomic_load(&cancelled[index_of(id)])) usleep(1000); + atomic_fetch_sub(&active, 1); + } + return NULL; +} +aimux_error_t *aimux_operation_next_part(uint64_t id, int64_t timeout, char **out, int32_t *state) { + background(); *out = NULL; *state = 0; + if (timeout == -1) { + atomic_fetch_add(&active, 1); + while (!atomic_load(&cancelled[index_of(id)])) usleep(1000); + atomic_fetch_sub(&active, 1); *state = 2; + } else if (timeout == 0) *state = 3; + else if (timeout == 1) *state = 2; + else { *state = 1; *out = owned("{\"text\":\"hello\"}"); } + return NULL; +} +void aimux_abort(uint64_t abort) { atomic_store(&cancelled[index_of(abort)], 1); atomic_fetch_add(&aborts, 1); } +void aimux_drop(uint64_t id) { background(); if (id) atomic_fetch_add(&drops[index_of(id)], 1); } +void aimux_handle_finalizer(void *token) { aimux_drop((uint64_t)(uintptr_t)token); } +void aimux_free_string(char *s) { if (s) { atomic_fetch_sub(&strings, 1); free(s); } } +void aimux_error_free(aimux_error_t *e) { if (e) { atomic_fetch_sub(&errors, 1); free(e->json); free(e); } } +int32_t aimux_error_code(const aimux_error_t *e) { return e->code; } +char *aimux_error_message(const aimux_error_t *e) { (void)e; return owned("future failure"); } +char *aimux_error_json(const aimux_error_t *e) { return owned(e->json); } diff --git a/bindings/swift/Tests/Standalone/NativeRunner.swift b/bindings/swift/Tests/Standalone/NativeRunner.swift new file mode 100644 index 000000000..f8730e3ff --- /dev/null +++ b/bindings/swift/Tests/Standalone/NativeRunner.swift @@ -0,0 +1,23 @@ +import Foundation + +@main struct NativeRunner { + static func main() async throws { + alarm(60) + let checks: [(String, @Sendable () async throws -> Void)] = [ + ("manifest", { try await NativeSmokeTests.manifest() }), + ("unknownOpEnvelope", { try await NativeSmokeTests.unknownOpEnvelope() }), + ("providerAndModel", { try await NativeSmokeTests.providerAndModel() }), + ("secondaryHandleReference", { try await NativeSmokeTests.secondaryHandleReference() }), + ("decision", { try await NativeSmokeTests.decision() }), + ("terminalErrorAndRejectedStream", { try await NativeSmokeTests.terminalErrorAndRejectedStream() }), + ("primitiveValidation", { try await NativeSmokeTests.primitiveValidation() }), + ("handleResults", { try WireTests.handleResults() }), + ] + for (name, check) in checks { + try await check() + print("PASS \(name)") + } + alarm(0) + print("Passed \(checks.count) native Swift checks") + } +} diff --git a/bindings/swift/Tests/Standalone/Runner.swift b/bindings/swift/Tests/Standalone/Runner.swift new file mode 100644 index 000000000..084ce4d09 --- /dev/null +++ b/bindings/swift/Tests/Standalone/Runner.swift @@ -0,0 +1,47 @@ +import Foundation + +@main struct StandaloneRunner { + static func main() async throws { + alarm(60) + // The same checks used by swift test, runnable when SwiftPM cannot load. + let checks: [(String, @Sendable () async throws -> Void)] = [ + ("manifestRequiresExactVersionAndDigest", { try ContractTests.manifestRequiresExactVersionAndDigest() }), + ("manifestAndHandles", { try await ContractTests.manifestAndHandles() }), + ("versionMismatch", { try await ContractTests.versionMismatch() }), + ("mainActorHeartbeat", { try await ContractTests.mainActorHeartbeat() }), + ("boundedLosslessStream", { try await ContractTests.boundedLosslessStream() }), + ("byteBudgetAndTerminalFullQueue", { try await ContractTests.byteBudgetAndTerminalFullQueue() }), + ("cancelBeforeAcceptanceAndWhileWaiting", { try await ContractTests.cancelBeforeAcceptanceAndWhileWaiting() }), + ("earlyLoopExit", { try await ContractTests.earlyLoopExit() }), + ("cancelFullQueue", { try await ContractTests.cancelFullQueue() }), + ("closeFullQueue", { try await ContractTests.closeFullQueue() }), + ("concurrentCloseAndAdmission", { try await ContractTests.concurrentCloseAndAdmission() }), + ("taskCancellationAbortsCall", { try await ContractTests.taskCancellationAbortsCall() }), + ("unknownErrorPreserved", { try await ContractTests.unknownErrorPreserved() }), + ("rejectedStream", { try await ContractTests.rejectedStream() }), + ("terminalFailureOnce", { try await ContractTests.terminalFailureOnce() }), + ("callbackFailureLatch", { try await ContractTests.callbackFailureLatch() }), + ("missingTerminalFails", { try await ContractTests.missingTerminalFails() }), + ("oversizedFrameAndLimits", { try await ContractTests.oversizedFrameAndLimits() }), + ("secondConsumerRejected", { try await ContractTests.secondConsumerRejected() }), + ("operationPrimitives", { try await ContractTests.operationPrimitives() }), + ("cancelBlockedPushAndPoll", { try await ContractTests.cancelBlockedPushAndPoll() }), + ("handleResults", { try WireTests.handleResults() }), + ("wireValues", { try ContractTests.wireValues() }), + ("testToolCallProviderMetadataRoundTrip", { try WireTests.testToolCallProviderMetadataRoundTrip() }), + ("testUnknownFieldsKeepNullApartFromMissing", { try WireTests.testUnknownFieldsKeepNullApartFromMissing() }), + ("testGeneratedFilesAreBase64AndMediaType", { try WireTests.testGeneratedFilesAreBase64AndMediaType() }), + ("testTextStreamToolPartsAndUsageAreCamelCase", { try WireTests.testTextStreamToolPartsAndUsageAreCamelCase() }), + ("testTextStreamDeltasAreText", { try WireTests.testTextStreamDeltasAreText() }), + ("testFilePartsAreTheAiSdkFilePart", { try WireTests.testFilePartsAreTheAiSdkFilePart() }), + ("testToolChoiceCodableWire", { try WireTests.testToolChoiceCodableWire() }), + ("testGenerateTextResultRoundTrip", { try WireTests.testGenerateTextResultRoundTrip() }), + ] + for (name, check) in checks { + try await check() + print("PASS \(name)") + } + alarm(0) + print("Passed \(checks.count) Swift ABI contract checks") + } +} diff --git a/bindings/swift/generated/AimuxOps.swift b/bindings/swift/generated/AimuxOps.swift new file mode 100644 index 000000000..47f3a3cd6 --- /dev/null +++ b/bindings/swift/generated/AimuxOps.swift @@ -0,0 +1,78 @@ +// Generated by aimux-codegen. Do not edit. Manifest SHA-256: 2bc7cb7ec77a0ed20496411f0dbd199b949b9887cc74d119e11627c8573d82a2 +public enum AimuxOps { + public static let ABI_VERSION: UInt32 = 65536 + public static let OPS_VERSION = "1.0" + public static let MANIFEST_DIGEST = "2bc7cb7ec77a0ed20496411f0dbd199b949b9887cc74d119e11627c8573d82a2" + public static let OP_ABORT_CREATE = "abort.create" + public static let OP_ABORT_SIGNAL = "abort.signal" + public static let OP_AUTH_CODEX_REFRESH = "auth.codexRefresh" + public static let OP_CATALOGUE_FETCH = "catalogue.fetch" + public static let OP_COMPOSITE_MOA = "composite.moa" + public static let OP_COMPOSITE_ROUTER = "composite.router" + public static let OP_CREDENTIAL_CREATE = "credential.create" + public static let OP_DECISION_CAPABILITIES = "decision.capabilities" + public static let OP_DECISION_DECIDE = "decision.decide" + public static let OP_EMBEDDING_EMBED = "embedding.embed" + public static let OP_EMBEDDING_EMBED_MANY = "embedding.embedMany" + public static let OP_FILES_UPLOAD = "files.upload" + public static let OP_HANDLE_DROP = "handle.drop" + public static let OP_IMAGE_GENERATE = "image.generate" + public static let OP_MIDDLEWARE_CREATE = "middleware.create" + public static let OP_MODEL_INFO = "model.info" + public static let OP_MODEL_RESOLVE = "model.resolve" + public static let OP_MODEL_WRAP = "model.wrap" + public static let OP_OPERATION_CANCEL = "operation.cancel" + public static let OP_OPERATION_INPUT_DONE = "operation.inputDone" + public static let OP_OPERATION_NEXT = "operation.next" + public static let OP_OPERATION_PUSH = "operation.push" + public static let OP_OPERATION_STATUS = "operation.status" + public static let OP_OUTPUT_TO_OPEN_AI = "output.toOpenAI" + public static let OP_PROVIDER_CREATE = "provider.create" + public static let OP_PROVIDER_CUSTOM = "provider.custom" + public static let OP_PROVIDER_DEFAULT = "provider.default" + public static let OP_PROVIDER_FILES = "provider.files" + public static let OP_PROVIDER_INVOKE = "provider.invoke" + public static let OP_PROVIDER_MODEL = "provider.model" + public static let OP_PROVIDER_WRAP = "provider.wrap" + public static let OP_RECORDER_ATTACH = "recorder.attach" + public static let OP_RECORDER_CREATE = "recorder.create" + public static let OP_RECORDER_EXPORT = "recorder.export" + public static let OP_RECORDER_FLUSH = "recorder.flush" + public static let OP_RECORDER_STATS = "recorder.stats" + public static let OP_RECORDER_STOP = "recorder.stop" + public static let OP_REGISTRY_CREATE = "registry.create" + public static let OP_REGISTRY_PROVIDER = "registry.provider" + public static let OP_REPLAY_CAPABILITIES = "replay.capabilities" + public static let OP_REPLAY_CREATE = "replay.create" + public static let OP_REPLAY_DRY_RUN = "replay.dryRun" + public static let OP_REPLAY_INSPECT = "replay.inspect" + public static let OP_REPLAY_LIVE = "replay.live" + public static let OP_RERANKING_RERANK = "reranking.rerank" + public static let OP_RUNTIME_CONFIGURE = "runtime.configure" + public static let OP_RUNTIME_DESCRIBE = "runtime.describe" + public static let OP_SEARCH_SEARCH = "search.search" + public static let OP_SESSION_STORE_ATTACH = "sessionStore.attach" + public static let OP_SESSION_STORE_CALLS = "sessionStore.calls" + public static let OP_SESSION_STORE_CREATE = "sessionStore.create" + public static let OP_SESSION_STORE_LIST = "sessionStore.list" + public static let OP_SPEECH_GENERATE = "speech.generate" + public static let OP_TEXT_CONSUME = "text.consume" + public static let OP_TEXT_GENERATE = "text.generate" + public static let OP_TEXT_GENERATE_OBJECT = "text.generateObject" + public static let OP_TEXT_GENERATE_OPEN_AI = "text.generateOpenAI" + public static let OP_TEXT_STREAM = "text.stream" + public static let OP_TEXT_STREAM_OPEN_AI = "text.streamOpenAI" + public static let OP_TOOL_REPAIR_APPLY = "toolRepair.apply" + public static let OP_TOOL_REPAIR_APPLY_RESULT = "toolRepair.applyResult" + public static let OP_TOOL_REPAIR_CONTEXT = "toolRepair.context" + public static let OP_TRACE_AGGREGATE = "trace.aggregate" + public static let OP_TRACE_CLEAR = "trace.clear" + public static let OP_TRACE_CREATE = "trace.create" + public static let OP_TRACE_EXPORT = "trace.export" + public static let OP_TRACE_SESSION_CHAIN = "trace.sessionChain" + public static let OP_TRACE_SESSION_TRAJECTORY = "trace.sessionTrajectory" + public static let OP_TRANSCRIPTION_GENERATE = "transcription.generate" + public static let OP_TRANSCRIPTION_START = "transcription.start" + public static let OP_TRANSPORT_CREATE = "transport.create" + public static let OP_VIDEO_GENERATE = "video.generate" +} diff --git a/bindings/swift/scripts/test-native-standalone.sh b/bindings/swift/scripts/test-native-standalone.sh new file mode 100755 index 000000000..65021d46b --- /dev/null +++ b/bindings/swift/scripts/test-native-standalone.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Runs the real-library checks directly when SwiftPM is unavailable. +set -euo pipefail +cd "$(dirname "$0")/.." +: "${AIMUX_LIBRARY_DIR:?Set AIMUX_LIBRARY_DIR to the directory containing aimux_ffi}" +mkdir -p .build/native .build/native-module-cache +sdk="${AIMUX_SWIFT_SDK:-$(xcrun --sdk macosx --show-sdk-path)}" +flags=(-sdk "$sdk" -target "$(uname -m)-apple-macosx12.0" -module-cache-path "$PWD/.build/native-module-cache" -swift-version 6 -strict-concurrency=complete -warnings-as-errors) +swiftc "${flags[@]}" -emit-library -emit-module -enable-testing -module-name Aimux -I Sources/CAimuxFFI Sources/Aimux/*.swift generated/AimuxOps.swift -L "$AIMUX_LIBRARY_DIR" -laimux_ffi -Xlinker -rpath -Xlinker "$AIMUX_LIBRARY_DIR" -emit-module-path .build/native/Aimux.swiftmodule -o .build/native/libAimux.dylib +swiftc "${flags[@]}" -D AIMUX_STANDALONE -parse-as-library -I .build/native -I Sources/CAimuxFFI -L .build/native -lAimux -Xlinker -rpath -Xlinker "$PWD/.build/native" Tests/AimuxTests/{ContractTests,WireTests,NativeSmokeTests}.swift Tests/Standalone/NativeRunner.swift -o .build/native/NativeTests +.build/native/NativeTests diff --git a/bindings/swift/scripts/test-standalone.sh b/bindings/swift/scripts/test-standalone.sh new file mode 100755 index 000000000..9b598d1cc --- /dev/null +++ b/bindings/swift/scripts/test-standalone.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Runs exactly the contract checks used by SwiftPM, with the test-only C ABI. +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p .build/standalone .build/module-cache +sdk="${AIMUX_SWIFT_SDK:-$(xcrun --sdk macosx --show-sdk-path)}" +flags=(-sdk "$sdk" -target "$(uname -m)-apple-macosx12.0" -module-cache-path "$PWD/.build/module-cache" -swift-version 6 -strict-concurrency=complete -warnings-as-errors) +clang -isysroot "$sdk" -mmacosx-version-min=12.0 -I Tests/CAimuxTestABI/include -c Tests/CAimuxTestABI/mock.c -o .build/standalone/mock.o +swiftc "${flags[@]}" -emit-library -emit-module -enable-testing -module-name Aimux -I Sources/CAimuxFFI Sources/Aimux/*.swift generated/AimuxOps.swift .build/standalone/mock.o -emit-module-path .build/standalone/Aimux.swiftmodule -o .build/standalone/libAimux.dylib +swiftc "${flags[@]}" -D AIMUX_STANDALONE -parse-as-library -I .build/standalone -I Sources/CAimuxFFI -I Tests/CAimuxTestABI/include -L .build/standalone -lAimux -Xlinker -rpath -Xlinker "$PWD/.build/standalone" Tests/AimuxTests/{ContractTests,WireTests}.swift Tests/Standalone/Runner.swift -o .build/standalone/ContractTests +.build/standalone/ContractTests diff --git a/contract-tests/ops/errors/aimux-aborted.json b/contract-tests/ops/errors/aimux-aborted.json new file mode 100644 index 000000000..8e7a4082a --- /dev/null +++ b/contract-tests/ops/errors/aimux-aborted.json @@ -0,0 +1,9 @@ +{ + "kind": "aimux", + "code": 13, + "message": "request aborted", + "error": { + "name": "AbortError", + "message": "request aborted" + } +} diff --git a/contract-tests/ops/errors/aimux-api-call.json b/contract-tests/ops/errors/aimux-api-call.json new file mode 100644 index 000000000..51ab4ee4d --- /dev/null +++ b/contract-tests/ops/errors/aimux-api-call.json @@ -0,0 +1,22 @@ +{ + "kind": "aimux", + "code": 11, + "message": "API call error: HTTP 429: slow down", + "error": { + "name": "AI_APICallError", + "url": "https://example.test/v1", + "requestBodyValues": { + "input": null + }, + "statusCode": 429, + "providerCode": "rate_limit", + "message": "slow down", + "responseBody": "{\"error\":null}", + "responseHeaders": { + "retry-after-ms": "1500" + }, + "data": null, + "isRetryable": true + }, + "retryAfterMs": 1500 +} diff --git a/contract-tests/ops/errors/aimux-invalid-argument.json b/contract-tests/ops/errors/aimux-invalid-argument.json new file mode 100644 index 000000000..70efdb5be --- /dev/null +++ b/contract-tests/ops/errors/aimux-invalid-argument.json @@ -0,0 +1,9 @@ +{ + "kind": "aimux", + "code": 5, + "message": "invalid argument: bad arg", + "error": { + "name": "AI_InvalidArgumentError", + "message": "bad arg" + } +} diff --git a/contract-tests/ops/errors/aimux-invalid-prompt.json b/contract-tests/ops/errors/aimux-invalid-prompt.json new file mode 100644 index 000000000..b1aba438d --- /dev/null +++ b/contract-tests/ops/errors/aimux-invalid-prompt.json @@ -0,0 +1,9 @@ +{ + "kind": "aimux", + "code": 6, + "message": "invalid prompt: bad prompt", + "error": { + "name": "AI_InvalidPromptError", + "message": "bad prompt" + } +} diff --git a/contract-tests/ops/errors/aimux-invalid-response-data.json b/contract-tests/ops/errors/aimux-invalid-response-data.json new file mode 100644 index 000000000..f424cd1a5 --- /dev/null +++ b/contract-tests/ops/errors/aimux-invalid-response-data.json @@ -0,0 +1,9 @@ +{ + "kind": "aimux", + "code": 3, + "message": "invalid response data: eof", + "error": { + "name": "AI_InvalidResponseDataError", + "message": "eof" + } +} diff --git a/contract-tests/ops/errors/aimux-invalid-tool-input.json b/contract-tests/ops/errors/aimux-invalid-tool-input.json new file mode 100644 index 000000000..abebbc314 --- /dev/null +++ b/contract-tests/ops/errors/aimux-invalid-tool-input.json @@ -0,0 +1,11 @@ +{ + "kind": "aimux", + "code": 16, + "message": "Invalid input for tool weather: JSON parsing failed", + "error": { + "name": "AI_InvalidToolInputError", + "toolName": "weather", + "toolInput": "{", + "cause": "JSON parsing failed" + } +} diff --git a/contract-tests/ops/errors/aimux-json-parse.json b/contract-tests/ops/errors/aimux-json-parse.json new file mode 100644 index 000000000..3c0ec2a13 --- /dev/null +++ b/contract-tests/ops/errors/aimux-json-parse.json @@ -0,0 +1,9 @@ +{ + "kind": "aimux", + "code": 2, + "message": "JSON parse error: bad json", + "error": { + "name": "AI_JSONParseError", + "message": "bad json" + } +} diff --git a/contract-tests/ops/errors/aimux-load-api-key.json b/contract-tests/ops/errors/aimux-load-api-key.json new file mode 100644 index 000000000..b9c7cc74a --- /dev/null +++ b/contract-tests/ops/errors/aimux-load-api-key.json @@ -0,0 +1,10 @@ +{ + "kind": "aimux", + "code": 19, + "message": "No API key found for OpenAI. Please provide it via the `api_key` parameter or set the `OPENAI_API_KEY` environment variable.", + "error": { + "name": "AI_LoadAPIKeyError", + "envVar": "OPENAI_API_KEY", + "description": "OpenAI" + } +} diff --git a/contract-tests/ops/errors/aimux-load-setting.json b/contract-tests/ops/errors/aimux-load-setting.json new file mode 100644 index 000000000..a4b497820 --- /dev/null +++ b/contract-tests/ops/errors/aimux-load-setting.json @@ -0,0 +1,10 @@ +{ + "kind": "aimux", + "code": 20, + "message": "No value found for setting `region`. Please provide it via the `region` parameter or set the `AWS_REGION` environment variable.", + "error": { + "name": "AI_LoadSettingError", + "envVar": "AWS_REGION", + "settingName": "region" + } +} diff --git a/contract-tests/ops/errors/aimux-missing-tool-results.json b/contract-tests/ops/errors/aimux-missing-tool-results.json new file mode 100644 index 000000000..8aedbe6a9 --- /dev/null +++ b/contract-tests/ops/errors/aimux-missing-tool-results.json @@ -0,0 +1,11 @@ +{ + "kind": "aimux", + "code": 6, + "message": "Tool result is missing for tool call call-1.", + "error": { + "name": "AI_MissingToolResultsError", + "toolCallIds": [ + "call-1" + ] + } +} diff --git a/contract-tests/ops/errors/aimux-no-output-generated.json b/contract-tests/ops/errors/aimux-no-output-generated.json new file mode 100644 index 000000000..ae43e7965 --- /dev/null +++ b/contract-tests/ops/errors/aimux-no-output-generated.json @@ -0,0 +1,9 @@ +{ + "kind": "aimux", + "code": 18, + "message": "No output generated.", + "error": { + "name": "AI_NoOutputGeneratedError", + "message": "No output generated." + } +} diff --git a/contract-tests/ops/errors/aimux-no-such-model.json b/contract-tests/ops/errors/aimux-no-such-model.json new file mode 100644 index 000000000..26cb1c8f8 --- /dev/null +++ b/contract-tests/ops/errors/aimux-no-such-model.json @@ -0,0 +1,10 @@ +{ + "kind": "aimux", + "code": 9, + "message": "no such model: gpt-9", + "error": { + "name": "AI_NoSuchModelError", + "modelId": "gpt-9", + "modelType": "languageModel" + } +} diff --git a/contract-tests/ops/errors/aimux-no-such-provider.json b/contract-tests/ops/errors/aimux-no-such-provider.json new file mode 100644 index 000000000..8edb4ac47 --- /dev/null +++ b/contract-tests/ops/errors/aimux-no-such-provider.json @@ -0,0 +1,14 @@ +{ + "kind": "aimux", + "code": 10, + "message": "No such provider: acme (available providers: other)", + "error": { + "name": "AI_NoSuchProviderError", + "providerId": "acme", + "modelId": "acme:model", + "modelType": "languageModel", + "availableProviders": [ + "other" + ] + } +} diff --git a/contract-tests/ops/errors/aimux-no-such-tool.json b/contract-tests/ops/errors/aimux-no-such-tool.json new file mode 100644 index 000000000..08a6c77cb --- /dev/null +++ b/contract-tests/ops/errors/aimux-no-such-tool.json @@ -0,0 +1,14 @@ +{ + "kind": "aimux", + "code": 15, + "message": "Model tried to call unavailable tool 'weathr'. Available tools: weather, search.", + "error": { + "name": "AI_NoSuchToolError", + "toolName": "weathr", + "availableTools": [ + "weather", + "search" + ], + "toolInput": "{\"city\":\"上海\"}" + } +} diff --git a/contract-tests/ops/errors/aimux-other.json b/contract-tests/ops/errors/aimux-other.json new file mode 100644 index 000000000..cebceaa11 --- /dev/null +++ b/contract-tests/ops/errors/aimux-other.json @@ -0,0 +1,9 @@ +{ + "kind": "aimux", + "code": 1, + "message": "misc", + "error": { + "name": "AI_Error", + "message": "misc" + } +} diff --git a/contract-tests/ops/errors/aimux-retry.json b/contract-tests/ops/errors/aimux-retry.json new file mode 100644 index 000000000..f5ca950e1 --- /dev/null +++ b/contract-tests/ops/errors/aimux-retry.json @@ -0,0 +1,27 @@ +{ + "kind": "aimux", + "code": 14, + "message": "Failed after 1 attempts. Last error: slow down", + "error": { + "name": "AI_RetryError", + "reason": "maxRetriesExceeded", + "errors": [ + { + "name": "AI_APICallError", + "url": "https://example.test/v1", + "requestBodyValues": { + "input": null + }, + "statusCode": 429, + "providerCode": "rate_limit", + "message": "slow down", + "responseBody": "{\"error\":null}", + "responseHeaders": { + "retry-after-ms": "1500" + }, + "data": null, + "isRetryable": true + } + ] + } +} diff --git a/contract-tests/ops/errors/aimux-timeout.json b/contract-tests/ops/errors/aimux-timeout.json new file mode 100644 index 000000000..27e6e84c4 --- /dev/null +++ b/contract-tests/ops/errors/aimux-timeout.json @@ -0,0 +1,9 @@ +{ + "kind": "aimux", + "code": 12, + "message": "request timed out: total timeout", + "error": { + "name": "TimeoutError", + "message": "total timeout" + } +} diff --git a/contract-tests/ops/errors/aimux-token-expired.json b/contract-tests/ops/errors/aimux-token-expired.json new file mode 100644 index 000000000..7d6b58954 --- /dev/null +++ b/contract-tests/ops/errors/aimux-token-expired.json @@ -0,0 +1,9 @@ +{ + "kind": "aimux", + "code": 7, + "message": "token expired: expired", + "error": { + "name": "AI_TokenExpiredError", + "message": "expired" + } +} diff --git a/contract-tests/ops/errors/aimux-tool-call-not-found-for-approval.json b/contract-tests/ops/errors/aimux-tool-call-not-found-for-approval.json new file mode 100644 index 000000000..3abc0f442 --- /dev/null +++ b/contract-tests/ops/errors/aimux-tool-call-not-found-for-approval.json @@ -0,0 +1,10 @@ +{ + "kind": "aimux", + "code": 3, + "message": "Tool call \"call-1\" not found for approval request \"approval-1\".", + "error": { + "name": "AI_ToolCallNotFoundForApprovalError", + "toolCallId": "call-1", + "approvalId": "approval-1" + } +} diff --git a/contract-tests/ops/errors/aimux-tool-call-repair.json b/contract-tests/ops/errors/aimux-tool-call-repair.json new file mode 100644 index 000000000..68a6f8d1c --- /dev/null +++ b/contract-tests/ops/errors/aimux-tool-call-repair.json @@ -0,0 +1,16 @@ +{ + "kind": "aimux", + "code": 17, + "message": "Error repairing tool call: repair failed", + "error": { + "name": "AI_ToolCallRepairError", + "originalError": { + "name": "AI_NoSuchToolError", + "toolName": "weathr" + }, + "cause": { + "name": "AI_Error", + "message": "repair failed" + } + } +} diff --git a/contract-tests/ops/errors/aimux-tool-choice-violation.json b/contract-tests/ops/errors/aimux-tool-choice-violation.json new file mode 100644 index 000000000..3dcd82ea7 --- /dev/null +++ b/contract-tests/ops/errors/aimux-tool-choice-violation.json @@ -0,0 +1,21 @@ +{ + "kind": "aimux", + "code": 3, + "message": "Model response did not contain a call to the required tool 'weather'.", + "error": { + "name": "AI_ToolChoiceViolationError", + "toolChoice": { + "type": "tool", + "toolName": "weather" + }, + "finishReason": "stop", + "provider": "openai.chat", + "modelId": "gpt-4o", + "content": [ + { + "type": "text", + "text": "sunny" + } + ] + } +} diff --git a/contract-tests/ops/errors/aimux-unsupported-functionality.json b/contract-tests/ops/errors/aimux-unsupported-functionality.json new file mode 100644 index 000000000..ef8f0e25b --- /dev/null +++ b/contract-tests/ops/errors/aimux-unsupported-functionality.json @@ -0,0 +1,9 @@ +{ + "kind": "aimux", + "code": 8, + "message": "unsupported functionality: no audio", + "error": { + "name": "AI_UnsupportedFunctionalityError", + "message": "no audio" + } +} diff --git a/contract-tests/ops/errors/binding-callback-failure.json b/contract-tests/ops/errors/binding-callback-failure.json new file mode 100644 index 000000000..308db1663 --- /dev/null +++ b/contract-tests/ops/errors/binding-callback-failure.json @@ -0,0 +1,9 @@ +{ + "kind": "binding", + "code": 206, + "message": "host callback failed: callback stopped", + "error": { + "name": "CallbackFailure", + "message": "callback stopped" + } +} diff --git a/contract-tests/ops/errors/binding-invalid-handle.json b/contract-tests/ops/errors/binding-invalid-handle.json new file mode 100644 index 000000000..6a5ae87bb --- /dev/null +++ b/contract-tests/ops/errors/binding-invalid-handle.json @@ -0,0 +1,13 @@ +{ + "kind": "binding", + "code": 203, + "message": "invalid or expired handle: expected [Language, Embedding], actual Some(Provider)", + "error": { + "name": "InvalidHandle", + "expected": [ + "Language", + "Embedding" + ], + "actual": "Provider" + } +} diff --git a/contract-tests/ops/errors/binding-invalid-utf8.json b/contract-tests/ops/errors/binding-invalid-utf8.json new file mode 100644 index 000000000..0f9a029ae --- /dev/null +++ b/contract-tests/ops/errors/binding-invalid-utf8.json @@ -0,0 +1,9 @@ +{ + "kind": "binding", + "code": 201, + "message": "op: must be valid UTF-8", + "error": { + "name": "InvalidUtf8", + "argument": "op" + } +} diff --git a/contract-tests/ops/errors/binding-invalid-wire-json.json b/contract-tests/ops/errors/binding-invalid-wire-json.json new file mode 100644 index 000000000..ca410b6c0 --- /dev/null +++ b/contract-tests/ops/errors/binding-invalid-wire-json.json @@ -0,0 +1,10 @@ +{ + "kind": "binding", + "code": 202, + "message": "params_json: invalid JSON: expected object", + "error": { + "name": "InvalidWireJson", + "argument": "params_json", + "message": "expected object" + } +} diff --git a/contract-tests/ops/errors/binding-null-pointer.json b/contract-tests/ops/errors/binding-null-pointer.json new file mode 100644 index 000000000..d29c6268d --- /dev/null +++ b/contract-tests/ops/errors/binding-null-pointer.json @@ -0,0 +1,9 @@ +{ + "kind": "binding", + "code": 200, + "message": "params_json: must not be NULL", + "error": { + "name": "NullPointer", + "argument": "params_json" + } +} diff --git a/contract-tests/ops/errors/binding-reentrant-call.json b/contract-tests/ops/errors/binding-reentrant-call.json new file mode 100644 index 000000000..975f10bbd --- /dev/null +++ b/contract-tests/ops/errors/binding-reentrant-call.json @@ -0,0 +1,8 @@ +{ + "kind": "binding", + "code": 204, + "message": "re-entrant FFI call from within a callback is not allowed", + "error": { + "name": "ReentrantCall" + } +} diff --git a/contract-tests/ops/errors/binding-result-serialization.json b/contract-tests/ops/errors/binding-result-serialization.json new file mode 100644 index 000000000..90fcc9646 --- /dev/null +++ b/contract-tests/ops/errors/binding-result-serialization.json @@ -0,0 +1,9 @@ +{ + "kind": "binding", + "code": 205, + "message": "could not serialize result: invalid result", + "error": { + "name": "ResultSerialization", + "message": "invalid result" + } +} diff --git a/contract-tests/ops/errors/protocol-concurrent-consumer.json b/contract-tests/ops/errors/protocol-concurrent-consumer.json new file mode 100644 index 000000000..e6dcfbbf7 --- /dev/null +++ b/contract-tests/ops/errors/protocol-concurrent-consumer.json @@ -0,0 +1,8 @@ +{ + "kind": "protocol", + "code": 303, + "message": "operation already has a consumer", + "error": { + "name": "ConcurrentConsumer" + } +} diff --git a/contract-tests/ops/errors/protocol-handle-exhausted.json b/contract-tests/ops/errors/protocol-handle-exhausted.json new file mode 100644 index 000000000..4c8e76dfb --- /dev/null +++ b/contract-tests/ops/errors/protocol-handle-exhausted.json @@ -0,0 +1,8 @@ +{ + "kind": "protocol", + "code": 306, + "message": "handle IDs exhausted", + "error": { + "name": "HandleExhausted" + } +} diff --git a/contract-tests/ops/errors/protocol-invalid-op.json b/contract-tests/ops/errors/protocol-invalid-op.json new file mode 100644 index 000000000..56bf5853d --- /dev/null +++ b/contract-tests/ops/errors/protocol-invalid-op.json @@ -0,0 +1,9 @@ +{ + "kind": "protocol", + "code": 300, + "message": "invalid op: missing.op", + "error": { + "name": "InvalidOp", + "message": "missing.op" + } +} diff --git a/contract-tests/ops/errors/protocol-invalid-params.json b/contract-tests/ops/errors/protocol-invalid-params.json new file mode 100644 index 000000000..dfb677181 --- /dev/null +++ b/contract-tests/ops/errors/protocol-invalid-params.json @@ -0,0 +1,9 @@ +{ + "kind": "protocol", + "code": 301, + "message": "invalid params: expected object", + "error": { + "name": "InvalidParams", + "message": "expected object" + } +} diff --git a/contract-tests/ops/errors/protocol-invalid-state.json b/contract-tests/ops/errors/protocol-invalid-state.json new file mode 100644 index 000000000..27b185ea0 --- /dev/null +++ b/contract-tests/ops/errors/protocol-invalid-state.json @@ -0,0 +1,9 @@ +{ + "kind": "protocol", + "code": 304, + "message": "invalid state: already finished", + "error": { + "name": "InvalidState", + "message": "already finished" + } +} diff --git a/contract-tests/ops/errors/protocol-resource-limit.json b/contract-tests/ops/errors/protocol-resource-limit.json new file mode 100644 index 000000000..31107ad23 --- /dev/null +++ b/contract-tests/ops/errors/protocol-resource-limit.json @@ -0,0 +1,9 @@ +{ + "kind": "protocol", + "code": 305, + "message": "resource limit: input queue full", + "error": { + "name": "ResourceLimit", + "message": "input queue full" + } +} diff --git a/contract-tests/ops/errors/protocol-unsupported-ops-version.json b/contract-tests/ops/errors/protocol-unsupported-ops-version.json new file mode 100644 index 000000000..4dce97533 --- /dev/null +++ b/contract-tests/ops/errors/protocol-unsupported-ops-version.json @@ -0,0 +1,9 @@ +{ + "kind": "protocol", + "code": 302, + "message": "unsupported ops version: 2.0", + "error": { + "name": "UnsupportedOpsVersion", + "message": "2.0" + } +} diff --git a/contract-tests/ops/errors/recording-flush-timeout.json b/contract-tests/ops/errors/recording-flush-timeout.json new file mode 100644 index 000000000..9ddd50563 --- /dev/null +++ b/contract-tests/ops/errors/recording-flush-timeout.json @@ -0,0 +1,8 @@ +{ + "kind": "recording", + "code": 104, + "message": "recording flush timed out", + "error": { + "name": "FlushTimeout" + } +} diff --git a/contract-tests/ops/errors/recording-init.json b/contract-tests/ops/errors/recording-init.json new file mode 100644 index 000000000..708631a9e --- /dev/null +++ b/contract-tests/ops/errors/recording-init.json @@ -0,0 +1,10 @@ +{ + "kind": "recording", + "code": 100, + "message": "recording init failed for recordings/session.jsonl: permission denied", + "error": { + "name": "Init", + "path": "recordings/session.jsonl", + "source": "permission denied" + } +} diff --git a/contract-tests/ops/errors/recording-open-file.json b/contract-tests/ops/errors/recording-open-file.json new file mode 100644 index 000000000..c0cc5bc3c --- /dev/null +++ b/contract-tests/ops/errors/recording-open-file.json @@ -0,0 +1,10 @@ +{ + "kind": "recording", + "code": 101, + "message": "failed to open recording file recordings/session.jsonl: permission denied", + "error": { + "name": "OpenFile", + "path": "recordings/session.jsonl", + "source": "permission denied" + } +} diff --git a/contract-tests/ops/errors/recording-spawn.json b/contract-tests/ops/errors/recording-spawn.json new file mode 100644 index 000000000..c39d5ddd4 --- /dev/null +++ b/contract-tests/ops/errors/recording-spawn.json @@ -0,0 +1,9 @@ +{ + "kind": "recording", + "code": 102, + "message": "failed to spawn recording writer thread: thread limit", + "error": { + "name": "Spawn", + "source": "thread limit" + } +} diff --git a/contract-tests/ops/errors/recording-write.json b/contract-tests/ops/errors/recording-write.json new file mode 100644 index 000000000..b7cb31aba --- /dev/null +++ b/contract-tests/ops/errors/recording-write.json @@ -0,0 +1,9 @@ +{ + "kind": "recording", + "code": 105, + "message": "recording write failed: disk full", + "error": { + "name": "Write", + "message": "disk full" + } +} diff --git a/contract-tests/ops/errors/recording-writer-gone.json b/contract-tests/ops/errors/recording-writer-gone.json new file mode 100644 index 000000000..75e6ded77 --- /dev/null +++ b/contract-tests/ops/errors/recording-writer-gone.json @@ -0,0 +1,8 @@ +{ + "kind": "recording", + "code": 103, + "message": "recording writer thread unavailable", + "error": { + "name": "WriterGone" + } +} diff --git a/contract-tests/ops/errors/unknown-aimux.json b/contract-tests/ops/errors/unknown-aimux.json new file mode 100644 index 000000000..5c9f81895 --- /dev/null +++ b/contract-tests/ops/errors/unknown-aimux.json @@ -0,0 +1,18 @@ +{ + "kind": "aimux", + "code": 21, + "message": "future error", + "error": { + "name": "AI_FutureError", + "providerData": { + "explicitNull": null, + "nested": [ + 1, + { + "name": "FutureCause", + "value": "保留 😀" + } + ] + } + } +} diff --git a/contract-tests/ops/scenarios/accepted.json b/contract-tests/ops/scenarios/accepted.json new file mode 100644 index 000000000..9e891c4b4 --- /dev/null +++ b/contract-tests/ops/scenarios/accepted.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": 1, + "name": "accepted", + "description": "Acceptance is a successful stream return, never an accepted callback frame; empty accepted source ends explicitly.", + "boundary": "eventStream", + "queueLimits": { + "items": 256, + "bytes": 8388608, + "maxEventBytes": 1048576 + }, + "script": [ + { + "action": "eof" + } + ], + "controls": [ + { + "action": "drain" + } + ], + "expected": { + "accepted": true, + "preflightError": null, + "frames": [ + { + "type": "end", + "seq": "0", + "outcome": "success" + } + ], + "consumerFrames": [ + { + "type": "end", + "seq": "0", + "outcome": "success" + } + ], + "abortObserved": null, + "queueFullObserved": false, + "consumerClosed": false, + "sourceStarted": true, + "sourceEventsPolled": 0 + } +} diff --git a/contract-tests/ops/scenarios/cancel.json b/contract-tests/ops/scenarios/cancel.json new file mode 100644 index 000000000..b488fe243 --- /dev/null +++ b/contract-tests/ops/scenarios/cancel.json @@ -0,0 +1,88 @@ +{ + "schemaVersion": 1, + "name": "cancel", + "description": "Cancel interrupts a pending source after delivered data; resumed next observes exactly one cancelled end.", + "boundary": "eventStream", + "queueLimits": { + "items": 256, + "bytes": 8388608, + "maxEventBytes": 1048576 + }, + "script": [ + { + "action": "event", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "action": "wait", + "gate": "continue" + }, + { + "action": "eof" + } + ], + "controls": [ + { + "action": "read", + "count": 1 + }, + { + "action": "waitForSourceGate", + "gate": "continue" + }, + { + "action": "cancel" + }, + { + "action": "cancel" + }, + { + "action": "drain" + } + ], + "expected": { + "accepted": true, + "preflightError": null, + "frames": [ + { + "type": "event", + "seq": "0", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "end", + "seq": "1", + "outcome": "cancelled" + } + ], + "consumerFrames": [ + { + "type": "event", + "seq": "0", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "end", + "seq": "1", + "outcome": "cancelled" + } + ], + "abortObserved": true, + "queueFullObserved": false, + "consumerClosed": false, + "sourceStarted": true, + "sourceEventsPolled": 1 + } +} diff --git a/contract-tests/ops/scenarios/duplicate-finish.json b/contract-tests/ops/scenarios/duplicate-finish.json new file mode 100644 index 000000000..7d9d6f639 --- /dev/null +++ b/contract-tests/ops/scenarios/duplicate-finish.json @@ -0,0 +1,114 @@ +{ + "schemaVersion": 1, + "name": "duplicate-finish", + "description": "Two finish data parts are preserved without generating two control ends.", + "boundary": "eventStream", + "queueLimits": { + "items": 256, + "bytes": 8388608, + "maxEventBytes": 1048576 + }, + "script": [ + { + "action": "event", + "event": { + "type": "finish", + "finishReason": "stop", + "totalUsage": { + "inputTokens": {}, + "outputTokens": {} + } + } + }, + { + "action": "event", + "event": { + "type": "finish", + "finishReason": "stop", + "totalUsage": { + "inputTokens": {}, + "outputTokens": {} + } + } + }, + { + "action": "eof" + } + ], + "controls": [ + { + "action": "drain" + } + ], + "expected": { + "accepted": true, + "preflightError": null, + "frames": [ + { + "type": "event", + "seq": "0", + "event": { + "type": "finish", + "finishReason": "stop", + "totalUsage": { + "inputTokens": {}, + "outputTokens": {} + } + } + }, + { + "type": "event", + "seq": "1", + "event": { + "type": "finish", + "finishReason": "stop", + "totalUsage": { + "inputTokens": {}, + "outputTokens": {} + } + } + }, + { + "type": "end", + "seq": "2", + "outcome": "success" + } + ], + "consumerFrames": [ + { + "type": "event", + "seq": "0", + "event": { + "type": "finish", + "finishReason": "stop", + "totalUsage": { + "inputTokens": {}, + "outputTokens": {} + } + } + }, + { + "type": "event", + "seq": "1", + "event": { + "type": "finish", + "finishReason": "stop", + "totalUsage": { + "inputTokens": {}, + "outputTokens": {} + } + } + }, + { + "type": "end", + "seq": "2", + "outcome": "success" + } + ], + "abortObserved": null, + "queueFullObserved": false, + "consumerClosed": false, + "sourceStarted": true, + "sourceEventsPolled": 2 + } +} diff --git a/contract-tests/ops/scenarios/finish.json b/contract-tests/ops/scenarios/finish.json new file mode 100644 index 000000000..bd9e1be24 --- /dev/null +++ b/contract-tests/ops/scenarios/finish.json @@ -0,0 +1,105 @@ +{ + "schemaVersion": 1, + "name": "finish", + "description": "A finish data part does not consume the control terminal slot.", + "boundary": "eventStream", + "queueLimits": { + "items": 256, + "bytes": 8388608, + "maxEventBytes": 1048576 + }, + "script": [ + { + "action": "event", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "action": "event", + "event": { + "type": "finish", + "finishReason": "stop", + "totalUsage": { + "inputTokens": {}, + "outputTokens": {} + } + } + }, + { + "action": "eof" + } + ], + "controls": [ + { + "action": "drain" + } + ], + "expected": { + "accepted": true, + "preflightError": null, + "frames": [ + { + "type": "event", + "seq": "0", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "1", + "event": { + "type": "finish", + "finishReason": "stop", + "totalUsage": { + "inputTokens": {}, + "outputTokens": {} + } + } + }, + { + "type": "end", + "seq": "2", + "outcome": "success" + } + ], + "consumerFrames": [ + { + "type": "event", + "seq": "0", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "1", + "event": { + "type": "finish", + "finishReason": "stop", + "totalUsage": { + "inputTokens": {}, + "outputTokens": {} + } + } + }, + { + "type": "end", + "seq": "2", + "outcome": "success" + } + ], + "abortObserved": null, + "queueFullObserved": false, + "consumerClosed": false, + "sourceStarted": true, + "sourceEventsPolled": 2 + } +} diff --git a/contract-tests/ops/scenarios/full-queue-cancel.json b/contract-tests/ops/scenarios/full-queue-cancel.json new file mode 100644 index 000000000..40eb4a9d4 --- /dev/null +++ b/contract-tests/ops/scenarios/full-queue-cancel.json @@ -0,0 +1,4674 @@ +{ + "schemaVersion": 1, + "name": "full-queue-cancel", + "description": "Fill all 256 output slots before cancelling; cancellation does not need a slot and draining retains all admitted data plus one cancelled end.", + "boundary": "eventStream", + "queueLimits": { + "items": 256, + "bytes": 8388608, + "maxEventBytes": 1048576 + }, + "script": [ + { + "action": "repeatEvent", + "count": 256, + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "action": "wait", + "gate": "continue" + }, + { + "action": "eof" + } + ], + "controls": [ + { + "action": "startProducer" + }, + { + "action": "waitForQueueFull" + }, + { + "action": "cancel" + }, + { + "action": "drain" + } + ], + "expected": { + "accepted": true, + "preflightError": null, + "frames": [ + { + "type": "event", + "seq": "0", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "1", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "2", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "3", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "4", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "5", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "6", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "7", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "8", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "9", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "10", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "11", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "12", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "13", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "14", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "15", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "16", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "17", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "18", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "19", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "20", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "21", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "22", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "23", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "24", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "25", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "26", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "27", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "28", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "29", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "30", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "31", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "32", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "33", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "34", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "35", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "36", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "37", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "38", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "39", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "40", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "41", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "42", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "43", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "44", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "45", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "46", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "47", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "48", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "49", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "50", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "51", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "52", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "53", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "54", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "55", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "56", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "57", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "58", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "59", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "60", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "61", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "62", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "63", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "64", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "65", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "66", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "67", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "68", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "69", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "70", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "71", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "72", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "73", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "74", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "75", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "76", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "77", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "78", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "79", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "80", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "81", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "82", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "83", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "84", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "85", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "86", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "87", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "88", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "89", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "90", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "91", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "92", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "93", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "94", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "95", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "96", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "97", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "98", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "99", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "100", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "101", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "102", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "103", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "104", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "105", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "106", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "107", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "108", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "109", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "110", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "111", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "112", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "113", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "114", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "115", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "116", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "117", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "118", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "119", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "120", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "121", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "122", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "123", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "124", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "125", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "126", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "127", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "128", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "129", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "130", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "131", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "132", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "133", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "134", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "135", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "136", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "137", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "138", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "139", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "140", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "141", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "142", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "143", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "144", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "145", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "146", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "147", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "148", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "149", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "150", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "151", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "152", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "153", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "154", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "155", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "156", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "157", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "158", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "159", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "160", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "161", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "162", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "163", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "164", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "165", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "166", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "167", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "168", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "169", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "170", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "171", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "172", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "173", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "174", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "175", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "176", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "177", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "178", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "179", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "180", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "181", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "182", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "183", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "184", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "185", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "186", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "187", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "188", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "189", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "190", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "191", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "192", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "193", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "194", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "195", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "196", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "197", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "198", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "199", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "200", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "201", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "202", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "203", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "204", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "205", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "206", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "207", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "208", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "209", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "210", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "211", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "212", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "213", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "214", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "215", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "216", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "217", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "218", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "219", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "220", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "221", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "222", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "223", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "224", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "225", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "226", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "227", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "228", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "229", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "230", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "231", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "232", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "233", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "234", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "235", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "236", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "237", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "238", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "239", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "240", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "241", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "242", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "243", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "244", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "245", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "246", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "247", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "248", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "249", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "250", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "251", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "252", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "253", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "254", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "255", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "end", + "seq": "256", + "outcome": "cancelled" + } + ], + "consumerFrames": [ + { + "type": "event", + "seq": "0", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "1", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "2", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "3", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "4", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "5", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "6", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "7", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "8", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "9", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "10", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "11", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "12", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "13", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "14", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "15", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "16", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "17", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "18", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "19", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "20", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "21", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "22", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "23", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "24", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "25", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "26", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "27", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "28", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "29", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "30", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "31", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "32", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "33", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "34", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "35", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "36", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "37", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "38", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "39", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "40", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "41", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "42", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "43", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "44", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "45", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "46", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "47", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "48", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "49", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "50", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "51", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "52", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "53", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "54", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "55", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "56", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "57", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "58", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "59", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "60", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "61", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "62", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "63", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "64", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "65", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "66", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "67", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "68", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "69", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "70", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "71", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "72", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "73", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "74", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "75", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "76", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "77", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "78", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "79", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "80", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "81", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "82", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "83", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "84", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "85", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "86", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "87", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "88", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "89", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "90", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "91", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "92", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "93", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "94", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "95", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "96", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "97", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "98", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "99", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "100", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "101", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "102", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "103", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "104", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "105", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "106", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "107", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "108", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "109", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "110", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "111", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "112", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "113", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "114", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "115", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "116", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "117", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "118", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "119", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "120", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "121", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "122", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "123", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "124", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "125", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "126", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "127", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "128", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "129", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "130", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "131", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "132", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "133", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "134", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "135", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "136", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "137", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "138", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "139", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "140", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "141", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "142", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "143", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "144", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "145", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "146", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "147", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "148", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "149", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "150", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "151", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "152", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "153", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "154", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "155", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "156", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "157", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "158", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "159", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "160", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "161", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "162", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "163", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "164", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "165", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "166", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "167", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "168", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "169", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "170", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "171", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "172", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "173", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "174", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "175", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "176", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "177", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "178", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "179", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "180", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "181", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "182", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "183", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "184", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "185", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "186", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "187", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "188", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "189", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "190", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "191", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "192", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "193", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "194", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "195", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "196", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "197", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "198", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "199", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "200", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "201", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "202", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "203", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "204", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "205", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "206", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "207", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "208", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "209", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "210", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "211", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "212", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "213", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "214", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "215", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "216", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "217", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "218", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "219", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "220", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "221", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "222", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "223", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "224", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "225", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "226", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "227", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "228", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "229", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "230", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "231", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "232", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "233", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "234", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "235", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "236", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "237", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "238", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "239", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "240", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "241", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "242", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "243", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "244", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "245", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "246", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "247", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "248", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "249", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "250", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "251", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "252", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "253", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "254", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "255", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "end", + "seq": "256", + "outcome": "cancelled" + } + ], + "abortObserved": true, + "queueFullObserved": true, + "consumerClosed": false, + "sourceStarted": true, + "sourceEventsPolled": 256 + } +} diff --git a/contract-tests/ops/scenarios/late-cancel-after-success.json b/contract-tests/ops/scenarios/late-cancel-after-success.json new file mode 100644 index 000000000..dddc9439b --- /dev/null +++ b/contract-tests/ops/scenarios/late-cancel-after-success.json @@ -0,0 +1,76 @@ +{ + "schemaVersion": 1, + "name": "late-cancel-after-success", + "description": "Cancel after the success end cannot rewrite it or produce another end.", + "boundary": "eventStream", + "queueLimits": { + "items": 256, + "bytes": 8388608, + "maxEventBytes": 1048576 + }, + "script": [ + { + "action": "event", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "action": "eof" + } + ], + "controls": [ + { + "action": "drain" + }, + { + "action": "cancel" + }, + { + "action": "drain" + } + ], + "expected": { + "accepted": true, + "preflightError": null, + "frames": [ + { + "type": "event", + "seq": "0", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "end", + "seq": "1", + "outcome": "success" + } + ], + "consumerFrames": [ + { + "type": "event", + "seq": "0", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "end", + "seq": "1", + "outcome": "success" + } + ], + "abortObserved": true, + "queueFullObserved": false, + "consumerClosed": false, + "sourceStarted": true, + "sourceEventsPolled": 1 + } +} diff --git a/contract-tests/ops/scenarios/preflight-failure.json b/contract-tests/ops/scenarios/preflight-failure.json new file mode 100644 index 000000000..213fb3e4e --- /dev/null +++ b/contract-tests/ops/scenarios/preflight-failure.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 1, + "name": "preflight-failure", + "description": "Control DTO rejection happens before source startup; no acceptance or callback.", + "boundary": "eventStream", + "queueLimits": { + "items": 256, + "bytes": 8388608, + "maxEventBytes": 1048576 + }, + "script": [ + { + "action": "eof" + } + ], + "controls": [ + { + "action": "drain" + } + ], + "expected": { + "accepted": false, + "preflightError": { + "kind": "protocol", + "code": 301, + "error": { + "name": "InvalidParams" + } + }, + "frames": [], + "consumerFrames": [], + "abortObserved": false, + "queueFullObserved": false, + "consumerClosed": false, + "sourceStarted": false, + "sourceEventsPolled": 0 + }, + "fault": { + "stage": "preflight", + "kind": "invalidParams", + "message": "mock invalid params" + } +} diff --git a/contract-tests/ops/scenarios/reader-close.json b/contract-tests/ops/scenarios/reader-close.json new file mode 100644 index 000000000..5aba57939 --- /dev/null +++ b/contract-tests/ops/scenarios/reader-close.json @@ -0,0 +1,80 @@ +{ + "schemaVersion": 1, + "name": "reader-close", + "description": "Iterator close aborts native work; native cleanup receives its end but the closed consumer receives no more frames.", + "boundary": "eventStream", + "queueLimits": { + "items": 256, + "bytes": 8388608, + "maxEventBytes": 1048576 + }, + "script": [ + { + "action": "event", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "action": "wait", + "gate": "continue" + }, + { + "action": "eof" + } + ], + "controls": [ + { + "action": "read", + "count": 1 + }, + { + "action": "waitForSourceGate", + "gate": "continue" + }, + { + "action": "closeReader" + }, + { + "action": "drainNative" + } + ], + "expected": { + "accepted": true, + "preflightError": null, + "frames": [ + { + "type": "event", + "seq": "0", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "end", + "seq": "1", + "outcome": "cancelled" + } + ], + "consumerFrames": [ + { + "type": "event", + "seq": "0", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + } + ], + "abortObserved": true, + "queueFullObserved": false, + "consumerClosed": true, + "sourceStarted": true, + "sourceEventsPolled": 1 + } +} diff --git a/contract-tests/ops/scenarios/recoverable-error-part.json b/contract-tests/ops/scenarios/recoverable-error-part.json new file mode 100644 index 000000000..e7e382d53 --- /dev/null +++ b/contract-tests/ops/scenarios/recoverable-error-part.json @@ -0,0 +1,146 @@ +{ + "schemaVersion": 1, + "name": "recoverable-error-part", + "description": "An Ok(error part) remains data and does not prevent later data or success.", + "boundary": "eventStream", + "queueLimits": { + "items": 256, + "bytes": 8388608, + "maxEventBytes": 1048576 + }, + "script": [ + { + "action": "event", + "event": { + "type": "error", + "error": { + "name": "AI_APICallError", + "message": "recoverable mock error", + "url": "https://mock.invalid", + "requestBodyValues": {}, + "isRetryable": false + } + } + }, + { + "action": "event", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "action": "event", + "event": { + "type": "finish", + "finishReason": "stop", + "totalUsage": { + "inputTokens": {}, + "outputTokens": {} + } + } + }, + { + "action": "eof" + } + ], + "controls": [ + { + "action": "drain" + } + ], + "expected": { + "accepted": true, + "preflightError": null, + "frames": [ + { + "type": "event", + "seq": "0", + "event": { + "type": "error", + "error": { + "name": "AI_APICallError", + "message": "recoverable mock error", + "url": "https://mock.invalid", + "requestBodyValues": {}, + "isRetryable": false + } + } + }, + { + "type": "event", + "seq": "1", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "2", + "event": { + "type": "finish", + "finishReason": "stop", + "totalUsage": { + "inputTokens": {}, + "outputTokens": {} + } + } + }, + { + "type": "end", + "seq": "3", + "outcome": "success" + } + ], + "consumerFrames": [ + { + "type": "event", + "seq": "0", + "event": { + "type": "error", + "error": { + "name": "AI_APICallError", + "message": "recoverable mock error", + "url": "https://mock.invalid", + "requestBodyValues": {}, + "isRetryable": false + } + } + }, + { + "type": "event", + "seq": "1", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "event", + "seq": "2", + "event": { + "type": "finish", + "finishReason": "stop", + "totalUsage": { + "inputTokens": {}, + "outputTokens": {} + } + } + }, + { + "type": "end", + "seq": "3", + "outcome": "success" + } + ], + "abortObserved": null, + "queueFullObserved": false, + "consumerClosed": false, + "sourceStarted": true, + "sourceEventsPolled": 3 + } +} diff --git a/contract-tests/ops/scenarios/serialization-failure.json b/contract-tests/ops/scenarios/serialization-failure.json new file mode 100644 index 000000000..d5ab5e7e6 --- /dev/null +++ b/contract-tests/ops/scenarios/serialization-failure.json @@ -0,0 +1,88 @@ +{ + "schemaVersion": 1, + "name": "serialization-failure", + "description": "An injected native event-serialization failure ends with binding ResultSerialization, retaining earlier events.", + "boundary": "eventStream", + "queueLimits": { + "items": 256, + "bytes": 8388608, + "maxEventBytes": 1048576 + }, + "script": [ + { + "action": "event", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "action": "serializationFailure", + "message": "mock serializer failure" + }, + { + "action": "eof" + } + ], + "controls": [ + { + "action": "drain" + } + ], + "expected": { + "accepted": true, + "preflightError": null, + "frames": [ + { + "type": "event", + "seq": "0", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "end", + "seq": "1", + "outcome": "error", + "error": { + "kind": "binding", + "code": 205, + "error": { + "name": "ResultSerialization" + } + } + } + ], + "consumerFrames": [ + { + "type": "event", + "seq": "0", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "end", + "seq": "1", + "outcome": "error", + "error": { + "kind": "binding", + "code": 205, + "error": { + "name": "ResultSerialization" + } + } + } + ], + "abortObserved": null, + "queueFullObserved": false, + "consumerClosed": false, + "sourceStarted": true, + "sourceEventsPolled": 1 + } +} diff --git a/contract-tests/ops/scenarios/terminal-error.json b/contract-tests/ops/scenarios/terminal-error.json new file mode 100644 index 000000000..6ce3eeb68 --- /dev/null +++ b/contract-tests/ops/scenarios/terminal-error.json @@ -0,0 +1,94 @@ +{ + "schemaVersion": 1, + "name": "terminal-error", + "description": "An Err from the runtime preserves earlier data and produces one error end; no second returned error.", + "boundary": "eventStream", + "queueLimits": { + "items": 256, + "bytes": 8388608, + "maxEventBytes": 1048576 + }, + "script": [ + { + "action": "event", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "action": "terminalError", + "kind": "apiCall", + "message": "mock transport failure" + }, + { + "action": "event", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "must not be delivered" + } + } + ], + "controls": [ + { + "action": "drain" + } + ], + "expected": { + "accepted": true, + "preflightError": null, + "frames": [ + { + "type": "event", + "seq": "0", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "end", + "seq": "1", + "outcome": "error", + "error": { + "kind": "aimux", + "code": 11, + "error": { + "name": "AI_APICallError" + } + } + } + ], + "consumerFrames": [ + { + "type": "event", + "seq": "0", + "event": { + "type": "text-delta", + "id": "text-0", + "text": "hello 世界 👋" + } + }, + { + "type": "end", + "seq": "1", + "outcome": "error", + "error": { + "kind": "aimux", + "code": 11, + "error": { + "name": "AI_APICallError" + } + } + } + ], + "abortObserved": null, + "queueFullObserved": false, + "consumerClosed": false, + "sourceStarted": true, + "sourceEventsPolled": 1 + } +} diff --git a/docs/aisdk-architecture-alignment.md b/docs/aisdk-architecture-alignment.md index c4388520b..18402aca1 100644 --- a/docs/aisdk-architecture-alignment.md +++ b/docs/aisdk-architecture-alignment.md @@ -1518,6 +1518,29 @@ providerOptions 的 namespace 不跟随 registry alias。将 `openai` 注册为 - AWS SigV4 fetch(Bedrock、Anthropic-AWS)也给 GET 请求签名,即 `list_models`;上游只签带 body 的 POST(`amazon-bedrock-sigv4-fetch.ts:57-62`、`anthropic-aws-fetch.ts:57-62`)。模型列表是 aimux 独有的接口,不签名就调不通,所以保留这一差异,不再修改(§3.3)。 - Swift 绑定的类型化值不保键序:`ProviderOptions`、`ProviderMetadata`、`UploadFileResult.providerReference` 与 `JSONValue.object` 是 Swift `Dictionary`,经 `JSONEncoder` / `JSONSerialization` 解码再编码后键的顺序可能改变;上游是 JS 对象,按插入顺序保留(#229 NP2-1,附录"namespace map 的顺序")。Swift API 返回的 JSON 字符串保序,需要顺序时原样传递字符串(`docs/api/swift.md`)。要消除这一差异,需要把这些类型换成保序的容器,随绑定迁移那一层做。 +- RFC-0039 D1:错误封装遵循 RFC-0042:外层为 `kind`、`code`、`message`、`error` 和可选的 `retryAfterMs`,`error` 使用 AI SDK 的 `name` 和适用时的 `isRetryable`,外层没有 `retryable` 或 `type`;因为业务错误联合类型由 RFC-0042 定义,而 RFC 文件不在本分支的基线上,RFC-0039 §6.1 示例仍有的 `type` 将在本分支与 master 合并时改为 `name`([实现说明](rfc-0039-implementation.md#registered-differences-from-rfc-0039))。 +- RFC-0039 D2:`Decision`、`decision.decide` 和 `decision.capabilities` 将原表的八类模型、70 个操作扩为九类模型、72 个操作,因为基线已经提供 Decision 及 Jev 构造入口([实现说明](rfc-0039-implementation.md#registered-differences-from-rfc-0039))。 +- RFC-0039 D3:协议错误固定使用 300..306,AiMux 错误扩至 20,保留空缺码 4,码 3 对应三个变体、码 6 对应两个变体,因为替换 ABI 时必须保留既有数值含义([实现说明](rfc-0039-implementation.md#registered-differences-from-rfc-0039))。 +- RFC-0039 D4:`aimux_handle_finalizer(void *token)` 作为第十四个导出,因为 GC 清理需要不阻塞的指针签名入口,不能强转 `aimux_drop(uint64_t)`([实现说明](rfc-0039-implementation.md#registered-differences-from-rfc-0039))。 +- RFC-0039 D5:已实现的输入 64 条、输出 256 条、队列 8 MiB、单个事件或二进制块 1 MiB 和清理五秒上限仍是稳定冻结前的建议值,因为还缺 RFC-0038 测量及平台验证证据([实现说明](rfc-0039-implementation.md#registered-differences-from-rfc-0039))。 +- RFC-0039 D6(已解决):`operation.next` 已按 RFC §3.2 登记为 `poll`,JSON 与原生轮询仍共用 Operation 服务及 part/ended/timeout 结果([实现说明](rfc-0039-implementation.md#registered-differences-from-rfc-0039))。 +- RFC-0039 D7:`operation.push` 只接受 BinaryPush,同步原生入口在队列满时返回 ResourceLimit,Rust 的 `Operation::push` 仍异步等待容量,因为同步入口必须在复制前预留容量,不能等待异步队列([实现说明](rfc-0039-implementation.md#registered-differences-from-rfc-0039))。 +- RFC-0039 D8:跨所有者查询返回与未知 ID 相同且不含 `actual` 类型的 InvalidHandle,跨所有者的 `handle.drop` 则成功但无效果,因为所有权边界不能泄露资源信息([实现说明](rfc-0039-implementation.md#registered-differences-from-rfc-0039))。 +- RFC-0039 D9:唯一的重试提示解析器位于 `aimux_core::error::retry_after_ms_from_headers` 并由 ops 重新导出,已解析但无效的优先数值阻止回退、格式错误的文本允许回退,因为 core 的重试策略不能依赖适配层 crate([实现说明](rfc-0039-implementation.md#registered-differences-from-rfc-0039))。 +- RFC-0039 D10:未暴露 stdio、宿主 fetch/token/model/telemetry 回调、单次原始二进制上传、原生透传及 skills/Batch/Evaluation 操作,因为这些能力需要超出现有引擎范围的独立实现([实现说明](rfc-0039-implementation.md#registered-differences-from-rfc-0039))。 +- RFC-0039 D11:维护者接受 D8 在 ops/ABI 切换后按 RFC-0042 交付,ts-rs 与 `scripts/gen_ts_types.py` 保留为业务类型过渡链,因为本 PR 先完成控制对象、头文件和操作常量的同源生成;D8 完成时须删除过渡链及其漂移门禁,G-d 仍未关闭([实现说明](rfc-0039-implementation.md#registered-differences-from-rfc-0039))。 +- RFC-0039 D12:维护者接受 schema-2 读写保留至本系列最后一个 PR,因为 schema-3 迁移由 RFC-0041 的最后一步完成;届时必须删除 schema-2 及更早格式的读取,不能提前删除或长期保留([实现说明](rfc-0039-implementation.md#registered-differences-from-rfc-0039))。 +- RFC-0039 G-a:录制器、会话存储挂接及推断状态仍为进程全局,因为按所有者隔离采集需要修改 core 的运行时上下文([实现说明](rfc-0039-implementation.md#registered-gaps))。 +- RFC-0039 G-b:`aimux-core/src/recording.rs` 的写入格式是 schema 2,读取接受 schema 1 和 2,因为迁至 schema 3 并移除旧 schema 读取计划作为本系列的最后一步按 RFC-0041 完成([实现说明](rfc-0039-implementation.md#registered-gaps))。 +- RFC-0039 G-c:`credential.create` 和 `replay.capabilities` 仍属 Future 并返回 UnsupportedFunctionality,回放只保留既有 Language/Decision 模型路径,因为尚未实现凭据构造及 Fetch/WebSocket/Download 回放引擎([实现说明](rfc-0039-implementation.md#registered-gaps))。 +- RFC-0039 G-d:业务类型只有稳定的 Rust 类型引用,尚无完整生成的 schema 和语言 DTO 链,`scripts/gen_ts_types.py` 仍保留原有输出,因为这部分由 RFC-0042 负责([实现说明](rfc-0039-implementation.md#registered-gaps))。 +- RFC-0039 G-e:发布配置已将 ops 排在 FFI 前并打包头文件,但本文尚未提供所有平台产物的安装和运行验证,因为构建与打包定义不能证明发布验收完成([实现说明](rfc-0039-implementation.md#registered-gaps))。 +- RFC-0039 G-f:完整包来源、所有包的权威默认方法、通用厂商命名方法分发及文本请求的 registry/字符串模型提升仍缺失,已解析模型只有按所有者隔离的引用元数据且 `provider.invoke` 只接受 `listModels`,因为注册项保留的是规范 Arc,现有工厂和控制 DTO 未提供全部元数据或提升逻辑([实现说明](rfc-0039-implementation.md#registered-gaps))。 +- RFC-0039 G-g:队列和线格式限制尚不能约束所有 provider 分配及 core 现有的无界文本转发队列,因为本层只控制已准入的数据,不改写 core 的流式内部实现([实现说明](rfc-0039-implementation.md#registered-gaps))。 +- RFC-0039 G-h:尚无逐业务类型的版本或哈希校验,因为消费类型元数据尚未完整实现;各绑定加载时已精确校验 ABI 版本、ops 版本和 manifest 摘要([实现说明](rfc-0039-implementation.md#registered-gaps))。 +- RFC-0039 G-i:本文尚未建立八种语言的完整业务向量、同源发布、已安装 wheel 及全部平台 sanitizer/链接/安装验证证据,Dart Windows 流式调用也明确不支持,因为局部和替身测试不能证明完整矩阵通过([实现说明](rfc-0039-implementation.md#registered-gaps))。 +- RFC-0039 G-k:旧 MockReplayModel 构造器尚无暴露给离线文本生成的替代入口,因为 `replay.create` 返回 ReplaySession、文本操作只接受 Language,而 `replay.live` 还需要调用者另行提供目标([实现说明](rfc-0039-implementation.md#registered-gaps))。 + ### 9.2 必须通过的构建检查 - workspace fmt、clippy、test; diff --git a/docs/api/ops.md b/docs/api/ops.md new file mode 100644 index 000000000..9e437373e --- /dev/null +++ b/docs/api/ops.md @@ -0,0 +1,45 @@ +# Shared operations + +`aimux-ops` exposes the following registered operation names. Use +`runtime.describe` for versions, manifest digest and available names. The Rust +`manifest::manifest()` and C `aimux_manifest_json` provide authoritative modes, +allowed handle kinds, cancellation and DTO schemas. See the +[crate README](../../aimux-ops/README.md) for ownership, limits and registered gaps. + +| Family | Operations | +|---|---| +| abort | `abort.create`, `abort.signal` | +| auth | `auth.codexRefresh` | +| catalogue | `catalogue.fetch` | +| composite | `composite.moa`, `composite.router` | +| credential | `credential.create` | +| decision | `decision.capabilities`, `decision.decide` | +| embedding | `embedding.embed`, `embedding.embedMany` | +| files | `files.upload` | +| handle | `handle.drop` | +| image | `image.generate` | +| middleware | `middleware.create` | +| model | `model.info`, `model.resolve`, `model.wrap` | +| operation | `operation.cancel`, `operation.inputDone`, `operation.next`, `operation.push`, `operation.status` | +| output | `output.toOpenAI` | +| provider | `provider.create`, `provider.custom`, `provider.default`, `provider.files`, `provider.invoke`, `provider.model`, `provider.wrap` | +| recorder | `recorder.attach`, `recorder.create`, `recorder.export`, `recorder.flush`, `recorder.stats`, `recorder.stop` | +| registry | `registry.create`, `registry.provider` | +| replay | `replay.capabilities`, `replay.create`, `replay.dryRun`, `replay.inspect`, `replay.live` | +| reranking | `reranking.rerank` | +| runtime | `runtime.configure`, `runtime.describe` | +| search | `search.search` | +| sessionStore | `sessionStore.attach`, `sessionStore.calls`, `sessionStore.create`, `sessionStore.list` | +| speech | `speech.generate` | +| text | `text.consume`, `text.generate`, `text.generateObject`, `text.generateOpenAI`, `text.stream`, `text.streamOpenAI` | +| toolRepair | `toolRepair.apply`, `toolRepair.applyResult`, `toolRepair.context` | +| trace | `trace.aggregate`, `trace.clear`, `trace.create`, `trace.export`, `trace.sessionChain`, `trace.sessionTrajectory` | +| transcription | `transcription.generate`, `transcription.start` | +| transport | `transport.create` | +| video | `video.generate` | + +`output.toOpenAI` is registered once by the output-conversion handler in misc. +`credential.create` and `replay.capabilities` advertise Future scope and return +UnsupportedFunctionality; the other registered operations use existing Rust +capabilities. Unsupported middleware kinds and native factory settings fail +explicitly. This inventory does not claim live provider or platform validation. diff --git a/docs/external-provider-config-research.md b/docs/external-provider-config-research.md index c3eb1c746..d05c41f67 100644 --- a/docs/external-provider-config-research.md +++ b/docs/external-provider-config-research.md @@ -1,7 +1,7 @@ # 调研:允许外部提供 provider 配置的可行性与设计 > **Status**: 调研报告(非 RFC,供决策) -> **Historical (RFC-0036)**: 本报告写于 provider 工厂重构之前,下文的 `OpenAIConfig`、provider 配置上的 retry/body 覆盖字段等旧 API 已不存在,仅作决策记录保留;现行形态见 [docs/api/rust.md](api/rust.md#providers)(`create_provider(name, PresetSettings)`、`default_providers()` + `create_provider_registry`、各包 `XxxProviderSettings` + `create_xxx`)。旧 `provider()` / `ProviderOptions` / 注册与 JSON 加载入口只保留在 `aimux_providers::provider` 绑定兼容模块,不再从 Rust crate root 导出。 +> **Historical (RFC-0036)**: 本报告写于 provider 工厂重构之前,下文的 `OpenAIConfig`、provider 配置上的 retry/body 覆盖字段等旧 API 已不存在,仅作决策记录保留;现行形态见 [docs/api/rust.md](api/rust.md#providers)(`create_provider(name, PresetSettings)`、`default_providers()` + `create_provider_registry`、各包 `XxxProviderSettings` + `create_xxx`)。旧 `provider()` / `ProviderOptions` / 注册与 JSON 加载入口已删除,绑定改经 ops 层创建 provider。 > **Date**: 2026-08-05 > **Scope**: 评估"运行时/宿主应用/配置文件提供 provider 配置"对 aimux 的可行性、形态、成本收益,给出明确建议 > **Related**: [RFC-0017](../rfc/0017-provider-config-dx.md) 配置 DX、[RFC-0019](../rfc/0019-session-affinity.md) 会话亲和 diff --git a/docs/rfc-0039-conformance.md b/docs/rfc-0039-conformance.md new file mode 100644 index 000000000..6dd7d7bde --- /dev/null +++ b/docs/rfc-0039-conformance.md @@ -0,0 +1,346 @@ +# RFC-0039 conformance matrix + +This matrix indexes 203 implementation and release obligations, including +explicit MUST rules outside the operation table. Section +references identify RFC-0039; the stdio wire specification in §8 is deferred. +The requirement text summarizes the normative target, not current behavior. +Where the target differs, the status points to a registered difference or gap. +The RFC-0039 source file is absent from this checkout; this index is not an +independent certification that every normative sentence has been transcribed. + +**met** means the current code supplies the obligation and the named existing +test covers it at the stated layer. It does not mean that test was run for this +documentation change or that every platform passed. **gap** includes partial +coverage, future capabilities, missing execution evidence and release/freeze +obligations that a unit test cannot establish. A related test in a gap row is +only evidence of the narrower behavior its name describes. + +All `path#test` references below name tests present in this tree, including +macro-emitted tests. Paths into source modules identify embedded unit tests. +The [implementation document](rfc-0039-implementation.md#registered-differences-from-rfc-0039) +defines D1–D10, G-a–G-i and G-k; these are not waivers declaring release readiness. +The [coverage ledger](rfc-0039-coverage-ledger.md) covers old APIs separately. + +Generated-output tests exist; this documentation audit does not claim they +passed. No complete cutover, platform release, 1.0 freeze or successful +live-provider test is claimed here. The Node native lifecycle test expects +trace middleware creation and model wrapping to succeed. The external +shared-driver test is ignored; the native eleven-scenario runner is enabled. + +## Evidence index + +Repeated evidence keys keep the matrix readable; each maps to an exact test +unless explicitly labelled as a file-level reference. + +| Key | Existing test | +|---|---| +| abi | `aimux-ffi/tests/abi_primitives.rs#call_validates_strings_and_clears_output` | +| abipoll | `aimux-ffi/tests/abi_primitives.rs#poll_clears_both_outputs_including_partial_null_arguments` | +| abipush | `aimux-ffi/tests/abi_primitives.rs#push_validates_before_reading_and_accepts_null_empty_input` | +| abort | `aimux-ops/tests/handles.rs#dropping_an_abort_preserves_cloned_capabilities_without_signalling` | +| base64 | `aimux-ops/src/ops/modalities.rs#binary_results_use_v4_base64_wire` | +| callback | `aimux-ffi/src/abi.rs#callback_borrows_frames_rejects_reentry_and_allows_abort` | +| cleanup | `aimux-ops/tests/operation.rs#cleanup_is_capped_at_five_seconds_and_failure_is_recorded` | +| controls | `aimux-ops/tests/manifest_contract.rs#all_owned_types_publish_schemas_and_family_controls_are_strict` | +| dart | `bindings/flutter/test/native_runtime_test.dart#provider/model handles retain children after provider close` | +| dartcredit | `bindings/flutter/test/stream_bridge_test.dart#pause exhausts credits, resume preserves every event` | +| dartfinalizer | `bindings/flutter/test/aimux_test.dart#typed token trampoline queues cleanup off the finalizer thread` | +| date | `aimux-ops/tests/retry_hint.rs#fixed_clock_http_date_golden_vectors` | +| digest | `aimux-ops/tests/manifest_contract.rs#digest_is_stable_and_hashes_the_complete_canonical_manifest` | +| dispatch | `aimux-ops/tests/dispatch_contract.rs#global_target_and_mode_preflight_are_explicit_and_projected` | +| error | `aimux-ops/tests/error_envelope_golden.rs#codes_preserve_existing_groups_and_have_disjoint_ranges` | +| exhaust | `aimux-ops/src/handle.rs#allocator_exhaustion_does_not_wrap` | +| exports | `aimux-ffi/tests/exports_smoke_test.rs#cdylib_exports_only_abi_primitives` | +| finalizer | `aimux-ffi/src/abi.rs#finalizer_enqueues_idempotent_c_owned_cleanup` | +| fullcancel | `aimux-ops/tests/operation.rs#full_output_queue_never_loses_cancellation_terminal` | +| gen | `tools/aimux-codegen/tests/generation.rs#deterministic_complete_outputs_cover_every_language_and_abi_primitive` | +| gencheck | `tools/aimux-codegen/tests/generation.rs#check_detects_missing_stale_and_changed_files_without_rewriting` | +| go | `bindings/go/abi_test.go#TestStreamCancellationFullQueueNoConsumer` | +| goclose | `bindings/go/abi_test.go#TestCloseCallRace` | +| gowire | `bindings/go/roundtrip_test.go#TestGenerateTextOptionsRoundTrip` | +| ids | `aimux-ops/tests/handle_lifecycle.rs#ids_are_nonzero_monotonic_and_never_reused_after_drop` | +| inventory | `aimux-ops/tests/manifest_contract.rs#rfc_inventory_has_exact_names_modes_and_handle_scopes` | +| java | `bindings/java/src/test/java/ai/arcships/aimux/AbiContractTest.java#declaresOnlyTheFourteenPrimitivesWithPortableSizeT` | +| javacallback | `bindings/java/src/test/java/ai/arcships/aimux/AbiContractTest.java#latchesConsumerThrowableSignalsAbortAndThrowsOnceAfterNativeReturns` | +| javaclose | `bindings/java/src/test/java/ai/arcships/aimux/AbiContractTest.java#closeFromConsumerDoesNotWaitAndDropsAfterNativeReturn` | +| javafuture | `bindings/java/src/test/java/ai/arcships/aimux/AbiContractTest.java#cancellationOfRunningCallSignalsNativeAbortWithoutInterruptingThread` | +| kotlin | `bindings/kotlin/src/test/kotlin/ai/arcships/aimux/kotlin/NativeIntegrationTest.kt#real manifest loads and Java provider model objects retain identity` | +| lifetime | `aimux-ops/tests/handle_lifecycle.rs#models_survive_provider_and_registry_removal` | +| limits | `aimux-ops/tests/limits.rs#default_eight_mib_budget_and_terminal_survive_a_full_queue` | +| locks | `aimux-ops/tests/dispatch_contract.rs#owner_allocations_are_unique_and_teardown_releases_locks_before_destruction` | +| matrix | `aimux-ops/tests/ops_matrix.rs#every_manifest_op_crosses_every_kind_and_invalid_handle_state` | +| metadata | `aimux-ops/tests/manifest_contract.rs#metadata_and_owned_control_schemas_are_complete` | +| names | `aimux-ops/tests/manifest_contract.rs#names_are_case_sensitive_and_have_no_implicit_aliases` | +| nested | `aimux-ops/tests/retry_hint.rs#retry_nesting_has_no_top_level_hint_and_retains_attempt_hints` | +| node | `bindings/node/__test__/event_loop.spec.ts#event loop: timers advance with bounded drift during a native call` | +| noderealm | `bindings/node/__test__/contract.native.ts` (file-level reference: foreign-Env rejection and environment teardown test) | +| null | `aimux-ops/tests/error_envelope_golden.rs#optional_payload_fields_distinguish_absent_and_explicit_null` | +| runtimeconfig | `aimux-ops/tests/runtime.rs#startup_configuration_uses_native_freeze_authorities` | +| providercreate | `aimux-ops/src/ops/provider.rs#create_uses_the_existing_package_factory` | +| registrycreate | `aimux-ops/src/ops/registry.rs#duplicate_keys_are_rejected` | +| providermodel | `aimux-ops/src/ops/provider.rs#model_returns_all_eight_exact_handle_kinds` | +| providerinvoke | `aimux-ops/src/ops/provider.rs#invoke_returns_only_runtime_discovery_and_forbids_arbitrary_rpc` | +| providerwrap | `aimux-ops/src/ops/provider.rs#wrap_applies_each_middleware_and_keeps_files_and_discovery` | +| providercustom | `aimux-ops/src/ops/provider.rs#custom_keeps_aliases_default_method_and_strong_references` | +| modelresolve | `aimux-ops/src/ops/registry.rs#resolution_preserves_exact_ids_and_reports_safe_source` | +| modelinfo | `aimux-ops/src/ops/registry.rs#direct_models_have_no_invented_source_and_empty_wrap_retains_modality` | +| composite | `aimux-ops/tests/composite.rs#dropped_router_children_keep_working_with_independent_retry_budgets` | +| text | `aimux-ops/src/ops/text.rs#real_dispatch_runs_all_six_text_operations` | +| embedding | `aimux-ops/src/ops/modalities.rs#embedding_many_preserves_all_inputs` | +| modality | `aimux-ops/src/ops/modalities.rs#transcription_base64_bytes` | +| files | `aimux-ops/tests/files.rs#uploads_canonical_base64_and_preserves_options_and_context_abort` | +| transcription | `aimux-ops/tests/operation.rs#native_binary_and_json_poll_share_the_registered_operation_service` | +| binaryinput | `aimux-ops/tests/operation.rs#native_binary_and_json_poll_share_the_registered_operation_service` | +| operationstate | `aimux-ops/tests/operation.rs#native_binary_and_json_poll_share_the_registered_operation_service` | +| credential | `aimux-ops/tests/handles.rs#credential_creation_is_future_and_never_exports_supplied_material` | +| middleware | `aimux-ops/tests/handles.rs#trace_middleware_creation_retains_store_and_audit_configuration` | +| recording | `aimux-ops/tests/recording.rs#ring_lifecycle_keeps_snapshots_and_replacement_and_dropped_handles` | +| session | `aimux-ops/tests/session.rs#queries_use_selected_store_and_preserve_core_wire_and_bounds` | +| replaycapabilities | `aimux-ops/tests/replay.rs#future_capabilities_return_projected_unsupported_even_with_complete_http_data` | +| replaylive | `aimux-ops/tests/replay.rs#language_live_matches_the_existing_core_result_twice` | +| repair | `aimux-ops/src/ops/misc.rs#context_matches_core_and_preserves_no_tools_null` | +| auth | `aimux-ops/tests/misc.rs#invalid_auth_request_is_projected_once_without_token_values` | +| drop | `aimux-ops/tests/ops_matrix.rs#every_manifest_op_crosses_every_kind_and_invalid_handle_state` | +| runtimeinfo | `aimux-ops/tests/runtime.rs#description_matches_the_real_manifest_and_digest` | +| registryresolve | `aimux-ops/src/ops/registry.rs#resolution_preserves_exact_ids_and_reports_safe_source` | +| operation | `aimux-ops/tests/operation.rs#native_binary_and_json_poll_share_the_registered_operation_service` | +| ownership | `aimux-ops/tests/handle_lifecycle.rs#every_owner_pair_is_isolated_and_foreign_errors_reveal_nothing` | +| poll | `aimux-ops/tests/operation.rs#a_concurrent_reader_fails_and_timeout_keeps_operation_running` | +| projection | `aimux-ops/tests/dispatch_contract.rs#the_dispatch_projection_keeps_the_original_clock_and_cause` | +| python | `bindings/python/tests/test_ops_native.py#test_gil_heartbeat_every_blocking_call_family` | +| pythonstream | `bindings/python/tests/test_ops_native.py#test_full_queue_close_does_not_deadlock` | +| pythonwire | `bindings/python/tests/test_ops_native.py#test_python_serialization_errors_use_binding_domain` | +| queue | `aimux-ops/tests/limits.rs#item_budget_backpressures_and_preserves_fifo` | +| recordingschema | `aimux-core/src/recording.rs#schema_two_is_written_and_released_schemas_are_read` | +| reserve | `aimux-ops/tests/limits.rs#oversize_is_rejected_before_payload_allocation_even_when_full` | +| retired | `aimux-ffi/tests/exports_smoke_test.rs#retired_headers_and_session_are_absent` | +| retry | `aimux-ops/tests/retry_hint.rs#header_precedence_golden_vectors` | +| schema | `tools/aimux-codegen/tests/generation.rs#schemas_resolve_locally_and_validate_as_draft_2020_12` | +| secondary | `aimux-ops/tests/dispatch_contract.rs#secondary_handles_use_the_same_owner_resolver_before_handler_entry` | +| stream | `aimux-ops/tests/stream_terminal.rs#run_native_shared_vectors` | +| swift | `bindings/swift/Tests/AimuxTests/ContractTests.swift#boundedLosslessStream` | +| swiftcancel | `bindings/swift/Tests/AimuxTests/ContractTests.swift#cancelFullQueue` | +| swiftclose | `bindings/swift/Tests/AimuxTests/ContractTests.swift#concurrentCloseAndAdmission` | +| terminal | `aimux-ffi/src/abi.rs#terminal_error_is_only_in_callback_and_missing_end_is_reported_there` | +| trace | `aimux-ops/tests/trace_middleware.rs#model_and_provider_wrap_trace_generation_streams_and_retain_dependencies` | +| types | `aimux-ops/tests/manifest_contract.rs#every_descriptor_type_reference_names_an_existing_type` | +| unknown | `aimux-ops/tests/error_envelope_golden.rs#unknown_variants_and_nested_nulls_survive_without_reclassification` | + +## MUST and lifecycle obligations + +| ID | RFC section | Requirement | Existing test keys | Status | +|---|---|---|---|---| +| R001 | §2.1 | Use one typed dispatch path; do not duplicate the provider table or retry policy. | dispatch | met | +| R002 | §2.1 | Shared dispatch returns Future/Stream and typed results; only the C adapter owns guarded blocking execution; Node/Python use their execution bridges. | dispatch, node, python | met | +| R003 | §2.1 | Canonical types never depend on FFI. | metadata, controls | gap — [G-f](rfc-0039-implementation.md#registered-gaps): The crate dependency direction is checked from Cargo manifests; no dedicated architectural dependency test proves the full closure. | +| R004 | §2.2 | Every op descriptor carries the RFC metadata: version, mode, scope, allowed kinds, type references, runtime handle fields, capabilities, cancellation, side effects, ownership, byte policy, retry owner and source inventory. | metadata, controls | met | +| R005 | §2.2 | Reference canonical stable type IDs; never define duplicate business types in the op table. | metadata, controls | gap — [G-d](rfc-0039-implementation.md#registered-gaps): Business type IDs exist; complete canonical schemas/type generation are absent. | +| R006 | §2.2 | Match exact case-sensitive op names; reject aliases and case changes. | names | met | +| R007 | §2.2 | Reject unknown control fields while preserving open providerOptions and JSON values. | metadata, controls | met | +| R008 | §3.1 | Handle 0 is global control scope only; never infer a provider. | matrix, inventory | met | +| R009 | §3.1 | Represent every listed kind distinctly, including Registry, eight model kinds, runtime and devtools kinds. | matrix, inventory | met — registered differences D1–D4/D6–D9 apply where relevant. | +| R010 | §3.1 | Operation subkind/state/capabilities govern access; audio push is transcription-only; distinguish trace middleware/store and session strings/operations. | matrix, inventory | met | +| R011 | §3.2 | Every unlisted op × kind fails InvalidHandle with expected/actual before provider work; no wildcard fallback except explicit handle.drop rules. | matrix, inventory | met | +| R012 | §3.2 | Expand each multi-op RFC row into independent descriptors and tests. | matrix, inventory | met — registered differences D1–D4/D6–D9 apply where relevant. | +| R013 | §3.2 | runtime.describe returns credential-free versions/capabilities; runtime.configure permits only existing logging, startup proxy and defaults, rejects unknown keys and frozen changes. | runtimeconfig | met | +| R014 | §3.2 | provider.create/default use the existing descriptor table, validate each typed runtime slot, and reject callback JSON. | providercreate | gap — [G-f](rfc-0039-implementation.md#registered-gaps): Construction validates typed slots, but there is no complete authoritative provider/default descriptor inventory; some declared slots fail explicitly. | +| R015 | §3.2 | registry.create rejects duplicate keys and resolves Provider references; registry.provider preserves source/extensions. | registrycreate | gap — [G-f](rfc-0039-implementation.md#registered-gaps): Duplicate keys and provider retention are covered; complete per-handle source/extensions are not. | +| R016 | §3.2 | provider.model selects exact modality by method/modelId, uses descriptor default, and reports UnsupportedFunctionality for missing methods. | providermodel | gap — [G-f](rfc-0039-implementation.md#registered-gaps): Explicit methods work; every package does not supply an authoritative default method. | +| R017 | §3.2 | provider.invoke accepts registered existing discovery/vendor methods only; never arbitrary method RPC. | providerinvoke | gap — [G-f](rfc-0039-implementation.md#registered-gaps): Only listModels is supported, not a complete registered named-vendor-method surface. | +| R018 | §3.2 | provider.wrap accepts only built-in middleware with per-capability checks and preserves source/extensions. | providerwrap | gap — [G-f](rfc-0039-implementation.md#registered-gaps): Existing wrappers work; complete per-handle/package provenance is absent. | +| R019 | §3.2 | provider.custom accepts explicit existing model mappings/defaults/built-in wrappers; reject host function objects. | providercustom | met | +| R020 | §3.2 | model.resolve uses explicit Registry namespace/key/method/modelId and never Vercel Gateway. | modelresolve | met | +| R021 | §3.2 | model.info exposes identity/modality/capabilities/source without settings/keys; model.wrap preserves modality with compatible built-in middleware. | modelinfo | gap — [G-f](rfc-0039-implementation.md#registered-gaps): Identity/modality and wrappers exist; source metadata is object-level and owner-scoped. | +| R022 | §3.2 | Router requires nonempty Language children, explicit strategy and per-child retry budgets; MoA requires Language references and aggregator; retain strong references rather than serialized identities. | composite | met | +| R023 | §3.2 | Text call/stream/OpenAI operations resolve optional Abort and registry/model references during DTO lift; output.toOpenAI uses an explicit metadata selector. | text | gap — [G-f](rfc-0039-implementation.md#registered-gaps): Text dispatch takes a Language handle; the stronger registry/string-model lifting requirement is not implemented in TextRequest. | +| R024 | §3.2 | embedding.embedMany processes every input, not just the first; embedding operations honor optional Abort. | embedding | met | +| R025 | §3.2 | Image/speech/transcription/reranking/video/search return canonical modality results; transcription bytes are V4 base64; adapter does not own video submit/poll retry. | modality | met | +| R026 | §3.2 | provider.files requires existing advertised files capability; files.upload uses canonical base64 and never retries an entire upload automatically. | files | met | +| R027 | §3.2 | transcription.start requires doStream; accepted connection failures are delivered through Operation terminal, not a second start error. | transcription | met | +| R028 | §3.2 | operation.push accepts bytes only while transcription input is open; acknowledgement means queued acceptance, not provider processing. | binaryinput | met | +| R029 | §3.2 | operation.inputDone is idempotent; operation.next is single-consumer poll returning part/ended/timeout; status retains terminal outcome; cancel distinguishes first/alreadyRequested/alreadyTerminal. | operationstate | met | +| R030 | §3.2 | Abort signal is idempotent across threads; transport and credential creation expose only existing Rust built-ins and never secrets. | credential | gap — [G-c](rfc-0039-implementation.md#registered-gaps): Credential creation remains Future/UnsupportedFunctionality. | +| R031 | §3.2 | Middleware is built-in only and trace variant requires TraceStore; trace queries/clear use data filters and session strings. | middleware | met | +| R032 | §3.2 | Recorder selects ring/JSONL and explicit capture policy; attach is explicit; flush reports actual persistence failure; drop never claims flush. | recording | gap — [G-b](rfc-0039-implementation.md#registered-gaps): Current full capture and persistence errors are covered; the complete RFC-0041 capture/schema policy is not implemented. | +| R033 | §3.2 | SessionStore infer is explicit; sessionId stays a string and unknown-session queries preserve their existing semantics. | session | met | +| R034 | §3.2 | Replay uses current RFC-0041 format; capabilities are built-in typed handles; inspect/dryRun have no side effects. | replaycapabilities | gap — [G-b/G-c](rfc-0039-implementation.md#registered-gaps): Current recording reader remains; transport capability handles are unavailable. | +| R035 | §3.2 | replay.live requires explicit current Registry/Model target and credentials; never restore old configuration. | replaylive | met | +| R036 | §3.2 | Catalogue keeps existing source/download guards; tool repair operations are pure DTOs with no host callback handles. | repair | met | +| R037 | §3.2 | auth.codexRefresh uses existing refresh input with no automatic retry, token recording or generic logging. | auth | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Invalid-input/secrecy coverage exists; no successful authenticated refresh test establishes the full requirement. | +| R038 | §3.2 | handle.drop accepts every kind and 0/unknown idempotently; remove registry entry before cancel/reclaim. | drop | met | +| R039 | §3.2 | Do not add descriptor-only skills/Evaluation/Batch/SpeechTranslation capabilities; unavailable current capabilities return UnsupportedFunctionality, never empty success. | runtimeinfo | met | +| R040 | §3.2 | Resolve runtime string-model inputs through an explicit registry; never weaken the primary-handle matrix. | registryresolve | gap — [G-f](rfc-0039-implementation.md#registered-gaps): Explicit registry resolution exists; no general runtime string-model lift is supplied by text control DTOs. | +| R041 | §3.3 | Allocate globally monotonic IDs from 1 without wrap/reuse; exhaustion fails without overwriting existing entries. | ids, exhaust | met | +| R042 | §3.3 | JSON handles/request IDs/seq are decimal strings; C handles are uint64_t. | ids | met | +| R043 | §3.3 | Record and validate unforgeable owner context: Node Env, Python interpreter and shared CProcess; foreign owners fail InvalidHandle without object details. | ownership, noderealm | met | +| R044 | §3.3 | Never transfer bare handles across Node realms, Python interpreters or C/native adapter owners; no owner-transfer API. | ownership, noderealm | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Registry and Node realm rejection are covered; actual Python subinterpreter import/teardown is not established. | +| R045 | §3.3 | Validate and clone under registry lock, release before await, callback, I/O, join or serialization. | locks | met | +| R046 | §3.3 | Ordinary drop invalidates future lookups while an acquired strong-reference call may finish; explicit Abort cancels calls; Operation drop cancels its task. | lifetime, secondary | met | +| R047 | §3.3 | Provider/Registry/Model/Files/composites retain dependencies; dropping parent or child handles leaves created objects usable. | lifetime, secondary | met | +| R048 | §3.3 | Runtime capability injection captures strong references and obeys resolver/provider lifecycle, never dangling integer references. | lifetime, secondary | met | +| R049 | §3.3 | Allow one reader plus concurrent push/cancel; reject a second reader as ConcurrentConsumer; linearize push/inputDone admission. | poll, operation | met | +| R050 | §3.3 | Close/Dispose atomically takes the handle and is idempotent; finalizer is leak prevention, not flush or successful terminal. | finalizer, goclose | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Native cleanup and several close races are covered; every language/platform finalizer race has not been executed. | +| R051 | §3.3 | Future stdio owner is connection-local; reject foreign-connection handles without details and never transfer bare handles across connections. | ownership | gap — [D10](rfc-0039-implementation.md#registered-differences-from-rfc-0039): Stdio connection ownership is deferred; the cited test covers native owners only. | +| R052 | §4.1 | Generate exactly the frozen primitive signatures, uint64 handles, callback signature and ABI encoding major<<16 | minor (v1). | gen, exports | met — registered differences D1–D4/D6–D9 apply where relevant. | +| R053 | §4.1 | Only result callbacks are exposed; no reverse fetch/token/model callbacks; generated named conveniences call the primitives, with no old-ABI forwarding compatibility. | gen, exports | met | +| R054 | §4.1 | aimux_abort only signals without block_on and is callable in callbacks; abort.signal reports invalid handles; drop/free tolerate 0/NULL. | callback | met | +| R055 | §4.1 | Document invalid owned pointers, double free and unterminated C strings as caller violations; do not promise runtime recovery. | abi | met | +| R056 | §4.2 | Use NUL-terminated UTF-8 with escaped internal NUL; reject invalid UTF-8 and do not normalize NULL/empty/null configuration to {}. | abi | met | +| R057 | §4.2 | Fallible APIs return NULL on success and an owned error on failure; zero every provided pointer/numeric output before failure so no stale value remains. | abi | met | +| R058 | §4.2 | All JSON/manifest/error getter strings are owned and freed with aimux_free_string; errors use aimux_error_free; never mix borrowed manifest ownership. | abi | met | +| R059 | §4.2 | Callback bytes expire on callback return; wrappers synchronously copy retained bytes; keep context through aimux_stream return and never callback afterward. | callback | met | +| R060 | §4.2 | Push copies/consumes bytes before returning; NULL+0 is legal, NULL+positive length is NullPointer; all length arithmetic is checked. | abipush | met | +| R061 | §4.2 | Poll uses PART=1/ENDED=2/TIMEOUT=3; no JSON allocation for ended/timeout; adapters project the same typed PollResult; remove old next_part symbol. | abipoll | met | +| R062 | §4.2 | Poll wait timeout is not Operation Timeout; poll errors zero out_part/out_state; actual operation timeout arrives as terminal error. | abipoll | met | +| R063 | §4.3 | Run blocking C adapter work off GUI/JS/UI-isolate threads; detect reentrancy before every blocking path and return ReentrantCall without catch_unwind reliance. | callback, node | gap — [G-i](rfc-0039-implementation.md#registered-gaps): C reentry and native bridges are covered; all UI/main-actor heartbeat paths lack platform execution evidence. | +| R064 | §4.3 | Catch host callback exceptions at boundary, latch first failure, abort, suppress later consumer delivery and throw exactly one CallbackFailure only after native/callback return. | javacallback | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Java/C++ latching has tests; equivalent execution for every language is not established. | +| R065 | §4.3 | Void native callbacks do not detect host exceptions; native observes Abort/cancelled; no C++ exception crosses extern C. | javacallback | met | +| R066 | §4.3 | Public callback contract requires bounded callbacks that only transfer results and states that a nonreturning callback cannot be forcibly cancelled. | callback | gap — [G-i](rfc-0039-implementation.md#registered-gaps): The public bounded-callback rule is documented; no test can prove forced interruption of a nonreturning callback. | +| R067 | §4.3 | Background joins run outside registry locks; initial transcription cleanup cap is 5 seconds; expiry records cleanup failure/cancelled, never normal finish. | cleanup, locks | met | +| R068 | §4.3 | Finalizers only cancel and enqueue reclamation; never block GC/finalizer threads. | finalizer | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Native queueing is tested; every platform GC/finalizer route still needs execution evidence. | +| R069 | §5.1 | Pre-acceptance wire/handle/capability/start errors return exactly one error with no callback. | stream, terminal | met | +| R070 | §5.1 | Every accepted Operation has exactly one terminal, including first connection failure; terminal control is distinct from business parts. | stream, terminal | met | +| R071 | §5.1 | Recoverable V4 error and finish parts remain events; only actual runtime failure terminates the Operation. | stream, terminal | met | +| R072 | §5.1 | Each C callback delivers one event/end; the last callback is end even for cancellation/failure; late cancellation cannot rewrite completed success. | stream, terminal | met | +| R073 | §5.1 | After end delivery stream returns NULL and errors occur only in end.error; preflight errors occur only in the return value. | stream, terminal | met | +| R074 | §5.1 | Callback transfer failure uses the wrapper latch: native emits cancelled end and returns NULL; wrapper delivers only saved CallbackFailure; exceptions never cross ABI. | stream, terminal | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Native terminal and host latches have tests; not every adapter has shared-vector execution evidence. | +| R075 | §5.1 | No old on_done callback or NULL-JSON success sentinel; EOF alone is never success. | stream, terminal | met | +| R076 | §5.1 | Pull terminal has a separate state slot; after buffered parts deliver terminal error once then ENDED; status retains outcome; full queues never lose Aborted. | stream, terminal | met | +| R077 | §5.2 | Abort is one-shot/shareable/idempotent; operations hold a clone; dropping Abort handle does not signal. | abort | met | +| R078 | §5.2 | Future.cancel, Task cancellation, iterator return/close and collector cancellation trigger underlying Abort. | javafuture, swiftcancel, pythonstream | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Cancellation paths exist with local tests; exhaustive cross-language runtime execution is incomplete. | +| R079 | §5.2 | Cancellation interrupts connect, retry backoff, HTTP-body/WS receive, input wait, output-capacity wait and blocked push. | fullcancel | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Operation waits/full queues are covered; all real transports and retry/backoff routes are not proved by these tests. | +| R080 | §5.2 | Cancellation stops subsequent work but cannot revoke completed billable submission; do not claim billing rollback. | nested | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Billing limits are documented; no automated test proves external submission rollback, which is not promised. | +| R081 | §5.2 | After first semantic output adapters never regenerate, switch models or replay events; retries belong only to operation policy/safe poll/download, never another binding retry loop. | nested | met | +| R082 | §5.2 | Disconnected execution can have unknown outcome; never automatically replay by the same ID on reconnect. | nested | gap — [D10](rfc-0039-implementation.md#registered-differences-from-rfc-0039): Unknown-outcome policy is documented; no reconnecting stdio transport exists. | +| R083 | §5.3 | Enforce both input 64 items/8 MiB and output 256 items/8 MiB; one event/chunk ≤1 MiB; Node/Python smaller 64-item bridges obey byte/terminal rules; allow lowering, never unlimited. | limits | met | +| R084 | §5.3 | Reject oversized items with ResourceLimit before allocation; item count alone is not a memory bound. | reserve | gap — [G-g](rfc-0039-implementation.md#registered-gaps): Admission is bounded before copy/serialization; this does not bound all preceding provider/core allocations. | +| R085 | §5.3 | Resource limits need RFC-0038 measurements before stable freeze; do not present them as performance results. | limits | gap — [D5](rfc-0039-implementation.md#registered-differences-from-rfc-0039): Implemented values are proposals; no RFC-0038 freeze measurements are claimed. | +| R086 | §5.3 | Full queue stops upstream polling without dropping/overwriting deltas or collecting the full stream. | queue | gap — [G-g](rfc-0039-implementation.md#registered-gaps): Bounded bridges stop their producer, but the upstream core text pump is still unbounded. | +| R087 | §5.3 | Abort/drop never require data capacity; independent terminal slot preserves outcome; explicit consumer close ends cancelled/consumerClosed and may discard pending data. | fullcancel | met | +| R088 | §5.3 | Swift implements lossless awaitable/bounded or background-blocking bridge; no AsyncThrowingStream drop-oldest/newest policy. | swift | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Bounded Swift implementation/tests exist; full Swift runtime/platform evidence is not established here. | +| R089 | §5.3 | Flutter cross-isolate bridge uses credit/ack; an unbounded SendPort is not backpressure. | dartcredit | met | +| R090 | §6.1 | Use kind/code/message/error plus optional retryAfterMs and camelCase; RFC-0039 §6.1 still shows a type payload discriminator. | error, unknown, null | met — [D1](rfc-0039-implementation.md#registered-differences-from-rfc-0039): RFC-0042 uses name and isRetryable where defined, with no top-level retryable or type; correct the RFC example to name when merged up with master (the RFC file is absent from this branch base). | +| R091 | §6.1 | Keep aimux/recording/binding/protocol domains with discriminated payloads; Node/Python parameter/serialization failures use binding, not provider API failure. | error, unknown, null | met | +| R092 | §6.1 | Preserve current code meanings/ranges: AiMux 1–20, Recording 100–105, binding 200–206; protocol 300–306; do not reassign historic code slots. | error, unknown, null | met — registered differences D1–D4/D6–D9 apply where relevant. | +| R093 | §6.1 | Preserve LoadApiKey/LoadSetting/provider/model/unsupported errors, Retry history, repair original/cause chains, RecordingError::Write and FFI parameter fields; never collapse to string Other. | error, unknown, null | met | +| R094 | §6.1 | Expose HTTP status only when a response was observed; no fabricated 401/500 for key, parse or offline auth failures; preserve token-expired meaning. | error, unknown, null | met | +| R095 | §6.1 | Preserve absent versus explicit null and missing hint versus 0; unknown errors keep their raw object/code without guessing a known subtype. | error, unknown, null | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Shared envelopes preserve unknown variants; exhaustive unknown-error preservation by every language is incomplete; no common unknown-error class is supplied. | +| R096 | §6.1 | Project an error once with captured time; C/Node/Python share it; repeated getters must not recompute HTTP-date hint. | projection, date | met | +| R097 | §6.2 | Capture injectable now once at first shared-dispatch error crossing. | projection, date | met | +| R098 | §6.2 | Only ApiCall response headers derive hints; try retry-after-ms numeric, retry-after fractional seconds, then HTTP-date minus now, case-insensitively. | retry, date | met — registered differences D1–D4/D6–D9 apply where relevant. | +| R099 | §6.2 | Accept finite nonnegative checked values, truncate milliseconds toward zero; NaN/Infinity/overflow/past dates yield no hint, never saturate; freeze negative-ms precedence by upstream golden. | retry, date | met — registered differences D1–D4/D6–D9 apply where relevant. | +| R100 | §6.2 | Retry aggregate keeps attempt errors/hints, has no aggregate hint, and is nonretryable under existing AI SDK payload semantics; never copy the final attempt hint to the top. | nested, retry | met | +| R101 | §6.2 | Hint never authorizes automatic retry; Abort/timeout do not retry; operation policy checks budget, attempts, semantic output and duplicate-charge risk. | nested, retry | met | +| R102 | §6.2 | Use one normalized parser shared from core; no eight language-specific parsers; fixed-clock tests cover all listed numeric/date/header/nesting cases. | nested, retry | met — registered differences D1–D4/D6–D9 apply where relevant. | +| R103 | §6.3 | Record boundary-specific classification: preflight proves no provider execution; runtime failure retains classification/attempts and prior data. | stream | met | +| R104 | §6.3 | Recoverable provider errors remain data and retain subsequent events without changing terminal. | stream | met | +| R105 | §6.3 | Serialization/bridge failure cancels and produces one binding/bridge failure, never {} or false success; cancellation preserves its source error and never resends. | stream | met | +| R106 | §6.3 | Redact default errors, trace and recordings; refresh-token/credential payloads never enter generic debug dumps; capture policy follows RFC-0041. | trace | gap — [G-b](rfc-0039-implementation.md#registered-gaps): Current canonical redaction remains; full future recording policy and secret-path audit are not established by the cited test. | +| R107 | §6.3 | For future stdio disconnect/write failure, expose transport failure/unknown outcome rather than success or redelivery; cancel unterminated operations and record incomplete reason. | stream | gap — [D10](rfc-0039-implementation.md#registered-differences-from-rfc-0039): Stdio disconnect/write-failure behavior is deferred. | +| R108 | §7.1 | Reject ABI major mismatch at load; minor additions cannot alter existing layout/calling convention/ownership. | java | met | +| R109 | §7.1 | opsVersion is major.minor with explicit supported type revisions/capabilities; same major is required; highest-common-minor selection applies only to future hello negotiation. | metadata | gap — [G-h](rfc-0039-implementation.md#registered-gaps): opsVersion exists; consumed-business-type revision/capability negotiation metadata does not. | +| R110 | §7.1 | Manifest revision/digest identify canonical sorted-key JSON SHA-256, not another protocol version or proof of binary equality. | digest | met | +| R111 | §7.1 | Recording schema is independently owned by RFC-0041; only new recordings are readable; no extra live-ops handshake version. | recordingschema | gap — [G-b](rfc-0039-implementation.md#registered-gaps): Writers use schema 2 and readers accept schemas 1 and 2; schema 3 and removal of old-schema reading are planned as the final step of this series under RFC-0041. | +| R112 | §7.1 | Manifest records RFC-0042 schema/type revision and pinned SDK baseline; no dataFormatVersion/data_format_version handshake; JSON parseability does not establish variant compatibility. | types | gap — [G-d/G-h](rfc-0039-implementation.md#registered-gaps): There is no complete RFC-0042 type revision/hash and pinned SDK baseline catalogue. | +| R113 | §7.2 | Future stdio starts with hello and decimal-string request ID; examples do not claim implementation. | schema | gap — [D10](rfc-0039-implementation.md#registered-differences-from-rfc-0039): Stdio hello/request-ID transport is deferred. | +| R114 | §7.2 | Future hello returns chosen version/type capabilities/digest/supportedOps/flags/minimum limits and projects exact negotiated control schema; never ignore unknown controls. | schema | gap — [D10](rfc-0039-implementation.md#registered-differences-from-rfc-0039): Stdio negotiation and negotiated schema projection are deferred. | +| R115 | §7.2 | Future hello rejects/closes on no common version, missing requiredOp or unavailable type capability; no legacy downgrade and no provider work before handshake. | schema | gap — [D10](rfc-0039-implementation.md#registered-differences-from-rfc-0039): No stdio handshake implementation exists. | +| R116 | §7.2 | FFI bindings and raw C initialize by checking ABI then manifest, exact current/expected ops version and consumed schema revision/hash; reject mismatch with no implicit minor negotiation. | gen | gap — [G-h](rfc-0039-implementation.md#registered-gaps): ABI/ops loaders exist; consumed-business-type revision/hash checks are unavailable. | +| R117 | §7.2 | Bundled Node/Python also check generated-product consistency and expose version/digest; co-packaging does not waive drift checks. | gen | met — Node and Python require the exact generated ABI version, ops version and manifest digest at load. | +| R118 | §7.2 | Unconsumed package/catalogue metadata changes need not change consumed type hash; only explicit future stdio hello offers minor projection. | gen | gap — [G-h](rfc-0039-implementation.md#registered-gaps): No per-consumed-business-type hashes exist; metadata independence is not testable yet. | +| R119 | §7.3 | Generate JSON Schema 2020-12 from manifest with stable resolving $id/type-ID refs and disjoint oneOf discriminators. | schema, controls | gap — [G-d](rfc-0039-implementation.md#registered-gaps): Control schemas resolve; business schemas and all business discriminators are incomplete. | +| R120 | §7.3 | Controls set additionalProperties:false; explicitly open providerOptions/metadata/JSON values; never globally strip private provider data. | schema, controls | met | +| R121 | §7.3 | Provide schemas and positive/negative vectors for requests/results/events/end/errors, all handles/options missing/null, integer ranges, base64 bytes, time strings and modalities. | schema, controls | gap — [G-d/G-i](rfc-0039-implementation.md#registered-gaps): Control vectors and representative business tests exist; complete result/event/modality vectors do not. | +| R122 | §7.3 | Schema validation does not replace live-handle, capability, state-machine or reentrancy runtime validation; duplicate request-ID checks belong to future request transport. | matrix | met | +| R123 | §7.3 | Unknown business unions fail explicitly rather than decode to empty content; unknown-error projections preserve raw values without executing unknown behavior. | unknown, schema | gap — [G-d/G-i](rfc-0039-implementation.md#registered-gaps): Shared unknown errors survive; no common unknown-error class or complete language-wide strict business-union evidence is supplied. | +| R124 | §7.3 | Different major, unsupported type capability or changed meaning of the same op must fail compatibility tests. | unknown, schema | gap — [G-h](rfc-0039-implementation.md#registered-gaps): ABI/ops mismatch tests exist; unsupported business-type capability checks cannot run without metadata. | +| R125 | §7.3 | Future negotiated old minor sends exactly that control schema; missing requiredOps fails. | unknown, schema | gap — [D10](rfc-0039-implementation.md#registered-differences-from-rfc-0039): Negotiated old-minor stdio projection is deferred. | +| R126 | §9 | Expose Provider, Registry, typed models, Operation/stream, Abort, devtools and structured errors; typed and raw JSON layers behave identically. | inventory | gap — [G-d/G-i](rfc-0039-implementation.md#registered-gaps): Current resources expose the ops; complete raw/typed equivalence for every business DTO is not established. | +| R127 | §9.1 | Generate aimux.h/op constants/opaque error and ownership comments; C++ examples use RAII for owned strings/errors/handles with no exception crossing ABI. | gen, exports | met | +| R128 | §9.1 | Named provider/model convenience APIs implement the new contract; old aimux_openai_new-style symbols disappear and exact export allowlist is published. | gen, exports | met | +| R129 | §9.1 | Run C11/C++ compile/link/runtime, every error free, NULL output sentinels, invalid UTF-8, oversized length, callback lifetime, drop races, full wrong-kind matrix and ASan/LSan; distinguish cross-compile from executed targets. | abi, callback | gap — [G-e/G-i](rfc-0039-implementation.md#registered-gaps): Native tests exist; all-platform runtime/ASan/LSan and accepted live callback coverage are incomplete. | +| R130 | §9.2 | Keep cgo; explicit Close and runtime.KeepAlive guard native resources; context cancellation aborts; cgo.Handle stores only callback context and no retained Go pointers in Rust. | goclose | met | +| R131 | §9.2 | Use bounded Go channel, producer as only closer, Err readable only after close, and cancellation-select on full-queue callback send. | go | met | +| R132 | §9.2 | Generated Go DTOs preserve missing/null and uint64 decimal strings; errors.As exposes typed errors with full envelope. | gowire | gap — [G-d/G-i](rfc-0039-implementation.md#registered-gaps): Representative Go codecs exist; generated full DTO coverage and all optional/null distinctions remain incomplete. | +| R133 | §9.2 | Exercise race detector, concurrent Close/Generate, cancellation before start/full queue/terminal race, unread Cancel, zero cgo handles, child-drop composite and all-modality typed round trips. | go, goclose | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Race/fixture/native tests exist; the all-modality real-service and platform matrix is incomplete. | +| R134 | §9.3 | Generate JNA declarations once; retain Java 8 final classes/constructors/accessors rather than records; document any removed redundant business DTO Builders as breaking. | java | gap — [G-d](rfc-0039-implementation.md#registered-gaps): Portable Java declarations and Java 8 classes exist; JNA declarations are handwritten, not generated by the manifest generator. | +| R135 | §9.3 | Use AutoCloseable with sync methods and explicit Executor Future; Future.cancel triggers Abort; callback context stays strongly referenced through native return. | javafuture | met | +| R136 | §9.3 | Never wait for close write lock in callback holding model read lock; retain admitted-call references and separate close from cancellation. | javaclose | met | +| R137 | §9.3 | Test supported JDK matrix, JNA 32/64 signatures, exception subtypes, UTF-8, Future.cancel, slow/throwing consumers, repeated close, Java 8 compile and early native-version mismatch. | java, javacallback | gap — [G-e/G-i](rfc-0039-implementation.md#registered-gaps): Portable signatures and local tests exist; the supported JDK/32-bit runtime matrix is incomplete. | +| R138 | §9.4 | Kotlin depends on same-version Java artifact; remove duplicate JNA/loader; keep exhaustive sealed AimuxException mapped from Java envelope without another field decoder. | kotlin | met | +| R139 | §9.4 | Coroutines/Flow use IO dispatcher and bounded channel; collector cancellation aborts; no unbounded callbackFlow buffer. | kotlin | met | +| R140 | §9.4 | Kotlin DTOs derive from the same manifest, never a second wire authority; Gradle/CI explicitly wires Java public types to Kotlin sealed projections. | kotlin | gap — [G-d](rfc-0039-implementation.md#registered-gaps): Kotlin reuses Java DTOs and sealed projections, but complete manifest-driven DTO generation is absent. | +| R141 | §9.4 | Verify actual Java artifact resolution, sealed when, Flow.take(1)/cancel/errors, off-UI execution, provider/model interchange and matching error subtypes/unknown fallback. | kotlin | gap — [G-e/G-i](rfc-0039-implementation.md#registered-gaps): Source-build interop tests exist; published artifact resolution and every platform are not verified. | +| R142 | §9.5 | Use UInt64 wrappers with explicit close; blocking C calls run on background executor; AsyncSequence has real bounded backpressure. | swift | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Background Swift bounded implementation exists; full execution evidence is incomplete. | +| R143 | §9.5 | Iterator termination or Task cancellation aborts; Unmanaged context releases exactly once after last callback; error conversion cannot throw across callback. | swiftcancel | gap — [G-i](rfc-0039-implementation.md#registered-gaps): BoundedStream uses iterator cancel and lease cleanup; Swift cancellation/context tests exist, but platform execution remains incomplete. | +| R144 | §9.5 | Sendable claims require synchronization and close/admission race control; unchecked annotation alone is insufficient. | swiftclose | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Swift synchronization and race tests exist; platform execution remains incomplete. | +| R145 | §9.5 | Test main-actor heartbeat, early for-await exit, full-queue cancel, concurrent close, context release counts, iOS simulator link, macOS vectors and unknown error preservation. | swift, swiftcancel | gap — [G-e/G-i](rfc-0039-implementation.md#registered-gaps): Swift tests do not establish simulator linkage or all platform execution. | +| R146 | §9.6 | Derive Dart FFI declarations from new header; keep platform loader/iOS DynamicLibrary.process; verify loaded version and manifest. | dart | met | +| R147 | §9.6 | Blocking native work uses background isolate; callbacks obey Dart thread/isolate rules, never arbitrary Rust threads calling main-isolate callbacks; controlled bridge uses credit. | dartcredit | met | +| R148 | §9.6 | NativeFinalizer uses separate nonblocking void(void*) trampoline/token, never casts uint64 drop to pointer ABI; explicit close detaches to prevent double release. | dartfinalizer | met | +| R149 | §9.6 | Test UI heartbeat, stream pause/resume, credit-exhausted cancel, finalizer/close races, Android ABIs and iOS static force-link; replace old nm symbol expectation with required new symbols rather than deleting gate. | dart | gap — [G-e/G-i](rfc-0039-implementation.md#registered-gaps): Dart bridge/runtime tests exist; Android ABI/iOS force-link installation is unverified and Windows streaming is unsupported. | +| R150 | §9.7 | Keep napi-rs async Promise and AsyncIterator/ReadableStream; call shared typed dispatch without blocking C adapter; poll futures only in documented Tokio context. | node | met | +| R151 | §9.7 | Wire AbortSignal, iterator.return, reader.cancel and environment shutdown to Abort; isolate realm-local handles and JS object lifetimes. | noderealm | met | +| R152 | §9.7 | Existing result TSFN/Promise transport may remain, but expose no JS fetch/model/token/telemetry injection or deferred host callback capabilities. | node | met | +| R153 | §9.7 | Test event-loop timer, slow/aborted/unconsumed streams, realm crossing, shutdown without hang, complete error properties, raw/typed consistency and provider/trace/replay vectors matching C. | node, noderealm | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Native lifecycle tests expect trace middleware creation to succeed; successful execution of every socket-dependent scenario and the complete shared vector set is not established here. | +| R154 | §9.8 | Keep thin PyO3; extract owned parameters then release GIL for every potentially blocking generate, I/O construction, discovery, poll, flush and join; hold GIL only for Python-object access. | python | met | +| R155 | §9.8 | Synchronous iterator has bounded Rust channel; close/context exit/cancel abort; StopIteration only for successful exhaustion; terminal error raises correct AimuxError subtype. | pythonstream | met | +| R156 | §9.8 | No arbitrary Python callable fetch/credential/model or new asyncio callback bridge; retained async surface uses Python execution bridge and never labels blocking iterator async generator. | pythonwire | met | +| R157 | §9.8 | Keep native PyO3 wheel packaging without ctypes library discovery or dual wheel scheme. | python | gap — [G-e/G-i](rfc-0039-implementation.md#registered-gaps): The PyO3 packaging path remains; an installed-wheel build/install/offline smoke is not established here. | +| R158 | §9.8 | Test GIL heartbeat for all blocking routes, full-queue iterator.close, KeyboardInterrupt/explicit cancel, GC leaks, cause/Retry history, installed-wheel offline smoke and all-modalities typed round trip. | python, pythonstream | gap — [G-d/G-e/G-i](rfc-0039-implementation.md#registered-gaps): Local GIL/queue tests exist; installed wheel, Pydantic and exhaustive modality/platform evidence are incomplete. | +| R159 | §10.1 | Keep RFC-0042 canonical types/wire/errors, RFC-0040 descriptors/settings/auth slots and RFC-0041 recording/replay ownership; this RFC owns op/control/lifecycle/bridges. | gen, types | gap — [G-b/G-d/G-f](rfc-0039-implementation.md#registered-gaps): Layer ownership is retained; the full descriptor/recording/type specification is not complete. | +| R160 | §10.1 | One manifest drives dispatch registration, header, language ops/types/factories, schema, reference docs and vectors; handwritten loaders/bridges are allowed, handwritten provider behavior/field tables/per-provider constructor lists are not. | gen, types | gap — [G-d](rfc-0039-implementation.md#registered-gaps): The manifest generates headers/constants/control schemas, not all DTOs/factories/reference docs/vectors. | +| R161 | §10.1 | Provider tool-call input remains V4 raw String while core tool input is parsed JSON; never reintroduce untagged ToolInput to merge them. | base64 | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Raw/parsed tool input separation remains in core; the cited byte test is not a complete tool-input conformance gate. | +| R162 | §10.1 | EndFrame is transport control, separate from RFC-0042 business stream event types; no fifth StreamPart type system. | stream | met | +| R163 | §10.2 | aimux-codegen --check regenerates in temporary directory and compares complete output sets; missing/stale/differing/format/digest/ref/doc-table drift fails; no divergent build.rs generation. | gencheck | gap — [G-d](rfc-0039-implementation.md#registered-gaps): Complete managed-output drift is tested; docs/facades/business vectors are not generated outputs. | +| R164 | §10.2 | Generate op × kind × error-state matrix: allowed reaches correct probe handler; rejected never invokes provider; include 0/unknown/dropped/other runtime/exhausted. | matrix, exhaust | met | +| R165 | §10.2 | Each DTO vector goes Rust encode → eight-language decode/encode → Rust decode; ignore object-key order only; preserve array order/precision/missing/null/metadata/bytes and unknown raw values where permitted. | gowire, schema | gap — [G-d/G-i](rfc-0039-implementation.md#registered-gaps): Representative round trips exist; no exhaustive eight-language corpus is executed. | +| R166 | §10.2 | Dispatch and every binding share semantic records for accepted, preflight failure, finish, recoverable/terminal error, cancel/late cancel, reader close, duplicate finish, serialization failure and full queue. | stream | gap — [G-i](rfc-0039-implementation.md#registered-gaps): All native shared vectors run; the external binding-driver hook remains ignored and full adapter execution is incomplete. | +| R167 | §10.2 | Compare export allowlist and generated header both ways; old constructors/payload getters/header references must scan to zero. | exports, retired | met | +| R168 | §10.2 | Node/Python Rust dependency trees never reintroduce retired provider entry points. | exports, retired | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Retired files are checked, but this test does not audit both adapters full dependency trees. | +| R169 | §10.2 | Common gates and every language-specific suite, Rust workspace tests/clippy/rustdoc, contract tests, binding jobs and packaging smoke use the same integrated SHA; language-only CI is insufficient. | stream, gencheck | gap — [G-e/G-i](rfc-0039-implementation.md#registered-gaps): CI definitions are not same-source all-platform build/test/package/install results. | +| R170 | §10.2 | Keep byte-level cassette gates and existing fixtures; new/re-recorded recording data follows RFC-0041; historic inputs are explicit incompatibility negatives/assets, never fake old-format success. | recordingschema | gap — [G-b](rfc-0039-implementation.md#registered-gaps): Readers accept schemas 1 and 2; schema 3 and removal of old-schema reading remain the final planned step under RFC-0041. | +| R171 | §10.2 | RFC-0038 owns performance/size/FFI/Node/Python overhead and RSS thresholds; this RFC supplies workloads/lifecycle assertions, not competing thresholds. | stream, gencheck | gap — [D5](rfc-0039-implementation.md#registered-differences-from-rfc-0039): RFC-0038 owns thresholds; measurements/reports for freeze are not supplied. | +| R172 | §10.2 | Required suites include ops_matrix, handle_lifecycle, stream_terminal, error_envelope_golden, manifest_contract and every language vector suite; never mark an unrun platform as passed. | stream, gencheck | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Required suite sources exist; unrun platform results are not passes. | +| R173 | §11.1 | Architecture acceptance requires resolved numbering/status/supersession, roadmap and architecture conflicts, with old coexistence dependencies revoked or rewritten. | exports, gencheck | gap — [G-e](rfc-0039-implementation.md#registered-gaps): Acceptance/supersession decisions are not proved by a runtime unit test. | +| R174 | §11.1 | Schema readiness requires one authority for RFC-0042 DTOs, RFC-0040 descriptors and op table with generatable manifest/schema/vectors. | exports, gencheck | gap — [G-d/G-f](rfc-0039-implementation.md#registered-gaps): Canonical Rust types and op descriptors exist; the complete generated descriptor/type closure does not. | +| R175 | §11.1 | Consumer migration requires full provider/runtime/devtools/C/eight-binding/CLI/Web/replay dependency closure, successful builds and zero old direct paths. | exports, gencheck | gap — [G-e/G-i](rfc-0039-implementation.md#registered-gaps): Consumer source migration is not an all-platform build/install result; the obsolete Node benchmark package has been removed. | +| R176 | §11.1 | Behavior verification requires same-SHA behavior and performance evidence for errors/terminal/cancel/backpressure/ownership/all-modalities/devtools; known failures have fixes, never hidden skips. | exports, gencheck | gap — [G-e/G-i](rfc-0039-implementation.md#registered-gaps): Complete same-source behavior/performance evidence is absent. | +| R177 | §11.1 | Final cutover removes old exports/headers/getters/constructors/old-only tests atomically with replacement coverage, symbol diff, installed-package smoke and asset manifest. | exports, gencheck | gap — [G-e/G-i/G-k](rfc-0039-implementation.md#registered-gaps): Export deletion is tested; installed assets, complete replacement coverage and offline mock-model migration remain incomplete. | +| R178 | §11.1 | Release packaging builds all packages/binaries from one SHA with breaking guide and per-asset checksums/ABI/ops/type/manifest identity. | exports, gencheck | gap — [G-e/G-h](rfc-0039-implementation.md#registered-gaps): No complete published asset manifest with verified type identity/install results is supplied. | +| R179 | §11.1 | Schema, consumer, behavior and cutover changes may be separate integration commits, never per-language compatibility releases; merge/replay parallel mainline changes and rerun all gates before publication. | exports, gencheck | gap — [G-e/G-i](rfc-0039-implementation.md#registered-gaps): No final publication/rebase gate record is supplied. | +| R180 | §11.2 | Maintain coverage ledger for every old public capability → new op/type/facade/test; deleting provider symbols cannot delete trace/session/recording capabilities. | exports, gencheck | gap — [G-d/G-i/G-k](rfc-0039-implementation.md#registered-gaps): The ledger maps all old declarations but explicitly records partial equivalence and offline replay gaps. | +| R181 | §11.2 | Maintain automated deletion ledger for old symbols/headers/imports/dependency edges; name replacement test before deleting old-only test; line-count reduction is not acceptance. | exports, gencheck | gap — [G-i](rfc-0039-implementation.md#registered-gaps): Symbol/file deletion tests exist; exhaustive old-only-test equivalence and dependency-edge deletion are not proved. | +| R182 | §11.2 | Release matrix records build/test/package/install separately for every supported OS/arch/library/package, including C/C++, Swift modulemap, Flutter iOS force-link, JNA native, Go linking, Node prebuild and Python wheel. | exports, gencheck | gap — [G-e](rfc-0039-implementation.md#registered-gaps): Build/test/package/install results are incomplete for the full release matrix. | +| R183 | §11.2 | Every asset carries identical source SHA, ABI/ops/type revision, recording schema and manifest digest; Kotlin pins same-series Java; reject accidental old native loads. | exports, gencheck | gap — [G-e/G-h](rfc-0039-implementation.md#registered-gaps): Kotlin pins Java; all asset/type-revision identity and install evidence are incomplete. | +| R184 | §11.2 | Regression evidence contains shared vectors, wrong-kind tables, terminal/full-queue cancel, GIL/event-loop/UI heartbeats, resource counts and RFC-0038 reports. | exports, gencheck | gap — [G-e/G-i](rfc-0039-implementation.md#registered-gaps): Narrow local tests do not provide the full same-source platform/performance bundle. | +| R185 | §11.2 | Breaking guide states removed one-shot factories/settings/aliases/old wire/recording readers, new provider→model examples and upgraded calls; do not add compatibility adapter or data migrator. | recordingschema | gap — [G-b](rfc-0039-implementation.md#registered-gaps): The guide states schema-2 writing and schema-1/2 reading; schema 3 and removal of old-schema reading remain the final planned step under RFC-0041. | +| R186 | §11.2 | Never declare cutover complete while a required language is unmigrated, a supported platform is build-only, or an asset is unverified; support reduction needs separate accepted product decision, never automatic skip. | exports | gap — [G-e](rfc-0039-implementation.md#registered-gaps): No complete release/cutover acceptance is claimed. | +| R187 | §11.3 | Before merge, fix or withdraw entire failed integration branch; mainline stays old version with no temporary half-switch aliases. | exports | gap — [G-e](rfc-0039-implementation.md#registered-gaps): No branch withdrawal/recovery rehearsal evidence is supplied. | +| R188 | §11.3 | After release failure, pause rollout and withdraw/mark bad assets under repository policy; users can pin previous complete release. | exports | gap — [G-e](rfc-0039-implementation.md#registered-gaps): Pinning previous complete assets is documented; publisher withdrawal/incident evidence is absent. | +| R189 | §11.3 | New bindings never auto-load old ABI and never silently read new recordings as old format. | exports | gap — [G-b/G-e](rfc-0039-implementation.md#registered-gaps): Old ABI mismatches are rejected; retained recording compatibility and full platform installs remain gaps. | +| R190 | §11.3 | Release notes explicitly warn rollback may not read new-version data, require keeping originals and promise no automatic conversion. | exports | gap — [G-e](rfc-0039-implementation.md#registered-gaps): Rollback data preservation is documented; actual release notes/assets are not certified here. | +| R191 | §11.3 | Repair release rebuilds complete assets from new all-green SHA; no same-name platform binary substitution with different source/manifest; unretractable package versions stay traceable with a new fix version. | exports | gap — [G-e](rfc-0039-implementation.md#registered-gaps): No complete repair-release asset identity evidence is supplied. | +| R192 | §11.4 | stdio, raw-file binary entry and native passthrough are separate future capabilities and do not block current cleanup switch. | inventory | gap — [D10](rfc-0039-implementation.md#registered-differences-from-rfc-0039): The deferred capabilities are not implemented; native inventory tests only prove current names. | +| R193 | §11.4 | Before future stdio approval: finish native cutover, confirm scope/CLI/process boundary, pass §8 fuzz/protocol/credit/cancel/EOF/shutdown/FFI vectors, establish RFC-0038 baselines and publish truthful implemented-capability table. | inventory | gap — [D10/G-e](rfc-0039-implementation.md#registered-gaps): Stdio acceptance and protocol/performance evidence are deferred. | +| R194 | §11.4 | Until stdio acceptance passes, docs/manifest never claim stdio is supported; schema or candidate protocol text is not an implemented entry. | inventory | met | +| R195 | §11.5 | Before 1.0, obtain separate signoff on every freeze criterion below; elapsed stability alone is insufficient. | inventory, exports | gap — [G-e](rfc-0039-implementation.md#registered-gaps): No 1.0 per-criterion signoff is supplied. | +| R196 | §11.5 | Freeze requires ops-v1 golden semantics for op/kind/ownership/thread/reentrancy/error code/cancel/terminal/backpressure/version rules. | inventory, exports | gap — [G-e/G-i](rfc-0039-implementation.md#registered-gaps): Local golden semantics are not full candidate freeze approval. | +| R197 | §11.5 | Freeze requires ABI-v1 header/exports/calling-convention/loader-mismatch tests and installed verification on every publicly supported platform. | inventory, exports | gap — [G-e](rfc-0039-implementation.md#registered-gaps): Every public platform still needs ABI install verification. | +| R198 | §11.5 | Freeze registers RFC-0042 V4 schema/type revision in manifest; never silently replace V4/V5 business shapes via ops minor; recording schema is independent with documented upgrade impact. | inventory, exports | gap — [G-d/G-h](rfc-0039-implementation.md#registered-gaps): Complete V4 type revision/hash metadata is absent. | +| R199 | §11.5 | If 1.0 promises stdio, its acceptance checks must pass first; otherwise record accepted reduced 1.0 scope before release; schema files alone cannot prove stdio support. | inventory, exports | gap — [D10/G-e](rfc-0039-implementation.md#registered-gaps): No accepted stdio/reduced-1.0 release scope is certified. | +| R200 | §11.5 | L0/L1 stable boundaries come from RFC-0040/#200 decisions; do not freeze unapproved passthrough or host injection. | inventory, exports | gap — [G-f/G-e](rfc-0039-implementation.md#registered-gaps): No full stable-boundary freeze approval is supplied. | +| R201 | §11.5 | Native cutover must already be atomically complete; no shadow old exports, compatibility aliases or dual generator chains. | inventory, exports | gap — [G-d/G-e/G-k](rfc-0039-implementation.md#registered-gaps): Export deletion alone is not complete cutover; business generation and capability gaps remain. | +| R202 | §11.5 | Freeze candidate has stable #95 errors/runtime/recording/replay and all language contracts on same candidate; RFC-0038 defines performance green-cycle/statistics criteria. | inventory, exports | gap — [G-e/G-i](rfc-0039-implementation.md#registered-gaps): Same-candidate language/performance stability evidence is incomplete. | +| R203 | §11.5 | Public English binding docs accurately describe retry/billing side effects, drop versus abort, noninterruptible callbacks, unknown outcomes and incompatible-data rollback. | inventory, exports | gap — [G-i](rfc-0039-implementation.md#registered-gaps): This guide covers the limitations; a complete public-doc/language execution audit is still absent. | + +## How to close gap rows + +Run the named suites on the candidate source, record fixture versus actual +native-library execution, and retain failures/skips as gaps. G-d/G-h need real +business schemas, type revisions and loader checks; they cannot be closed by +renaming type IDs. G-e/G-i need separate build/test/package/install records for +each supported OS/architecture/package, plus the RFC-0038 reports. Stdio needs +its separate transport implementation and tests. G-k needs an exposed offline +mock Language generation path with replacement tests, or an accepted explicit +capability removal; replay.live is not an equivalent offline path. diff --git a/docs/rfc-0039-coverage-ledger.md b/docs/rfc-0039-coverage-ledger.md new file mode 100644 index 000000000..b1e4945ec --- /dev/null +++ b/docs/rfc-0039-coverage-ledger.md @@ -0,0 +1,298 @@ +# RFC-0039 coverage ledger + +This Markdown document is the coverage ledger. It records the 130 retired or +retained C exports and operation-level evidence, plus representative binding +facades and tests. It does not enumerate every former language declaration, +overload, DTO, builder or codec, and does not claim individual replacement +coverage for them. The C inventory baseline is +`7dbce97af1a50017c4598bf2a684bfec4f50029d`. + +## Reading the ledger + +The C rows map old exports to current operations or ABI primitives; the +capability and binding tables identify existing tests (`path#name`) and current +facade sources. ` + ` is a migration recipe, not a new op. Old names are +historical inventory records, not assertions that those declarations still +exist in this checkout or compatibility aliases. + +- **mapped:** a current operation/ABI path and related existing test are identified; + this is not complete behavioral equivalence or a test-pass claim. +- **partial:** representative coverage exists, but old per-method/DTO equivalence + or adapter evidence is incomplete; the note states the limitation. +- **gap:** a specific former capability is not supplied by the replacement path. + +The bindings use the actual shared dispatcher/ABI, with Python Client/Handle, +Node Client/resources, Go Create/Handle, Java Aimux/NativeHandle (also used by +Kotlin), Swift AimuxRuntime/AimuxHandle and Dart Aimux/OpsHandle. Generic dispatch +is intentional: a ledger recipe does not invent a convenience method. +Representative DTO tests are explicitly partial, not asserted coverage of every +builder or every raw/typed round trip. Business DTO generation remains G-d. + +## Binding evidence + +These sources expose current dispatch facades. Each test establishes only the +behavior named, not equivalence for every former API in that language; complete +replacement coverage remains a gap under G-d/G-i, with offline mock generation +tracked separately as G-k. Test presence is not an execution result. + +| Interface | Current facade source | Representative existing test | +|---|---|---| +| Go | `bindings/go/aimux.go` (`Create`, `Handle.Call`); `bindings/go/operation.go` (`Handle.StartOperation`) | `bindings/go/native_test.go#TestNativeDispatchAndCompositeLifetime` | +| Java | `bindings/java/src/main/java/ai/arcships/aimux/Aimux.java` (`Aimux`); `bindings/java/src/main/java/ai/arcships/aimux/NativeHandle.java` (`NativeHandle`) | `bindings/java/src/test/java/ai/arcships/aimux/AbiContractTest.java#allEightModalitiesAndFilesUseTheSameOwnedProviderPrimitives` | +| Kotlin | `bindings/kotlin/src/main/kotlin/ai/arcships/aimux/kotlin/Coroutines.kt` (`aimuxCall`, `aimuxFlow`), using Java resources | `bindings/kotlin/src/test/kotlin/ai/arcships/aimux/kotlin/NativeIntegrationTest.kt#real manifest loads and Java provider model objects retain identity` | +| Swift | `bindings/swift/Sources/Aimux/Aimux.swift` (`AimuxRuntime`, `AimuxHandle`) | `bindings/swift/Tests/AimuxTests/NativeSmokeTests.swift#providerAndModel` | +| Dart | `bindings/flutter/lib/aimux.dart` (`Aimux`, `OpsHandle`) | `bindings/flutter/test/native_runtime_test.dart#provider/model handles retain children after provider close` | +| Node | `bindings/node/src/index.ts` (`Client`, `Resource`); `bindings/node/src/native.ts` (`Runtime`) | `bindings/node/__test__/contract.native.ts#typed lifecycle uses the real provider, registry, files, composite and devtools DTOs` | +| Python | `bindings/python/python/aimux/__init__.py` (`Client`, `Handle`, `EventStream`) | `bindings/python/tests/test_ops_integration.py#test_control_dtos_reject_unknown_fields` | + +## Capability evidence + +Every operation used in a recipe has an existing related test below. Registration +tests establish only registration; other family tests establish behavior as +described by their names. A successful provider/network request, every business +DTO and all language projections require additional evidence (G-i). + +| New operation | Existing test | +|---|---| +| `abort.create` | `aimux-ops/tests/handles.rs#abort_is_idempotent_and_signals_another_thread` | +| `abort.signal` | `aimux-ops/tests/handles.rs#abort_is_idempotent_and_signals_another_thread` | +| `auth.codexRefresh` | `aimux-ops/tests/misc.rs#invalid_auth_request_is_projected_once_without_token_values` | +| `catalogue.fetch` | `aimux-ops/src/ops/misc.rs#catalogue_rejects_untrusted_sources_before_io` | +| `composite.moa` | `aimux-ops/tests/composite.rs#moa_retains_references_and_aggregator_and_completes` | +| `composite.router` | `aimux-ops/tests/composite.rs#dropped_router_children_keep_working_with_independent_retry_budgets` | +| `credential.create` | `aimux-ops/tests/handles.rs#credential_creation_is_future_and_never_exports_supplied_material` | +| `decision.capabilities` | `aimux-ops/tests/decision.rs#capabilities_match_existing_providers_and_children_survive_parent_drop` | +| `decision.decide` | `aimux-ops/tests/decision.rs#decide_matches_core_for_all_state_shapes_and_question_types` | +| `embedding.embed` | `aimux-ops/src/ops/modalities.rs#embedding_single` | +| `embedding.embedMany` | `aimux-ops/src/ops/modalities.rs#embedding_many_preserves_all_inputs` | +| `files.upload` | `aimux-ops/tests/files.rs#uploads_canonical_base64_and_preserves_options_and_context_abort` | +| `handle.drop` | `aimux-ops/tests/ops_matrix.rs#every_manifest_op_crosses_every_kind_and_invalid_handle_state` | +| `image.generate` | `aimux-ops/src/ops/modalities.rs#image_generation` | +| `middleware.create` | `aimux-ops/tests/handles.rs#trace_middleware_creation_retains_store_and_audit_configuration` | +| `model.info` | `aimux-ops/src/ops/registry.rs#direct_models_have_no_invented_source_and_empty_wrap_retains_modality` | +| `model.resolve` | `aimux-ops/src/ops/registry.rs#resolution_preserves_exact_ids_and_reports_safe_source` | +| `model.wrap` | `aimux-ops/tests/registry.rs#language_and_image_middleware_use_core_order_and_keep_source` | +| `operation.cancel` | `aimux-ops/tests/operation.rs#native_binary_and_json_poll_share_the_registered_operation_service` | +| `operation.inputDone` | `aimux-ops/tests/operation.rs#native_binary_and_json_poll_share_the_registered_operation_service` | +| `operation.next` | `aimux-ops/tests/operation.rs#native_binary_and_json_poll_share_the_registered_operation_service` | +| `operation.push` | `aimux-ops/tests/operation.rs#native_binary_and_json_poll_share_the_registered_operation_service` | +| `operation.status` | `aimux-ops/tests/operation.rs#native_binary_and_json_poll_share_the_registered_operation_service` | +| `output.toOpenAI` | `aimux-ops/tests/misc.rs#output_uses_explicit_metadata_and_model_fallback_without_generation` | +| `provider.create` | `aimux-ops/src/ops/provider.rs#create_uses_the_existing_package_factory` | +| `provider.custom` | `aimux-ops/src/ops/provider.rs#custom_keeps_aliases_default_method_and_strong_references` | +| `provider.default` | `aimux-ops/src/ops/provider.rs#default_uses_preset_descriptor_and_requires_global_scope` | +| `provider.files` | `aimux-ops/tests/files.rs#provider_files_returns_an_owned_handle_that_survives_parent_drop` | +| `provider.invoke` | `aimux-ops/src/ops/provider.rs#invoke_returns_only_runtime_discovery_and_forbids_arbitrary_rpc` | +| `provider.model` | `aimux-ops/src/ops/provider.rs#model_returns_all_eight_exact_handle_kinds` | +| `provider.wrap` | `aimux-ops/src/ops/provider.rs#wrap_applies_each_middleware_and_keeps_files_and_discovery` | +| `recorder.attach` | `aimux-ops/tests/recording.rs#ring_lifecycle_keeps_snapshots_and_replacement_and_dropped_handles` | +| `recorder.create` | `aimux-ops/tests/recording.rs#ring_lifecycle_keeps_snapshots_and_replacement_and_dropped_handles` | +| `recorder.export` | `aimux-ops/tests/recording.rs#ring_lifecycle_keeps_snapshots_and_replacement_and_dropped_handles` | +| `recorder.flush` | `aimux-ops/tests/recording.rs#ring_lifecycle_keeps_snapshots_and_replacement_and_dropped_handles` | +| `recorder.stats` | `aimux-ops/tests/recording.rs#ring_lifecycle_keeps_snapshots_and_replacement_and_dropped_handles` | +| `recorder.stop` | `aimux-ops/tests/recording.rs#ring_lifecycle_keeps_snapshots_and_replacement_and_dropped_handles` | +| `registry.create` | `aimux-ops/src/ops/registry.rs#duplicate_keys_are_rejected` | +| `registry.provider` | `aimux-ops/src/ops/registry.rs#registry_retains_exact_provider_after_original_handle_drop` | +| `replay.capabilities` | `aimux-ops/tests/replay.rs#future_capabilities_return_projected_unsupported_even_with_complete_http_data` | +| `replay.create` | `aimux-ops/tests/replay.rs#creation_inspection_and_planning_use_real_session_payloads` | +| `replay.dryRun` | `aimux-ops/tests/replay.rs#creation_inspection_and_planning_use_real_session_payloads` | +| `replay.inspect` | `aimux-ops/tests/replay.rs#creation_inspection_and_planning_use_real_session_payloads` | +| `replay.live` | `aimux-ops/tests/replay.rs#language_live_matches_the_existing_core_result_twice` | +| `reranking.rerank` | `aimux-ops/src/ops/modalities.rs#reranking_text_documents` | +| `runtime.configure` | `aimux-ops/tests/runtime.rs#startup_configuration_uses_native_freeze_authorities` | +| `runtime.describe` | `aimux-ops/tests/runtime.rs#description_matches_the_real_manifest_and_digest` | +| `search.search` | `aimux-ops/src/ops/modalities.rs#search_extension` | +| `sessionStore.attach` | `aimux-ops/tests/session.rs#create_and_attach_preserve_process_global_infer_and_capture_behavior` | +| `sessionStore.calls` | `aimux-ops/tests/session.rs#queries_use_selected_store_and_preserve_core_wire_and_bounds` | +| `sessionStore.create` | `aimux-ops/tests/session.rs#create_and_attach_preserve_process_global_infer_and_capture_behavior` | +| `sessionStore.list` | `aimux-ops/tests/session.rs#queries_use_selected_store_and_preserve_core_wire_and_bounds` | +| `speech.generate` | `aimux-ops/src/ops/modalities.rs#speech_generation` | +| `text.consume` | `aimux-ops/src/ops/text.rs#real_dispatch_runs_all_six_text_operations` | +| `text.generate` | `aimux-ops/src/ops/text.rs#real_dispatch_runs_all_six_text_operations` | +| `text.generateObject` | `aimux-ops/src/ops/text.rs#real_dispatch_runs_all_six_text_operations` | +| `text.generateOpenAI` | `aimux-ops/src/ops/text.rs#real_dispatch_runs_all_six_text_operations` | +| `text.stream` | `aimux-ops/src/ops/text.rs#real_dispatch_runs_all_six_text_operations` | +| `text.streamOpenAI` | `aimux-ops/src/ops/text.rs#real_dispatch_runs_all_six_text_operations` | +| `toolRepair.apply` | `aimux-ops/src/ops/misc.rs#apply_matches_core_for_all_reply_variants` | +| `toolRepair.applyResult` | `aimux-ops/src/ops/misc.rs#apply_result_updates_calls_and_response_messages` | +| `toolRepair.context` | `aimux-ops/src/ops/misc.rs#context_matches_core_and_preserves_no_tools_null` | +| `trace.aggregate` | `aimux-ops/tests/trace.rs#aggregate_matches_six_canonical_filter_queries` | +| `trace.clear` | `aimux-ops/tests/trace.rs#sessions_export_and_idempotent_clear_preserve_ffi_query_semantics` | +| `trace.create` | `aimux-ops/tests/trace.rs#create_uses_real_store_capacity_defaults_and_owned_handle` | +| `trace.export` | `aimux-ops/tests/trace.rs#sessions_export_and_idempotent_clear_preserve_ffi_query_semantics` | +| `trace.sessionChain` | `aimux-ops/tests/trace.rs#sessions_export_and_idempotent_clear_preserve_ffi_query_semantics` | +| `trace.sessionTrajectory` | `aimux-ops/tests/trace.rs#sessions_export_and_idempotent_clear_preserve_ffi_query_semantics` | +| `transcription.generate` | `aimux-ops/src/ops/modalities.rs#transcription_base64_bytes` | +| `transcription.start` | `aimux-ops/tests/operation.rs#native_binary_and_json_poll_share_the_registered_operation_service` | +| `transport.create` | `aimux-ops/tests/handles.rs#transports_have_precise_kinds_and_decimal_ids` | +| `video.generate` | `aimux-ops/src/ops/modalities.rs#video_uses_core_batching_and_polling` | + +## C export inventory + +Four old names remain primitives: aimux_error_free, aimux_error_code, +aimux_error_message and aimux_free_string. The other 126 old exports are retired; +the current 14-symbol ABI adds ten names. The actual-cdylib allowlist is tested +by `aimux-ffi/tests/exports_smoke_test.rs#cdylib_exports_only_abi_primitives`. +The existing [ABI migration inventory](../aimux-ffi/ABI-MIGRATION.md) lists the +same 130 old names. The capability table supplies related operation evidence; +the export allowlist alone does not establish old per-export equivalence. + +| Old export | Replacement | Status | +|---|---|---| +| `aimux_error_free` | `abi: aimux_error_free` | mapped | +| `aimux_error_code` | `abi: aimux_error_code` | mapped | +| `aimux_error_message` | `abi: aimux_error_message` | mapped | +| `aimux_error_status` | `abi: aimux_error_json` | mapped | +| `aimux_error_retry_ms` | `abi: aimux_error_json` | mapped | +| `aimux_error_retryable` | `abi: aimux_error_json` | mapped | +| `aimux_error_provider_code` | `abi: aimux_error_json` | mapped | +| `aimux_error_provider_message` | `abi: aimux_error_json` | mapped | +| `aimux_error_response_body` | `abi: aimux_error_json` | mapped | +| `aimux_error_url` | `abi: aimux_error_json` | mapped | +| `aimux_error_request_body_values` | `abi: aimux_error_json` | mapped | +| `aimux_error_response_headers` | `abi: aimux_error_json` | mapped | +| `aimux_error_provider_data` | `abi: aimux_error_json` | mapped | +| `aimux_error_retry_reason` | `abi: aimux_error_json` | mapped | +| `aimux_error_retry_count` | `abi: aimux_error_json` | mapped | +| `aimux_error_retry_error_at` | `abi: aimux_error_json` | mapped | +| `aimux_error_model_id` | `abi: aimux_error_json` | mapped | +| `aimux_error_model_type` | `abi: aimux_error_json` | mapped | +| `aimux_error_provider_id` | `abi: aimux_error_json` | mapped | +| `aimux_error_tool_name` | `abi: aimux_error_json` | mapped | +| `aimux_error_available_tools` | `abi: aimux_error_json` | mapped | +| `aimux_error_tool_input` | `abi: aimux_error_json` | mapped | +| `aimux_error_original_error` | `abi: aimux_error_json` | mapped | +| `aimux_openai_new` | `provider.create + provider.model` | mapped | +| `aimux_openai_new_with_base` | `provider.create + provider.model` | mapped | +| `aimux_anthropic_new` | `provider.create + provider.model` | mapped | +| `aimux_anthropic_new_with_base` | `provider.create + provider.model` | mapped | +| `aimux_anthropic_aws_new` | `provider.create + provider.model` | mapped | +| `aimux_anthropic_aws_new_with_base` | `provider.create + provider.model` | mapped | +| `aimux_azure_new` | `provider.create + provider.model` | mapped | +| `aimux_azure_new_with_base` | `provider.create + provider.model` | mapped | +| `aimux_bedrock_new` | `provider.create + provider.model` | mapped | +| `aimux_bedrock_new_with_base` | `provider.create + provider.model` | mapped | +| `aimux_vertex_new` | `provider.create + provider.model` | mapped | +| `aimux_vertex_new_with_base` | `provider.create + provider.model` | mapped | +| `aimux_cohere_new` | `provider.create + provider.model` | mapped | +| `aimux_cohere_new_with_base` | `provider.create + provider.model` | mapped | +| `aimux_mistral_new` | `provider.create + provider.model` | mapped | +| `aimux_mistral_new_with_base` | `provider.create + provider.model` | mapped | +| `aimux_xai_new` | `provider.create + provider.model` | mapped | +| `aimux_xai_new_with_base` | `provider.create + provider.model` | mapped | +| `aimux_provider_new` | `provider.create + provider.model` | mapped | +| `aimux_provider_from_env` | `provider.default + provider.model` | mapped | +| `aimux_provider_handle_new` | `provider.create` | mapped | +| `aimux_provider_list_models` | `provider.invoke` | mapped | +| `aimux_provider_model` | `provider.model` | mapped | +| `aimux_get_model_specs` | `catalogue.fetch` | mapped | +| `aimux_generate_text` | `text.generate` | mapped | +| `aimux_generate_object` | `text.generateObject` | mapped | +| `aimux_consume_stream_text` | `text.consume` | mapped | +| `aimux_stream_text` | `text.stream` | mapped | +| `aimux_abort_signal_new` | `abort.create` | mapped | +| `aimux_abort_signal_abort` | `abort.signal` | mapped | +| `aimux_abort_signal_drop` | `handle.drop` | mapped | +| `aimux_stream_text_with_abort` | `text.stream` | mapped | +| `aimux_tool_call_repair_context` | `toolRepair.context` | mapped | +| `aimux_apply_tool_call_repair` | `toolRepair.apply` | mapped | +| `aimux_apply_tool_call_repair_to_result` | `toolRepair.applyResult` | mapped | +| `aimux_generate_text_result_as_openai` | `output.toOpenAI` | mapped | +| `aimux_generate_text_as_openai` | `text.generateOpenAI` | mapped | +| `aimux_stream_text_as_openai` | `text.streamOpenAI` | mapped | +| `aimux_stream_text_as_openai_with_abort` | `text.streamOpenAI` | mapped | +| `aimux_drop_handle` | `handle.drop` | mapped | +| `aimux_drop_handle_ptr` | `abi: aimux_handle_finalizer` | mapped | +| `aimux_free_string` | `abi: aimux_free_string` | mapped | +| `aimux_openai_embedding_new` | `provider.create + provider.model` | mapped | +| `aimux_openai_embedding_new_with_base` | `provider.create + provider.model` | mapped | +| `aimux_cohere_embedding_new` | `provider.create + provider.model` | mapped | +| `aimux_cohere_embedding_new_with_base` | `provider.create + provider.model` | mapped | +| `aimux_google_embedding_new` | `provider.create + provider.model` | mapped | +| `aimux_google_embedding_new_with_base` | `provider.create + provider.model` | mapped | +| `aimux_embed` | `embedding.embedMany` | mapped | +| `aimux_openai_speech_new` | `provider.create + provider.model` | mapped | +| `aimux_openai_speech_new_with_base` | `provider.create + provider.model` | mapped | +| `aimux_speech_generate` | `speech.generate` | mapped | +| `aimux_openai_image_new` | `provider.create + provider.model` | mapped | +| `aimux_openai_image_new_with_base` | `provider.create + provider.model` | mapped | +| `aimux_google_image_new` | `provider.create + provider.model` | mapped | +| `aimux_google_image_new_with_base` | `provider.create + provider.model` | mapped | +| `aimux_image_generate` | `image.generate` | mapped | +| `aimux_openai_transcription_new` | `provider.create + provider.model` | mapped | +| `aimux_openai_transcription_new_with_base` | `provider.create + provider.model` | mapped | +| `aimux_transcription_generate` | `transcription.generate` | mapped | +| `aimux_transcription_session_new` | `transcription.start` | mapped | +| `aimux_transcription_push_audio` | `operation.push` | mapped | +| `aimux_transcription_input_done` | `operation.inputDone` | mapped | +| `aimux_transcription_next_part` | `operation.next` | mapped | +| `aimux_transcription_session_drop` | `handle.drop` | mapped | +| `aimux_openai_files_new` | `provider.create + provider.files` | mapped | +| `aimux_openai_files_new_with_base` | `provider.create + provider.files` | mapped | +| `aimux_file_upload` | `files.upload` | mapped | +| `aimux_cohere_reranking_new` | `provider.create + provider.model` | mapped | +| `aimux_cohere_reranking_new_with_base` | `provider.create + provider.model` | mapped | +| `aimux_rerank` | `reranking.rerank` | mapped | +| `aimux_google_video_new` | `provider.create + provider.model` | mapped | +| `aimux_google_video_new_with_base` | `provider.create + provider.model` | mapped | +| `aimux_video_generate` | `video.generate` | mapped | +| `aimux_provider_decision_model` | `provider.model` | mapped | +| `aimux_jev_decision_new` | `provider.create + provider.model` | mapped | +| `aimux_jev_decision_new_with_probability_source` | `provider.create + provider.model` | mapped | +| `aimux_decision_capabilities` | `decision.capabilities` | mapped | +| `aimux_decide` | `decision.decide` | mapped | +| `aimux_decide_with_abort` | `decision.decide` | mapped | +| `aimux_tavily_search_new` | `provider.create + provider.model` | mapped | +| `aimux_tavily_search_new_with_base` | `provider.create + provider.model` | mapped | +| `aimux_search` | `search.search` | mapped | +| `aimux_codex_refresh` | `auth.codexRefresh` | partial | +| `aimux_init_logging` | `runtime.configure` | mapped | +| `aimux_session_store_init` | `sessionStore.create + sessionStore.attach` | mapped | +| `aimux_session_infer_init` | `sessionStore.create + sessionStore.attach` | mapped | +| `aimux_session_calls` | `sessionStore.calls` | mapped | +| `aimux_list_sessions` | `sessionStore.list` | mapped | +| `aimux_trace_new` | `trace.create + middleware.create + model.wrap` | mapped | +| `aimux_trace_new_audited` | `trace.create + middleware.create + model.wrap` | mapped | +| `aimux_trace_aggregate` | `trace.aggregate` | mapped | +| `aimux_trace_session_chain` | `trace.sessionChain` | mapped | +| `aimux_trace_session_trajectory` | `trace.sessionTrajectory` | mapped | +| `aimux_trace_export_jsonl` | `trace.export` | mapped | +| `aimux_trace_clear` | `trace.clear` | mapped | +| `aimux_init_recording` | `recorder.create + recorder.attach` | mapped | +| `aimux_init_recording_ring` | `recorder.create + recorder.attach` | mapped | +| `aimux_init_recording_ring_default` | `recorder.create + recorder.attach` | mapped | +| `aimux_recording_stop` | `recorder.stop` | mapped | +| `aimux_recording_flush` | `recorder.flush` | mapped | +| `aimux_recording_try_flush` | `recorder.flush` | mapped | +| `aimux_register_providers` | `provider.create + registry.create` | mapped | +| `aimux_init_proxy` | `runtime.configure` | mapped | +| `aimux_mock_replay_new` | `replay.create + replay.inspect + replay.dryRun + replay.live` | gap | +| `aimux_router_new` | `composite.router` | mapped | +| `aimux_moa_new` | `composite.moa` | mapped | + +## Explicit limits of replacement coverage + +Decision is no longer a missing op-table entry: model selection with method +decision, capabilities and decide are implemented and tested against core. +The old Jev constructor options are reachable through provider.create. +Trace wrapping now uses canonical TraceLayer and shared tests cover real +generation/streaming; the Node native lifecycle test expects trace middleware +creation and model wrapping to succeed. +The same existing provider factory supplies package construction; no separate +provider table is introduced by this ledger. + +The old mock-replay constructor is **not fully replaced** by replay.create. +It now returns ReplaySession, which cannot be passed to text.generate/stream; +replay.live needs a separate live Model/Registry target. Offline mock Language +generation remains G-k. Future replay transport capabilities cannot be used as +a workaround because replay.capabilities currently fails (G-c). + +Writers use schema 2 and readers accept schemas 1 and 2; migration to schema 3 +and removal of old-schema reading are planned as the final step of this series +under RFC-0041 (G-b). Process-wide capture remains G-a. Generated-output tests +exist, but this audit does not claim a successful run; per-business-type hash +checks remain G-h. Installed assets, platform +execution, successful token refresh, exhaustive eight-language wire vectors and +old-only-test equivalence are not proved by this inventory (G-d/G-e/G-i). +See the [registered gaps](rfc-0039-implementation.md#registered-gaps) and +[conformance matrix](rfc-0039-conformance.md). diff --git a/docs/rfc-0039-implementation.md b/docs/rfc-0039-implementation.md new file mode 100644 index 000000000..758e9decc --- /dev/null +++ b/docs/rfc-0039-implementation.md @@ -0,0 +1,377 @@ +# RFC-0039 implementation + +This document describes the implementation in this tree. The runtime manifest +contains 72 operations, 23 resource kinds and 93 referenced type entries. ABI v1 +is `65536` (`1 << 16`) and ops is `1.0`. Capability registration is not evidence +that a live provider or every supported platform has been tested. See the +[conformance matrix](rfc-0039-conformance.md) and +[coverage ledger](rfc-0039-coverage-ledger.md) for evidence and its limits. + +## Crate placement and dependencies + +An arrow means “depends on”; the C ABI arrow is a native call boundary. + +```text +C/C++, Go, Java, Swift, Dart ──C ABI──> aimux-ffi +Kotlin ──> same-version Java +aimux-ffi ────────────────┐ +bindings/node (napi-rs) ──┼──> aimux-ops ──> aimux-providers +bindings/python (PyO3) ──┘ ├──────> aimux-provider-utils + └──────> aimux-core +aimux-providers ──> aimux-core, aimux-provider-utils +aimux-provider-utils ──> aimux-core +tools/aimux-codegen ──> aimux-ops +``` + +`aimux-ops/Cargo.toml` also declares serde, serde_json, Tokio, futures and SHA-256. +Ops does not depend on FFI, napi or PyO3. Canonical business DTOs stay in core; +ops owns control DTOs, registration, handle lifting, error projection, framing +and Operation admission. Providers retain protocol conversion and factories; +core retains generation, retry, tracing, recording and replay algorithms. +Jev construction uses the existing public `JevConfig`/`JevProvider` constructors. + +## Dispatch and handle ownership + +```text +① adapter extracts owned inputs + C: validate pointers/UTF-8, reset outputs, use checked abi::block_on + Node: async napi runtime bridge; Python: release GIL around blocking work +② dispatch::{call,stream}(Ctx, HandleId, op, params) + exact descriptor/mode → object controls → primary and secondary owner/kind checks +③ ops::::handler(Ctx, Target, params) + canonical Rust call → canonical result or business event +④ cached ErrorEnvelope or FrameStream(event*, end) → language projection +``` + +`Target::Global` is separate from `Target::Handle(Entry)`. Only ID 0 selects +global scope; it is not a HandleKind. IDs allocate monotonically from 1, never +reuse or wrap, and fail with HandleExhausted on exhaustion. JSON uses unsigned +decimal strings; C uses `uint64_t`. The deserializer accepts leading zeros, +while emitted IDs are normalized decimal strings. + +`Owner::CProcess` is shared by C ABI consumers. Trusted adapters allocate +`Owner::Node(id)` per Env and `Owner::Python(id)` for interpreter ownership using +the process allocator; user inputs never supply owner tokens. The registry +clones strong references while locked and releases the lock before invoking or +disposing resources. A resolved model, files object, wrapper or composite can +outlive the source handles. Admitted ordinary calls can finish after drop; +dropping Abort does not signal it, while dropping Operation closes/cancels it. +`release_owner` removes entries before cleanup and signals retained Operations. + +All secondary fields listed in `runtimeHandleFields`, including wildcard paths, +and the C abort argument use the same typed owner resolver. Foreign and unknown +handles have identical undisclosing errors. `handle.drop` is admitted before +lookup: 0, unknown, already-dropped and foreign IDs succeed; foreign drops do +nothing. This preserves idempotence without disclosing whether another owner +has a resource or allowing it to be disposed. Explicit close/drop does not flush a recorder. C drop may dispose a +resource synchronously; the finalizer helper queues it off the GC thread. + +The 23 kinds are Provider, Registry, Language, Embedding, Image, Speech, +Transcription, Reranking, Video, Search, Decision, Files, Operation, Abort, +Fetch, WebSocket, Download, CredentialResolver, Middleware, TraceStore, +Recorder, SessionStore and ReplaySession. + +The provider `default_method` metadata uses a process-level weak side table keyed +by canonical `Arc` identity. It never retains a provider strongly, +and insertions prune expired entries. This is descriptor metadata; the handle +registry still holds the canonical provider and enforces owner isolation. + +## Complete operation table + +`G` means only global handle 0. `Models` means Language, Embedding, Image, +Speech, Transcription, Reranking, Video, Search and Decision. `All` means the +23 resource kinds; handle.drop additionally admits 0/unknown IDs. Functions +below are in `aimux_ops::ops`; `dispatch` and `Operation` refer to +`aimux-ops/src/dispatch.rs` and `aimux-ops/src/operation.rs`. Modality functions +are emitted by the local `model_call!` macro. Mode is the actual descriptor +mode, not an inference from the operation name. + +| Operation | Mode | Primary handle kinds | Implementing function | Scope | +|---|---|---|---|---| +| `abort.create` | call | G | `handles::abort_create` | Current | +| `abort.signal` | call | Abort | `handles::abort_signal` | Current | +| `auth.codexRefresh` | call | G | `misc::codex_refresh` | Current | +| `catalogue.fetch` | call | G | `misc::catalogue_fetch` | Current | +| `composite.moa` | call | G | `composite::moa` | Current | +| `composite.router` | call | G | `composite::router` | Current | +| `credential.create` | call | G | `handles::credential_create` | Future: UnsupportedFunctionality | +| `decision.capabilities` | call | Decision | `decision::decision_capabilities` | Current | +| `decision.decide` | call | Decision | `decision::decision_decide` | Current | +| `embedding.embed` | call | Embedding | `modalities::embed` | Current | +| `embedding.embedMany` | call | Embedding | `modalities::embed` | Current | +| `files.upload` | call | Files | `files::upload` | Current | +| `handle.drop` | call | All | `dispatch::Dispatcher::call_registered (drop special case)` | Current | +| `image.generate` | call | Image | `modalities::generate_image` | Current | +| `middleware.create` | call | G | `handles::middleware_create` | Current | +| `model.info` | call | Models | `registry::info` | Current | +| `model.resolve` | call | Registry | `registry::resolve` | Current | +| `model.wrap` | call | Models | `registry::wrap` | Current | +| `operation.cancel` | call | Operation | `operation::cancel` | Current | +| `operation.inputDone` | call | Operation | `operation::input_done` | Current | +| `operation.next` | poll | Operation | `operation::next` | Current | +| `operation.push` | binaryPush | Operation | `dispatch::operation_push → Operation::try_push` | Current | +| `operation.status` | call | Operation | `operation::status` | Current | +| `output.toOpenAI` | call | Language | `misc::to_openai` | Current | +| `provider.create` | call | G | `provider::create` | Current | +| `provider.custom` | call | G | `provider::custom` | Current | +| `provider.default` | call | G | `provider::default_provider` | Current | +| `provider.files` | call | Provider | `files::provider_files` | Current | +| `provider.invoke` | call | Provider | `provider::invoke` | Current | +| `provider.model` | call | Provider | `provider::model` | Current | +| `provider.wrap` | call | Provider | `provider::wrap` | Current | +| `recorder.attach` | call | Recorder | `recording::attach` | Current | +| `recorder.create` | call | G | `recording::create` | Current | +| `recorder.export` | call | Recorder | `recording::export` | Current | +| `recorder.flush` | call | Recorder | `recording::flush` | Current | +| `recorder.stats` | call | Recorder | `recording::stats` | Current | +| `recorder.stop` | call | Recorder | `recording::stop` | Current | +| `registry.create` | call | G | `registry::create` | Current | +| `registry.provider` | call | Registry | `registry::provider` | Current | +| `replay.capabilities` | call | ReplaySession | `replay::capabilities` | Future: UnsupportedFunctionality | +| `replay.create` | call | G | `replay::create` | Current | +| `replay.dryRun` | call | ReplaySession | `replay::dry_run` | Current | +| `replay.inspect` | call | ReplaySession | `replay::inspect` | Current | +| `replay.live` | call | ReplaySession | `replay::live` | Current | +| `reranking.rerank` | call | Reranking | `modalities::rerank` | Current | +| `runtime.configure` | call | G | `runtime::configure` | Current | +| `runtime.describe` | call | G | `runtime::describe` | Current | +| `search.search` | call | Search | `modalities::search` | Current | +| `sessionStore.attach` | call | SessionStore | `session::attach` | Current | +| `sessionStore.calls` | call | SessionStore | `session::calls` | Current | +| `sessionStore.create` | call | G | `session::create` | Current | +| `sessionStore.list` | call | SessionStore | `session::list` | Current | +| `speech.generate` | call | Speech | `modalities::generate_speech` | Current | +| `text.consume` | call | Language | `text::consume` | Current | +| `text.generate` | call | Language | `text::generate` | Current | +| `text.generateObject` | call | Language | `text::generate_object` | Current | +| `text.generateOpenAI` | call | Language | `text::generate_openai` | Current | +| `text.stream` | stream | Language | `text::stream` | Current | +| `text.streamOpenAI` | stream | Language | `text::stream_openai` | Current | +| `toolRepair.apply` | call | G | `misc::repair_apply` | Current | +| `toolRepair.applyResult` | call | G | `misc::repair_apply_result` | Current | +| `toolRepair.context` | call | G | `misc::repair_context` | Current | +| `trace.aggregate` | call | TraceStore | `trace::aggregate` | Current | +| `trace.clear` | call | TraceStore | `trace::clear` | Current | +| `trace.create` | call | G | `trace::create` | Current | +| `trace.export` | call | TraceStore | `trace::export` | Current | +| `trace.sessionChain` | call | TraceStore | `trace::session_chain` | Current | +| `trace.sessionTrajectory` | call | TraceStore | `trace::session_trajectory` | Current | +| `transcription.generate` | call | Transcription | `modalities::transcribe` | Current | +| `transcription.start` | call | Transcription | `operation::start` | Current | +| `transport.create` | call | G | `handles::transport_create` | Current | +| `video.generate` | call | Video | `modalities::generate_video` | Current | + +`provider.create` accepts strict settings (`apiKey`, `baseUrl`, `headers`, +`organization`, `project`, `params`, `fetch`, `webSocket`, `credentialResolver`, +`endpoint`, `probabilitySource`); accepted keys do not imply support by every +factory. WebSocket/credential slots are resolved then explicitly rejected when +unsupported. `provider.default` takes a package and default settings. Supply an +explicit model method for vendor packages without default-method metadata. +`provider.invoke` only accepts `listModels`. `model.resolve` takes an explicit +`{modelRef:{namespace,key,method,modelId}}`; it never falls back to a gateway. +`model.wrap` supports existing Language/Image wrappers, not invented wrappers +for every model kind. Trace middleware uses the canonical `TraceLayer` hook. + +Runtime logging/proxy configuration shares native initialization authorities; +identical settings are idempotent and changed/frozen settings fail InvalidState. +An empty inspection request does not freeze proxy state. Logging and proxy +updates are separate settings, not a transaction. Summaries omit secret values. +Recording requires explicit `capturePolicy: "full"`; there is no audit-only +recording format. Trace stores support off/shared/strict audit policy; trace +middleware inherits it unless disabled/shared/strict is supplied explicitly. + +## C ABI: fourteen exports + +The generated authority is [aimux.h](../aimux-ffi/include/aimux.h); implementations +are in `aimux-ffi/src/abi.rs`. All fallible functions return NULL on success or +an owned `aimux_error_t*` on failure. Outputs are reset before validation. + +| Export | Behavior and ownership | +|---|---| +| `aimux_abi_version()` | Returns packed ABI major/minor. | +| `aimux_manifest_json(char **out_json)` | Allocates manifest JSON. | +| `aimux_call(uint64_t, const char*, const char*, uint64_t, char**)` | Blocking call; last handle is optional Abort (0 = none). | +| `aimux_stream(uint64_t, const char*, const char*, uint64_t, aimux_event_cb, void*)` | Blocking stream; callback receives borrowed frame JSON and context. | +| `aimux_operation_push(uint64_t, const uint8_t*, size_t)` | Validates and copies/consumes input before return; NULL is valid only with length 0. | +| `aimux_operation_next_part(uint64_t, int64_t, char**, int32_t*)` | Nonnegative timeout; PART=1 allocates JSON; ENDED=2/TIMEOUT=3 allocate none; errors clear both outputs. | +| `aimux_abort(uint64_t)` | Callback-safe signal without block_on; invalid handles are ignored. | +| `aimux_drop(uint64_t)` | Idempotent C-owner removal; 0/unknown are harmless. | +| `aimux_handle_finalizer(void*)` | Pointer value encodes the handle; never dereferences it; queues drop using the initialized runtime. | +| `aimux_free_string(char*)` | Frees an owned returned string; NULL-safe. | +| `aimux_error_free(aimux_error_t*)` | Frees an owned error; NULL-safe. | +| `aimux_error_code(const aimux_error_t*)` | Reads saved code; NULL gives 0. | +| `aimux_error_message(const aimux_error_t*)` | Allocates saved message; embedded NUL is replaced in this display accessor. | +| `aimux_error_json(const aimux_error_t*)` | Allocates saved full envelope; JSON preserves escaped NUL and cached hint. | + +The pointer-encoded finalizer token is limited to uintptr_t width. Dart uses +its own allocated uint64 token and native queue trampoline on 32-bit targets. + +Inputs are borrowed NUL-terminated UTF-8. Do not pass invalid owned pointers, +double-free, or omit terminators: these are caller violations. Free every owned +JSON/getter string with aimux_free_string and every owned error with +aimux_error_free; never free callback JSON. Copy callback bytes before return +and retain context until aimux_stream returns. Blocking reentry, including from +a Tokio caller, returns ReentrantCall. Abort is safe inside a callback. Host +exceptions must be latched by adapters and raised after native return; a +callback that never returns cannot be forcibly interrupted. + +## Error envelope and retries + +```json +{"kind":"protocol","code":300,"message":"invalid op: missing.op","error":{"name":"InvalidOp","message":"missing.op"}} +``` + +The four domains are aimux, recording, binding and protocol. `error` preserves +the canonical payload, nested causes, Retry attempt history, open fields and +missing/null distinctions. A payload name discriminates variants sharing a +code. AI_APICallError uses code 11, not 1; no HTTP status is invented for local +configuration/parse failures. Unknown payloads remain available to consumers. + +`OpsError::Projected(Box)` caches the envelope at the first +dispatch crossing with `Ctx.now`. C getters, stream terminals and Operation +status reuse it. Only a direct ApiCall error derives `retryAfterMs`; a Retry +aggregate retains its attempts without copying a hint to the outer object. +Absent hint differs from zero. The sole core parser checks case-insensitive +retry-after-ms, retry-after seconds and HTTP dates with checked finite, +nonnegative conversion and truncation toward zero. Parsed invalid preferred +numeric values stop fallback; malformed text permits the next header. The +core legacy delegate rejects values above i64::MAX. Hints never authorize a +retry; generation policy stays in core and bindings add no retry loop. + +| Protocol payload | Code | +|---|---:| +| InvalidOp | 300 | +| InvalidParams | 301 | +| UnsupportedOpsVersion | 302 | +| ConcurrentConsumer | 303 | +| InvalidState | 304 | +| ResourceLimit | 305 | +| HandleExhausted | 306 | + +AiMux uses 1–20 with 4 reserved; Recording uses 100–105 and Binding 200–206. +Code 3 aliases InvalidResponseData, ToolCallNotFoundForApproval and +ToolChoiceViolation; code 6 aliases InvalidPrompt and MissingToolResults. + +## Streams, Operation and limits + +Preflight errors emit no frames. Accepted streams emit zero-based decimal +`seq` values, `event` frames and exactly one `end` with success/error/cancelled. +Business finish/recoverable error parts remain events. After delivering end, +the C stream returns NULL rather than returning a second error. Wrapper failure +latches once, aborts native work and is reported after callbacks finish. + +`transcription.start` checks doStream before acceptance and returns a decimal +Operation ID. Connection failure after acceptance becomes durable terminal +state. Binary push admission is ordered against idempotent inputDone; acceptance +does not mean provider processing. Only one poll reader is admitted; another +gets ConcurrentConsumer. Buffered parts precede the terminal error, delivered +once; later polls return Ended and status retains the outcome. Poll Timeout +does not terminate the operation. Cancel reports first/alreadyRequested/ +alreadyTerminal and cannot overwrite completed success. + +| Bound | Implemented value | +|---|---| +| Realtime input | 64 admitted items and 8 MiB | +| Output | 256 admitted items and 8 MiB | +| Single binary chunk / serialized stream frame | 1 MiB, including frame overhead | +| Node/Python bridge | 64 frames and 8 MiB; terminal separate | +| Realtime cleanup | Five seconds total; cleanup failure separate from outcome | + +QueueLimits/OperationLimits can be lowered, never made zero or increased beyond +defaults. Checked wire-size counters and binary admission run before copying; +terminal storage does not need data capacity. Full bounded queues stop their +producer; consumer close can abandon queued data and cancels the operation. +Synchronous binary admission reports ResourceLimit on a full input queue. +One-shot JSON requests have no byte cap here. These are admitted-payload bounds, +not a bound on all engine allocations (G-g), and remain proposals for freeze. +Recorder I/O uses spawn_blocking, and registry locks do not cross await/join. + +## Manifest and generation chain + +```text +① family descriptors + register → dispatch::registrations (sorted by name) +② manifest::manifest → descriptors + kind/code/type catalogue + control schemas +③ manifest_digest → compact JSON, recursively sorted object keys, SHA-256 +④ aimux-codegen → C header + seven language constant files + schema/ops/*.json + + tools/aimux-codegen/generated-files.json +⑤ language loaders/bridges + handwritten facades consume shared dispatch/ABI +``` + +The digest excludes its own digest field; array order is preserved. Each type +entry has an ID and Rust path; ops-owned controls/wrappers carry schemas and +embedded business fields have x-aimux-type references. Schema IDs use JSON Schema +2020-12 with local resolvable refs. Strict controls reject unknown keys while +providerOptions/metadata/explicit JSON stay open. `--check` builds temporary +outputs and compares complete managed file sets and bytes without rewriting. +The generator does not generate business DTOs, language facades, the operation +registration code, this document or the complete eight-language vector corpus. +Those stronger RFC generation goals remain G-d/G-i rather than implicit claims. +Separately, `bindings/flutter/tool/generate_ffi.py` consumes the C header to generate its +FFI declarations and packaged header; Java's JNA declarations are handwritten +and tested against the primitive signatures, not emitted by aimux-codegen. + +The generated file set is checked against the current manifest by the commands below; +this documentation audit does not claim a successful generator run. +Every adapter requires the exact generated ABI version, ops version and manifest +digest at load. No adapter can claim per-consumed-business-type hash validation +until that metadata exists. Schema presence does not validate live handles, +owner isolation, capabilities or state transitions. + +## Verification gates + +These commands are reproducible gates, not a claim that all ran for this +documentation change or that their existence establishes release readiness. + +| Gate | Existing command / evidence | +|---|---| +| Dispatch, ownership, stream, error and manifest | `cargo test -p aimux-ops` (including ops_matrix, handle_lifecycle, stream_terminal, error_envelope_golden, manifest_contract) | +| Native ABI and deletion | `cargo build -p aimux-ffi` then `cargo test -p aimux-ffi`; exports_smoke_test compares actual cdylib symbols | +| Generated outputs and schemas | `cargo test -p aimux-codegen`; `cargo run -p aimux-codegen -- --check` | +| C/C++ | `bash scripts/test-c-abi.sh --run`; separate `--asan` mode; accepted live-stream branch needs configured provider parameters | +| Go | `bindings/go/test-abi.sh` and `bindings/go/test-native.sh` (fixture and real-library results separate) | +| Java / Kotlin | Gradle tests for each binding; cached-classpath local runners are separate fallback evidence | +| Swift | Binding SwiftPM tests or its standalone runners; macOS execution does not establish simulator linkage | +| Dart | Binding analyzer, native runtime tests and bridge tests; Android/iOS packaging and symbol gates remain separate | +| Node / Python | Native and facade/bridge suites; socket-dependent provider cases and installed packages require their own results | +| Workspace | `cargo fmt --all -- --check`; `cargo clippy --workspace --all-targets -- -D warnings`; workspace tests and docs | + +The manifest matrix uses probe handlers to test routing/admission across all +72 names and 23 kinds plus global/invalid states; it does not execute every +business operation. Family suites test real dispatch with existing engine +traits. stream_terminal runs all eleven native shared scenarios; its external +driver hook remains ignored unless explicitly enabled. Cross-language complete +vectors, performance/size gates (RFC-0038) and per-platform build/test/package/ +install evidence are additional obligations. Release order/header packaging +are configured in `.github/workflows/release.yml` but do not establish G-e closure. + +## Registered differences from RFC-0039 + +These register implementation differences against the RFC examples and original +inventory; the canonical business error union remains owned by RFC-0042. + +- **D1 — Error envelope (§6.1 example):** The implementation follows RFC-0042: the outer object is `kind`, `code`, `message`, `error` and optional `retryAfterMs`; `error` is AI SDK error JSON with `name` and, where defined, `isRetryable`, with no top-level `retryable` or `type`, because RFC-0042 owns the canonical error union. The RFC-0039 §6.1 example still shows `type`; it is to be corrected to `name` when this branch is merged up with master, since the RFC file is not on this branch's base. +- **D2 — Decision (§3.1–§3.2):** `Decision`, `decision.decide` and `decision.capabilities` extend the original eight-model, 70-operation table to nine model kinds and 72 operations because the source baseline already exposes Decision and its Jev constructors. +- **D3 — Error registrations (§6.1):** Protocol errors use fixed codes 300..306, AiMux codes extend through 20, code 4 stays reserved, code 3 aliases three variants and code 6 aliases two variants, because existing numeric meanings must survive the ABI replacement. +- **D4 — Finalizer ABI (§4.1):** `aimux_handle_finalizer(void *token)` is the fourteenth export because GC cleanup needs a nonblocking pointer-signature entry rather than a cast of `aimux_drop(uint64_t)`. +- **D5 — Resource budgets (§5.3):** The implemented 64/256-item, 8 MiB queue, 1 MiB event/chunk and five-second cleanup values remain proposals for stable freeze because RFC-0038 measurement and platform evidence are still required. +- **D6 — Operation polling (§3.2), resolved:** `operation.next` is registered as `poll`, matching the RFC. JSON dispatch and the native polling primitive still use the same Operation service and part/ended/timeout results. +- **D7 — Binary input (§3.2):** `operation.push` is BinaryPush-only and the synchronous native admission path returns ResourceLimit when full, because it must reserve input capacity before copying without waiting on an async queue; Rust `Operation::push` retains asynchronous backpressure. +- **D8 — Foreign handles (§3.3):** Foreign-owner lookup returns InvalidHandle with no `actual` kind and the same message as an unknown ID, while foreign `handle.drop` succeeds without effect, because the owner boundary must not disclose resources. +- **D9 — Retry-hint parser (§6.2):** The single parser lives in `aimux_core::error::retry_after_ms_from_headers` and is re-exported by ops because core retry policy cannot depend on an adapter-layer crate; a parsed invalid preferred number blocks fallback, while malformed text permits it. +- **D10 — Current capability scope (§11.4):** Stdio, host fetch/token/model/telemetry callbacks, raw one-shot binary upload, native passthrough and skills/Batch/Evaluation operations are not exposed because they require separate capabilities beyond the existing engine. + +- **D11 — D8 delivery order (§0.1.7):** The maintainer accepted business Schema/DTO generation after the ops/ABI switch, with ts-rs and `scripts/gen_ts_types.py` retained as the transitional business generation chain. D8 under RFC-0042 must replace that chain and its drift gate; control schemas, headers and constants already come from the manifest. This is a delivery-order exception, not closure of G-d. +- **D12 — Schema-2 retention (§7.1):** The maintainer accepted schema-2 read/write retention until the last PR of this series. That PR must migrate to schema 3 and remove schema-2 and earlier reading under RFC-0041; the switch must not remove it early or turn it into a permanent compatibility path. + +## Registered gaps + +- **G-a — Process-wide capture:** Recorder, session-store attachment and inference state remain process-wide because owner-local capture requires a core runtime-context change. +- **G-b — Recording compatibility:** Writers use schema 2 and readers accept schemas 1 and 2, as implemented in `aimux-core/src/recording.rs`, because migration to schema 3 and removal of old-schema reading are planned as the final step of this series under RFC-0041. +- **G-c — Future capabilities:** `credential.create` and `replay.capabilities` have Future scope and return UnsupportedFunctionality as amended in RFC-0039 §0.2 on the basis of RFC-0041, because no credential-construction or Fetch/WebSocket/Download replay engine is implemented; replay retains existing Language/Decision model replay. +- **G-d — Business schemas and DTO generation:** Business types have stable Rust type references but no complete generated schemas or language DTO chain because RFC-0042 owns that work; `scripts/gen_ts_types.py` remains for its existing outputs. +- **G-e — Release installation evidence:** Release publishing orders ops before FFI and packages the header, but installation and execution of every platform asset are not established here because build and packaging definitions alone do not establish release closure. +- **G-f — Provider metadata:** Complete package provenance, authoritative default methods for every package, general named vendor-method dispatch and registry/string-model lifting in text requests are absent because entries retain canonical Arcs and current factories/control DTOs do not supply all that metadata or lifting; owner-scoped reference metadata exists for registry-resolved models, and `provider.invoke` accepts only `listModels`. +- **G-g — End-to-end memory bound:** Queue and wire limits do not bound every provider allocation or the existing unbounded core text pump because this layer controls admitted payloads rather than rewriting core streaming internals. +- **G-h — Generated-product consistency:** Per-business-type revision/hash checks are unavailable because consumed-type metadata is incomplete; every adapter already pins the exact ABI version, ops version and manifest digest. +- **G-i — Cross-language and platform conformance:** Exhaustive eight-language business-vector execution, same-source release evidence, installed-wheel checks and all platform sanitizer/link/install results are not established here because narrower local and fixture suites do not prove the full matrix; Dart Windows streaming is explicitly unsupported. +- **G-k — Offline mock-model migration:** The old MockReplayModel constructor has no exposed replacement for offline text generation because replay.create returns ReplaySession, text operations admit only Language, and replay.live requires a separate caller-supplied target. diff --git a/docs/upgrading-to-ops-abi.md b/docs/upgrading-to-ops-abi.md new file mode 100644 index 000000000..b2bcc0cf1 --- /dev/null +++ b/docs/upgrading-to-ops-abi.md @@ -0,0 +1,307 @@ +# Upgrading to the ops ABI + +Upgrade the native library, header, language packages and generated metadata +together. ABI 1 (`65536`) and ops `1.0` replace the unversioned exports and +provider-specific constructors. Rebuild native consumers against +`aimux-ffi/include/aimux.h`. There are no old ABI forwarding aliases. + +## Breaking changes + +| Previous usage | Current replacement | +|---|---| +| `aimux_openai_new`, language-specific one-shot/vendor constructors | `provider.create`, then `provider.model` with explicit method/modelId | +| Payload-specific C error getters | `aimux_error_json`; code/message/free remain, with new ownership rules | +| Global provider registration | Explicit `registry.create` entries and `model.resolve` | +| Realtime transcription session pointer | Operation handle from `transcription.start`, binary push and typed poll | +| NULL/on_done stream completion | Business event frames and one explicit transport end | +| Global recording/session convenience initialization | Explicit recorder/sessionStore creation and attachment; capture still process-wide | +| Kotlin's independent native loader and DTO copies | Matching Java artifact plus `ai.arcships.aimux.kotlin` coroutine helpers | +| Old wire fields or permissive settings | Exact current control fields and canonical business JSON; unknown controls fail | + +Existing Java `Types`/`MultimodalTypes` builders and several other language DTO +codecs remain. Business DTOs are not all regenerated by aimux-codegen yet. +Python's former top-level model helpers are replaced by Client/Handle dispatch; +Node has Client and typed resource facades. Go and Swift primarily expose the +shared handle/operation interface. Check the actual API below before renaming. + +Recording writers use schema 2 and readers accept schemas 1 and 2, as defined +in `aimux-core/src/recording.rs`. Migration to schema 3 and removal of old-schema +reading are planned as the final step of this series under RFC-0041. Preserve +original files. A future recording change can make rollback unable to read new +data; no automatic converter or compatibility release is promised. Pin the +entire previous release when rolling back, including packages and native assets. + +## Load and validate + +Read the ABI and manifest before creating resources. Raw C consumers must check +the ABI major, their required ABI minor, exact expected opsVersion, required +operations and kinds. There is no FFI minor negotiation or stdio hello. The +manifest digest identifies canonical metadata, not binary identity. + +Python verifies its generated ABI/ops/digest at import. Node checks ABI/ops and +exposes manifestDigest; it does not currently enforce a generated-digest match. +Go, Java, Swift and Dart enforce their implemented ABI/ops checks, and Dart can +require operation names. Per-business-type schema revisions/hashes are not yet +available. Use one coherent release set; these current checks do not replace +the missing per-type compatibility gate. + +## Provider → model in eight interfaces + +Each example uses an OpenAI language model. Set credentials through your +environment or current settings, and replace the model ID with one supported by +your account. The API names and argument order below come from this tree; the +snippets demonstrate calls and do not claim a successful provider network run. + +### C / C++ + +C has only the fourteen primitives; use your JSON library to extract the +decimal `handle` field from resource results. The calls are: + +```c +/* After ABI/manifest checks; provider and model are parsed uint64_t IDs. + * Each call returns an owned error or NULL; handle it before using output. */ +char *provider_json = NULL; +aimux_error_t *error = aimux_call(0, "provider.create", + "{\"package\":\"openai\",\"settings\":{}}", 0, &provider_json); +/* On success: parse provider_json.handle, then aimux_free_string(provider_json). */ +char *model_json = NULL; +error = aimux_call(provider, "provider.model", + "{\"method\":\"languageModel\",\"modelId\":\"gpt-4o-mini\"}", 0, &model_json); +/* On success: parse model_json.handle, then aimux_free_string(model_json). */ +char *result_json = NULL; +error = aimux_call(model, "text.generate", "{\"prompt\":\"Hello\"}", 0, &result_json); +/* Handle error, then release result_json and both handles. */ +aimux_free_string(result_json); +aimux_drop(model); +aimux_drop(provider); +``` + +The fragment shows native calls; the comments mark required JSON parsing and +error handling, rather than a complete standalone program. For executable +offline examples see [example.c](../bindings/c/example.c) and +[example.cpp](../bindings/c/example.cpp). C++ can use `aimux::Handle`, +`aimux::String`, `aimux::Error` and `aimux::check` from `bindings/c/aimux_raii.hpp`. +`aimux::stream` catches callback exceptions before crossing C and rethrows the +saved exception after return. C callers must implement equivalent latching. + +### Go + +```go +ctx := context.Background() +provider, err := aimux.Create(ctx, nil, "provider.create", map[string]any{ + "package": "openai", "settings": map[string]any{}, +}) +if err != nil { return err } +defer provider.Close() +model, err := aimux.Create(ctx, provider, "provider.model", map[string]any{ + "method": "languageModel", "modelId": "gpt-4o-mini", +}) +if err != nil { return err } +defer model.Close() +result, err := aimux.CallInto[aimux.GenerateTextResult](ctx, model, "text.generate", + map[string]any{"prompt": "Hello"}) +if err != nil { return err } +fmt.Println(result.Text) +``` + +Use `aimux.Call` for globals, `Handle.Call`/`Handle.Stream` for resources and +`Create` to adopt returned handles. Context cancellation signals Abort. +Consume `Stream.Events()` and check `Stream.Err()` after completion; close an +abandoned stream. `Handle.StartOperation` returns an Operation with `Push`, +`InputDone`, `NextPart`, `Status`, `Cancel` and `Close`. The cgo fixture suite +and real-library suite exercise different boundaries. + +### Java + +```java +Aimux client = Aimux.load(); +try (ProviderHandle provider = client.createProvider( + "{\"package\":\"openai\",\"settings\":{}}"); + Model model = provider.languageModel("gpt-4o-mini")) { + System.out.println(model.generate("{\"prompt\":\"Hello\"}")); +} +``` + +Imports are from `ai.arcships.aimux`. Java 8 remains supported. Use +`Aimux.create`/`Resource.call` for registry, recorder, trace, session and replay +operations. `ProviderHandle` exposes the modality constructors and files; +`decisionModel(id)` selects method `decision`. Async methods such as +`Model.generateAsync(params, executor)` require an explicit Executor. Future +cancellation signals native Abort, including before execution; interrupting a +thread is not the native cancellation mechanism. Close and cancellation have +separate lifetimes. A Consumer gets a copied `StreamFrame`, including End; +terminal errors are raised after native return. Consumer failures are latched +and cannot cross C. `TranscriptionSession.next(timeoutMs)` returns PART/ENDED/ +TIMEOUT. Existing DTO builders remain; no blanket builder deletion is required. + +### Kotlin + +```kotlin +import ai.arcships.aimux.Aimux +import ai.arcships.aimux.StreamFrame +import ai.arcships.aimux.kotlin.aimuxCall +import ai.arcships.aimux.kotlin.aimuxFlow +import kotlinx.coroutines.flow.collect + +suspend fun generate() { + val client = Aimux.load() + client.createProvider("""{"package":"openai","settings":{}}""").use { provider -> + provider.languageModel("gpt-4o-mini").use { model -> + val params = """{"prompt":"Hello"}""" + println(aimuxCall { executor -> model.generateAsync(params, executor) }) + aimuxFlow { executor, consumer -> + model.streamAsync(params, consumer, executor) + }.collect { frame -> println(frame.json) } + } + } +} +``` + +Use Java resources and DTOs plus the Kotlin helpers; the old independent Kotlin +model/factory/Sequence API is removed. The artifact strictly pins same-version +`ai.arcships:aimux-java`. `aimuxCall`, `awaitAimux` and bounded `aimuxFlow` use +IO execution and forward cancellation to Java Future/Abort. The sealed Kotlin +AimuxException projects Java domain errors rather than decoding JSON again. + +### Swift + +```swift +let provider = try await AimuxRuntime.open(AimuxOps.OP_PROVIDER_CREATE, params: .object([ + "package": .string("openai"), "settings": .object([:]), +])) +defer { provider.close() } +let model = try await provider.open(AimuxOps.OP_PROVIDER_MODEL, params: .object([ + "method": .string("languageModel"), "modelId": .string("gpt-4o-mini"), +])) +defer { model.close() } +let result = try await model.call(AimuxOps.OP_TEXT_GENERATE, + params: .object(["prompt": .string("Hello")])) +print(result) +``` + +`AimuxRuntime` addresses globals; `AimuxHandle` owns UInt64 IDs. `call`/`open`/ +`stream` run blocking C work on GCD threads. `BoundedStream` is the bounded +AsyncSequence; call `cancel()` on stored iterators when abandoned. Task cancellation signals +Abort. `push(Data)` and `nextPart(timeoutMs:)` use binary/poll primitives; +closing an active Operation requests cancellation. ARC cleanup is a fallback, +not a flush. Existing Codable DTOs remain; dictionary re-encoding can change +object-key order. macOS execution alone does not verify iOS simulator linking. + +### Dart / Flutter + +```dart +final client = await Aimux.open(requiredOps: ['provider.create', 'text.generate']); +final providerResult = await client.call('provider.create', + params: {'package': 'openai', 'settings': {}}) + as Map; +final provider = client.adopt(providerResult['handle'] as String); +try { + final modelResult = await client.call('provider.model', handle: provider, + params: {'method': 'languageModel', 'modelId': 'gpt-4o-mini'}) + as Map; + final model = client.adopt(modelResult['handle'] as String); + try { + print(await client.call('text.generate', handle: model, params: {'prompt': 'Hello'})); + } finally { await model.close(); } +} finally { await provider.close(); } +``` + +Import `package:aimux/aimux.dart`. Adopt each returned handle once. Blocking +calls/push/poll/drop run on background isolates; Future abandonment alone does +not cancel. Create Abort explicitly and use `client.abort(abort)` when needed. +Raw streams expose EndFrame with any terminal error once; TypedModel projects +it into its typed error behavior. Pause withholds credit; subscription cancel +wakes the bridge without waiting for credit. NativeCallable.listener delivers +notifications on the owning isolate. NativeFinalizer uses its allocated uint64 +token trampoline; do not cast a uint64 drop function to a pointer callback. +Windows streaming is unsupported because the credit bridge uses POSIX pipes. +Android ABI packaging and iOS static force-linking require their platform gates. + +### Node.js / TypeScript + +```typescript +import { Client, LanguageModel } from '@arcships/aimux' +const client = new Client() +const provider = await client.provider({ package: 'openai', settings: {} }) +try { + const model = await provider.model('gpt-4o-mini', 'languageModel') + try { + if (!(model instanceof LanguageModel)) throw new Error('Expected language model') + console.log((await model.generate('Hello')).text) + } finally { await model.close() } +} finally { await provider.close() } +``` + +Typed calls use `Client.call(handle, op, params)` and resource helpers; the raw +`Runtime.call(handle, op, paramsJson)` returns JSON strings. Streams are async +iterators of event/end frames. Pass `{signal}` or `{abort}`, not both; +iterator.return and ReadableStream reader.cancel propagate cancellation. +`Client.abort()` and Operation.cancel are explicit controls. Handles belong to +one Env and cannot be transferred across Envs. The native addon calls shared +Rust dispatch, never the synchronous C adapter. The native lifecycle test +expects trace middleware creation and model wrapping to succeed. + +### Python + +```python +from aimux import Client + +client = Client() +provider = client.call("provider.create", {"package": "openai", "settings": {}}) +with client.adopt(provider["handle"]) as provider_handle: + model = provider_handle.call("provider.model", { + "method": "languageModel", "modelId": "gpt-4o-mini", + }) + with client.adopt(model["handle"]) as model_handle: + print(model_handle.call("text.generate", {"prompt": "Hello"})["text"]) +``` + +`Client.call(op, params, handle=..., abort=...)` returns Python JSON values. +`Handle.call` binds a handle; `Handle.stream` returns a synchronous EventStream +context manager. The iterator yields event frames, consumes successful end, +and raises a typed exception for failed/cancelled end. Close/cancel/join release +the GIL on blocking native paths. This is not an async generator: use your +executor for asyncio integration. `Abort.cancel()` signals; dropping it does +not. `Client.operation_push`/`Handle.push` accept bytes-like input; poll via +`operation.next` with timeoutMs. The native PyO3 package remains; optional +Pydantic codecs do not replace native dispatch. Subinterpreter import support +is constrained by the current PyO3 version. + +## Common request and lifecycle changes + +Text uses `{"prompt":...,"options":{...}}`. Embedding uses `values` (both embed +names currently call the canonical multi-value function); transcription uses +base64 `audio` and mediaType. Decision uses `{"options":...}`. Method names are +languageModel, embeddingModel, imageModel, speechModel, transcriptionModel, +rerankingModel, videoModel, searchModel and decision. Files uses provider.files. +Treat control keys and op names as case-sensitive. Handles and seq are decimal +strings in JSON, including IDs above 2^53. Do not substitute pointers or numbers. + +Use explicit recorder.create → recorder.attach → recorder.flush/stop/export; +flush reports persistence failures. Use sessionStore.create with explicit infer +and attach the selected store. Their capture state remains process-wide across +C clients and Node/Python owners. TraceStore and Middleware are distinct handles. +Replay accepts recordingsJsonl, inspect/dryRun and explicit Language/Decision +targets for replay.live; transport replay and credential.create currently raise +UnsupportedFunctionality, not empty success. + +Dropping an ordinary model does not cancel an admitted call. Dropping Abort +does not signal it; dropping Operation cancels it. Finalization is leak +prevention, not guaranteed flush or successful completion. Raw binary push is +limited to 1 MiB and returns ResourceLimit when input admission is full; JSON +operation.push is InvalidOp. Poll part/ended/timeout are distinct and a poll +timeout does not end the operation. Queues have independent terminal slots. + +Read `kind`, `code`, `message`, `error` and optional `retryAfterMs` from errors. +Use `error.name` and `error.isRetryable` where defined; there is no top-level +retryable or type. Preserve nested causes/history and unknown payloads. Missing +retryAfterMs differs from zero. Bindings never add a retry loop. Cancellation +cannot revoke a completed billable submission; a disconnected request can have +an unknown outcome, so do not automatically replay it. Callbacks must return in +bounded time: Abort cannot interrupt a callback that never returns. + +The [implementation document](rfc-0039-implementation.md#registered-gaps) lists +remaining schema, provenance, memory-bound and platform verification gaps. +The [conformance matrix](rfc-0039-conformance.md) records existing tests without +claiming unexecuted platforms passed. Stdio and arbitrary host provider +callbacks are outside the current API. diff --git a/rfc/0039-ops-bindings-contract.md b/rfc/0039-ops-bindings-contract.md index e6409a416..b225158e6 100644 --- a/rfc/0039-ops-bindings-contract.md +++ b/rfc/0039-ops-bindings-contract.md @@ -20,9 +20,9 @@ 4. 八种接口为 C、Go、Java、Kotlin、Swift、Flutter/Dart、Node、Python;C++ 示例属于 C ABI 的 RAII 用法,不计作第九套实现。 5. C、Go、Java、Kotlin、Swift、Flutter 走同步 C ABI;Node 保留 napi-rs、Python 保留 PyO3,直接调用共享异步 dispatch,禁止经同步 C ABI 嵌套 `block_on`。 6. provider/model/fetch/token/telemetry 的任意宿主控制流回调在**所有八种接口**后置。结果流的搬运 callback、取消通知和 RFC-0035 已有宿主侧修复能力仍在范围内。 -7. D8 是切换前提,不是最后删除手写类型之后的补救。descriptor → manifest → codegen 是唯一生成链,JSON Schema 是其产物,不能再养一条 ts-rs 或手写 Schema 真相链。 +7. D8(业务 DTO 与语言类型生成)在本次 ops/ABI 切换之后按 RFC-0042 交付,不是切换前提。切换时 descriptor → manifest → codegen 负责控制对象 Schema、头文件及操作常量;业务类型暂由现有 ts-rs / `scripts/gen_ts_types.py` 链生成,并保留其漂移门禁。D8 完成时业务 Schema 与语言 DTO 收束到同源生成链,同时删除过渡 ts-rs 链及其门禁;不长期维护两份业务类型权威。 8. 当前切换只交付已有能力的清理、V4 对齐和缺陷修复。stdio 服务、新的一次性二进制上传接口、任意 native passthrough、新模态均需单独通过后续范围门禁。 -9. 一次性替换整个依赖闭包;不发布旧/新 ABI 并存的 minor,不添加 deprecated alias、配置归一化 shim 或旧录制读取路径。 +9. 一次性替换整个依赖闭包;不发布旧/新 ABI 并存的 minor,不添加 deprecated alias 或配置归一化 shim。schema-2 录制读写作为明确的临时顺序例外保留到本系列最后一个 PR,届时迁至 schema 3 并移除 schema-2 及更早格式的读取(§7.1)。 ### 0.2 精确替代清单 @@ -38,7 +38,9 @@ | #166 D1、D2–D7 逐语言独立发布 | amended | 可以分工和内部评审,发布必须来自同一全绿集成 SHA | | #166 D5 先做旧头文件 ffigen 过渡版 | superseded-pending-#200 | 直接生成新头文件对应声明,不花一个发布周期维护旧 ABI 声明 | | #166 D7 ctypes 原型 → PyO3 fallback | superseded-pending-#200 | 选定薄 PyO3;消除 GIL 阻塞与重复解析,不引入第二套 ctypes wheel/线程方案 | -| #166 D8 serde/ts-rs → Schema → 类型、“最后一项” | superseded-pending-#200 | RFC-0042 的规范类型/Wire DTO + descriptor → manifest → codegen;Schema 与语言代码同批生成 | +| #166 D8 serde/ts-rs → Schema → 类型、“最后一项” | superseded-pending-#200 | RFC-0042 的规范类型/Wire DTO + descriptor → manifest → codegen;业务 Schema 与语言 DTO 在切换后同批交付,此前 ts-rs 为过渡链(§0.1.7) | +| RFC-0039 §3.2 `replay.capabilities` 直接返回内建能力句柄 | amended | 按 RFC-0041 的传输回放实施边界后置;本次注册为 Future,返回 UnsupportedFunctionality,待 Fetch/WebSocket/Download 回放引擎交付后启用 | +| RFC-0039 §3.2 `credential.create` 直接构造 resolver | amended | 按 RFC-0041 的回放与凭据治理边界后置;本次注册为 Future,返回 UnsupportedFunctionality,不以空 resolver 或导出秘密代替未实现的构造能力 | | RFC-0036 §4/§9、ROADMAP 0.8“FFI 与 stdio 同时实现” | amended | 一套协议保留;当前结构切换先完成 FFI/原生绑定。stdio 为后续独立能力里程碑,不混入 #200 清理范围 | | ROADMAP“瘦身不改变对外 API” | 保留在纯性能工作;收窄适用范围 | RFC-0038 不能以瘦身之名删 API;本 RFC 与 #200 是明确声明的 breaking 结构替换 | | RFC-0036 §3/§9“L2 独立版本化、不随 1.0 冻结” | amended-pending-#200 | 采用 V4 四层数据模型;ops/ABI 与录制 schema 分别管理;V4 类型兼容性归 ops 版本/能力协商,type revision 只记入 manifest;不新增独立 data_format_version 或保留旧 L2 投影实现 | @@ -119,6 +121,8 @@ resultOwnership, requestBytePolicy, retryOwner, sourceSymbolInventory `Operation` 有 `subkind`、`state` 和 `capabilities`;当前外部可创建的双向 Operation 为实时转写。未来不能因为 kind 相同而向文本流写音频。Trace middleware 与 trace store 分开,聚合 session 字符串与实时转写 Operation 也分开。 +`provider.custom` 的 `default_method` 元数据存放于进程级 Weak 侧表:以规范 `Arc` 的对象身份匹配,不增加强引用;插入时清理已失效条目。句柄表仍持有规范 provider,不创建第二份 provider 实现或配置表。 + ### 3.2 规范合法矩阵 下表列出**全部允许组合**。除 `handle.drop` 的显式规则外,任何未列出的 op × kind 组合都返回 `InvalidHandle`,并携带 expected/actual;没有通配 fallback。每行列出多 op 时,生成器必须展开为独立条目和测试。各模态缩写只在本表使用。 @@ -159,7 +163,7 @@ resultOwnership, requestBytePolicy, retryOwner, sourceSymbolInventory | `abort.create` | G | 无 | call → Abort | | `abort.signal` | Abort | 无;可跨线程、幂等 | call → 空结果 | | `transport.create` | G | kind=fetch/ws/download,仅内建实现/已存在设置 | call → 相应 typed 能力 | -| `credential.create` | G | 仅 RFC-0040 已有内建 resolver | call → CredentialResolver;不导出秘密 | +| `credential.create` | G | 仅 Rust 内建 resolver;按 RFC-0041 后置(§0.2) | call;本次 Future → UnsupportedFunctionality;不导出秘密 | | `middleware.create` | G | 内建能力;trace variant 需 TraceStore | call → Middleware | | `trace.create` | G | 容量、审计模式 | call → TraceStore | | `trace.aggregate`, `trace.sessionChain`, `trace.sessionTrajectory`, `trace.export`, `trace.clear` | TraceStore | filters/sessionId 是数据,不是句柄 | call → 各自查询/清理结果 | @@ -168,14 +172,14 @@ resultOwnership, requestBytePolicy, retryOwner, sourceSymbolInventory | `sessionStore.create` | G | 明确 infer 开关 | call → SessionStore | | `sessionStore.attach`, `sessionStore.list`, `sessionStore.calls` | SessionStore | sessionId 为字符串 | call → 结果;未知 session 保留各查询既有语义 | | `replay.create` | G | RFC-0041 schema 与规则 | call → ReplaySession | -| `replay.capabilities` | ReplaySession | 无 | call → 内建 Fetch/WS/Download 等能力句柄 | +| `replay.capabilities` | ReplaySession | 按 RFC-0041 后置(§0.2) | call;本次 Future → UnsupportedFunctionality;后续返回内建 Fetch/WS/Download 等能力句柄 | | `replay.inspect`, `replay.dryRun` | ReplaySession | 无副作用 | call → 验证/计划 | | `replay.live` | ReplaySession | 明确 Registry/Model 目标及当前凭证环境 | call → operation 结果;禁止恢复旧配置 | | `catalogue.fetch` | G | 已有 source 规则与下载守卫 | call → catalogue DTO | | `toolRepair.context`, `toolRepair.apply`, `toolRepair.applyResult` | G | 纯 DTO,无宿主 callback 句柄 | call → repair DTO/结果 | | `output.toOpenAI` | Language | metadata selector 明确;不猜 openai namespace | call → 输出 DTO | | `auth.codexRefresh` | G | 既有 refresh 输入;不记录 token、不自动重试 | call → token 结果;不得进入通用日志 | -| `handle.drop` | 所有非零 kind;0/未知值也可 | 无 | 幂等;先移除 registry,再取消/回收 | +| `handle.drop` | 所有非零 kind;0/未知值也可 | 无 | 幂等;先移除 registry,再取消/回收;foreign owner 的句柄成功但无效果 | 这里的 provider/transport/devtools 生命周期 op 是 #200 对现有行为的显式对象化,不授权本期实现 descriptor 中尚未具备的 skills、Evaluation、Batch、SpeechTranslation 等能力。方法列在 descriptor 中但不属于 current capability 集时,返回 `UnsupportedFunctionality`,不能返回伪造的成功空值。 @@ -192,6 +196,8 @@ Rust runtime 的字符串模型入口先通过显式 registry 解析成模型再 - Operation 单一 reader,允许另一个线程 push 和一个线程 cancel;第二个 reader 返回 `ConcurrentConsumer`,不隐式分流。inputDone 与 push 通过 admission 序号线性化:关闭前已接纳的 push 完成,关闭后拒绝。 - wrapper 的 Close/Dispose 幂等且原子取走句柄;finalizer 是防漏手段,不是 flush 或正常终结协议。 +`handle.drop` 对其他 owner 的句柄返回成功且不修改资源,与 0、未知和已释放 ID 的幂等结果一致;这样既不泄露句柄是否存在或其类型,也不会允许跨 owner 回收。 + ## 4. C ABI:所有权和执行边界 ### 4.1 规范原语 @@ -272,6 +278,8 @@ Abort 是一次性、可共享、幂等的取消信号。operation 持有其 clo 当前切换默认预算:音频输入最多 64 项且最多 8 MiB;输出 data 队列最多 256 项且最多 8 MiB;单个事件/音频块最大 1 MiB;先达到任何限制即背压。Node/Python 可保留更小的 64 项桥接队列,但总字节上限与终结规则一致。允许显式降低限额,不允许“无限”配置。 +C ABI 的同步 `aimux_operation_push` 使用非等待准入:队列满时在复制输入前立即返回 ResourceLimit;调用方待容量释放后重试。Rust 内部异步 `Operation::push` 可以等待容量,不能把该等待行为带入同步 C 入口。 + 这些是协议资源限额,不是性能成果;数值须由 RFC-0038 压测核定才能进入稳定冻结。超大单项在分配前返回 ResourceLimit;禁止通过 item count 为 256 宣称大对象内存已受限。 流队列 full 时停止向上游拉取,不丢 delta、不覆盖最老事件、不预先收集全流。abort/drop 通路不依赖获取队列容量;terminal 独立槽确保可读消费者最终看到真实结果。消费者不再读取且显式关闭时可放弃未交付事件,状态必须是 cancelled/consumerClosed。 @@ -333,11 +341,15 @@ Swift AsyncThrowingStream 的 drop-oldest/drop-newest 策略不能用来实现 |---|---|---| | `abiVersion` | C 符号、布局、调用约定、所有权 | major 不同立即拒绝加载;minor 只追加,不能改变已有布局 | | `opsVersion` | 信封、op 语义、handle kind、流终结、V4 类型兼容性与控制规则 | `major.minor`;major 必须相同,选择双方最高公共 minor;每版本明确支持的 type revision/能力 | -| `manifestRevision` / `manifestDigest` | 本次 op/type/package/capability 实例、schema/type revision | 修订标识 + canonical JSON SHA-256;不是独立握手协议版本;digest 不同可继续按 ops/requiredOps/类型能力协商,不伪称二进制相同 | -| `recordingSchema` | 录制格式 | 由 RFC-0041 定义;本期只支持新格式,不加载旧录制;它不是 live ops 握手的附加版本 | +| `manifestRevision` / `digest` | 本次 op/type/package/capability 实例、schema/type revision | 修订标识 + canonical JSON SHA-256;不是独立握手协议版本;digest 不同可继续按 ops/requiredOps/类型能力协商,不伪称二进制相同 | +| `recordingSchema` | 录制格式 | 由 RFC-0041 定义;schema-2 读写保留至本系列最后一个 PR,届时切换 schema 3 并移除 schema-2 读取;它不是 live ops 握手的附加版本 | RFC-0042 的 schema/type revision 与固定 SDK 基线记录在 manifest,由 ops 兼容矩阵解释;不额外引入 dataFormatVersion/data_format_version 握手。冻结的是明示版本,不是把某一 SDK npm patch 号当永久 ABI。V4 新 variant 是否为 additive 必须由数据规范和兼容测试决定;不能因为 JSON 能解析就认为有语义兼容。 +本次切换保留现有 schema-2 写入以及 schema-1/schema-2 读取,作为系列内过渡,不视为长期兼容承诺。系列最后一个 PR 必须完成 schema-3 迁移并删除 schema-2(及更早 schema)读取路径;不能在本次切换提前删除,也不能在最后一个 PR 后继续保留。 + +manifest JSON 的 `digest` 为 canonical manifest 的 SHA-256,计算时排除 `digest` 字段自身;生成常量继续命名为 `MANIFEST_DIGEST`。 + ### 7.2 握手 未来 stdio 第一帧必须为 hello,请求 id 为十进制字符串。示例中的版本值为候选版本,并不表示已有可运行 stdio: @@ -477,6 +489,8 @@ RFC-0042 拥有四层类型定义、Wire DTO、type IDs、错误 variants、null 这些输入在同一 manifest 汇总并一次生成:Rust dispatch registration、C header、各语言 op 常量与类型、provider 工厂外观、JSON Schema、文档参考和 contract vectors。语言 loader、executor、finalizer、stream pump、错误投影外观可以手写;provider 行为、wire 字段表和 per-provider 构造列表不能手写。 +这是 D8 完成后的完整生成目标。按 §0.1.7 的实施顺序,ops/ABI 切换先交付控制对象 Schema、头文件及操作常量,业务 DTO 暂由 ts-rs 过渡链提供;不能把完整目标倒置为本次切换前提。 + provider 输出的 tool-call input 是 V4 原文 String,core 工具输入是解析后的 JSON;不得在此重新引入 `ToolInput` 无标签联合混淆两层。stream 事件类型归 RFC-0042;本 RFC EndFrame 是传输控制类型,不成为第五套业务 StreamPart。 ### 10.2 可执行门禁 @@ -547,7 +561,7 @@ stdio、原始文件二进制入口及 RFC-0040 的 native passthrough 分别立 - V4 DTO 的 schema/type revision 由 RFC-0042 登记在 manifest,类型兼容性由 ops 版本/能力协商承载;后续 V4/V5 对齐不借 ops minor 偷换既有业务数据形态。录制 schema 独立,升级影响明确。 - 若 1.0 对外承诺 stdio,则 S 门禁必须先完成;若尚未完成,修改 1.0 支持范围的决策须先入库,不可借主线已存在 schema 文件声称入口完成。 - L0/L1 的具体稳定边界由 RFC-0040 与 #200 决策登记;本 RFC 不冻结尚未批准的 passthrough 或宿主能力注入。 -- C4 已在原子切换时完成;没有旧 ABI 影子出口、兼容 alias 或生成器双轨。 +- C4 已在原子切换时完成,没有旧 ABI 影子出口或兼容 alias;切换后交付的 D8 也已完成并删除 ts-rs 过渡链,没有生成器双轨。 - #95 错误体系、请求运行时、recording/replay 完整性与全部语言 contract tests 在同一候选版本稳定;性能门禁连续绿的测量周期和统计准则由 RFC-0038 给出。 - 重试与收费副作用、drop/abort 差异、callback 不可强制终止、未知结果、数据不兼容回退等限制在公开英文 binding 文档中准确可见。 diff --git a/schema/ops/error-envelope.json b/schema/ops/error-envelope.json new file mode 100644 index 000000000..ae1882ffe --- /dev/null +++ b/schema/ops/error-envelope.json @@ -0,0 +1,45 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/error-envelope.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "code": { + "type": "integer" + }, + "error": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "kind": { + "enum": [ + "aimux", + "recording", + "binding", + "protocol" + ] + }, + "message": { + "type": "string" + }, + "retryAfterMs": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind", + "code", + "message", + "error" + ], + "type": "object" +} diff --git a/schema/ops/handle-id.json b/schema/ops/handle-id.json new file mode 100644 index 000000000..1a110d6e6 --- /dev/null +++ b/schema/ops/handle-id.json @@ -0,0 +1,10 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/handle-id.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" +} diff --git a/schema/ops/provider-options.json b/schema/ops/provider-options.json new file mode 100644 index 000000000..63ec69df6 --- /dev/null +++ b/schema/ops/provider-options.json @@ -0,0 +1,7 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/provider-options.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "type": "object" +} diff --git a/schema/ops/request.json b/schema/ops/request.json new file mode 100644 index 000000000..6d8273125 --- /dev/null +++ b/schema/ops/request.json @@ -0,0 +1,1836 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/request.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "abort.create" + }, + "params": { + "$ref": "type-EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "abort.signal" + }, + "params": { + "$ref": "type-EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "auth.codexRefresh" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_MISC_CODEX_REFRESH_REQUEST.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "catalogue.fetch" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_MISC_CATALOGUE_FETCH_REQUEST.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "composite.moa" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_COMPOSITE_MOA_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "composite.router" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_COMPOSITE_ROUTER_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "credential.create" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_HANDLES_CREDENTIAL_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "decision.capabilities" + }, + "params": { + "$ref": "type-EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "decision.decide" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_DECISION_DECISION_DECIDE_REQUEST.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "embedding.embed" + }, + "params": { + "$comment": "Business DTO schema generation belongs to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::embedding_model::EmbeddingCallOptions" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "embedding.embedMany" + }, + "params": { + "$comment": "Business DTO schema generation belongs to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::embedding_model::EmbeddingCallOptions" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "files.upload" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_FILES_UPLOAD_FILE_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json" + }, + "op": { + "const": "handle.drop" + }, + "params": { + "$ref": "type-EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "image.generate" + }, + "params": { + "$comment": "Business DTO schema generation belongs to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::image_model::ImageCallOptions" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "middleware.create" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_HANDLES_MIDDLEWARE_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "model.info" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_REGISTRY_MODEL_INFO_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "model.resolve" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_REGISTRY_MODEL_RESOLVE_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "model.wrap" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_REGISTRY_MODEL_WRAP_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "operation.cancel" + }, + "params": { + "$ref": "type-EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "operation.inputDone" + }, + "params": { + "$ref": "type-EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "operation.next" + }, + "params": { + "$ref": "type-OPERATION_NEXT_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "operation.push" + }, + "params": { + "$ref": "type-BINARY_INPUT.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "operation.status" + }, + "params": { + "$ref": "type-EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "output.toOpenAI" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_MISC_OPEN_AI_OUTPUT_REQUEST.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "provider.create" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_CREATE_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "provider.custom" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_CUSTOM_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "provider.default" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_DEFAULT_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "provider.files" + }, + "params": { + "$ref": "type-EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "provider.invoke" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_INVOKE_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "provider.model" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_MODEL_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "provider.wrap" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_WRAP_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "recorder.attach" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_RECORDING_EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "recorder.create" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_RECORDING_CREATE_RECORDER_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "recorder.export" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_RECORDING_EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "recorder.flush" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_RECORDING_EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "recorder.stats" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_RECORDING_EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "recorder.stop" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_RECORDING_EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "registry.create" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_REGISTRY_REGISTRY_CREATE_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "registry.provider" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_REGISTRY_REGISTRY_PROVIDER_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "replay.capabilities" + }, + "params": { + "$ref": "type-EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "replay.create" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_REPLAY_REPLAY_CREATE_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "replay.dryRun" + }, + "params": { + "$ref": "type-EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "replay.inspect" + }, + "params": { + "$ref": "type-EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "replay.live" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_REPLAY_REPLAY_LIVE_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "reranking.rerank" + }, + "params": { + "$comment": "Business DTO schema generation belongs to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::reranking_model::RerankingCallOptions" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "runtime.configure" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_RUNTIME_RUNTIME_CONFIGURE_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "runtime.describe" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_RUNTIME_RUNTIME_DESCRIBE_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "search.search" + }, + "params": { + "$comment": "Business DTO schema generation belongs to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::search_model::SearchCallOptions" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "sessionStore.attach" + }, + "params": { + "$ref": "type-EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "sessionStore.calls" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_SESSION_SESSION_STORE_CALLS_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "sessionStore.create" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_SESSION_SESSION_STORE_CREATE_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "sessionStore.list" + }, + "params": { + "$ref": "type-EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "speech.generate" + }, + "params": { + "$comment": "Business DTO schema generation belongs to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::speech_model::SpeechCallOptions" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "text.consume" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_TEXT_TEXT_REQUEST.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "text.generate" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_TEXT_TEXT_REQUEST.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "text.generateObject" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_TEXT_TEXT_REQUEST.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "text.generateOpenAI" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_TEXT_TEXT_REQUEST.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "text.stream" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_TEXT_TEXT_REQUEST.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "text.streamOpenAI" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_TEXT_OPEN_AI_TEXT_REQUEST.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "toolRepair.apply" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_APPLY_REQUEST.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "toolRepair.applyResult" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_APPLY_RESULT_REQUEST.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "toolRepair.context" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_CONTEXT_REQUEST.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "trace.aggregate" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_TRACE_TRACE_AGGREGATE_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "trace.clear" + }, + "params": { + "$ref": "type-EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "trace.create" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_TRACE_TRACE_CREATE_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "trace.export" + }, + "params": { + "$ref": "type-EMPTY_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "trace.sessionChain" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_TRACE_TRACE_SESSION_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "trace.sessionTrajectory" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_TRACE_TRACE_SESSION_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "transcription.generate" + }, + "params": { + "$comment": "Business DTO schema generation belongs to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::transcription_model::TranscriptionCallOptions" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "transcription.start" + }, + "params": { + "$ref": "type-TRANSCRIPTION_START_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "const": "0" + }, + "op": { + "const": "transport.create" + }, + "params": { + "$ref": "type-AIMUX_OPS_OPS_HANDLES_TRANSPORT_PARAMS.json" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "abort": { + "$ref": "handle-id.json" + }, + "handle": { + "$ref": "handle-id.json", + "not": { + "const": "0" + } + }, + "op": { + "const": "video.generate" + }, + "params": { + "$comment": "Business DTO schema generation belongs to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::video_model::VideoCallOptions" + } + }, + "required": [ + "handle", + "op", + "params" + ], + "type": "object" + } + ] +} diff --git a/schema/ops/stream-frame.json b/schema/ops/stream-frame.json new file mode 100644 index 000000000..52a748b08 --- /dev/null +++ b/schema/ops/stream-frame.json @@ -0,0 +1,130 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$defs": { + "ErrorEnvelope": { + "additionalProperties": false, + "properties": { + "code": { + "type": "integer" + }, + "error": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "kind": { + "enum": [ + "aimux", + "recording", + "binding", + "protocol" + ] + }, + "message": { + "type": "string" + }, + "retryAfterMs": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind", + "code", + "message", + "error" + ], + "type": "object" + } + }, + "$id": "https://aimux.dev/schema/ops/stream-frame.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "event": {}, + "seq": { + "pattern": "^[0-9]+$", + "type": "string" + }, + "type": { + "const": "event" + } + }, + "required": [ + "type", + "seq", + "event" + ], + "type": "object" + }, + { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "outcome": { + "const": "error" + } + } + }, + "then": { + "required": [ + "error" + ] + } + }, + { + "if": { + "properties": { + "outcome": { + "const": "success" + } + } + }, + "then": { + "not": { + "required": [ + "error" + ] + } + } + } + ], + "properties": { + "error": { + "$ref": "#/$defs/ErrorEnvelope" + }, + "outcome": { + "enum": [ + "success", + "error", + "cancelled" + ] + }, + "seq": { + "pattern": "^[0-9]+$", + "type": "string" + }, + "type": { + "const": "end" + } + }, + "required": [ + "type", + "seq", + "outcome" + ], + "type": "object" + } + ] +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_COMPOSITE_MOA_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_COMPOSITE_MOA_PARAMS.json new file mode 100644 index 000000000..5a8caaa52 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_COMPOSITE_MOA_PARAMS.json @@ -0,0 +1,55 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_COMPOSITE_MOA_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "aggregator": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "aggregatorInstructions": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "failMode": { + "enum": [ + "best-effort", + "fail-fast" + ] + }, + "modelId": { + "type": "string" + }, + "providerName": { + "type": "string" + }, + "references": { + "items": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "type": "array" + }, + "stripReferenceTools": { + "type": "boolean" + } + }, + "required": [ + "references", + "aggregator" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_COMPOSITE_ROUTER_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_COMPOSITE_ROUTER_PARAMS.json new file mode 100644 index 000000000..d2958492e --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_COMPOSITE_ROUTER_PARAMS.json @@ -0,0 +1,85 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_COMPOSITE_ROUTER_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "children": { + "items": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "fallback": { + "enum": [ + "on-error", + "none" + ] + }, + "modelId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "strategy": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "type": { + "const": "rule" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "type": { + "const": "weighted" + }, + "weights": { + "items": { + "type": "number" + }, + "type": "array" + } + }, + "required": [ + "type", + "weights" + ], + "type": "object" + } + ] + } + }, + "required": [ + "children", + "strategy" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_DECISION_DECISION_DECIDE_REQUEST.json b/schema/ops/type-AIMUX_OPS_OPS_DECISION_DECISION_DECIDE_REQUEST.json new file mode 100644 index 000000000..9b5ab238e --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_DECISION_DECISION_DECIDE_REQUEST.json @@ -0,0 +1,30 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_DECISION_DECISION_DECIDE_REQUEST.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "abort": { + "anyOf": [ + { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "options": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::decision_model::DecideOptions" + } + }, + "required": [ + "options" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_FILES_UPLOAD_FILE_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_FILES_UPLOAD_FILE_PARAMS.json new file mode 100644 index 000000000..9d9cd29f3 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_FILES_UPLOAD_FILE_PARAMS.json @@ -0,0 +1,87 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_FILES_UPLOAD_FILE_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "abort": { + "anyOf": [ + { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "data": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "data": { + "contentEncoding": "base64", + "type": "string" + }, + "type": { + "const": "data" + } + }, + "required": [ + "type", + "data" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "text": { + "type": "string" + }, + "type": { + "const": "text" + } + }, + "required": [ + "type", + "text" + ], + "type": "object" + } + ] + }, + "filename": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mediaType": { + "type": "string" + }, + "providerOptions": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "data", + "mediaType" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_HANDLES_CREDENTIAL_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_HANDLES_CREDENTIAL_PARAMS.json new file mode 100644 index 000000000..8e5162730 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_HANDLES_CREDENTIAL_PARAMS.json @@ -0,0 +1,15 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_HANDLES_CREDENTIAL_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_HANDLES_HANDLE_RESULT.json b/schema/ops/type-AIMUX_OPS_OPS_HANDLES_HANDLE_RESULT.json new file mode 100644 index 000000000..81a8fe9cc --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_HANDLES_HANDLE_RESULT.json @@ -0,0 +1,47 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_HANDLES_HANDLE_RESULT.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "handle": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "kind": { + "enum": [ + "Provider", + "Registry", + "Language", + "Embedding", + "Image", + "Speech", + "Transcription", + "Reranking", + "Video", + "Search", + "Decision", + "Files", + "Operation", + "Abort", + "Fetch", + "WebSocket", + "Download", + "CredentialResolver", + "Middleware", + "TraceStore", + "Recorder", + "SessionStore", + "ReplaySession" + ] + } + }, + "required": [ + "handle", + "kind" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_HANDLES_MIDDLEWARE_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_HANDLES_MIDDLEWARE_PARAMS.json new file mode 100644 index 000000000..e386cf02c --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_HANDLES_MIDDLEWARE_PARAMS.json @@ -0,0 +1,53 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_HANDLES_MIDDLEWARE_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "auditMode": { + "anyOf": [ + { + "enum": [ + "disabled", + "shared", + "strict" + ] + }, + { + "type": "null" + } + ] + }, + "kind": { + "type": "string" + }, + "scopeName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "traceStore": { + "anyOf": [ + { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_HANDLES_TRANSPORT_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_HANDLES_TRANSPORT_PARAMS.json new file mode 100644 index 000000000..a52278bcb --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_HANDLES_TRANSPORT_PARAMS.json @@ -0,0 +1,149 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_HANDLES_TRANSPORT_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "fetch" + }, + "proxy": { + "additionalProperties": false, + "properties": { + "all_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "http_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "https_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "no_proxy": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "type": "object" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "ws" + }, + "proxy": { + "additionalProperties": false, + "properties": { + "all_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "http_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "https_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "no_proxy": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "type": "object" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "download" + }, + "maxBytes": { + "anyOf": [ + { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ] +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_MISC_CATALOGUE_FETCH_REQUEST.json b/schema/ops/type-AIMUX_OPS_OPS_MISC_CATALOGUE_FETCH_REQUEST.json new file mode 100644 index 000000000..9eff84afd --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_MISC_CATALOGUE_FETCH_REQUEST.json @@ -0,0 +1,34 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_MISC_CATALOGUE_FETCH_REQUEST.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "abort": { + "anyOf": [ + { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "sourceUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_MISC_CODEX_REFRESH_REQUEST.json b/schema/ops/type-AIMUX_OPS_OPS_MISC_CODEX_REFRESH_REQUEST.json new file mode 100644 index 000000000..61c18b259 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_MISC_CODEX_REFRESH_REQUEST.json @@ -0,0 +1,19 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_MISC_CODEX_REFRESH_REQUEST.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "clientId": { + "type": "string" + }, + "refreshToken": { + "type": "string" + } + }, + "required": [ + "refreshToken", + "clientId" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_MISC_OPEN_AI_OUTPUT_REQUEST.json b/schema/ops/type-AIMUX_OPS_OPS_MISC_OPEN_AI_OUTPUT_REQUEST.json new file mode 100644 index 000000000..eb35f4ae9 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_MISC_OPEN_AI_OUTPUT_REQUEST.json @@ -0,0 +1,21 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_MISC_OPEN_AI_OUTPUT_REQUEST.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "metadataNamespace": { + "minLength": 1, + "type": "string" + }, + "result": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::generate::GenerateTextResult" + } + }, + "required": [ + "result", + "metadataNamespace" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_APPLY_REQUEST.json b/schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_APPLY_REQUEST.json new file mode 100644 index 000000000..7dd5bfd0e --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_APPLY_REQUEST.json @@ -0,0 +1,25 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_APPLY_REQUEST.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "options": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::GenerateTextOptions" + }, + "reply": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::ToolCallRepairReply" + }, + "toolCall": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::ToolCall" + } + }, + "required": [ + "toolCall", + "reply" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_APPLY_RESULT_REQUEST.json b/schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_APPLY_RESULT_REQUEST.json new file mode 100644 index 000000000..20b47eee8 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_APPLY_RESULT_REQUEST.json @@ -0,0 +1,26 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_APPLY_RESULT_REQUEST.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "options": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::GenerateTextOptions" + }, + "reply": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::ToolCallRepairReply" + }, + "result": {}, + "toolCallId": { + "type": "string" + } + }, + "required": [ + "result", + "toolCallId", + "reply" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_CONTEXT_REQUEST.json b/schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_CONTEXT_REQUEST.json new file mode 100644 index 000000000..c30a669ba --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_CONTEXT_REQUEST.json @@ -0,0 +1,25 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_CONTEXT_REQUEST.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "options": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::GenerateTextOptions" + }, + "prompt": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::ModelPrompt" + }, + "toolCall": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::ToolCall" + } + }, + "required": [ + "toolCall", + "prompt" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_CONTEXT_RESULT.json b/schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_CONTEXT_RESULT.json new file mode 100644 index 000000000..f33477717 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_CONTEXT_RESULT.json @@ -0,0 +1,14 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_CONTEXT_RESULT.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "serde_json::Value" + }, + { + "type": "null" + } + ] +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_HANDLE_RESULT.json b/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_HANDLE_RESULT.json new file mode 100644 index 000000000..84c8628ba --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_HANDLE_RESULT.json @@ -0,0 +1,47 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_HANDLE_RESULT.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "handle": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "kind": { + "enum": [ + "Provider", + "Registry", + "Language", + "Embedding", + "Image", + "Speech", + "Transcription", + "Reranking", + "Video", + "Search", + "Decision", + "Files", + "Operation", + "Abort", + "Fetch", + "WebSocket", + "Download", + "CredentialResolver", + "Middleware", + "TraceStore", + "Recorder", + "SessionStore", + "ReplaySession" + ] + } + }, + "required": [ + "handle", + "kind" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_CREATE_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_CREATE_PARAMS.json new file mode 100644 index 000000000..ac52aafca --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_CREATE_PARAMS.json @@ -0,0 +1,143 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_CREATE_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "package": { + "type": "string" + }, + "settings": { + "additionalProperties": false, + "properties": { + "apiKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "baseUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "credentialResolver": { + "anyOf": [ + { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "fetch": { + "anyOf": [ + { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "headers": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": "object" + }, + "organization": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "params": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "probabilitySource": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "project": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "webSocket": { + "anyOf": [ + { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "type": "object" + } + }, + "required": [ + "package" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_CUSTOM_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_CUSTOM_PARAMS.json new file mode 100644 index 000000000..1a2ccc66d --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_CUSTOM_PARAMS.json @@ -0,0 +1,158 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_CUSTOM_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "decisionModels": { + "additionalProperties": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "type": "object" + }, + "defaultMethod": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "embeddingModels": { + "additionalProperties": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "type": "object" + }, + "fallbackProvider": { + "anyOf": [ + { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "files": { + "anyOf": [ + { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "imageModelMiddleware": { + "items": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "type": "array" + }, + "imageModels": { + "additionalProperties": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "type": "object" + }, + "languageModelMiddleware": { + "items": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "type": "array" + }, + "languageModels": { + "additionalProperties": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "type": "object" + }, + "rerankingModels": { + "additionalProperties": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "type": "object" + }, + "searchModels": { + "additionalProperties": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "type": "object" + }, + "speechModels": { + "additionalProperties": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "type": "object" + }, + "transcriptionModels": { + "additionalProperties": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "type": "object" + }, + "videoModels": { + "additionalProperties": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "type": "object" + } + }, + "required": [], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_DEFAULT_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_DEFAULT_PARAMS.json new file mode 100644 index 000000000..272214e68 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_DEFAULT_PARAMS.json @@ -0,0 +1,15 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_DEFAULT_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "package": { + "type": "string" + } + }, + "required": [ + "package" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_INVOKE_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_INVOKE_PARAMS.json new file mode 100644 index 000000000..07e95cd44 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_INVOKE_PARAMS.json @@ -0,0 +1,29 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_INVOKE_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "abort": { + "anyOf": [ + { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "method": { + "type": "string" + } + }, + "required": [ + "method" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_INVOKE_RESULT.json b/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_INVOKE_RESULT.json new file mode 100644 index 000000000..6bafe99e9 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_INVOKE_RESULT.json @@ -0,0 +1,10 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_INVOKE_RESULT.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "items": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::model_catalogue::RuntimeModel" + }, + "type": "array" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_MODEL_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_MODEL_PARAMS.json new file mode 100644 index 000000000..1c6a6940a --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_MODEL_PARAMS.json @@ -0,0 +1,25 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_MODEL_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "method": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "modelId": { + "type": "string" + } + }, + "required": [ + "modelId" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_WRAP_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_WRAP_PARAMS.json new file mode 100644 index 000000000..3e65257f8 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_WRAP_PARAMS.json @@ -0,0 +1,30 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_WRAP_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "imageModelMiddleware": { + "items": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "type": "array" + }, + "languageModelMiddleware": { + "items": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "type": "array" + } + }, + "required": [], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_RECORDING_CREATE_RECORDER_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_RECORDING_CREATE_RECORDER_PARAMS.json new file mode 100644 index 000000000..8305c75fe --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_RECORDING_CREATE_RECORDER_PARAMS.json @@ -0,0 +1,60 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_RECORDING_CREATE_RECORDER_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "backend": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "capacity": { + "anyOf": [ + { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "type": { + "const": "ring" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "directory": { + "type": "string" + }, + "type": { + "const": "jsonl" + } + }, + "required": [ + "type", + "directory" + ], + "type": "object" + } + ] + }, + "capturePolicy": { + "const": "full" + } + }, + "required": [ + "backend", + "capturePolicy" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_RECORDING_EMPTY_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_RECORDING_EMPTY_PARAMS.json new file mode 100644 index 000000000..79010d99f --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_RECORDING_EMPTY_PARAMS.json @@ -0,0 +1,9 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_RECORDING_EMPTY_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_RECORDING_RECORDER_EXPORT.json b/schema/ops/type-AIMUX_OPS_OPS_RECORDING_RECORDER_EXPORT.json new file mode 100644 index 000000000..29ab4281b --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_RECORDING_RECORDER_EXPORT.json @@ -0,0 +1,15 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_RECORDING_RECORDER_EXPORT.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "jsonl": { + "type": "string" + } + }, + "required": [ + "jsonl" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_RECORDING_RECORDER_HANDLE_RESULT.json b/schema/ops/type-AIMUX_OPS_OPS_RECORDING_RECORDER_HANDLE_RESULT.json new file mode 100644 index 000000000..f01f1144a --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_RECORDING_RECORDER_HANDLE_RESULT.json @@ -0,0 +1,19 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_RECORDING_RECORDER_HANDLE_RESULT.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "handle": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + } + }, + "required": [ + "handle" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_RECORDING_RECORDER_STATS.json b/schema/ops/type-AIMUX_OPS_OPS_RECORDING_RECORDER_STATS.json new file mode 100644 index 000000000..31ba25cf3 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_RECORDING_RECORDER_STATS.json @@ -0,0 +1,90 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_RECORDING_RECORDER_STATS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "completed": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "dropped": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "inconsistentCallIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "pending": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "pendingEvicted": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "type": { + "const": "ring" + } + }, + "required": [ + "type", + "completed", + "pending", + "dropped", + "pendingEvicted", + "inconsistentCallIds" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "dropped": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "inconsistentCallIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "lastWriteError": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "path": { + "type": "string" + }, + "type": { + "const": "jsonl" + } + }, + "required": [ + "type", + "path", + "dropped", + "inconsistentCallIds", + "lastWriteError" + ], + "type": "object" + } + ] +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_HANDLE_RESULT.json b/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_HANDLE_RESULT.json new file mode 100644 index 000000000..6d2cb5f7d --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_HANDLE_RESULT.json @@ -0,0 +1,47 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_HANDLE_RESULT.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "handle": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "kind": { + "enum": [ + "Provider", + "Registry", + "Language", + "Embedding", + "Image", + "Speech", + "Transcription", + "Reranking", + "Video", + "Search", + "Decision", + "Files", + "Operation", + "Abort", + "Fetch", + "WebSocket", + "Download", + "CredentialResolver", + "Middleware", + "TraceStore", + "Recorder", + "SessionStore", + "ReplaySession" + ] + } + }, + "required": [ + "handle", + "kind" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_INFO.json b/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_INFO.json new file mode 100644 index 000000000..04e31f97d --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_INFO.json @@ -0,0 +1,27 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_INFO.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "capabilities": {}, + "modality": { + "type": "string" + }, + "modelId": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "source": {} + }, + "required": [ + "provider", + "modelId", + "modality", + "capabilities", + "source" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_INFO_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_INFO_PARAMS.json new file mode 100644 index 000000000..0dd54d6dc --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_INFO_PARAMS.json @@ -0,0 +1,9 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_INFO_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_RESOLVE_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_RESOLVE_PARAMS.json new file mode 100644 index 000000000..84a44559d --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_RESOLVE_PARAMS.json @@ -0,0 +1,46 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_RESOLVE_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "modelRef": { + "additionalProperties": false, + "properties": { + "key": { + "type": "string" + }, + "method": { + "enum": [ + "languageModel", + "embeddingModel", + "imageModel", + "speechModel", + "transcriptionModel", + "rerankingModel", + "videoModel", + "searchModel", + "decision" + ] + }, + "modelId": { + "type": "string" + }, + "namespace": { + "type": "string" + } + }, + "required": [ + "namespace", + "key", + "method", + "modelId" + ], + "type": "object" + } + }, + "required": [ + "modelRef" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_WRAP_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_WRAP_PARAMS.json new file mode 100644 index 000000000..3ada2e7f6 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_WRAP_PARAMS.json @@ -0,0 +1,22 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_WRAP_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "middleware": { + "items": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "middleware" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_REGISTRY_CREATE_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_REGISTRY_CREATE_PARAMS.json new file mode 100644 index 000000000..ee859249d --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_REGISTRY_CREATE_PARAMS.json @@ -0,0 +1,38 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_REGISTRY_CREATE_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "entries": { + "items": { + "additionalProperties": false, + "properties": { + "key": { + "type": "string" + }, + "provider": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + } + }, + "required": [ + "key", + "provider" + ], + "type": "object" + }, + "type": "array" + }, + "namespace": { + "type": "string" + } + }, + "required": [ + "entries" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_REGISTRY_PROVIDER_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_REGISTRY_PROVIDER_PARAMS.json new file mode 100644 index 000000000..91564eb14 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_REGISTRY_PROVIDER_PARAMS.json @@ -0,0 +1,15 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_REGISTRY_REGISTRY_PROVIDER_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "key": { + "type": "string" + } + }, + "required": [ + "key" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_CREATE_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_CREATE_PARAMS.json new file mode 100644 index 000000000..24fad23b4 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_CREATE_PARAMS.json @@ -0,0 +1,15 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_CREATE_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "recordingsJsonl": { + "type": "string" + } + }, + "required": [ + "recordingsJsonl" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_INSPECTION.json b/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_INSPECTION.json new file mode 100644 index 000000000..f0ae96201 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_INSPECTION.json @@ -0,0 +1,51 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_INSPECTION.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "recordings": { + "items": { + "additionalProperties": false, + "properties": { + "callId": { + "type": "string" + }, + "complete": { + "type": "boolean" + }, + "exchangeCount": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "operation": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::recording::RecordingOperation" + }, + "provider": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::recording::ProviderRecord" + }, + "transportClosed": { + "type": "boolean" + } + }, + "required": [ + "callId", + "provider", + "operation", + "complete", + "transportClosed", + "exchangeCount" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "recordings" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_LIVE_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_LIVE_PARAMS.json new file mode 100644 index 000000000..64c3dfa24 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_LIVE_PARAMS.json @@ -0,0 +1,53 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_LIVE_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "abort": { + "anyOf": [ + { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "callId": { + "type": "string" + }, + "target": { + "additionalProperties": false, + "properties": { + "handle": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + "kind": { + "enum": [ + "model", + "registry" + ] + } + }, + "required": [ + "kind", + "handle" + ], + "type": "object" + } + }, + "required": [ + "callId", + "target" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_LIVE_RESULT.json b/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_LIVE_RESULT.json new file mode 100644 index 000000000..c14e590a6 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_LIVE_RESULT.json @@ -0,0 +1,6 @@ +{ + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_LIVE_RESULT.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "x-aimux-type": "aimux_core::generate::GenerateTextResult | aimux_core::decision_model::DecideResult" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_PLAN.json b/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_PLAN.json new file mode 100644 index 000000000..cec6323b6 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_PLAN.json @@ -0,0 +1,45 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_PLAN.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "calls": { + "items": { + "additionalProperties": false, + "properties": { + "callId": { + "type": "string" + }, + "liveSupported": { + "type": "boolean" + }, + "operation": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::recording::RecordingOperation" + }, + "provider": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::recording::ProviderRecord" + }, + "requiresTarget": { + "type": "boolean" + } + }, + "required": [ + "callId", + "provider", + "operation", + "liveSupported", + "requiresTarget" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "calls" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_SESSION_HANDLE.json b/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_SESSION_HANDLE.json new file mode 100644 index 000000000..101f5faad --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_SESSION_HANDLE.json @@ -0,0 +1,19 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_SESSION_HANDLE.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "handle": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + } + }, + "required": [ + "handle" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_RUNTIME_RUNTIME_CONFIGURE_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_RUNTIME_RUNTIME_CONFIGURE_PARAMS.json new file mode 100644 index 000000000..f152b4d82 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_RUNTIME_RUNTIME_CONFIGURE_PARAMS.json @@ -0,0 +1,90 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_RUNTIME_RUNTIME_CONFIGURE_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "logging": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "level": { + "enum": [ + "off", + "error", + "warn", + "info", + "debug", + "trace" + ] + } + }, + "required": [ + "level" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "proxy": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "all_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "http_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "https_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "no_proxy": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "type": "object" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_RUNTIME_RUNTIME_DESCRIBE_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_RUNTIME_RUNTIME_DESCRIBE_PARAMS.json new file mode 100644 index 000000000..9903eca24 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_RUNTIME_RUNTIME_DESCRIBE_PARAMS.json @@ -0,0 +1,9 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_RUNTIME_RUNTIME_DESCRIBE_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_CALLS.json b/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_CALLS.json new file mode 100644 index 000000000..270a2d689 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_CALLS.json @@ -0,0 +1,10 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_CALLS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "items": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::session::SessionCall" + }, + "type": "array" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_STORE_CALLS_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_STORE_CALLS_PARAMS.json new file mode 100644 index 000000000..6c2429f64 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_STORE_CALLS_PARAMS.json @@ -0,0 +1,15 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_STORE_CALLS_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_STORE_CREATE_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_STORE_CREATE_PARAMS.json new file mode 100644 index 000000000..654640d2d --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_STORE_CREATE_PARAMS.json @@ -0,0 +1,15 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_STORE_CREATE_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "infer": { + "type": "boolean" + } + }, + "required": [ + "infer" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_STORE_HANDLE.json b/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_STORE_HANDLE.json new file mode 100644 index 000000000..bf9c026cb --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_STORE_HANDLE.json @@ -0,0 +1,19 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_STORE_HANDLE.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "handle": { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + } + }, + "required": [ + "handle" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_VIEWS.json b/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_VIEWS.json new file mode 100644 index 000000000..a409eb752 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_VIEWS.json @@ -0,0 +1,10 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_VIEWS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "items": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::session::SessionView" + }, + "type": "array" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_TEXT_OPEN_AI_TEXT_REQUEST.json b/schema/ops/type-AIMUX_OPS_OPS_TEXT_OPEN_AI_TEXT_REQUEST.json new file mode 100644 index 000000000..c79529067 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_TEXT_OPEN_AI_TEXT_REQUEST.json @@ -0,0 +1,47 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_TEXT_OPEN_AI_TEXT_REQUEST.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "abort": { + "anyOf": [ + { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "options": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::GenerateTextOptions" + }, + "prompt": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::ModelPrompt" + }, + "streamOptions": { + "additionalProperties": false, + "properties": { + "includeReasoning": { + "type": "boolean" + }, + "includeUsage": { + "type": "boolean" + } + }, + "required": [], + "type": "object" + } + }, + "required": [ + "prompt" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_TEXT_TEXT_REQUEST.json b/schema/ops/type-AIMUX_OPS_OPS_TEXT_TEXT_REQUEST.json new file mode 100644 index 000000000..587b3713f --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_TEXT_TEXT_REQUEST.json @@ -0,0 +1,34 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_TEXT_TEXT_REQUEST.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "abort": { + "anyOf": [ + { + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "options": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::GenerateTextOptions" + }, + "prompt": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::ModelPrompt" + } + }, + "required": [ + "prompt" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_AGGREGATE_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_AGGREGATE_PARAMS.json new file mode 100644 index 000000000..b90073cff --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_AGGREGATE_PARAMS.json @@ -0,0 +1,59 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_AGGREGATE_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "sessionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "sinceUnixMs": { + "anyOf": [ + { + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "type": "object" + } + }, + "required": [], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_AGGREGATE_RESULT.json b/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_AGGREGATE_RESULT.json new file mode 100644 index 000000000..6cb2bdab5 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_AGGREGATE_RESULT.json @@ -0,0 +1,10 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_AGGREGATE_RESULT.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "items": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::trace::TraceStats" + }, + "type": "array" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_CREATE_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_CREATE_PARAMS.json new file mode 100644 index 000000000..68408e375 --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_CREATE_PARAMS.json @@ -0,0 +1,27 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_CREATE_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "auditMode": { + "enum": [ + "off", + "shared", + "strict" + ] + }, + "capacity": { + "maximum": 18446744073709551615, + "minimum": 1, + "type": "integer" + }, + "perScopeCapacity": { + "maximum": 18446744073709551615, + "minimum": 1, + "type": "integer" + } + }, + "required": [], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_SESSION_PARAMS.json b/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_SESSION_PARAMS.json new file mode 100644 index 000000000..ac1a40dfa --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_SESSION_PARAMS.json @@ -0,0 +1,15 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_SESSION_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "type": "object" +} diff --git a/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_TRAJECTORY_RESULT.json b/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_TRAJECTORY_RESULT.json new file mode 100644 index 000000000..98e2edaee --- /dev/null +++ b/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_TRAJECTORY_RESULT.json @@ -0,0 +1,10 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_TRAJECTORY_RESULT.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "items": { + "$comment": "Canonical business schema is deferred to RFC-0042 (G-d).", + "x-aimux-type": "aimux_core::trace::SessionStepStat" + }, + "type": "array" +} diff --git a/schema/ops/type-BINARY_INPUT.json b/schema/ops/type-BINARY_INPUT.json new file mode 100644 index 000000000..df487b1e4 --- /dev/null +++ b/schema/ops/type-BINARY_INPUT.json @@ -0,0 +1,12 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-BINARY_INPUT.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Native binary push bytes; generic JSON dispatch is not admitted.", + "items": { + "maximum": 255, + "minimum": 0, + "type": "integer" + }, + "type": "array" +} diff --git a/schema/ops/type-CANCEL_RESULT.json b/schema/ops/type-CANCEL_RESULT.json new file mode 100644 index 000000000..007d7b662 --- /dev/null +++ b/schema/ops/type-CANCEL_RESULT.json @@ -0,0 +1,11 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-CANCEL_RESULT.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "enum": [ + "first", + "alreadyRequested", + "alreadyTerminal" + ], + "type": "string" +} diff --git a/schema/ops/type-EMPTY_PARAMS.json b/schema/ops/type-EMPTY_PARAMS.json new file mode 100644 index 000000000..7791e520b --- /dev/null +++ b/schema/ops/type-EMPTY_PARAMS.json @@ -0,0 +1,8 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-EMPTY_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": {}, + "type": "object" +} diff --git a/schema/ops/type-ERROR_ENVELOPE.json b/schema/ops/type-ERROR_ENVELOPE.json new file mode 100644 index 000000000..ac8704dc0 --- /dev/null +++ b/schema/ops/type-ERROR_ENVELOPE.json @@ -0,0 +1,45 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-ERROR_ENVELOPE.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "code": { + "type": "integer" + }, + "error": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "kind": { + "enum": [ + "aimux", + "recording", + "binding", + "protocol" + ] + }, + "message": { + "type": "string" + }, + "retryAfterMs": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind", + "code", + "message", + "error" + ], + "type": "object" +} diff --git a/schema/ops/type-HANDLE_ID.json b/schema/ops/type-HANDLE_ID.json new file mode 100644 index 000000000..6333caf74 --- /dev/null +++ b/schema/ops/type-HANDLE_ID.json @@ -0,0 +1,10 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-HANDLE_ID.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "not": { + "pattern": "[^0-9]" + }, + "pattern": "^0*([0-9]{1,19}|[0-0][0-9]{19}|1[0-7][0-9]{18}|18[0-3][0-9]{17}|184[0-3][0-9]{16}|1844[0-5][0-9]{15}|18446[0-6][0-9]{14}|184467[0-3][0-9]{13}|1844674[0-3][0-9]{12}|184467440[0-6][0-9]{10}|1844674407[0-2][0-9]{9}|18446744073[0-6][0-9]{8}|1844674407370[0-8][0-9]{6}|18446744073709[0-4][0-9]{5}|184467440737095[0-4][0-9]{4}|1844674407370955[0-0][0-9]{3}|18446744073709551[0-5][0-9]{2}|184467440737095516[0-0][0-9]{1}|1844674407370955161[0-4][0-9]{0}|18446744073709551615)$", + "type": "string" +} diff --git a/schema/ops/type-OPERATION_NEXT_PARAMS.json b/schema/ops/type-OPERATION_NEXT_PARAMS.json new file mode 100644 index 000000000..f43cdf4fc --- /dev/null +++ b/schema/ops/type-OPERATION_NEXT_PARAMS.json @@ -0,0 +1,14 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-OPERATION_NEXT_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "timeoutMs": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "type": "object" +} diff --git a/schema/ops/type-OPERATION_POLL_RESULT.json b/schema/ops/type-OPERATION_POLL_RESULT.json new file mode 100644 index 000000000..f064d9f00 --- /dev/null +++ b/schema/ops/type-OPERATION_POLL_RESULT.json @@ -0,0 +1,36 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-OPERATION_POLL_RESULT.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "part": {}, + "type": { + "const": "part" + } + }, + "required": [ + "type", + "part" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "type": { + "enum": [ + "ended", + "timeout" + ] + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] +} diff --git a/schema/ops/type-OPERATION_STATUS.json b/schema/ops/type-OPERATION_STATUS.json new file mode 100644 index 000000000..d61c41d76 --- /dev/null +++ b/schema/ops/type-OPERATION_STATUS.json @@ -0,0 +1,98 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-OPERATION_STATUS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "capabilities": { + "additionalProperties": false, + "properties": { + "cancel": { + "type": "boolean" + }, + "inputDone": { + "type": "boolean" + }, + "next": { + "type": "boolean" + }, + "push": { + "type": "boolean" + } + }, + "required": [ + "push", + "inputDone", + "next", + "cancel" + ], + "type": "object" + }, + "cleanupFailure": { + "type": "string" + }, + "consumerClosed": { + "type": "boolean" + }, + "inputDoneSequence": { + "pattern": "^[0-9]+$", + "type": "string" + }, + "queuedInputBytes": { + "minimum": 0, + "type": "integer" + }, + "queuedInputItems": { + "minimum": 0, + "type": "integer" + }, + "queuedOutputBytes": { + "minimum": 0, + "type": "integer" + }, + "queuedOutputItems": { + "minimum": 0, + "type": "integer" + }, + "state": { + "enum": [ + "running", + "inputClosed", + "terminal" + ] + }, + "subkind": { + "const": "transcription" + }, + "terminal": { + "additionalProperties": false, + "properties": { + "error": { + "type": "object" + }, + "outcome": { + "enum": [ + "success", + "error", + "cancelled" + ] + } + }, + "required": [ + "outcome" + ], + "type": "object" + } + }, + "required": [ + "subkind", + "state", + "capabilities", + "consumerClosed", + "queuedInputItems", + "queuedInputBytes", + "queuedOutputItems", + "queuedOutputBytes" + ], + "type": "object" +} diff --git a/schema/ops/type-STREAM_FRAME.json b/schema/ops/type-STREAM_FRAME.json new file mode 100644 index 000000000..48c77972a --- /dev/null +++ b/schema/ops/type-STREAM_FRAME.json @@ -0,0 +1,130 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$defs": { + "ErrorEnvelope": { + "additionalProperties": false, + "properties": { + "code": { + "type": "integer" + }, + "error": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "kind": { + "enum": [ + "aimux", + "recording", + "binding", + "protocol" + ] + }, + "message": { + "type": "string" + }, + "retryAfterMs": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind", + "code", + "message", + "error" + ], + "type": "object" + } + }, + "$id": "https://aimux.dev/schema/ops/type-STREAM_FRAME.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "event": {}, + "seq": { + "pattern": "^[0-9]+$", + "type": "string" + }, + "type": { + "const": "event" + } + }, + "required": [ + "type", + "seq", + "event" + ], + "type": "object" + }, + { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "outcome": { + "const": "error" + } + } + }, + "then": { + "required": [ + "error" + ] + } + }, + { + "if": { + "properties": { + "outcome": { + "const": "success" + } + } + }, + "then": { + "not": { + "required": [ + "error" + ] + } + } + } + ], + "properties": { + "error": { + "$ref": "#/$defs/ErrorEnvelope" + }, + "outcome": { + "enum": [ + "success", + "error", + "cancelled" + ] + }, + "seq": { + "pattern": "^[0-9]+$", + "type": "string" + }, + "type": { + "const": "end" + } + }, + "required": [ + "type", + "seq", + "outcome" + ], + "type": "object" + } + ] +} diff --git a/schema/ops/type-TRANSCRIPTION_START_PARAMS.json b/schema/ops/type-TRANSCRIPTION_START_PARAMS.json new file mode 100644 index 000000000..632b3351c --- /dev/null +++ b/schema/ops/type-TRANSCRIPTION_START_PARAMS.json @@ -0,0 +1,95 @@ +{ + "$comment": "Generated by aimux-codegen. Do not edit.", + "$id": "https://aimux.dev/schema/ops/type-TRANSCRIPTION_START_PARAMS.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "abort": { + "pattern": "^[0-9]+$", + "type": [ + "string", + "null" + ] + }, + "headers": { + "type": [ + "object", + "null" + ] + }, + "includeRawChunks": { + "type": "boolean" + }, + "inputAudioFormat": { + "additionalProperties": false, + "properties": { + "formatType": { + "type": "string" + }, + "rate": { + "maximum": 4294967295, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "formatType" + ], + "type": [ + "object", + "null" + ] + }, + "providerOptions": { + "type": [ + "object", + "null" + ] + }, + "timeout": { + "additionalProperties": false, + "properties": { + "chunkMs": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "firstChunkMs": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "stepMs": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "totalMs": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": [ + "object", + "null" + ] + } + }, + "type": "object" +} diff --git a/scripts/test-c-abi.sh b/scripts/test-c-abi.sh new file mode 100755 index 000000000..c1d100ef4 --- /dev/null +++ b/scripts/test-c-abi.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +mode=${1:---run} +case "$mode" in + --run|--asan|--asan-lsan|--compile-only|--asan-compile-only) ;; + *) echo "Usage: $0 [--run|--asan|--asan-lsan|--compile-only|--asan-compile-only]" >&2; exit 2 ;; +esac +header=${AIMUX_C_HEADER_DIR:-"$root/aimux-ffi/include"} +if [[ ! -f "$header/aimux.h" ]]; then + echo "ERROR: missing generated $header/aimux.h; no fallback header is used." >&2 + exit 1 +fi +build=$(mktemp -d "${TMPDIR:-/tmp}/aimux-c-abi.XXXXXX") +trap 'rm -rf "$build"' EXIT +cc=${CC:-cc} +cxx=${CXX:-c++} +flags=(-Wall -Wextra -Werror -pedantic -g -O1 -pthread -I"$header" -I"$root/bindings/c" -I"$root/aimux-ffi/tests/c") +if [[ "$mode" == --asan || "$mode" == --asan-lsan || "$mode" == --asan-compile-only ]]; then + flags+=(-fsanitize=address -fno-omit-frame-pointer) +fi +if [[ "$mode" == --asan-lsan ]]; then + if [[ $(uname -s) != Linux ]]; then + echo 'ERROR: --asan-lsan requires Linux LeakSanitizer; use --asan on macOS.' >&2 + exit 2 + fi + flags+=(-fsanitize=leak) + export ASAN_OPTIONS="${ASAN_OPTIONS:+$ASAN_OPTIONS:}detect_leaks=1:halt_on_error=1" + export LSAN_OPTIONS="${LSAN_OPTIONS:+$LSAN_OPTIONS:}exitcode=23" +fi +sources=("aimux-ffi/tests/c/primitives.c" "aimux-ffi/tests/c/raii.cpp" "bindings/c/example.c" "bindings/c/example.cpp") +objects=() +for index in "${!sources[@]}"; do + source=${sources[$index]} + compiler=$cc + standard=c11 + if [[ $source == *.cpp ]]; then compiler=$cxx; standard=c++17; fi + object="$build/$index.o" + "$compiler" -std="$standard" "${flags[@]}" -c "$root/$source" -o "$object" + objects+=("$object") + echo "COMPILED $source" +done +if [[ "$mode" == --compile-only || "$mode" == --asan-compile-only ]]; then + echo 'COMPILE ONLY: no cdylib was linked, no test was run.' + exit 0 +fi +libdir=${AIMUX_C_LIB_DIR:-} +if [[ -z "$libdir" ]]; then + # JSON artifact messages name the built library wherever CARGO_TARGET_DIR points. + cargo build -p aimux-ffi --message-format=json-render-diagnostics > "$build/artifacts.jsonl" + library=$(python3 - "$build/artifacts.jsonl" <<'PY' +import json, sys +libraries = [] +for line in open(sys.argv[1]): + try: + message = json.loads(line) + except ValueError: + continue + if message.get('reason') == 'compiler-artifact' and message.get('target', {}).get('name') == 'aimux_ffi': + libraries.extend(p for p in message.get('filenames', []) if p.endswith(('.dylib', '.so', '.dll'))) +if not libraries: + sys.exit('ERROR: cargo produced no aimux-ffi cdylib artifact') +print(libraries[-1]) +PY +) + libdir=$(dirname "$library") +fi +# Retain the tested artifact while compiling and running C consumers. +mkdir -p "$build/native" +for filename in libaimux_ffi.dylib libaimux_ffi.so aimux_ffi.dll; do + if [[ -f "$libdir/$filename" ]]; then cp "$libdir/$filename" "$build/native/$filename"; fi +done +libdir="$build/native" +failures=0 +for index in "${!sources[@]}"; do + compiler=$cc + [[ ${sources[$index]} == *.cpp ]] && compiler=$cxx + "$compiler" "${flags[@]}" "${objects[$index]}" -L"$libdir" -Wl,-rpath,"$libdir" -laimux_ffi -o "$build/test-$index" + echo "LINKED ${sources[$index]}" + if DYLD_LIBRARY_PATH="$libdir${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" LD_LIBRARY_PATH="$libdir${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" "$build/test-$index"; then + echo "PASSED ${sources[$index]} ($mode)" + else + result=$? + echo "FAILED ${sources[$index]} ($mode), exit $result" >&2 + failures=$((failures + 1)) + fi +done + +if [[ $failures != 0 ]]; then + echo "FAILED $failures / ${#sources[@]} C/C++ executables" >&2 + exit 1 +fi +echo "PASSED ${#sources[@]} / ${#sources[@]} C/C++ executables" diff --git a/tools/aimux-codegen/Cargo.toml b/tools/aimux-codegen/Cargo.toml new file mode 100644 index 000000000..c9f12311e --- /dev/null +++ b/tools/aimux-codegen/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "aimux-codegen" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Deterministic C ABI, control schema, and op constant generation from aimux-ops" + +[dependencies] +aimux-ops = { path = "../../aimux-ops" } +serde.workspace = true +serde_json.workspace = true +sha2 = "0.10" +tempfile = "3" + +[dev-dependencies] +jsonschema.workspace = true + +[lints] +workspace = true diff --git a/tools/aimux-codegen/README.md b/tools/aimux-codegen/README.md new file mode 100644 index 000000000..8a09b9a24 --- /dev/null +++ b/tools/aimux-codegen/README.md @@ -0,0 +1,48 @@ +# aimux-codegen + +The release generator reads `aimux_ops::manifest()` and checks its canonical +JSON SHA-256 against `aimux_ops::manifest_digest()`. It emits the C ABI header, +JSON Schema 2020-12 control schemas, and Go, Java, Kotlin, Swift, Dart, +TypeScript, and Python op constants. No build script generates these files. + +Run from the repository root: + +```sh +cargo run -p aimux-codegen +cargo run -p aimux-codegen -- --check +cargo test -p aimux-codegen --test generation +``` + +`--root PATH` selects an output/check root; it does not select a different +manifest. `--check` regenerates into a temporary directory and compares file +sets and bytes, including the digest inventory. It never rewrites the checked +tree. Normal generation replaces files and removes obsolete artifacts in its +owned locations: `schema/ops/*.json`, `aimux-ffi/include/aimux.h`, +`bindings/flutter/native/aimux_ops.h` (an identical packaged header), and files +starting with `ops.`, `ops-` or `AimuxOps` with the language's +source extension beside its constants file. Compiled caches and other generated +binding types belong to their respective toolchains or owners. + +Python constants live in `bindings/python/python/aimux/ops.py`; the other bindings use their `generated/` directories. +Go uses package `generated`; Java/Kotlin use `ai.arcships.aimux.generated` +with `AimuxOps` / `AimuxKotlinOps` to avoid duplicate JVM classes when Kotlin +depends on Java. Swift uses `AimuxOps`. Binding owners must add these sources to their package +or import them where their build layout requires it. + +The request schema validates dispatch arguments: `handle`, `op`, `params`, +and optional `abort`. Its manifest-derived branches close the control keys; +`params` retains canonical DTO JSON. Ops-owned `types[].schema` records emit +`type-*.json` and are referenced from request branches. Business types without +schemas retain `x-aimux-type`; business DTO schemas remain separate work. +Error payloads retain canonical domain fields inside a closed transport envelope. +`handle-id.json`, `error-envelope.json` and `stream-frame.json` copy the manifest's +control definitions; codegen does not compose a second set of control schemas. + +Schema IDs use `https://aimux.dev/schema/ops/.json`; references resolve +against that base. The files can be registered locally without network I/O. +Provider options are explicitly open. Handles and sequence numbers are decimal +u64 strings; handle decoding also accepts leading zeroes up to the exact numeric +upper bound. The inventory lives at +`tools/aimux-codegen/generated-files.json` and records the runtime digest. + +`scripts/gen_ts_types.py` still owns core and Web DTO outputs. diff --git a/tools/aimux-codegen/generated-files.json b/tools/aimux-codegen/generated-files.json new file mode 100644 index 000000000..d4b69507e --- /dev/null +++ b/tools/aimux-codegen/generated-files.json @@ -0,0 +1,85 @@ +{ + "files": [ + "aimux-ffi/include/aimux.h", + "bindings/flutter/lib/generated/ops.dart", + "bindings/flutter/native/aimux_ops.h", + "bindings/go/generated/ops.go", + "bindings/java/generated/AimuxOps.java", + "bindings/kotlin/generated/AimuxOps.kt", + "bindings/node/src/generated/ops.ts", + "bindings/python/python/aimux/ops.py", + "bindings/swift/generated/AimuxOps.swift", + "schema/ops/error-envelope.json", + "schema/ops/handle-id.json", + "schema/ops/provider-options.json", + "schema/ops/request.json", + "schema/ops/stream-frame.json", + "schema/ops/type-AIMUX_OPS_OPS_COMPOSITE_MOA_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_COMPOSITE_ROUTER_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_DECISION_DECISION_DECIDE_REQUEST.json", + "schema/ops/type-AIMUX_OPS_OPS_FILES_UPLOAD_FILE_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_HANDLES_CREDENTIAL_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_HANDLES_HANDLE_RESULT.json", + "schema/ops/type-AIMUX_OPS_OPS_HANDLES_MIDDLEWARE_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_HANDLES_TRANSPORT_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_MISC_CATALOGUE_FETCH_REQUEST.json", + "schema/ops/type-AIMUX_OPS_OPS_MISC_CODEX_REFRESH_REQUEST.json", + "schema/ops/type-AIMUX_OPS_OPS_MISC_OPEN_AI_OUTPUT_REQUEST.json", + "schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_APPLY_REQUEST.json", + "schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_APPLY_RESULT_REQUEST.json", + "schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_CONTEXT_REQUEST.json", + "schema/ops/type-AIMUX_OPS_OPS_MISC_TOOL_REPAIR_CONTEXT_RESULT.json", + "schema/ops/type-AIMUX_OPS_OPS_PROVIDER_HANDLE_RESULT.json", + "schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_CREATE_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_CUSTOM_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_DEFAULT_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_INVOKE_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_INVOKE_RESULT.json", + "schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_MODEL_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_PROVIDER_PROVIDER_WRAP_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_RECORDING_CREATE_RECORDER_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_RECORDING_EMPTY_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_RECORDING_RECORDER_EXPORT.json", + "schema/ops/type-AIMUX_OPS_OPS_RECORDING_RECORDER_HANDLE_RESULT.json", + "schema/ops/type-AIMUX_OPS_OPS_RECORDING_RECORDER_STATS.json", + "schema/ops/type-AIMUX_OPS_OPS_REGISTRY_HANDLE_RESULT.json", + "schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_INFO.json", + "schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_INFO_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_RESOLVE_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_REGISTRY_MODEL_WRAP_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_REGISTRY_REGISTRY_CREATE_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_REGISTRY_REGISTRY_PROVIDER_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_CREATE_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_INSPECTION.json", + "schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_LIVE_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_LIVE_RESULT.json", + "schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_PLAN.json", + "schema/ops/type-AIMUX_OPS_OPS_REPLAY_REPLAY_SESSION_HANDLE.json", + "schema/ops/type-AIMUX_OPS_OPS_RUNTIME_RUNTIME_CONFIGURE_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_RUNTIME_RUNTIME_DESCRIBE_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_CALLS.json", + "schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_STORE_CALLS_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_STORE_CREATE_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_STORE_HANDLE.json", + "schema/ops/type-AIMUX_OPS_OPS_SESSION_SESSION_VIEWS.json", + "schema/ops/type-AIMUX_OPS_OPS_TEXT_OPEN_AI_TEXT_REQUEST.json", + "schema/ops/type-AIMUX_OPS_OPS_TEXT_TEXT_REQUEST.json", + "schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_AGGREGATE_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_AGGREGATE_RESULT.json", + "schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_CREATE_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_SESSION_PARAMS.json", + "schema/ops/type-AIMUX_OPS_OPS_TRACE_TRACE_TRAJECTORY_RESULT.json", + "schema/ops/type-BINARY_INPUT.json", + "schema/ops/type-CANCEL_RESULT.json", + "schema/ops/type-EMPTY_PARAMS.json", + "schema/ops/type-ERROR_ENVELOPE.json", + "schema/ops/type-HANDLE_ID.json", + "schema/ops/type-OPERATION_NEXT_PARAMS.json", + "schema/ops/type-OPERATION_POLL_RESULT.json", + "schema/ops/type-OPERATION_STATUS.json", + "schema/ops/type-STREAM_FRAME.json", + "schema/ops/type-TRANSCRIPTION_START_PARAMS.json", + "tools/aimux-codegen/generated-files.json" + ], + "manifestDigest": "2bc7cb7ec77a0ed20496411f0dbd199b949b9887cc74d119e11627c8573d82a2" +} diff --git a/tools/aimux-codegen/src/lib.rs b/tools/aimux-codegen/src/lib.rs new file mode 100644 index 000000000..149bdd133 --- /dev/null +++ b/tools/aimux-codegen/src/lib.rs @@ -0,0 +1,513 @@ +//! Release artifacts derived from the ops manifest. No build script is involved. + +use serde::Deserialize; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, BTreeSet}, + error::Error, + fs, + path::{Path, PathBuf}, +}; + +pub type Artifacts = BTreeMap>; +type Result = std::result::Result>; +const INDEX: &str = "tools/aimux-codegen/generated-files.json"; +const HEADERS: [&str; 2] = [ + "aimux-ffi/include/aimux.h", + "bindings/flutter/native/aimux_ops.h", +]; +const SCHEMA_BASE: &str = "https://aimux.dev/schema/ops/"; +/// Each binding's constants file, where its own imports resolve it (Dart under `lib/`, +/// Node under `src/`, Python inside the package). +const LANGUAGES: &[(&str, &str)] = &[ + ("go", "bindings/go/generated/ops.go"), + ("java", "bindings/java/generated/AimuxOps.java"), + ("kotlin", "bindings/kotlin/generated/AimuxOps.kt"), + ("swift", "bindings/swift/generated/AimuxOps.swift"), + ("flutter", "bindings/flutter/lib/generated/ops.dart"), + ("node", "bindings/node/src/generated/ops.ts"), + ("python", "bindings/python/python/aimux/ops.py"), +]; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Manifest { + abi_version: u32, + ops_version: String, + manifest_revision: Value, + ops: Vec, + handle_kinds: Vec, + error_codes: Vec, + #[serde(default)] + types: Vec, +} + +#[derive(Deserialize)] +struct TypeSchema { + id: String, + schema: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Op { + name: String, + mode: Mode, + allowed_handle_kinds: Vec, + request_type: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +enum Mode { + Call, + Stream, + BinaryPush, + Poll, +} + +#[derive(Deserialize)] +struct ErrorCode { + name: String, + code: i32, + kind: Option, +} + +/// Sort object keys recursively, preserving array order and JSON number values. +/// +/// # Errors +/// Returns an error if JSON serialization fails. +pub fn canonical_json(value: &Value) -> Result> { + let mut value = value.clone(); + value.sort_all_objects(); + Ok(serde_json::to_vec(&value)?) +} + +fn pretty_json(value: &Value) -> Result> { + let mut bytes = + serde_json::to_vec_pretty(&serde_json::from_slice::(&canonical_json(value)?)?)?; + bytes.push(b'\n'); + Ok(bytes) +} + +fn identifier(value: &str) -> Result { + if value.is_empty() + || !value.as_bytes()[0].is_ascii_alphabetic() + || !value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'.' || c == b'_') + { + return Err(format!("invalid manifest identifier: {value:?}").into()); + } + let chars: Vec<_> = value.chars().collect(); + let mut name = String::new(); + for (i, &ch) in chars.iter().enumerate() { + if ch == '.' || ch == '_' { + if !name.ends_with('_') { + name.push('_'); + } + } else { + if ch.is_ascii_uppercase() && i > 0 { + let previous = chars[i - 1]; + let next_is_lower = chars.get(i + 1).is_some_and(char::is_ascii_lowercase); + if previous.is_ascii_lowercase() + || previous.is_ascii_digit() + || (previous.is_ascii_uppercase() && next_is_lower) + { + name.push('_'); + } + } + name.push(ch.to_ascii_uppercase()); + } + } + Ok(name) +} + +fn type_identifier(id: &str) -> Result { + identifier(&id.replace("::", ".")) +} + +fn error_domain(code: i32) -> Result<&'static str> { + match code { + 1..=20 => Ok("aimux"), + 100..=105 => Ok("recording"), + 200..=206 => Ok("binding"), + 300..=399 => Ok("protocol"), + _ => Err(format!("unregistered error code range: {code}").into()), + } +} + +fn validate(manifest: &Manifest) -> Result<()> { + let version: Vec<_> = manifest.ops_version.split('.').collect(); + if version.len() != 2 + || version + .iter() + .any(|part| part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit())) + || manifest.manifest_revision.is_null() + { + return Err("invalid ops major.minor version or missing manifest revision".into()); + } + let kinds: BTreeSet<_> = manifest.handle_kinds.iter().collect(); + if kinds.len() != manifest.handle_kinds.len() { + return Err("duplicate handle kind".into()); + } + let mut symbols = BTreeSet::new(); + for op in &manifest.ops { + if !symbols.insert(identifier(&op.name)?) { + return Err(format!("duplicate or colliding op constant: {}", op.name).into()); + } + if op.request_type.is_empty() { + return Err(format!("missing request type: {}", op.name).into()); + } + for kind in &op.allowed_handle_kinds { + if !kinds.contains(kind) { + return Err(format!("unknown handle kind {kind} in {}", op.name).into()); + } + } + } + let mut codes = BTreeSet::new(); + let mut names = BTreeSet::new(); + for error in &manifest.error_codes { + let domain = error_domain(error.code)?; + if error.kind.as_deref().is_some_and(|kind| kind != domain) { + return Err(format!("error domain disagrees with code {}", error.code).into()); + } + if !codes.insert(error.code) + || !names.insert(format!("{domain}_{}", identifier(&error.name)?)) + { + return Err(format!("duplicate error constant: {}", error.name).into()); + } + } + Ok(()) +} + +/// Generate all owned artifacts, rejecting disagreement with the runtime digest. +/// +/// # Errors +/// Returns an error for digest drift, malformed descriptors, constant name +/// collisions, or serialization failure. +pub fn generate(value: &Value, runtime_digest: &str) -> Result { + let mut unsigned = value.clone(); + unsigned + .as_object_mut() + .ok_or("manifest must be an object")? + .remove("digest"); + if let Some(ops) = unsigned.get_mut("ops").and_then(Value::as_array_mut) { + ops.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + } + let digest = format!("{:x}", Sha256::digest(canonical_json(&unsigned)?)); + if runtime_digest != digest { + return Err( + format!("manifest digest drift: runtime={runtime_digest}, canonical={digest}").into(), + ); + } + let mut manifest: Manifest = serde_json::from_value(value.clone())?; + validate(&manifest)?; + manifest.ops.sort_by(|a, b| a.name.cmp(&b.name)); + manifest.error_codes.sort_by_key(|error| error.code); + let mut artifacts = Artifacts::new(); + let header = header(&manifest, &digest)?.into_bytes(); + let [c_header, flutter_header] = HEADERS; + artifacts.insert(c_header.into(), header.clone()); + artifacts.insert(flutter_header.into(), header); + for &(language, path) in LANGUAGES { + artifacts.insert( + path.into(), + constants(&manifest, &digest, language)?.into_bytes(), + ); + } + for (name, schema) in schemas(&manifest)? { + artifacts.insert( + format!("schema/ops/{name}.json").into(), + pretty_json(&schema)?, + ); + } + let mut files: Vec<_> = artifacts + .keys() + .map(|path| path.to_string_lossy().into_owned()) + .collect(); + files.push(INDEX.into()); + artifacts.insert( + INDEX.into(), + pretty_json(&json!({"manifestDigest": digest, "files": files}))?, + ); + Ok(artifacts) +} + +fn header(manifest: &Manifest, digest: &str) -> Result { + let mut text = format!( + "/* Generated by aimux-codegen. Do not edit. Manifest SHA-256: {digest} */\n#ifndef AIMUX_H\n#define AIMUX_H\n\n#include \n#include \n\n#ifdef __cplusplus\nextern \"C\" {{\n#endif\n\n#define AIMUX_ABI_VERSION UINT32_C({})\n#define AIMUX_OPS_VERSION {}\n#define AIMUX_MANIFEST_DIGEST \"{digest}\"\n\n", + manifest.abi_version, + serde_json::to_string(&manifest.ops_version)? + ); + for op in &manifest.ops { + text.push_str(&format!( + "#define AIMUX_OP_{} \"{}\"\n", + identifier(&op.name)?, + op.name + )); + } + text.push_str("\ntypedef struct aimux_error aimux_error_t;\n\ntypedef enum aimux_error_code {\n AIMUX_ERROR_NONE = 0"); + for error in &manifest.error_codes { + text.push_str(&format!( + ",\n AIMUX_ERROR_{}_{} = {}", + error_domain(error.code)?.to_ascii_uppercase(), + identifier(&error.name)?, + error.code + )); + } + text.push_str("\n} aimux_error_code_t;\n\ntypedef enum aimux_poll_state {\n AIMUX_POLL_PART = 1,\n AIMUX_POLL_ENDED = 2,\n AIMUX_POLL_TIMEOUT = 3\n} aimux_poll_state_t;\n\n/* frame_json is borrowed until the callback returns; copy it to retain it.\n * context must remain valid until aimux_stream returns. */\ntypedef void (*aimux_event_cb)(const char *frame_json, void *context);\n\n/* ABI = (major << 16) | minor. Verify ABI and manifest before calling ops.\n * JSON inputs are borrowed NUL-terminated UTF-8, never implicit empty objects.\n * Handle 0 is global scope; nonzero IDs are process-owned and never reused.\n * Fallible calls return NULL on success or an owned error on failure.\n * Outputs are reset to NULL/0 before validation, including on failure.\n * All output JSON and error getter strings are owned: aimux_free_string.\n * Release errors with aimux_error_free. Do not free callback JSON.\n * Blocking calls cannot be reentered from a callback (ReentrantCall). */\nuint32_t aimux_abi_version(void);\naimux_error_t *aimux_manifest_json(char **out_json);\naimux_error_t *aimux_call(uint64_t handle, const char *op,\n const char *params_json, uint64_t abort, char **out_json);\naimux_error_t *aimux_stream(uint64_t handle, const char *op,\n const char *params_json, uint64_t abort,\n aimux_event_cb on_event, void *context);\n/* Input bytes are copied/consumed before return. NULL is valid only at length 0. */\naimux_error_t *aimux_operation_push(uint64_t operation,\n const uint8_t *data, size_t length);\n/* PART allocates out_part; ENDED/TIMEOUT do not. timeout_ms must be nonnegative. */\naimux_error_t *aimux_operation_next_part(uint64_t operation, int64_t timeout_ms,\n char **out_part, int32_t *out_state);\n/* Signal only; callback-safe. Use abort.signal to report an invalid handle. */\nvoid aimux_abort(uint64_t abort);\n/* Drop is idempotent, accepts 0, and cancels resources without implying flush. */\nvoid aimux_drop(uint64_t handle);\n/* Non-blocking finalizer; token encodes the handle in uintptr_t (never dereferenced). */\nvoid aimux_handle_finalizer(void *token);\n/* NULL-safe. Each owned allocation must be freed exactly once. */\nvoid aimux_free_string(char *string);\nvoid aimux_error_free(aimux_error_t *error);\nint32_t aimux_error_code(const aimux_error_t *error);\nchar *aimux_error_message(const aimux_error_t *error);\nchar *aimux_error_json(const aimux_error_t *error);\n\n#ifdef __cplusplus\n}\n#endif\n\n#endif /* AIMUX_H */\n"); + Ok(text) +} + +fn constants(manifest: &Manifest, digest: &str, language: &str) -> Result { + let comment = if language == "python" { "#" } else { "//" }; + let mut text = + format!("{comment} Generated by aimux-codegen. Do not edit. Manifest SHA-256: {digest}\n"); + let (prefix, suffix, indent) = match language { + "go" => ("package generated\n\nconst (\n", ")\n", "\t"), + "java" => ( + "package ai.arcships.aimux.generated;\n\npublic final class AimuxOps {\n private AimuxOps() {}\n", + "}\n", + " ", + ), + "kotlin" => ( + "package ai.arcships.aimux.generated\n\nobject AimuxKotlinOps {\n", + "}\n", + " ", + ), + "swift" => ("public enum AimuxOps {\n", "}\n", " "), + "flutter" | "node" | "python" => ("", "", ""), + _ => return Err(format!("unsupported language: {language}").into()), + }; + text.push_str(prefix); + let mut values = vec![ + ("ABI_VERSION".into(), manifest.abi_version.to_string(), true), + ( + "OPS_VERSION".into(), + serde_json::to_string(&manifest.ops_version)?, + false, + ), + ("MANIFEST_DIGEST".into(), format!("\"{digest}\""), false), + ]; + for op in &manifest.ops { + values.push(( + format!("OP_{}", identifier(&op.name)?), + format!("\"{}\"", op.name), + false, + )); + } + for (name, value, number) in values { + let declaration = match language { + "go" => format!("{name} = {value}"), + "java" => format!( + "public static final {} {name} = {value}{};", + if number { "long" } else { "String" }, + if number { "L" } else { "" } + ), + "kotlin" => format!( + "const val {name}{} = {value}", + if number { ": Long" } else { "" } + ), + "swift" => format!( + "public static let {name}{} = {value}", + if number { ": UInt32" } else { "" } + ), + "flutter" => format!( + "const {} {name} = {value};", + if number { "int" } else { "String" } + ), + "node" => format!("export const {name} = {value} as const;"), + "python" => format!("{name} = {value}"), + _ => return Err(format!("unsupported language: {language}").into()), + }; + text.push_str(&format!("{indent}{declaration}\n")); + } + text.push_str(suffix); + Ok(text) +} + +fn schema_document(name: &str, mut schema: Value) -> Value { + schema["$schema"] = json!("https://json-schema.org/draft/2020-12/schema"); + schema["$id"] = json!(format!("{SCHEMA_BASE}{name}.json")); + if schema.get("$comment").is_none() { + schema["$comment"] = json!("Generated by aimux-codegen. Do not edit."); + } + schema +} + +fn closed_object(properties: Value, required: &[&str]) -> Value { + json!({"type": "object", "properties": properties, "required": required, "additionalProperties": false}) +} + +fn schemas(manifest: &Manifest) -> Result> { + let mut documents = BTreeMap::new(); + for (id, name) in [ + ("HandleId", "handle-id"), + ("ErrorEnvelope", "error-envelope"), + ("StreamFrame", "stream-frame"), + ] { + let schema = manifest + .types + .iter() + .find(|ty| ty.id == id) + .and_then(|ty| ty.schema.as_ref()) + .ok_or_else(|| format!("missing manifest control schema: {id}"))?; + documents.insert(name.into(), schema_document(name, schema.clone())); + } + let mut requests = Vec::new(); + for op in &manifest.ops { + let mut handle = json!({"$ref": "handle-id.json"}); + if op.allowed_handle_kinds.is_empty() { + handle["const"] = json!("0"); + } else if op.name != "handle.drop" { + handle["not"] = json!({"const": "0"}); + } + let params = manifest.types.iter().find(|ty| ty.id == op.request_type) + .and_then(|ty| ty.schema.as_ref()) + .map_or_else(|| json!({"x-aimux-type": op.request_type, + "$comment":"Business DTO schema generation belongs to RFC-0042 (G-d)."}), + |_| json!({"$ref": format!("type-{}.json", type_identifier(&op.request_type).unwrap_or_default())})); + let mut properties = json!({"handle": handle, "op": {"const": op.name}, "params": params}); + if matches!(op.mode, Mode::Call | Mode::Stream) { + properties["abort"] = json!({"$ref": "handle-id.json"}); + } + requests.push(closed_object(properties, &["handle", "op", "params"])); + } + // Empty inventories accept no requests; an empty oneOf is not a valid schema. + let request_schema = if requests.is_empty() { + json!({"not": {}}) + } else { + json!({"oneOf": requests}) + }; + documents.insert("request".into(), schema_document("request", request_schema)); + documents.insert( + "provider-options".into(), + schema_document( + "provider-options", + json!({"type": "object", "additionalProperties": true}), + ), + ); + for ty in &manifest.types { + if let Some(schema) = &ty.schema { + let name = format!("type-{}", type_identifier(&ty.id)?); + if documents + .insert(name.clone(), schema_document(&name, schema.clone())) + .is_some() + { + return Err(format!("duplicate type schema: {}", ty.id).into()); + } + } + } + Ok(documents) +} + +fn managed_path(path: &Path) -> bool { + if path == Path::new(INDEX) || HEADERS.iter().any(|header| path == Path::new(header)) { + return true; + } + if path.starts_with("schema/ops") { + return path.extension().is_some_and(|ext| ext == "json"); + } + let constants = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.starts_with("ops.") || name.starts_with("ops-") || name.starts_with("AimuxOps") + }); + constants + && LANGUAGES.iter().any(|&(_, file)| { + let file = Path::new(file); + path.parent() == file.parent() && path.extension() == file.extension() + }) +} + +fn collect_files(root: &Path, relative: &Path, output: &mut BTreeSet) -> Result<()> { + let full = root.join(relative); + if !full.exists() { + return Ok(()); + } + let metadata = fs::symlink_metadata(&full)?; + if metadata.file_type().is_symlink() { + return Err(format!("symlink in generated files: {}", full.display()).into()); + } + if metadata.is_file() { + output.insert(relative.to_path_buf()); + } else if metadata.is_dir() { + for entry in fs::read_dir(&full)? { + collect_files(root, &relative.join(entry?.file_name()), output)?; + } + } + Ok(()) +} + +fn existing_files(root: &Path) -> Result> { + let mut files = BTreeSet::new(); + for path in [INDEX].into_iter().chain(HEADERS).chain(["schema/ops"]) { + collect_files(root, Path::new(path), &mut files)?; + } + for &(_, file) in LANGUAGES { + collect_files(root, Path::new(file).parent().unwrap(), &mut files)?; + } + files.retain(|path| managed_path(path)); + Ok(files) +} + +/// Write the complete set and remove obsolete artifacts only in owned locations. +/// +/// # Errors +/// Returns an error for an unowned path, a generated symlink, or filesystem I/O. +pub fn write(root: &Path, artifacts: &Artifacts) -> Result<()> { + for path in artifacts.keys() { + if !managed_path(path) + || path + .components() + .any(|part| !matches!(part, std::path::Component::Normal(_))) + { + return Err(format!("unowned artifact path: {}", path.display()).into()); + } + } + let existing = existing_files(root)?; + for (path, bytes) in artifacts { + let full = root.join(path); + if let Some(parent) = full.parent() { + fs::create_dir_all(parent)?; + } + fs::write(full, bytes)?; + } + for stale in existing { + if !artifacts.contains_key(&stale) { + fs::remove_file(root.join(stale))?; + } + } + Ok(()) +} + +/// Regenerate into a temporary directory, then compare file sets and bytes. +/// +/// # Errors +/// Returns an error listing missing, stale, or differing files, or on I/O failure. +pub fn check(root: &Path, artifacts: &Artifacts) -> Result<()> { + let temporary = tempfile::tempdir()?; + write(temporary.path(), artifacts)?; + let expected = existing_files(temporary.path())?; + let actual = existing_files(root)?; + let mut differences = Vec::new(); + for missing in expected.difference(&actual) { + differences.push(format!("missing: {}", missing.display())); + } + for stale in actual.difference(&expected) { + differences.push(format!("stale: {}", stale.display())); + } + for path in expected.intersection(&actual) { + if fs::read(root.join(path))? != fs::read(temporary.path().join(path))? { + differences.push(format!("differs: {}", path.display())); + } + } + if differences.is_empty() { + Ok(()) + } else { + Err(differences.join("\n").into()) + } +} diff --git a/tools/aimux-codegen/src/main.rs b/tools/aimux-codegen/src/main.rs new file mode 100644 index 000000000..78fed6609 --- /dev/null +++ b/tools/aimux-codegen/src/main.rs @@ -0,0 +1,40 @@ +use std::{env, path::PathBuf, process::ExitCode}; + +fn run() -> Result<(), Box> { + let mut check = false; + let mut root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let mut args = env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--check" => check = true, + "--root" => root = PathBuf::from(args.next().ok_or("--root requires a path")?), + "--help" | "-h" => { + println!("aimux-codegen [--check] [--root PATH]"); + return Ok(()); + } + _ => return Err(format!("unknown argument: {arg}").into()), + } + } + let artifacts = aimux_codegen::generate( + &aimux_ops::manifest::manifest(), + &aimux_ops::manifest::manifest_digest(), + )?; + if check { + aimux_codegen::check(&root, &artifacts)?; + println!("generated files and manifest digest match"); + } else { + aimux_codegen::write(&root, &artifacts)?; + println!("generated {} files", artifacts.len()); + } + Ok(()) +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("aimux-codegen: {error}"); + ExitCode::FAILURE + } + } +} diff --git a/tools/aimux-codegen/tests/generation.rs b/tools/aimux-codegen/tests/generation.rs new file mode 100644 index 000000000..37d488e60 --- /dev/null +++ b/tools/aimux-codegen/tests/generation.rs @@ -0,0 +1,483 @@ +use aimux_codegen::{Artifacts, canonical_json, check, generate, write}; +use jsonschema::{Draft, Registry, Resource}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{fs, path::Path}; + +#[test] +fn cli_generates_and_checks_the_runtime_manifest_in_a_selected_root() { + let root = tempfile::tempdir().unwrap(); + let binary = env!("CARGO_BIN_EXE_aimux-codegen"); + let generated = std::process::Command::new(binary) + .arg("--root") + .arg(root.path()) + .output() + .unwrap(); + assert!( + generated.status.success(), + "{}", + String::from_utf8_lossy(&generated.stderr) + ); + let checked = std::process::Command::new(binary) + .arg("--root") + .arg(root.path()) + .arg("--check") + .output() + .unwrap(); + assert!( + checked.status.success(), + "{}", + String::from_utf8_lossy(&checked.stderr) + ); + for header in [ + "aimux-ffi/include/aimux.h", + "bindings/flutter/native/aimux_ops.h", + ] { + fs::remove_file(root.path().join(header)).unwrap(); + let stale = std::process::Command::new(binary) + .arg("--root") + .arg(root.path()) + .arg("--check") + .output() + .unwrap(); + assert!(!stale.status.success()); + assert!(String::from_utf8_lossy(&stale.stderr).contains(&format!("missing: {header}"))); + assert!(!root.path().join(header).exists()); + } + assert!( + !std::process::Command::new(binary) + .arg("--unknown") + .status() + .unwrap() + .success() + ); +} + +fn fixture() -> Value { + let mut value = json!({"abiVersion": 65536, "opsVersion": "1.0", "manifestRevision": "test-1", + "handleKinds": ["Language", "Operation"], + "errorCodes": [ + {"name": "ApiCall", "kind": "aimux", "code": 1}, + {"name": "Io", "kind": "recording", "code": 100}, + {"name": "InvalidHandle", "kind": "binding", "code": 203}, + {"name": "InvalidOp", "kind": "protocol", "code": 300}], + "ops": [ + {"name": "text.generateOpenAI", "mode": "call", "allowedHandleKinds": ["Language"], "requestType": "GenerateTextOptions"}, + {"name": "runtime.describe", "mode": "call", "allowedHandleKinds": [], "requestType": "EmptyParams"}, + {"name": "operation.push", "mode": "binaryPush", "allowedHandleKinds": ["Operation"], "requestType": "Bytes"}, + {"name": "operation.next", "mode": "poll", "allowedHandleKinds": ["Operation"], "requestType": "PollParams"}, + {"name": "text.stream", "mode": "stream", "allowedHandleKinds": ["Language"], "requestType": "GenerateTextOptions"}, + {"name": "handle.drop", "mode": "call", "allowedHandleKinds": ["Language", "Operation"], "requestType": "EmptyParams"}]}); + value["types"] = json!( + aimux_ops::manifest::manifest()["types"] + .as_array() + .unwrap() + .iter() + .filter(|ty| ["HandleId", "ErrorEnvelope", "StreamFrame"] + .contains(&ty["id"].as_str().unwrap())) + .cloned() + .collect::>() + ); + value +} + +fn digest(value: &Value) -> String { + let mut value = value.clone(); + if let Some(object) = value.as_object_mut() { + object.remove("digest"); + } + if let Some(ops) = value.get_mut("ops").and_then(Value::as_array_mut) { + ops.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + } + format!("{:x}", Sha256::digest(canonical_json(&value).unwrap())) +} + +fn artifacts() -> Artifacts { + let manifest = fixture(); + generate(&manifest, &digest(&manifest)).unwrap() +} + +fn schema(artifacts: &Artifacts, name: &str) -> Value { + serde_json::from_slice(&artifacts[Path::new(&format!("schema/ops/{name}.json"))]).unwrap() +} + +fn validator(artifacts: &Artifacts, name: &str) -> jsonschema::Validator { + let mut registry = Registry::new(); + for (path, bytes) in artifacts { + if path.starts_with("schema/ops") { + let document: Value = serde_json::from_slice(bytes).unwrap(); + let id = document["$id"].as_str().unwrap().to_owned(); + registry = registry.add(id, Resource::from_contents(document)).unwrap(); + } + } + let registry = registry.prepare().unwrap(); + jsonschema::options() + .with_draft(Draft::Draft202012) + .with_registry(®istry) + .build(&schema(artifacts, name)) + .unwrap() +} + +#[test] +fn canonical_digest_sorts_nested_objects_without_reordering_arrays() { + let first: Value = serde_json::from_str(r#"{"z":{"b":2,"a":1},"a":[2,1]}"#).unwrap(); + let second = json!({"a": [2, 1], "z": {"a": 1, "b": 2}}); + assert_eq!( + canonical_json(&first).unwrap(), + br#"{"a":[2,1],"z":{"a":1,"b":2}}"# + ); + assert_eq!(digest(&first), digest(&second)); + assert_ne!( + digest(&first), + digest(&json!({"a": [1,2], "z": {"a": 1, "b": 2}})) + ); + assert_eq!( + canonical_json(&json!({"z": -0.0, "a": u64::MAX})).unwrap(), + br#"{"a":18446744073709551615,"z":-0.0}"# + ); + assert!( + generate(&fixture(), "wrong digest") + .unwrap_err() + .to_string() + .contains("manifest digest drift") + ); +} + +#[test] +fn deterministic_complete_outputs_cover_every_language_and_abi_primitive() { + let outputs = artifacts(); + assert_eq!(outputs, artifacts()); + assert_eq!(outputs.len(), 18); + assert_eq!( + outputs[Path::new("aimux-ffi/include/aimux.h")], + outputs[Path::new("bindings/flutter/native/aimux_ops.h")] + ); + let inventory: Value = + serde_json::from_slice(&outputs[Path::new("tools/aimux-codegen/generated-files.json")]) + .unwrap(); + assert_eq!( + inventory["files"], + json!(outputs.keys().collect::>()) + ); + let header = + String::from_utf8(outputs[Path::new("aimux-ffi/include/aimux.h")].clone()).unwrap(); + for primitive in [ + "aimux_abi_version", + "aimux_manifest_json", + "aimux_call", + "aimux_stream", + "aimux_operation_push", + "aimux_operation_next_part", + "aimux_abort", + "aimux_drop", + "aimux_free_string", + "aimux_error_free", + "aimux_error_code", + "aimux_error_message", + "aimux_error_json", + "aimux_handle_finalizer", + ] { + assert!( + header.contains(&format!("{primitive}(")), + "missing {primitive}" + ); + } + assert!(header.contains("AIMUX_ERROR_BINDING_INVALID_HANDLE = 203")); + assert!(header.contains("AIMUX_ERROR_PROTOCOL_INVALID_OP = 300")); + assert!(header.contains("AIMUX_OP_TEXT_GENERATE_OPEN_AI \"text.generateOpenAI\"")); + assert!(header.contains("borrowed until the callback returns")); + for language in ["go", "java", "kotlin", "swift", "flutter", "node", "python"] { + let (_, bytes) = outputs + .iter() + .find(|(path, _)| { + path.starts_with(format!("bindings/{language}")) + && (path.to_string_lossy().contains("generated/") || path.ends_with("ops.py")) + }) + .unwrap(); + let text = std::str::from_utf8(bytes).unwrap(); + for op in fixture()["ops"].as_array().unwrap() { + assert!(text.contains(op["name"].as_str().unwrap())); + } + assert!(text.contains(&digest(&fixture()))); + } +} + +#[test] +fn check_detects_missing_stale_and_changed_files_without_rewriting() { + let dir = tempfile::tempdir().unwrap(); + let outputs = artifacts(); + write(dir.path(), &outputs).unwrap(); + check(dir.path(), &outputs).unwrap(); + fs::remove_file(dir.path().join("bindings/node/src/generated/ops.ts")).unwrap(); + fs::write(dir.path().join("schema/ops/stale.json"), b"{}").unwrap(); + fs::write(dir.path().join("bindings/go/generated/ops.go"), b"changed").unwrap(); + let packaged = dir.path().join("bindings/flutter/native/aimux_ops.h"); + fs::write(&packaged, b"changed").unwrap(); + let error = check(dir.path(), &outputs).unwrap_err().to_string(); + assert!(error.contains("missing: bindings/node/src/generated/ops.ts")); + assert!(error.contains("stale: schema/ops/stale.json")); + assert!(error.contains("differs: bindings/go/generated/ops.go")); + assert!(error.contains("differs: bindings/flutter/native/aimux_ops.h")); + assert_eq!(fs::read(packaged).unwrap(), b"changed"); + assert_eq!( + fs::read(dir.path().join("bindings/go/generated/ops.go")).unwrap(), + b"changed" + ); + write(dir.path(), &outputs).unwrap(); + assert!(!dir.path().join("schema/ops/stale.json").exists()); + check(dir.path(), &outputs).unwrap(); +} + +#[test] +fn digest_and_previous_constant_drift_are_detected() { + let dir = tempfile::tempdir().unwrap(); + let outputs = artifacts(); + write(dir.path(), &outputs).unwrap(); + fs::write( + dir.path().join("tools/aimux-codegen/generated-files.json"), + b"{\"manifestDigest\":\"wrong\"}", + ) + .unwrap(); + fs::write( + dir.path().join("bindings/python/python/aimux/ops-old.py"), + b"OLD = 1", + ) + .unwrap(); + let error = check(dir.path(), &outputs).unwrap_err().to_string(); + assert!(error.contains("differs: tools/aimux-codegen/generated-files.json")); + assert!(error.contains("stale: bindings/python/python/aimux/ops-old.py")); +} + +#[test] +fn writes_preserve_unowned_binding_types_and_reject_path_traversal() { + let dir = tempfile::tempdir().unwrap(); + let outputs = artifacts(); + write(dir.path(), &outputs).unwrap(); + let types = dir.path().join("bindings/node/src/generated/types.ts"); + fs::write(&types, b"export type OwnedElsewhere = string;").unwrap(); + let native = dir.path().join("bindings/flutter/native/unowned.h"); + fs::write(&native, b"binding-owned").unwrap(); + let cache = dir.path().join("bindings/python/python/aimux/__pycache__"); + fs::create_dir_all(&cache).unwrap(); + fs::write(cache.join("ops.pyc"), b"compiled cache").unwrap(); + write(dir.path(), &outputs).unwrap(); + check(dir.path(), &outputs).unwrap(); + assert!(types.exists()); + assert_eq!(fs::read(native).unwrap(), b"binding-owned"); + assert!(cache.join("ops.pyc").exists()); + let mut invalid = outputs.clone(); + invalid.insert("schema/ops/../../README.md".into(), b"bad".to_vec()); + assert!(write(dir.path(), &invalid).is_err()); +} + +#[test] +fn schemas_resolve_locally_and_validate_as_draft_2020_12() { + let outputs = artifacts(); + for path in outputs.keys().filter(|path| path.starts_with("schema/ops")) { + let name = path.file_stem().unwrap().to_str().unwrap(); + let document = schema(&outputs, name); + assert_eq!( + document["$schema"], + "https://json-schema.org/draft/2020-12/schema" + ); + assert!(jsonschema::meta::is_valid(&document)); + let _validator = validator(&outputs, name); + } +} + +#[test] +fn request_controls_are_closed_case_sensitive_and_keep_provider_json_open() { + let outputs = artifacts(); + let requests = validator(&outputs, "request"); + let mut request = json!({"handle": "9", "op": "text.generateOpenAI", "abort": "0", + "params": {"providerOptions": {"vendor": {"future": true}}, "metadata": {"unknown": 1}}}); + assert!(requests.is_valid(&request)); + request["extra"] = json!(1); + assert!(!requests.is_valid(&request)); + request.as_object_mut().unwrap().remove("extra"); + request["op"] = json!("text.generateOpenAi"); + assert!(!requests.is_valid(&request)); + assert!(requests.is_valid(&json!({"handle": "0", "op": "runtime.describe", "params": {}}))); + assert!(!requests.is_valid(&json!({"handle": "9", "op": "runtime.describe", "params": {}}))); + assert!(!requests.is_valid(&json!({"handle": "0", "op": "text.stream", "params": {}}))); + assert!(requests.is_valid(&json!({"handle": "0", "op": "handle.drop", "params": {}}))); + assert!( + !requests + .is_valid(&json!({"handle": "1", "op": "operation.push", "params": {}, "abort": "0"})) + ); + assert!( + validator(&outputs, "provider-options").is_valid(&json!({"future": {"nested": [1, null]}})) + ); +} + +#[test] +fn handles_cover_u64_strings_and_reject_precision_loss_and_overflow() { + let validator = validator(&artifacts(), "handle-id"); + for handle in [ + "0", + "1", + "01", + "00018446744073709551615", + "9999999999999999999", + "10000000000000000000", + "18446744073709551614", + "18446744073709551615", + ] { + assert!(validator.is_valid(&json!(handle)), "rejected {handle}"); + } + for handle in [ + "", + "1\n", + "1\r", + "١", + "-1", + "+1", + "1.0", + "1e2", + "18446744073709551616", + "20000000000000000000", + "184467440737095516150", + ] { + assert!(!validator.is_valid(&json!(handle)), "accepted {handle}"); + } + assert!(!validator.is_valid(&json!(1))); +} + +#[test] +fn frame_and_error_envelope_use_contract_discriminators() { + let outputs = artifacts(); + let frames = validator(&outputs, "stream-frame"); + let error = json!({"kind": "aimux", "code": 1, "message": "failure", "error": {"name": "AI_APICallError", "isRetryable": true}, "retryAfterMs": 0}); + assert!( + frames.is_valid(&json!({"type": "end", "seq": "2", "outcome": "error", "error": error})) + ); + assert!(frames.is_valid(&json!({"type": "event", "seq": "1", "event": {"vendor": null}}))); + assert!(frames.is_valid(&json!({"type": "end", "seq": "2", "outcome": "cancelled"}))); + assert!( + !frames.is_valid(&json!({"type": "end", "seq": "2", "outcome": "success", "extra": true})) + ); + assert!(!frames.is_valid(&json!({"type": "unknown", "seq": "1"}))); + let errors = validator(&outputs, "error-envelope"); + assert!(errors.is_valid(&error)); + let mut wrong = error.clone(); + wrong["retryable"] = json!(true); + assert!(!errors.is_valid(&wrong)); + wrong = error.clone(); + wrong["retryAfterMs"] = Value::Null; + assert!(!errors.is_valid(&wrong)); +} + +#[test] +fn malformed_manifest_identifiers_kinds_modes_and_error_codes_fail() { + for mutation in ["collision", "kind", "mode", "identifier", "code", "domain"] { + let mut manifest = fixture(); + match mutation { + "collision" => manifest["ops"][1]["name"] = json!("text.generateOpenAI"), + "kind" => manifest["ops"][0]["allowedHandleKinds"] = json!(["Unknown"]), + "mode" => manifest["ops"][0]["mode"] = json!("futureMode"), + "identifier" => manifest["ops"][0]["name"] = json!("text.\"injection"), + "code" => manifest["errorCodes"][0]["code"] = json!(99), + "domain" => manifest["errorCodes"][0]["kind"] = json!("binding"), + _ => unreachable!(), + } + assert!( + generate(&manifest, &digest(&manifest)).is_err(), + "accepted {mutation}" + ); + } + let mut collision = fixture(); + collision["ops"][0]["name"] = json!("text.fooBar"); + collision["ops"][1]["name"] = json!("text.foo_bar"); + assert!(generate(&collision, &digest(&collision)).is_err()); + let mut version = fixture(); + version["opsVersion"] = json!("${injected}"); + assert!(generate(&version, &digest(&version)).is_err()); +} + +#[test] +fn real_manifest_control_schemas_are_emitted_and_enforced() { + let value = aimux_ops::manifest::manifest(); + let outputs = generate(&value, &aimux_ops::manifest::manifest_digest()).unwrap(); + for (id, name) in [ + ("HandleId", "handle-id"), + ("ErrorEnvelope", "error-envelope"), + ("StreamFrame", "stream-frame"), + ] { + let expected = &value["types"] + .as_array() + .unwrap() + .iter() + .find(|ty| ty["id"] == id) + .unwrap()["schema"]; + let mut emitted = schema(&outputs, name); + let object = emitted.as_object_mut().unwrap(); + object.remove("$id"); + object.remove("$schema"); + object.remove("$comment"); + assert_eq!(&emitted, expected, "{id}"); + } + let handles = validator(&outputs, "handle-id"); + for wire in ["01", "00018446744073709551615"] { + let value = json!(wire); + let _: aimux_ops::handle::HandleId = serde_json::from_value(value.clone()).unwrap(); + assert!(handles.is_valid(&value)); + } + let unknown = json!({"kind":"aimux", "code":21, "message":"future error", + "error":{"name":"AI_FutureError", "data":{"secret":null}}}); + let envelope: aimux_ops::envelope::ErrorEnvelope = + serde_json::from_value(unknown.clone()).unwrap(); + assert!(validator(&outputs, "error-envelope").is_valid(&unknown)); + let frame = aimux_ops::frame::StreamFrame::End { + seq: "01".into(), + outcome: aimux_ops::frame::Outcome::Error, + error: Some(envelope), + }; + assert!(validator(&outputs, "stream-frame").is_valid(&serde_json::to_value(frame).unwrap())); + for ty in value["types"].as_array().unwrap() { + if ty.get("schema").is_some() { + assert!( + outputs.values().any(|bytes| { + serde_json::from_slice::(bytes) + .ok() + .is_some_and(|document| { + let mut document = document; + if let Some(object) = document.as_object_mut() { + object.remove("$schema"); + object.remove("$id"); + if ty["schema"].get("$comment").is_none() { + object.remove("$comment"); + } + } + document == ty["schema"] + }) + }), + "missing schema {}", + ty["id"] + ); + } + } + let requests = validator(&outputs, "request"); + assert!( + requests.is_valid(&json!({"handle":"1","op":"operation.next","params":{"timeoutMs":0}})) + ); + assert!( + !requests.is_valid(&json!({"handle":"1","op":"operation.next","params":{"extra":true}})) + ); + for path in outputs.keys().filter(|p| p.starts_with("schema/ops")) { + let name = path.file_stem().unwrap().to_str().unwrap(); + assert!( + jsonschema::meta::is_valid(&schema(&outputs, name)), + "{name}" + ); + let _ = validator(&outputs, name); + } +} + +#[test] +fn manifest_digest_ignores_its_own_field_and_operation_order() { + let mut value = fixture(); + let expected = generate(&value, &digest(&value)).unwrap(); + value["digest"] = json!("excluded from digest"); + value["ops"].as_array_mut().unwrap().reverse(); + assert_eq!(generate(&value, &digest(&fixture())).unwrap(), expected); +}