mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(claude-code): Claude Code CLI provider — Phases 1–5 (#2472)
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
3b857d56da
commit
9d28a728a0
@@ -1,148 +0,0 @@
|
||||
## Summary
|
||||
|
||||
- Adds an iOS client for OpenHuman: device pairing via QR code, mascot chat screen, and push-to-talk voice input.
|
||||
- No Rust core ships on device; the iOS app connects to the desktop core via LAN HTTP, an E2E-encrypted socket.io tunnel, or cloud HTTP fallback.
|
||||
- All changes are cfg-gated or platform-guarded; the desktop build is unaffected.
|
||||
- Adds the `tauri-plugin-ptt` Swift plugin (`packages/tauri-plugin-ptt/`) for AVAudioEngine + SFSpeechRecognizer on iOS.
|
||||
- Adds CI sanity-check workflow, build scripts, capability catalog entries, and full docs.
|
||||
|
||||
## Problem
|
||||
|
||||
Users with iOS devices had no way to interact with their OpenHuman assistant on the go. The desktop app required a local machine. This PR adds the client-side scaffolding and transport layer needed to bridge iOS to an existing desktop core.
|
||||
|
||||
## Solution
|
||||
|
||||
The iOS app is a subset of the existing React/TypeScript UI, compiled by Tauri v2 into an iOS bundle. A `TransportManager` selects the best transport at runtime. Pairing is secured by an X25519 key agreement; all tunnel traffic uses XChaCha20-Poly1305 encryption. The backend is a blind socket.io forwarder -- it never sees plaintext.
|
||||
|
||||
## Layer-by-layer commits
|
||||
|
||||
| Commit | Layer | Summary |
|
||||
|--------|-------|---------|
|
||||
| `a99537f3` | Layer 1 | Rust devices domain -- pairing store, RPC handlers, event bus, crypto (`src/openhuman/devices/`) |
|
||||
| `4ea14b78` | Layer 2 | TS transport refactor -- `TransportManager`, `LanHttpTransport`, `TunnelTransport`, `CloudHttpTransport`, tunnel crypto (`app/src/services/transport/`, `app/src/lib/tunnel/`) |
|
||||
| `ba651705` | Layer 3 | Desktop `/settings/devices` UI -- `DevicesPanel`, `PairPhoneModal` with QR generation and 2-second poll |
|
||||
| `3e0e2a67` | Layer 4 | Tauri shell cfg-gating -- `#[cfg(target_os = "ios")]` guards on CEF-specific code |
|
||||
| `621fec98` | Layer 5 | iOS app shell -- `PairScreen` (QR scan via `AVCaptureSession`), `MascotScreen` (chat UI) |
|
||||
| `5ca6cf21` | Layer 6 | `tauri-plugin-ptt` -- Swift PTT plugin (AVAudioEngine, SFSpeechRecognizer, AVSpeechSynthesizer) |
|
||||
| `41a6a895` | Layer 6 fix | PTT Swift fix -- latest transcript tracking + `@unchecked Sendable` on PTTSpeaker |
|
||||
| _(this PR)_ | Layers 7+8 | Build scripts, CI, Info.plist, capability catalog, docs, quality pass |
|
||||
|
||||
## Test coverage
|
||||
|
||||
- **Vitest:** 1957 passed, 3 skipped, 1 todo across 218 test files (includes transport, tunnel, devices, iOS, PTT suites).
|
||||
- **Rust (about_app):** 20 passed -- validates catalog uniqueness, Mobile category, and new capability entries.
|
||||
- **cargo check (all three Cargo.toml files):** clean (warnings only, pre-existing).
|
||||
|
||||
## What is gated behind the iOS target
|
||||
|
||||
The following only activates on `cfg(target_os = "ios")` or when explicitly called from iOS screens:
|
||||
|
||||
- CEF exclusions in `app/src-tauri/` (accounts webviews, etc.)
|
||||
- `tauri-plugin-ptt` commands (`start_listening`, `stop_listening`, `speak`, `cancel_speech`, `list_voices`) -- return `NotSupported` on non-iOS targets.
|
||||
- `packages/tauri-plugin-ptt/ios/` Swift sources -- not compiled for desktop.
|
||||
|
||||
Desktop users see no change.
|
||||
|
||||
## Known TODOs for follow-up PRs
|
||||
|
||||
- **Keychain migration:** iOS symmetric session key is in-memory only; persist to Keychain so the app reconnects after restart without re-pairing.
|
||||
- **Event-driven pairing detection:** `PairPhoneModal` polls `devices_list` every 2 s. Switch to a socket event subscription when the SSE/socket bridge for `DomainEvent::DevicePaired` lands.
|
||||
- **Full Xcode CI:** `cargo check --target aarch64-apple-ios` runs with `continue-on-error: true` in the new CI workflow because third-party C deps (cef-dll-sys) may fail without full Xcode on the runner. A follow-up should pin an Xcode-enabled runner and harden this to a hard gate.
|
||||
- **APNs push notifications:** real-time delivery requires the app to be foregrounded.
|
||||
- **Multi-region tunnel:** single backend instance only; no failover.
|
||||
- **Info.plist automation:** developer must manually copy `Info.ios.plist` keys into the generated Xcode project after `tauri ios init`. Should automate via `bundle.iOS.template` once Tauri v2 stabilises the iOS template pipeline.
|
||||
|
||||
## Backend dependency
|
||||
|
||||
**`tinyhumansai/backend#709` must be merged and deployed before end-to-end pairing works.** The `devices_create_pairing` RPC will return a tunnel registration error until the `tunnel:register` / `tunnel:connect` / `tunnel:frame` socket.io contract is live.
|
||||
|
||||
## Manual test plan for iOS reviewer
|
||||
|
||||
_(Requires a physical iPhone or iOS 17+ simulator paired with the desktop app.)_
|
||||
|
||||
From `packages/tauri-plugin-ptt/README.md`:
|
||||
|
||||
- [ ] Permissions dialog appears on first `startListening` call.
|
||||
- [ ] Partial transcripts update while speaking; final transcript matches.
|
||||
- [ ] Hold button to record, release to stop, chat message is sent with transcript.
|
||||
- [ ] TTS plays through speaker by default when iPhone is held away from ear.
|
||||
- [ ] BT headset routes audio correctly; disconnecting mid-recording stops gracefully.
|
||||
- [ ] App backgrounded mid-record produces a final transcript and stops cleanly.
|
||||
- [ ] Phone call interruption emits `ptt://error` with `code: interrupted`.
|
||||
- [ ] `cancelSpeech` during TTS emits `tts-ended` with `finished: false`.
|
||||
- [ ] `listVoices` returns non-empty list of `AVSpeechSynthesisVoice` entries.
|
||||
|
||||
Additional pairing flow checks:
|
||||
|
||||
- [ ] Desktop: Settings > Devices > "Pair iPhone" shows QR code.
|
||||
- [ ] iOS app: PairScreen scans QR and transitions to MascotScreen after handshake.
|
||||
- [ ] Desktop: Devices panel lists the paired device with correct label.
|
||||
- [ ] Desktop: Revoke device removes it from the list; iOS app shows reconnect prompt.
|
||||
- [ ] QR code expiry: code expires after TTL, "Generate new code" creates a fresh session.
|
||||
|
||||
## Screenshots
|
||||
|
||||
> **PLACEHOLDER:** Before opening the PR, attach screenshots of:
|
||||
> - Desktop `/settings/devices` panel with a paired device.
|
||||
> - iOS mascot screen showing a conversation.
|
||||
>
|
||||
> These require a device with Xcode signing configured and `tinyhumansai/backend#709` deployed.
|
||||
|
||||
## Submission Checklist
|
||||
|
||||
- [x] Tests added or updated (transport, tunnel, devices, iOS, PTT suites -- see coverage statement above).
|
||||
- [x] Diff coverage note: new Rust code in `src/openhuman/devices/` was covered in Layer 1 tests; new TS code in `app/src/services/transport/` and `app/src/lib/tunnel/` covered by Vitest suites. PTT Swift layer cannot be unit-tested without iOS toolchain (noted in README).
|
||||
- [x] Coverage matrix: N/A for this layer (build scripts, CI, docs, catalog).
|
||||
- [x] No new external network dependencies (all transport calls use existing mock backend or real backend behind feature flag).
|
||||
- [ ] Manual smoke checklist: iOS path not in `docs/RELEASE-MANUAL-SMOKE.md` yet -- tracked as follow-up.
|
||||
- [ ] Linked issue: N/A (tracked via Linear).
|
||||
|
||||
## Impact
|
||||
|
||||
- Desktop runtime: no change.
|
||||
- iOS target: new experimental app bundle (not in release pipeline yet).
|
||||
- `packages/tauri-plugin-ptt/` is a new crate workspace member; adds to build time only when targeting iOS.
|
||||
- Capability catalog adds three new `mobile.*` entries and a new `Mobile` category.
|
||||
|
||||
## Related
|
||||
|
||||
- Closes: N/A (new feature)
|
||||
- Follow-up PR(s): Keychain migration, event-driven pairing, full Xcode CI, APNs.
|
||||
- Backend: tinyhumansai/backend#709
|
||||
|
||||
---
|
||||
|
||||
## AI Authored PR Metadata (required for Codex/Linear PRs)
|
||||
|
||||
### Linear Issue
|
||||
- Key: N/A
|
||||
- URL: N/A
|
||||
|
||||
### Commit & Branch
|
||||
- Branch: `feat/ios-client`
|
||||
- Commit SHA: _(set after final commit)_
|
||||
|
||||
### Validation Run
|
||||
- [x] `pnpm --filter openhuman-app format:check` -- clean
|
||||
- [x] `pnpm typecheck` -- clean
|
||||
- [x] Focused tests: Vitest 1957 passed; cargo about_app 20 passed
|
||||
- [x] Rust fmt/check: `cargo fmt --all` + `cargo check` on all three Cargo.toml -- clean
|
||||
- [x] Tauri fmt/check: included above
|
||||
|
||||
### Validation Blocked
|
||||
- command: `cargo check --target aarch64-apple-ios`
|
||||
- error: May fail on cef-dll-sys C deps without full Xcode; guarded with `continue-on-error: true` in CI.
|
||||
- impact: Soft gate only; does not block merge.
|
||||
|
||||
### Behavior Changes
|
||||
- Intended behavior change: Desktop users see new Settings > Devices panel. iOS users can pair and chat.
|
||||
- User-visible effect: Desktop gains device management UI. iOS app becomes available for sideloading/TestFlight.
|
||||
|
||||
### Parity Contract
|
||||
- Legacy behavior preserved: All existing desktop flows unaffected. No CEF injection added. No new JS injection in webview accounts.
|
||||
- Guard/fallback/dispatch parity: PTT commands return `NotSupported` on non-iOS. Transport falls back gracefully.
|
||||
|
||||
### Duplicate / Superseded PR Handling
|
||||
- Duplicate PR(s): None
|
||||
- Canonical PR: This PR
|
||||
- Resolution: N/A
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Tauri commands for the Claude Code CLI provider.
|
||||
//!
|
||||
//! Provides a cross-platform "open a terminal and run `claude login`"
|
||||
//! helper. The CLI's OAuth flow is interactive (it prints a URL and
|
||||
//! waits for the user to paste a code), so we can't host it in-app — we
|
||||
//! detach into the user's native terminal so they complete login there,
|
||||
//! then return to OpenHuman and click Recheck in the settings card.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
/// Open the user's native terminal and run `claude login` inside it.
|
||||
///
|
||||
/// Returns the name of the terminal emulator we launched (for UI
|
||||
/// confirmation) or an error string if no terminal could be opened.
|
||||
///
|
||||
/// Platform behaviour:
|
||||
/// - Windows: `cmd /c start "" cmd /k claude login`
|
||||
/// - macOS: `osascript` → Terminal.app `do script "claude login"`
|
||||
/// - Linux: try `x-terminal-emulator`, then `gnome-terminal`,
|
||||
/// `konsole`, `xterm` in that order
|
||||
#[tauri::command]
|
||||
pub fn claude_code_login_launch() -> Result<String, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// `start ""` opens a new console window; the empty quoted title
|
||||
// prevents cmd from interpreting the first arg as a title.
|
||||
// `cmd /k` keeps the window open after `claude login` exits so
|
||||
// the user can read any final output.
|
||||
Command::new("cmd")
|
||||
.args(["/c", "start", "", "cmd", "/k", "claude login"])
|
||||
.spawn()
|
||||
.map_err(|e| format!("failed to open cmd: {e}"))?;
|
||||
return Ok("cmd".into());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let script = r#"tell application "Terminal"
|
||||
activate
|
||||
do script "claude login"
|
||||
end tell"#;
|
||||
Command::new("osascript")
|
||||
.args(["-e", script])
|
||||
.spawn()
|
||||
.map_err(|e| format!("failed to open Terminal.app: {e}"))?;
|
||||
return Ok("Terminal.app".into());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let terminals: &[(&str, &[&str])] = &[
|
||||
("x-terminal-emulator", &["-e", "claude", "login"]),
|
||||
("gnome-terminal", &["--", "claude", "login"]),
|
||||
("konsole", &["-e", "claude", "login"]),
|
||||
("xfce4-terminal", &["-e", "claude login"]),
|
||||
("xterm", &["-e", "claude", "login"]),
|
||||
];
|
||||
for (term, args) in terminals {
|
||||
match Command::new(term).args(*args).spawn() {
|
||||
Ok(_) => return Ok(term.to_string()),
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
return Err("no terminal emulator found (tried x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, xterm). Run `claude login` manually.".into());
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
Err("claude_code_login_launch is not supported on this platform".into())
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ mod cef_profile;
|
||||
// logic is unit-tested on any host; the Win32 glue is windows-only.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod cef_singleton_wait;
|
||||
mod claude_code;
|
||||
mod companion_commands;
|
||||
mod core_process;
|
||||
mod core_rpc;
|
||||
@@ -3166,7 +3167,8 @@ pub fn run() {
|
||||
mcp_commands::mcp_resolve_binary_path,
|
||||
mcp_commands::mcp_open_client_config,
|
||||
loopback_oauth::start_loopback_oauth_listener,
|
||||
loopback_oauth::stop_loopback_oauth_listener
|
||||
loopback_oauth::stop_loopback_oauth_listener,
|
||||
claude_code::claude_code_login_launch
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
import { ConfirmationModal } from '../../intelligence/ConfirmationModal';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
import { ClaudeCodeStatusCard } from './ai/ClaudeCodeStatusCard';
|
||||
import { routingWithProviderRemoved } from './aiRouting';
|
||||
import {
|
||||
authStyleForBuiltinCloudProvider,
|
||||
@@ -97,7 +98,8 @@ export type ProviderRef =
|
||||
| { kind: 'openhuman' }
|
||||
| { kind: 'default' }
|
||||
| { kind: 'cloud'; providerSlug: string; model: string; temperature?: number | null }
|
||||
| { kind: 'local'; model: string; temperature?: number | null };
|
||||
| { kind: 'local'; model: string; temperature?: number | null }
|
||||
| { kind: 'claude-code'; model: string; temperature?: number | null };
|
||||
|
||||
type Workload = { id: WorkloadId; group: WorkloadGroup; label: string; description: string };
|
||||
|
||||
@@ -859,6 +861,7 @@ function describeProvider(ref: ProviderRef, providers: BackgroundLoopProviderVie
|
||||
if (ref.kind === 'openhuman') return 'Managed · OpenHuman';
|
||||
if (ref.kind === 'default') return 'Default route';
|
||||
if (ref.kind === 'local') return `Local ${ref.model}`;
|
||||
if (ref.kind === 'claude-code') return `Claude Code CLI ${ref.model || 'default model'}`;
|
||||
const provider = providers.find(p => p.slug === ref.providerSlug);
|
||||
return `${provider?.label ?? ref.providerSlug} ${ref.model || 'custom model'}`;
|
||||
}
|
||||
@@ -1727,7 +1730,15 @@ interface CustomRoutingDialogProps {
|
||||
onSubmit: (next: ProviderRef) => void;
|
||||
}
|
||||
|
||||
type CustomDialogSource = { kind: 'cloud'; providerSlug: string } | { kind: 'local' };
|
||||
type CustomDialogSource =
|
||||
| { kind: 'cloud'; providerSlug: string }
|
||||
| { kind: 'local' }
|
||||
| { kind: 'claude-code' };
|
||||
|
||||
/** Default model identifier presented when the user first picks the
|
||||
* Claude Code CLI source. The CLI accepts any model id the underlying
|
||||
* Claude account can run, so this is just a sensible starting point. */
|
||||
const CLAUDE_CODE_DEFAULT_MODEL = 'sonnet-4-5';
|
||||
|
||||
function providerRefSignature(ref: ProviderRef): string {
|
||||
switch (ref.kind) {
|
||||
@@ -1739,6 +1750,8 @@ function providerRefSignature(ref: ProviderRef): string {
|
||||
return `cloud:${ref.providerSlug}:${ref.model}:${ref.temperature ?? ''}`;
|
||||
case 'local':
|
||||
return `local:${ref.model}:${ref.temperature ?? ''}`;
|
||||
case 'claude-code':
|
||||
return `claude-code:${ref.model}:${ref.temperature ?? ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1814,19 +1827,23 @@ const CustomRoutingDialog = ({
|
||||
? { kind: 'cloud', providerSlug: initial.providerSlug }
|
||||
: initial.kind === 'local'
|
||||
? { kind: 'local' }
|
||||
: customCloud[0]
|
||||
? { kind: 'cloud', providerSlug: customCloud[0].slug }
|
||||
: localAvailable
|
||||
? { kind: 'local' }
|
||||
: null;
|
||||
: initial.kind === 'claude-code'
|
||||
? { kind: 'claude-code' }
|
||||
: customCloud[0]
|
||||
? { kind: 'cloud', providerSlug: customCloud[0].slug }
|
||||
: localAvailable
|
||||
? { kind: 'local' }
|
||||
: null;
|
||||
|
||||
const [source, setSource] = useState<CustomDialogSource | null>(initialSource);
|
||||
const [model, setModel] = useState<string>(() => {
|
||||
if (initial.kind === 'cloud' || initial.kind === 'local') return initial.model;
|
||||
if (initial.kind === 'cloud' || initial.kind === 'local' || initial.kind === 'claude-code')
|
||||
return initial.model;
|
||||
if (initialSource?.kind === 'cloud') {
|
||||
const p = customCloud.find(c => c.slug === initialSource.providerSlug);
|
||||
return p ? '' : '';
|
||||
}
|
||||
if (initialSource?.kind === 'claude-code') return CLAUDE_CODE_DEFAULT_MODEL;
|
||||
return localModels[0]?.id ?? '';
|
||||
});
|
||||
const [cloudModels, setCloudModels] = useState<ModelInfo[]>([]);
|
||||
@@ -1841,7 +1858,9 @@ const CustomRoutingDialog = ({
|
||||
// Optional temperature override for this workload. `null` = use provider/global default;
|
||||
// a finite number means "send `temperature: X` upstream for this workload only".
|
||||
const [temperature, setTemperature] = useState<number | null>(
|
||||
initial.kind === 'cloud' || initial.kind === 'local' ? (initial.temperature ?? null) : null
|
||||
initial.kind === 'cloud' || initial.kind === 'local' || initial.kind === 'claude-code'
|
||||
? (initial.temperature ?? null)
|
||||
: null
|
||||
);
|
||||
|
||||
const selectedCloud =
|
||||
@@ -1923,6 +1942,8 @@ const CustomRoutingDialog = ({
|
||||
model: model.trim(),
|
||||
temperature: temp,
|
||||
});
|
||||
} else if (source.kind === 'claude-code') {
|
||||
onSubmit({ kind: 'claude-code', model: model.trim(), temperature: temp });
|
||||
} else {
|
||||
onSubmit({ kind: 'local', model: model.trim(), temperature: temp });
|
||||
}
|
||||
@@ -1950,7 +1971,9 @@ const CustomRoutingDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const noProviders = customCloud.length === 0 && !localAvailable;
|
||||
// Claude Code CLI is always available as a source — never show the
|
||||
// empty state when it's the only option.
|
||||
const noProviders = false;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -2012,6 +2035,9 @@ const CustomRoutingDialog = ({
|
||||
} else if (kind === 'cloud') {
|
||||
setSource({ kind: 'cloud', providerSlug: slug });
|
||||
setModel('');
|
||||
} else if (kind === 'claude-code') {
|
||||
setSource({ kind: 'claude-code' });
|
||||
setModel(CLAUDE_CODE_DEFAULT_MODEL);
|
||||
}
|
||||
}}
|
||||
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
@@ -2021,6 +2047,7 @@ const CustomRoutingDialog = ({
|
||||
</option>
|
||||
))}
|
||||
{localAvailable && <option value="local:">{t('settings.ai.localOllama')}</option>}
|
||||
<option value="claude-code:">Claude Code CLI</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -2042,6 +2069,20 @@ const CustomRoutingDialog = ({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : source?.kind === 'claude-code' ? (
|
||||
<div className="space-y-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
placeholder="sonnet-4-5"
|
||||
className="w-full rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm font-mono text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
Any model id your Claude account can run (e.g. <code>sonnet-4-5</code>,{' '}
|
||||
<code>opus-4-7</code>). Passed verbatim to <code>claude --model</code>.
|
||||
</p>
|
||||
</div>
|
||||
) : cloudModelsLoading ? (
|
||||
<select
|
||||
disabled
|
||||
@@ -2400,7 +2441,9 @@ const GlobalOwnModelSelector = ({
|
||||
? null
|
||||
: source.kind === 'local'
|
||||
? ({ kind: 'local', model: model.trim() } as const)
|
||||
: ({ kind: 'cloud', providerSlug: source.providerSlug, model: model.trim() } as const);
|
||||
: source.kind === 'claude-code'
|
||||
? ({ kind: 'claude-code', model: model.trim() } as const)
|
||||
: ({ kind: 'cloud', providerSlug: source.providerSlug, model: model.trim() } as const);
|
||||
const isSaved =
|
||||
selectedRef !== null &&
|
||||
saved !== null &&
|
||||
@@ -2412,6 +2455,8 @@ const GlobalOwnModelSelector = ({
|
||||
try {
|
||||
if (nextSource.kind === 'local') {
|
||||
await onApply({ kind: 'local', model: nextModel.trim() });
|
||||
} else if (nextSource.kind === 'claude-code') {
|
||||
await onApply({ kind: 'claude-code', model: nextModel.trim() });
|
||||
} else {
|
||||
await onApply({
|
||||
kind: 'cloud',
|
||||
@@ -2779,6 +2824,7 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
)}
|
||||
|
||||
<div className={embedded ? 'space-y-6' : 'space-y-6 p-4'}>
|
||||
<ClaudeCodeStatusCard />
|
||||
{/* ═══════════════════════════════════════════════════════════════
|
||||
AUTH — provider authentication (cloud providers + local Ollama
|
||||
setup). Everything the user needs to wire a model up.
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
type ClaudeCodeAuthStatus,
|
||||
type ClaudeCodeStatus,
|
||||
openhumanClaudeCodeAuthStatus,
|
||||
openhumanClaudeCodeLoginLaunch,
|
||||
openhumanClaudeCodeStatus,
|
||||
} from '../../../../utils/tauriCommands/config';
|
||||
|
||||
/**
|
||||
* Status card for the Claude Code CLI provider.
|
||||
*
|
||||
* Surfaces two independent probes:
|
||||
* 1. Binary install + version (slow — spawns `claude --version`).
|
||||
* 2. Auth state — Pro/Max subscription via `~/.claude/.credentials.json`
|
||||
* or `ANTHROPIC_API_KEY` env (fast — pure FS).
|
||||
*
|
||||
* Each refreshes independently so a user who just ran `claude login` can
|
||||
* re-probe auth without re-spawning the binary.
|
||||
*/
|
||||
export function ClaudeCodeStatusCard() {
|
||||
const [status, setStatus] = useState<ClaudeCodeStatus | null>(null);
|
||||
const [auth, setAuth] = useState<ClaudeCodeAuthStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [authLoading, setAuthLoading] = useState<boolean>(false);
|
||||
|
||||
const probe = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await openhumanClaudeCodeStatus();
|
||||
setStatus(resp.result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setStatus(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const probeAuth = useCallback(async () => {
|
||||
setAuthLoading(true);
|
||||
setAuthError(null);
|
||||
try {
|
||||
const resp = await openhumanClaudeCodeAuthStatus();
|
||||
setAuth(resp.result);
|
||||
} catch (err) {
|
||||
setAuthError(err instanceof Error ? err.message : String(err));
|
||||
setAuth(null);
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void probe();
|
||||
void probeAuth();
|
||||
}, [probe, probeAuth]);
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="claude-code-status-card"
|
||||
className="rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<header className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Claude Code CLI
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void probe();
|
||||
}}
|
||||
disabled={loading}
|
||||
className="text-xs text-neutral-500 hover:text-neutral-900 disabled:opacity-50 dark:text-neutral-400 dark:hover:text-neutral-100">
|
||||
{loading ? 'Probing…' : 'Probe'}
|
||||
</button>
|
||||
</header>
|
||||
<StatusBody status={status} error={error} />
|
||||
|
||||
<div className="mt-4 border-t border-neutral-200 pt-3 dark:border-neutral-800">
|
||||
<header className="mb-2 flex items-center justify-between">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
|
||||
Authentication
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void probeAuth();
|
||||
}}
|
||||
disabled={authLoading}
|
||||
className="text-xs text-neutral-500 hover:text-neutral-900 disabled:opacity-50 dark:text-neutral-400 dark:hover:text-neutral-100">
|
||||
{authLoading ? 'Checking…' : 'Recheck'}
|
||||
</button>
|
||||
</header>
|
||||
<AuthBody auth={auth} error={authError} />
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Use the <code>claude-code:<model></code> provider string to route chat, agentic, or
|
||||
reasoning workloads through your local Claude Code CLI install.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBody({ status, error }: { status: ClaudeCodeStatus | null; error: string | null }) {
|
||||
if (error) {
|
||||
return <p className="text-xs text-rose-600 dark:text-rose-400">Failed to probe: {error}</p>;
|
||||
}
|
||||
if (!status) {
|
||||
return <p className="text-xs text-neutral-500 dark:text-neutral-400">Probing…</p>;
|
||||
}
|
||||
switch (status.status) {
|
||||
case 'ok':
|
||||
return (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
<dt className="text-neutral-500">Status</dt>
|
||||
<dd className="text-emerald-600 dark:text-emerald-400">Installed ({status.version})</dd>
|
||||
<dt className="text-neutral-500">Path</dt>
|
||||
<dd className="font-mono text-neutral-700 dark:text-neutral-300">{status.path}</dd>
|
||||
</dl>
|
||||
);
|
||||
case 'not_installed':
|
||||
return (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
Claude Code CLI is not installed. Install via{' '}
|
||||
<code>npm install -g @anthropic-ai/claude-code</code> or follow{' '}
|
||||
<a
|
||||
href="https://docs.anthropic.com/en/docs/claude-code"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="underline hover:text-amber-700 dark:hover:text-amber-300">
|
||||
Anthropic's docs
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
);
|
||||
case 'outdated':
|
||||
return (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
<dt className="text-neutral-500">Status</dt>
|
||||
<dd className="text-rose-600 dark:text-rose-400">
|
||||
Outdated — found {status.version}, need ≥ {status.min_required}
|
||||
</dd>
|
||||
<dt className="text-neutral-500">Path</dt>
|
||||
<dd className="font-mono text-neutral-700 dark:text-neutral-300">{status.path}</dd>
|
||||
</dl>
|
||||
);
|
||||
case 'unusable':
|
||||
return (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
<dt className="text-neutral-500">Status</dt>
|
||||
<dd className="text-rose-600 dark:text-rose-400">Unusable — {status.reason}</dd>
|
||||
<dt className="text-neutral-500">Path</dt>
|
||||
<dd className="font-mono text-neutral-700 dark:text-neutral-300">{status.path}</dd>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function AuthBody({ auth, error }: { auth: ClaudeCodeAuthStatus | null; error: string | null }) {
|
||||
if (error) {
|
||||
return <p className="text-xs text-rose-600 dark:text-rose-400">Failed to check: {error}</p>;
|
||||
}
|
||||
if (!auth) {
|
||||
return <p className="text-xs text-neutral-500 dark:text-neutral-400">Checking…</p>;
|
||||
}
|
||||
if (auth.source === 'subscription') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
<dt className="text-neutral-500">Signed in</dt>
|
||||
<dd className="text-emerald-600 dark:text-emerald-400">
|
||||
{auth.account_email ?? 'Claude subscription'}
|
||||
</dd>
|
||||
{auth.expires_at && (
|
||||
<>
|
||||
<dt className="text-neutral-500">Token expires</dt>
|
||||
<dd className="font-mono text-neutral-700 dark:text-neutral-300">
|
||||
{auth.expires_at}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
To sign out, run <code>claude logout</code> in your terminal, then click Recheck.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (auth.source === 'api_key_env') {
|
||||
return (
|
||||
<p className="text-xs text-emerald-600 dark:text-emerald-400">
|
||||
<code>ANTHROPIC_API_KEY</code> detected in environment.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return <SignedOut />;
|
||||
}
|
||||
|
||||
function SignedOut() {
|
||||
const [launchError, setLaunchError] = useState<string | null>(null);
|
||||
const [launching, setLaunching] = useState(false);
|
||||
|
||||
const launchLogin = async () => {
|
||||
setLaunching(true);
|
||||
setLaunchError(null);
|
||||
try {
|
||||
await openhumanClaudeCodeLoginLaunch();
|
||||
} catch (err) {
|
||||
setLaunchError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLaunching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Not signed in.</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void launchLogin();
|
||||
}}
|
||||
disabled={launching}
|
||||
className="rounded-md bg-neutral-900 px-2.5 py-1 text-xs font-medium text-white hover:bg-neutral-700 disabled:opacity-50 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-300">
|
||||
{launching ? 'Opening terminal…' : 'Sign in with Claude'}
|
||||
</button>
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Opens a terminal running <code>claude login</code>.
|
||||
</span>
|
||||
</div>
|
||||
{launchError && <p className="text-xs text-rose-600 dark:text-rose-400">{launchError}</p>}
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
After completing login, click <strong>Recheck</strong> above. Alternatively set{' '}
|
||||
<code>ANTHROPIC_API_KEY</code> to use an API key.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ClaudeCodeStatusCard } from '../ClaudeCodeStatusCard';
|
||||
|
||||
const probe = vi.fn();
|
||||
const authProbe = vi.fn();
|
||||
const loginLaunch = vi.fn();
|
||||
|
||||
vi.mock('../../../../../utils/tauriCommands/config', () => ({
|
||||
openhumanClaudeCodeStatus: () => probe(),
|
||||
openhumanClaudeCodeAuthStatus: () => authProbe(),
|
||||
openhumanClaudeCodeLoginLaunch: () => loginLaunch(),
|
||||
}));
|
||||
|
||||
describe('ClaudeCodeStatusCard', () => {
|
||||
beforeEach(() => {
|
||||
probe.mockReset();
|
||||
authProbe.mockReset();
|
||||
loginLaunch.mockReset();
|
||||
loginLaunch.mockResolvedValue('cmd');
|
||||
// Default auth response — individual tests override as needed.
|
||||
authProbe.mockResolvedValue({ result: { source: 'none', last_checked: 0 } });
|
||||
});
|
||||
|
||||
it('renders the installed version + path when CC is OK', async () => {
|
||||
probe.mockResolvedValueOnce({
|
||||
result: { status: 'ok', version: '2.0.4', path: '/usr/local/bin/claude' },
|
||||
});
|
||||
render(<ClaudeCodeStatusCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Installed \(2\.0\.4\)/)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('/usr/local/bin/claude')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the install hint when the binary is missing', async () => {
|
||||
probe.mockResolvedValueOnce({ result: { status: 'not_installed' } });
|
||||
render(<ClaudeCodeStatusCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Claude Code CLI is not installed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the outdated state with min_required', async () => {
|
||||
probe.mockResolvedValueOnce({
|
||||
result: {
|
||||
status: 'outdated',
|
||||
version: '1.9.0',
|
||||
min_required: '2.0.0',
|
||||
path: '/usr/local/bin/claude',
|
||||
},
|
||||
});
|
||||
render(<ClaudeCodeStatusCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Outdated — found 1\.9\.0, need ≥ 2\.0\.0/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces a probe error', async () => {
|
||||
probe.mockRejectedValueOnce(new Error('boom'));
|
||||
render(<ClaudeCodeStatusCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to probe: boom/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('re-probes when Refresh is clicked', async () => {
|
||||
probe
|
||||
.mockResolvedValueOnce({ result: { status: 'not_installed' } })
|
||||
.mockResolvedValueOnce({ result: { status: 'ok', version: '2.0.4', path: '/x/y/claude' } });
|
||||
const user = userEvent.setup();
|
||||
render(<ClaudeCodeStatusCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Claude Code CLI is not installed/i)).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /Probe/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Installed \(2\.0\.4\)/)).toBeInTheDocument();
|
||||
});
|
||||
expect(probe).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shows subscription auth with account email', async () => {
|
||||
probe.mockResolvedValueOnce({
|
||||
result: { status: 'ok', version: '2.0.4', path: '/usr/local/bin/claude' },
|
||||
});
|
||||
authProbe.mockReset();
|
||||
authProbe.mockResolvedValueOnce({
|
||||
result: {
|
||||
source: 'subscription',
|
||||
account_email: 'jamie@example.com',
|
||||
expires_at: '2026-06-01T00:00:00Z',
|
||||
last_checked: 1700000000,
|
||||
},
|
||||
});
|
||||
render(<ClaudeCodeStatusCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/jamie@example\.com/)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/claude logout/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows API key env auth state', async () => {
|
||||
probe.mockResolvedValueOnce({ result: { status: 'not_installed' } });
|
||||
authProbe.mockReset();
|
||||
authProbe.mockResolvedValueOnce({ result: { source: 'api_key_env', last_checked: 0 } });
|
||||
render(<ClaudeCodeStatusCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/detected in environment/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows not-signed-in with claude login hint', async () => {
|
||||
probe.mockResolvedValueOnce({ result: { status: 'not_installed' } });
|
||||
render(<ClaudeCodeStatusCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Not signed in\./)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/claude login/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Sign in with Claude/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Sign in with Claude button launches login terminal', async () => {
|
||||
probe.mockResolvedValueOnce({ result: { status: 'ok', version: '2.0.4', path: '/x/y' } });
|
||||
const user = userEvent.setup();
|
||||
render(<ClaudeCodeStatusCard />);
|
||||
const btn = await screen.findByRole('button', { name: /Sign in with Claude/i });
|
||||
await user.click(btn);
|
||||
expect(loginLaunch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Recheck triggers a second auth probe without re-running version probe', async () => {
|
||||
probe.mockResolvedValueOnce({
|
||||
result: { status: 'ok', version: '2.0.4', path: '/x/y/claude' },
|
||||
});
|
||||
authProbe.mockReset();
|
||||
authProbe
|
||||
.mockResolvedValueOnce({ result: { source: 'none', last_checked: 0 } })
|
||||
.mockResolvedValueOnce({
|
||||
result: {
|
||||
source: 'subscription',
|
||||
account_email: 'user@example.com',
|
||||
expires_at: null,
|
||||
last_checked: 1,
|
||||
},
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<ClaudeCodeStatusCard />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Not signed in\./)).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /Recheck/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/user@example\.com/)).toBeInTheDocument();
|
||||
});
|
||||
expect(probe).toHaveBeenCalledTimes(1);
|
||||
expect(authProbe).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -75,7 +75,8 @@ export type ProviderRef =
|
||||
| { kind: 'openhuman' }
|
||||
| { kind: 'default' }
|
||||
| { kind: 'cloud'; providerSlug: string; model: string; temperature?: number | null }
|
||||
| { kind: 'local'; model: string; temperature?: number | null };
|
||||
| { kind: 'local'; model: string; temperature?: number | null }
|
||||
| { kind: 'claude-code'; model: string; temperature?: number | null };
|
||||
|
||||
/** Parse a `<model>[@<temp>]` suffix into `(model, temperature)`. */
|
||||
function splitModelAndTemp(raw: string): { model: string; temperature: number | null } {
|
||||
@@ -156,6 +157,12 @@ export function parseProviderString(s: string | null | undefined): ProviderRef {
|
||||
const { model, temperature } = splitModelAndTemp(trimmed.slice('ollama:'.length));
|
||||
return temperature == null ? { kind: 'local', model } : { kind: 'local', model, temperature };
|
||||
}
|
||||
if (trimmed.startsWith('claude-code:')) {
|
||||
const { model, temperature } = splitModelAndTemp(trimmed.slice('claude-code:'.length));
|
||||
return temperature == null
|
||||
? { kind: 'claude-code', model }
|
||||
: { kind: 'claude-code', model, temperature };
|
||||
}
|
||||
const colonIdx = trimmed.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const slug = trimmed.slice(0, colonIdx).trim();
|
||||
@@ -182,6 +189,8 @@ export function serializeProviderRef(ref: ProviderRef): string {
|
||||
return `${ref.providerSlug}:${joinModelAndTemp(ref.model, ref.temperature)}`;
|
||||
case 'local':
|
||||
return `ollama:${joinModelAndTemp(ref.model, ref.temperature)}`;
|
||||
case 'claude-code':
|
||||
return `claude-code:${joinModelAndTemp(ref.model, ref.temperature)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Config and settings commands.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import debug from 'debug';
|
||||
|
||||
import { callCoreRpc } from '../../services/coreRpcClient';
|
||||
@@ -237,6 +238,79 @@ export async function openhumanGetClientConfig(): Promise<CommandResponse<Client
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Status payload for the Claude Code CLI provider — mirrors Rust
|
||||
* `claude_code::types::CliStatus`. The `status` discriminator is the
|
||||
* snake_case Serde rename; `path` and `version` may be absent depending
|
||||
* on which variant fired.
|
||||
*/
|
||||
export type ClaudeCodeStatus =
|
||||
| { status: 'ok'; version: string; path: string }
|
||||
| { status: 'not_installed' }
|
||||
| { status: 'outdated'; version: string; min_required: string; path: string }
|
||||
| { status: 'unusable'; path: string; reason: string };
|
||||
|
||||
/**
|
||||
* Probe the local `claude` CLI binary (Claude Code CLI provider). Returns
|
||||
* install + version status; never throws on a missing binary — the
|
||||
* `not_installed` variant signals that case explicitly.
|
||||
*/
|
||||
export async function openhumanClaudeCodeStatus(): Promise<CommandResponse<ClaudeCodeStatus>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<ClaudeCodeStatus>>({
|
||||
method: 'openhuman.inference_claude_code_status',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth state for the Claude Code CLI provider — mirrors Rust
|
||||
* `claude_code::auth_status::AuthSource`. The `source` discriminator is
|
||||
* the snake_case Serde rename. `account_email` / `expires_at` are
|
||||
* best-effort: absent when the CLI's credentials schema drifts.
|
||||
*/
|
||||
export type ClaudeCodeAuthStatus =
|
||||
| {
|
||||
source: 'subscription';
|
||||
account_email: string | null;
|
||||
expires_at: string | null;
|
||||
last_checked: number;
|
||||
}
|
||||
| { source: 'api_key_env'; last_checked: number }
|
||||
| { source: 'none'; last_checked: number };
|
||||
|
||||
/**
|
||||
* Detect Claude Code CLI auth state (Pro/Max subscription via
|
||||
* `~/.claude/.credentials.json`, `ANTHROPIC_API_KEY` env, or none).
|
||||
* Pure FS — no CLI spawn, safe to call on a tight refresh loop.
|
||||
*/
|
||||
export async function openhumanClaudeCodeAuthStatus(): Promise<
|
||||
CommandResponse<ClaudeCodeAuthStatus>
|
||||
> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<ClaudeCodeAuthStatus>>({
|
||||
method: 'openhuman.inference_claude_code_auth_status',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the user's native terminal and run `claude login` inside it. The
|
||||
* CLI's OAuth flow is interactive, so we can't host it in-app — we
|
||||
* detach into a terminal window and let the user complete the flow
|
||||
* there, then click Recheck back in the settings card.
|
||||
*
|
||||
* Returns the name of the terminal emulator that was launched.
|
||||
*/
|
||||
export async function openhumanClaudeCodeLoginLaunch(): Promise<string> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await invoke<string>('claude_code_login_launch');
|
||||
}
|
||||
|
||||
export async function openhumanUpdateModelSettings(
|
||||
update: ModelSettingsUpdate
|
||||
): Promise<CommandResponse<ConfigSnapshot>> {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Claude Code CLI provider
|
||||
|
||||
OpenHuman can route any chat workload through **Anthropic's `claude` CLI** instead of calling the Anthropic HTTP API directly. The CLI handles model selection, auth, and prompt-cache management; OpenHuman drives it as a child process per turn, parses its stream-json output, and re-exposes its own read-only tools back into the CLI over MCP so the model can reach native OpenHuman state (memory, threads, channels, people).
|
||||
|
||||
> Locked decisions live in [`.planning/claude-code-provider/PLAN.md`](../../../.planning/claude-code-provider/PLAN.md) §13.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Claude Code CLI **≥ 2.0.0** on `PATH` (or `OPENHUMAN_CLAUDE_CLI=/abs/path/to/claude`).
|
||||
- An Anthropic API key in `ANTHROPIC_API_KEY`, **or** a pre-existing `~/.claude/.credentials.json` from `claude login`.
|
||||
- The `openhuman-core` binary on disk — OpenHuman spawns `openhuman-core mcp` as a stdio MCP server so the CLI can call OpenHuman tools. The path is discovered via `std::env::current_exe()`.
|
||||
|
||||
## Routing a workload through the CLI
|
||||
|
||||
The factory grammar accepts a new prefix: `claude-code:<model>[@<temperature>]`. Apply it via the standard inference settings (per-role, locked decision #3):
|
||||
|
||||
```bash
|
||||
# Through the JSON-RPC update endpoint:
|
||||
openhuman-core rpc openhuman.inference_update_model_settings \
|
||||
--json '{"chat_provider":"claude-code:claude-sonnet-4-5"}'
|
||||
```
|
||||
|
||||
| Role string | Field updated |
|
||||
| --- | --- |
|
||||
| `chat_provider` | foreground chat replies |
|
||||
| `reasoning_provider` | long-context reasoning workloads |
|
||||
| `agentic_provider` | multi-step agentic loops |
|
||||
|
||||
A workload set to `claude-code:<model>` always spawns a fresh `claude` child per turn; concurrency is capped at `MAX_CONCURRENT_TURNS = 4` per `ClaudeCodeProvider` instance.
|
||||
|
||||
## Verifying the install
|
||||
|
||||
The status RPC is on the existing inference namespace:
|
||||
|
||||
```bash
|
||||
openhuman-core rpc openhuman.inference_claude_code_status
|
||||
```
|
||||
|
||||
Returns one of (`CliStatus` in [`src/openhuman/inference/provider/claude_code/types.rs`](../../../src/openhuman/inference/provider/claude_code/types.rs)):
|
||||
|
||||
- `{"status":"ok","version":"2.0.4","path":"/usr/local/bin/claude"}` — ready
|
||||
- `{"status":"not_installed"}` — `claude` not on `PATH`
|
||||
- `{"status":"outdated","version":"1.9.0","min_required":"2.0.0","path":"…"}` — bump CLI
|
||||
- `{"status":"unusable","path":"…","reason":"…"}` — binary present but the version probe failed
|
||||
|
||||
The same status is rendered in the settings panel via `ClaudeCodeStatusCard` ([`app/src/components/settings/panels/ai/ClaudeCodeStatusCard.tsx`](../../../app/src/components/settings/panels/ai/ClaudeCodeStatusCard.tsx)).
|
||||
|
||||
## Per-turn behavior
|
||||
|
||||
Each chat turn:
|
||||
|
||||
1. Resolve a per-thread CC session UUID from `<workspace>/claude-code-sessions.json`. New threads get a fresh RFC-4122 v4 UUID; the CLI requires v4 specifically for `--resume`.
|
||||
2. Write `mcp-config.json` to a tempdir pointing at `openhuman-core mcp` (stdio MCP server, no extra credentials).
|
||||
3. Spawn the CLI with:
|
||||
- `-p --input-format stream-json --output-format stream-json --verbose --include-partial-messages`
|
||||
- `--mcp-config <tmp> --strict-mcp-config` so only the configured MCP servers are visible
|
||||
- `--disallowedTools Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,TodoWrite,Task,BashOutput,KillShell` — CC's own builtins stay off so OpenHuman tools (`mcp__openhuman__*`) are authoritative
|
||||
- `--session-id <uuid>` on first turn, `--resume <uuid>` thereafter
|
||||
- `--model <model>` (the suffix after `claude-code:`)
|
||||
- `--append-system-prompt <…>` if the conversation carries a system message
|
||||
4. Pipe stdin: full conversation history on a new session, just the last user turn on `--resume` (the CLI already holds its own prior-turn context server-side).
|
||||
5. Stream stdout through the JSONL parser → event mapper → `ProviderDelta`s on the request's `stream` sink.
|
||||
|
||||
On exit non-zero the driver bubbles stderr (capped at 16 KiB) up as the error message.
|
||||
|
||||
## Auth resolution order
|
||||
|
||||
1. `ANTHROPIC_API_KEY` env var (highest precedence — set on the spawned child).
|
||||
2. Per-thread / per-agent key from `ChatRequest` config (future, not yet wired).
|
||||
3. `~/.claude/.credentials.json` — the CLI's own OAuth tokens from `claude login` (Pro / Max subscription). We never read or round-trip the access token; auth detection probes this file for non-secret metadata only.
|
||||
4. None — the CLI will fail with an auth error.
|
||||
|
||||
The `openhuman.inference_claude_code_auth_status` RPC probes sources 1 and 3 without spawning the CLI and surfaces the result in the Settings → AI panel.
|
||||
|
||||
## Tool surface exposed to the CLI
|
||||
|
||||
The CLI sees these tools as `mcp__openhuman__<name>` (delivered by the existing stdio MCP server in [`src/openhuman/mcp_server/`](../../../src/openhuman/mcp_server/)):
|
||||
|
||||
- `core.list_tools`, `core.tool_instructions`
|
||||
- `memory.search`, `memory.recall`
|
||||
- `tree.read_chunk`, `tree.browse`, `tree.top_entities`, `tree.list_sources`
|
||||
- `agent.list_subagents`, `agent.run_subagent` (write — flagged `destructiveHint` per MCP spec)
|
||||
- `searxng_search`
|
||||
|
||||
The MCP server enforces `SecurityPolicy::ToolOperation` checks; all tools except `agent.run_subagent` are read-only.
|
||||
|
||||
## Limitations (v1)
|
||||
|
||||
- Vision input is not forwarded — set the `vision_provider` to a different provider when you need images.
|
||||
- `agentic` runs share the same `Semaphore(4)`; under load a CC turn waits in queue rather than failing fast.
|
||||
- Cost accounting from the CLI's `result.total_cost_usd` is captured in the mapper but not yet wired into OpenHuman's billing layer ([`src/openhuman/cost/`](../../../src/openhuman/cost/)).
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Resolve an `ANTHROPIC_API_KEY` for the spawned `claude` CLI.
|
||||
//!
|
||||
//! v1 resolution order:
|
||||
//! 1. Process env `ANTHROPIC_API_KEY` (highest precedence).
|
||||
//! 2. `~/.claude/.credentials.json` — only used if the CLI is already
|
||||
//! logged in via `claude login`. We pass it through transparently by
|
||||
//! *not* setting `ANTHROPIC_API_KEY`; the CLI then reads its own
|
||||
//! credentials file.
|
||||
//!
|
||||
//! v1.1 will wire OpenHuman `AuthService` (auth-profiles.json) so an
|
||||
//! Anthropic key stored in settings is picked up automatically.
|
||||
//! Subscription / OAuth auth (Claude Pro/Max) deferred to v2.
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthSource {
|
||||
/// Explicit API key — pass via `ANTHROPIC_API_KEY` env var.
|
||||
EnvApiKey,
|
||||
/// No explicit key resolved. Defer to whatever the CLI finds in
|
||||
/// `~/.claude/.credentials.json`.
|
||||
CliCredentials,
|
||||
}
|
||||
|
||||
/// Probe sources in priority order. Returns the resolved API key plus the
|
||||
/// origin label (for logging) when found. The returned key is only the
|
||||
/// key value — call-sites set env on spawn, never log it.
|
||||
pub fn resolve() -> (AuthSource, Option<String>) {
|
||||
if let Ok(k) = std::env::var("ANTHROPIC_API_KEY") {
|
||||
let k = k.trim();
|
||||
if !k.is_empty() {
|
||||
return (AuthSource::EnvApiKey, Some(k.to_string()));
|
||||
}
|
||||
}
|
||||
(AuthSource::CliCredentials, None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_to_cli_credentials_without_env() {
|
||||
let prev = std::env::var("ANTHROPIC_API_KEY").ok();
|
||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||
|
||||
let (src, key) = resolve();
|
||||
assert_eq!(src, AuthSource::CliCredentials);
|
||||
assert!(key.is_none());
|
||||
|
||||
if let Some(v) = prev {
|
||||
std::env::set_var("ANTHROPIC_API_KEY", v);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Detect Claude Code CLI auth state without spawning the binary.
|
||||
//!
|
||||
//! Surfaces three sources, in priority order:
|
||||
//! 1. `ANTHROPIC_API_KEY` env var present → `ApiKeyEnv`.
|
||||
//! 2. `~/.claude/.credentials.json` parseable → `Subscription` (Claude
|
||||
//! Pro / Max OAuth tokens land here after `claude login`).
|
||||
//! 3. Neither → `None`.
|
||||
//!
|
||||
//! The credentials file is the CLI's source of truth; we never write to it
|
||||
//! and never round-trip the access token through RPC. We extract only
|
||||
//! non-secret metadata (account email, expiry) when the schema exposes it,
|
||||
//! and fall back to `Subscription { account_email: None, expires_at: None }`
|
||||
//! when Anthropic changes the shape on us.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Discriminator for who actually authenticates the spawned CLI.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "source")]
|
||||
pub enum AuthSource {
|
||||
/// Claude Pro / Max subscription — OAuth tokens in
|
||||
/// `~/.claude/.credentials.json`. Account email + expiry returned
|
||||
/// best-effort; absent when the schema drifts.
|
||||
Subscription {
|
||||
account_email: Option<String>,
|
||||
/// RFC3339-ish timestamp string copied verbatim from credentials
|
||||
/// when present. We do not parse + compare; UI surfaces it as
|
||||
/// "last seen" rather than a confident countdown.
|
||||
expires_at: Option<String>,
|
||||
},
|
||||
/// `ANTHROPIC_API_KEY` is set in the core process env. The spawned
|
||||
/// CLI inherits it.
|
||||
ApiKeyEnv,
|
||||
/// Nothing detected. The CLI will fail any chat with an auth error.
|
||||
None,
|
||||
}
|
||||
|
||||
/// Returned by the `claude_code_auth_status` RPC. Snake-case Serde so the
|
||||
/// TS side discriminates on `source`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthStatus {
|
||||
#[serde(flatten)]
|
||||
pub source: AuthSource,
|
||||
/// Unix seconds when this probe ran — UI shows "last checked" so users
|
||||
/// can tell a stale subscription badge from a fresh one.
|
||||
pub last_checked: u64,
|
||||
}
|
||||
|
||||
/// Resolve the on-disk path to `~/.claude/.credentials.json`. Overridable
|
||||
/// via `OPENHUMAN_CLAUDE_CREDENTIALS` for tests.
|
||||
pub fn credentials_path() -> Option<PathBuf> {
|
||||
if let Ok(explicit) = std::env::var("OPENHUMAN_CLAUDE_CREDENTIALS") {
|
||||
return Some(PathBuf::from(explicit));
|
||||
}
|
||||
dirs_next_home().map(|h| h.join(".claude").join(".credentials.json"))
|
||||
}
|
||||
|
||||
fn dirs_next_home() -> Option<PathBuf> {
|
||||
// Mirror the stdlib's home detection without pulling another dep.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(p) = std::env::var("USERPROFILE") {
|
||||
return Some(PathBuf::from(p));
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if let Ok(p) = std::env::var("HOME") {
|
||||
return Some(PathBuf::from(p));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Tolerant credentials parser. Inspects a few known shape variants
|
||||
/// without committing to any of them; on any failure we still return a
|
||||
/// `Subscription { None, None }` because the file existing at all is
|
||||
/// strong evidence the user has logged in.
|
||||
fn parse_credentials(raw: &str) -> AuthSource {
|
||||
let val: serde_json::Value = match serde_json::from_str(raw) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return AuthSource::Subscription {
|
||||
account_email: None,
|
||||
expires_at: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Schema observed in the wild:
|
||||
// { "claudeAiOauth": { "accessToken": "...", "expiresAt": "...",
|
||||
// "subscriptionType": "max", "email": "..." } }
|
||||
// We probe a few plausible spellings to be drift-tolerant.
|
||||
let oauth_obj = val
|
||||
.get("claudeAiOauth")
|
||||
.or_else(|| val.get("oauth"))
|
||||
.or_else(|| val.get("claude_ai_oauth"));
|
||||
|
||||
let lookup_str = |obj: &serde_json::Value, key: &str| -> Option<String> {
|
||||
obj.get(key).and_then(|v| v.as_str()).map(str::to_string)
|
||||
};
|
||||
|
||||
if let Some(obj) = oauth_obj {
|
||||
let email = lookup_str(obj, "email")
|
||||
.or_else(|| lookup_str(obj, "account_email"))
|
||||
.or_else(|| lookup_str(obj, "accountEmail"));
|
||||
let expires = lookup_str(obj, "expiresAt").or_else(|| lookup_str(obj, "expires_at"));
|
||||
return AuthSource::Subscription {
|
||||
account_email: email,
|
||||
expires_at: expires,
|
||||
};
|
||||
}
|
||||
|
||||
// Top-level email/expiresAt fallback.
|
||||
let email = lookup_str(&val, "email");
|
||||
let expires = lookup_str(&val, "expiresAt").or_else(|| lookup_str(&val, "expires_at"));
|
||||
AuthSource::Subscription {
|
||||
account_email: email,
|
||||
expires_at: expires,
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe auth state. Pure FS work — no CLI spawn, no network.
|
||||
pub fn probe() -> AuthStatus {
|
||||
let last_checked = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
if let Ok(k) = std::env::var("ANTHROPIC_API_KEY") {
|
||||
if !k.trim().is_empty() {
|
||||
return AuthStatus {
|
||||
source: AuthSource::ApiKeyEnv,
|
||||
last_checked,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let source = match credentials_path() {
|
||||
Some(p) if p.is_file() => match std::fs::read_to_string(&p) {
|
||||
Ok(raw) => parse_credentials(&raw),
|
||||
// File exists but unreadable — still signal "signed in" rather
|
||||
// than "none" so the user gets accurate UX. The CLI itself
|
||||
// will surface a permission error on next turn.
|
||||
Err(_) => AuthSource::Subscription {
|
||||
account_email: None,
|
||||
expires_at: None,
|
||||
},
|
||||
},
|
||||
_ => AuthSource::None,
|
||||
};
|
||||
|
||||
AuthStatus {
|
||||
source,
|
||||
last_checked,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_known_oauth_shape() {
|
||||
let raw = r#"{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "redacted",
|
||||
"refreshToken": "redacted",
|
||||
"expiresAt": "2026-06-01T00:00:00Z",
|
||||
"subscriptionType": "max",
|
||||
"email": "user@example.com"
|
||||
}
|
||||
}"#;
|
||||
match parse_credentials(raw) {
|
||||
AuthSource::Subscription {
|
||||
account_email,
|
||||
expires_at,
|
||||
} => {
|
||||
assert_eq!(account_email.as_deref(), Some("user@example.com"));
|
||||
assert_eq!(expires_at.as_deref(), Some("2026-06-01T00:00:00Z"));
|
||||
}
|
||||
other => panic!("expected Subscription, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drift_falls_back_to_subscription_without_details() {
|
||||
let raw = r#"{ "some_future_shape": { "token": "x" } }"#;
|
||||
match parse_credentials(raw) {
|
||||
AuthSource::Subscription {
|
||||
account_email,
|
||||
expires_at,
|
||||
} => {
|
||||
assert!(account_email.is_none());
|
||||
assert!(expires_at.is_none());
|
||||
}
|
||||
other => panic!("expected Subscription fallback, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_json_still_returns_subscription() {
|
||||
match parse_credentials("not json at all") {
|
||||
AuthSource::Subscription { .. } => {}
|
||||
other => panic!("expected Subscription, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_returns_none_when_no_env_and_no_file() {
|
||||
// Force the lookup to a path we control that doesn't exist.
|
||||
let tmp = std::env::temp_dir().join("openhuman-test-nonexistent-creds.json");
|
||||
if tmp.exists() {
|
||||
std::fs::remove_file(&tmp).ok();
|
||||
}
|
||||
// Save & clear env so the test is hermetic.
|
||||
let prev_key = std::env::var("ANTHROPIC_API_KEY").ok();
|
||||
let prev_creds = std::env::var("OPENHUMAN_CLAUDE_CREDENTIALS").ok();
|
||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||
std::env::set_var("OPENHUMAN_CLAUDE_CREDENTIALS", &tmp);
|
||||
|
||||
let s = probe();
|
||||
assert!(matches!(s.source, AuthSource::None));
|
||||
|
||||
// Restore env to avoid bleed.
|
||||
match prev_key {
|
||||
Some(v) => std::env::set_var("ANTHROPIC_API_KEY", v),
|
||||
None => std::env::remove_var("ANTHROPIC_API_KEY"),
|
||||
}
|
||||
match prev_creds {
|
||||
Some(v) => std::env::set_var("OPENHUMAN_CLAUDE_CREDENTIALS", v),
|
||||
None => std::env::remove_var("OPENHUMAN_CLAUDE_CREDENTIALS"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Spawn the `claude` CLI for one chat turn, stream its stdout into the
|
||||
//! event mapper, and return an aggregated `ChatResponse`.
|
||||
//!
|
||||
//! The driver does *not* own concurrency limits; the `ClaudeCodeProvider`
|
||||
//! holds a `Semaphore` and acquires a permit before calling this.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Hard timeout per turn (PLAN §8). If the CLI hangs (network stall,
|
||||
/// infinite loop, MCP deadlock) we kill the child and surface a timeout.
|
||||
const TURN_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
use super::event_mapper::EventMapper;
|
||||
use super::input_builder::build_stdin;
|
||||
use super::session_store::{generate_uuid_v4, is_uuid_v4, SessionStore};
|
||||
use super::stream_parser::StreamJsonParser;
|
||||
use crate::openhuman::inference::provider::traits::{ChatMessage, ChatResponse, ProviderDelta};
|
||||
|
||||
/// Builtin CC tools disabled in v1 so OpenHuman's MCP-exposed surface is
|
||||
/// authoritative. CC's `mcp__openhuman__*` tools remain enabled.
|
||||
const DISALLOWED_CC_BUILTINS: &[&str] = &[
|
||||
"Bash",
|
||||
"BashOutput",
|
||||
"KillShell",
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"TodoWrite",
|
||||
"Task",
|
||||
];
|
||||
|
||||
/// One CC chat turn.
|
||||
pub struct TurnContext<'a> {
|
||||
pub bin_path: PathBuf,
|
||||
pub workspace_dir: PathBuf,
|
||||
pub thread_id: String,
|
||||
pub model: String,
|
||||
pub append_system_prompt: Option<String>,
|
||||
pub messages: &'a [ChatMessage],
|
||||
pub session_store: Arc<SessionStore>,
|
||||
pub stream: Option<&'a mpsc::Sender<ProviderDelta>>,
|
||||
/// Optional explicit `ANTHROPIC_API_KEY` to set on the child. When
|
||||
/// `None`, the CLI falls back to its own `~/.claude/.credentials.json`.
|
||||
pub anthropic_api_key: Option<String>,
|
||||
/// Path to the OpenHuman core binary (`openhuman-core`). CC spawns it
|
||||
/// with `mcp` to get a stdio MCP server exposing OpenHuman tools.
|
||||
/// When `None`, MCP is not wired and CC runs with no extra tools.
|
||||
pub openhuman_core_bin: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Write a CC `--mcp-config` JSON file that spawns `openhuman-core mcp`
|
||||
/// as a stdio MCP server. Returns the on-disk path; caller cleans up.
|
||||
fn write_mcp_config(dir: &std::path::Path, core_bin: &std::path::Path) -> std::io::Result<PathBuf> {
|
||||
let path = dir.join("openhuman-mcp-config.json");
|
||||
let cfg = json!({
|
||||
"mcpServers": {
|
||||
"openhuman": {
|
||||
"type": "stdio",
|
||||
"command": core_bin.display().to_string(),
|
||||
"args": ["mcp"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
});
|
||||
std::fs::write(
|
||||
&path,
|
||||
serde_json::to_string_pretty(&cfg).unwrap_or_default(),
|
||||
)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Run one turn against the `claude` CLI. Awaits process exit. Forwards
|
||||
/// `ProviderDelta`s through `ctx.stream` as they arrive and returns the
|
||||
/// aggregated `ChatResponse` when done.
|
||||
pub async fn run_turn(ctx: TurnContext<'_>) -> anyhow::Result<ChatResponse> {
|
||||
let stored = ctx.session_store.get(&ctx.thread_id);
|
||||
let is_new = !stored.as_deref().map(is_uuid_v4).unwrap_or(false);
|
||||
let cc_session_id = if is_new {
|
||||
let id = generate_uuid_v4();
|
||||
if let Err(e) = ctx.session_store.set(&ctx.thread_id, &id) {
|
||||
log::warn!(
|
||||
"[claude-code][driver] failed to persist session uuid for thread {}: {}",
|
||||
ctx.thread_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
id
|
||||
} else {
|
||||
stored.expect("checked Some above")
|
||||
};
|
||||
|
||||
// Set up a per-turn scratch dir for --mcp-config and any other transient
|
||||
// state. Best-effort cleanup at end of turn.
|
||||
let scratch = tempfile::Builder::new()
|
||||
.prefix("openhuman-cc-")
|
||||
.tempdir()
|
||||
.map_err(|e| anyhow::anyhow!("create scratch dir: {e}"))?;
|
||||
let mut mcp_config_path: Option<PathBuf> = None;
|
||||
if let Some(core_bin) = ctx.openhuman_core_bin.as_ref() {
|
||||
match write_mcp_config(scratch.path(), core_bin) {
|
||||
Ok(p) => {
|
||||
log::debug!(
|
||||
"[claude-code][driver] wrote mcp-config path={} core_bin={}",
|
||||
p.display(),
|
||||
core_bin.display()
|
||||
);
|
||||
mcp_config_path = Some(p);
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"[claude-code][driver] failed to write mcp-config: {e}; CC will run without OpenHuman MCP tools"
|
||||
),
|
||||
}
|
||||
} else {
|
||||
log::debug!(
|
||||
"[claude-code][driver] no openhuman_core_bin provided; CC running without OpenHuman MCP tools"
|
||||
);
|
||||
}
|
||||
|
||||
let mut args: Vec<String> = vec![
|
||||
"-p".into(),
|
||||
"--input-format".into(),
|
||||
"stream-json".into(),
|
||||
"--output-format".into(),
|
||||
"stream-json".into(),
|
||||
"--verbose".into(),
|
||||
"--include-partial-messages".into(),
|
||||
"--add-dir".into(),
|
||||
ctx.workspace_dir.display().to_string(),
|
||||
if is_new {
|
||||
"--session-id".into()
|
||||
} else {
|
||||
"--resume".into()
|
||||
},
|
||||
cc_session_id.clone(),
|
||||
"--model".into(),
|
||||
ctx.model.clone(),
|
||||
];
|
||||
if let Some(sp) = ctx
|
||||
.append_system_prompt
|
||||
.as_ref()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
{
|
||||
args.push("--append-system-prompt".into());
|
||||
args.push(sp.clone());
|
||||
}
|
||||
if let Some(p) = mcp_config_path.as_ref() {
|
||||
args.push("--mcp-config".into());
|
||||
args.push(p.display().to_string());
|
||||
args.push("--strict-mcp-config".into());
|
||||
}
|
||||
// Disable CC's built-in tools so OpenHuman's MCP surface stays
|
||||
// authoritative. We disable per-builtin instead of using
|
||||
// `--dangerously-skip-permissions` to keep the permission-prompt
|
||||
// floor intact for any tools we forgot to list.
|
||||
args.push("--disallowedTools".into());
|
||||
args.push(DISALLOWED_CC_BUILTINS.join(","));
|
||||
|
||||
// Validate input *before* spawning so we don't launch a process we
|
||||
// can't feed (CodeRabbit: validate before spawn).
|
||||
let stdin_bytes = build_stdin(ctx.messages, is_new);
|
||||
if stdin_bytes.is_empty() {
|
||||
anyhow::bail!("[claude-code][driver] no input messages to deliver");
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"[claude-code][driver] spawn bin={} model={} is_new={} cc_session_id={}",
|
||||
ctx.bin_path.display(),
|
||||
ctx.model,
|
||||
is_new,
|
||||
cc_session_id
|
||||
);
|
||||
|
||||
let mut cmd = Command::new(&ctx.bin_path);
|
||||
cmd.args(&args)
|
||||
.current_dir(&ctx.workspace_dir)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
if let Some(key) = &ctx.anthropic_api_key {
|
||||
cmd.env("ANTHROPIC_API_KEY", key);
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("failed to spawn `claude`: {e}"))?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin
|
||||
.write_all(&stdin_bytes)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("write stdin: {e}"))?;
|
||||
stdin
|
||||
.shutdown()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("close stdin: {e}"))?;
|
||||
}
|
||||
|
||||
let mut stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("claude child stdout missing"))?;
|
||||
let mut stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("claude child stderr missing"))?;
|
||||
|
||||
let mut parser = StreamJsonParser::new();
|
||||
let mut mapper = EventMapper::new();
|
||||
let mut buf = [0u8; 8192];
|
||||
|
||||
// Drain stderr in parallel into a buffer for diagnostics.
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
let mut acc = String::new();
|
||||
let mut tmp = [0u8; 4096];
|
||||
while let Ok(n) = stderr.read(&mut tmp).await {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
acc.push_str(&String::from_utf8_lossy(&tmp[..n]));
|
||||
if acc.len() > 16_384 {
|
||||
acc.truncate(16_384);
|
||||
}
|
||||
}
|
||||
acc
|
||||
});
|
||||
|
||||
// Wrap the streaming + wait in a timeout so a stuck CLI doesn't
|
||||
// block this task forever (PLAN §8).
|
||||
let timed = tokio::time::timeout(TURN_TIMEOUT, async {
|
||||
loop {
|
||||
let n = stdout
|
||||
.read(&mut buf)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("read stdout: {e}"))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
for ev in parser.feed_bytes(&buf[..n]) {
|
||||
for delta in mapper.handle(ev) {
|
||||
if let Some(tx) = ctx.stream {
|
||||
let _ = tx.send(delta).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for ev in parser.end() {
|
||||
for delta in mapper.handle(ev) {
|
||||
if let Some(tx) = ctx.stream {
|
||||
let _ = tx.send(delta).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("wait child: {e}"))?;
|
||||
Ok::<_, anyhow::Error>(status)
|
||||
})
|
||||
.await;
|
||||
|
||||
let status = match timed {
|
||||
Ok(inner) => inner?,
|
||||
Err(_elapsed) => {
|
||||
log::error!(
|
||||
"[claude-code][driver] turn timeout ({TURN_TIMEOUT:?}) exceeded; killing child"
|
||||
);
|
||||
// kill_on_drop handles cleanup, but explicit kill gives us
|
||||
// a chance to collect stderr.
|
||||
let _ = child.kill().await;
|
||||
anyhow::bail!(
|
||||
"[claude-code][driver] turn timed out after {:?}",
|
||||
TURN_TIMEOUT
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let stderr_text = stderr_task.await.unwrap_or_default();
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!(
|
||||
"[claude-code][driver] exit {:?} stderr={}",
|
||||
status.code(),
|
||||
stderr_text.trim()
|
||||
);
|
||||
}
|
||||
if let Some(err) = mapper.error.clone() {
|
||||
anyhow::bail!("[claude-code][driver] {}", err);
|
||||
}
|
||||
|
||||
Ok(mapper.into_response())
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
//! Translate `ClaudeCodeEvent`s into OpenHuman `ProviderDelta`s plus a
|
||||
//! final aggregated `ChatResponse`.
|
||||
//!
|
||||
//! The CLI emits content as anthropic-style content blocks. We map:
|
||||
//! - `content_block_start` text → start a text accumulator
|
||||
//! - `content_block_delta` text → `ProviderDelta::TextDelta`
|
||||
//! - `content_block_start` tool → `ProviderDelta::ToolCallStart`
|
||||
//! - `content_block_delta` tool → `ProviderDelta::ToolCallArgsDelta`
|
||||
//! - `result` → finalize usage + cost
|
||||
//!
|
||||
//! Thinking blocks (`thinking_delta`) are forwarded as
|
||||
//! `ProviderDelta::ThinkingDelta`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::stream_parser::ClaudeCodeEvent;
|
||||
use crate::openhuman::inference::provider::traits::{
|
||||
ChatResponse, ProviderDelta, ToolCall, UsageInfo,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct BlockState {
|
||||
kind: BlockKind,
|
||||
call_id: Option<String>,
|
||||
tool_name: Option<String>,
|
||||
text_accum: String,
|
||||
input_accum: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum BlockKind {
|
||||
Text,
|
||||
Thinking,
|
||||
Tool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EventMapper {
|
||||
blocks: HashMap<u64, BlockState>,
|
||||
pub final_text: String,
|
||||
pub tool_calls: Vec<ToolCall>,
|
||||
pub usage: Option<UsageInfo>,
|
||||
pub error: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub finished: bool,
|
||||
}
|
||||
|
||||
impl EventMapper {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Process one event and return the deltas to forward to the stream
|
||||
/// sink (if any).
|
||||
pub fn handle(&mut self, event: ClaudeCodeEvent) -> Vec<ProviderDelta> {
|
||||
match event {
|
||||
ClaudeCodeEvent::System { session_id, .. } => {
|
||||
if let Some(id) = session_id {
|
||||
self.session_id = Some(id);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
ClaudeCodeEvent::Error { message } => {
|
||||
self.error = Some(message);
|
||||
Vec::new()
|
||||
}
|
||||
ClaudeCodeEvent::Result {
|
||||
subtype,
|
||||
usage,
|
||||
total_cost_usd,
|
||||
..
|
||||
} => {
|
||||
let mut parsed = usage.as_ref().map(parse_usage);
|
||||
// CC stream emits `total_cost_usd` on the terminal `result`
|
||||
// event — surface it as `UsageInfo.charged_amount_usd` so
|
||||
// downstream cost.rs can record it without re-pricing
|
||||
// tokens × model rates.
|
||||
if let Some(cost) = total_cost_usd {
|
||||
let usage = parsed.get_or_insert_with(UsageInfo::default);
|
||||
usage.charged_amount_usd = cost;
|
||||
}
|
||||
self.usage = parsed;
|
||||
if subtype.as_deref() == Some("error") && self.error.is_none() {
|
||||
self.error = Some("claude reported `result.subtype=error`".into());
|
||||
}
|
||||
self.finished = true;
|
||||
Vec::new()
|
||||
}
|
||||
ClaudeCodeEvent::Assistant { message } => {
|
||||
// CC 2.x emits a final assembled `assistant` event with
|
||||
// `message.type == "message"` after streaming completes via
|
||||
// `stream_event`. Skip to avoid double-emission.
|
||||
if message.get("type").and_then(Value::as_str) == Some("message") {
|
||||
return Vec::new();
|
||||
}
|
||||
self.handle_assistant_block(&message)
|
||||
}
|
||||
ClaudeCodeEvent::StreamEvent { event } => self.handle_assistant_block(&event),
|
||||
ClaudeCodeEvent::User { message } => {
|
||||
// tool_result blocks from the CLI's own tool runs aren't
|
||||
// surfaced to OpenHuman's harness (the harness owns tools
|
||||
// via MCP, not via CC internals). Track for completeness.
|
||||
let _ = message;
|
||||
Vec::new()
|
||||
}
|
||||
ClaudeCodeEvent::RateLimit { .. } | ClaudeCodeEvent::ParseError { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_assistant_block(&mut self, msg: &Value) -> Vec<ProviderDelta> {
|
||||
let ty = msg.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
let index = msg.get("index").and_then(Value::as_u64).unwrap_or(0);
|
||||
match ty {
|
||||
"content_block_start" => self.on_block_start(index, msg),
|
||||
"content_block_delta" => self.on_block_delta(index, msg),
|
||||
"content_block_stop" => self.on_block_stop(index),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_block_start(&mut self, index: u64, msg: &Value) -> Vec<ProviderDelta> {
|
||||
let block = match msg.get("content_block") {
|
||||
Some(b) => b,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
let kind = block.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
match kind {
|
||||
"text" => {
|
||||
self.blocks.insert(
|
||||
index,
|
||||
BlockState {
|
||||
kind: BlockKind::Text,
|
||||
call_id: None,
|
||||
tool_name: None,
|
||||
text_accum: String::new(),
|
||||
input_accum: String::new(),
|
||||
},
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
"thinking" => {
|
||||
self.blocks.insert(
|
||||
index,
|
||||
BlockState {
|
||||
kind: BlockKind::Thinking,
|
||||
call_id: None,
|
||||
tool_name: None,
|
||||
text_accum: String::new(),
|
||||
input_accum: String::new(),
|
||||
},
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
"tool_use" => {
|
||||
let call_id = block
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
let tool_name = block
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
if call_id.is_none() || tool_name.is_none() {
|
||||
log::warn!(
|
||||
"[claude-code][event-mapper] skipping tool_use block with missing id or name"
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
let call_id = call_id.unwrap();
|
||||
let tool_name = tool_name.unwrap();
|
||||
self.blocks.insert(
|
||||
index,
|
||||
BlockState {
|
||||
kind: BlockKind::Tool,
|
||||
call_id: Some(call_id.clone()),
|
||||
tool_name: Some(tool_name.clone()),
|
||||
text_accum: String::new(),
|
||||
input_accum: String::new(),
|
||||
},
|
||||
);
|
||||
vec![ProviderDelta::ToolCallStart { call_id, tool_name }]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_block_delta(&mut self, index: u64, msg: &Value) -> Vec<ProviderDelta> {
|
||||
let delta = match msg.get("delta") {
|
||||
Some(d) => d,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
let dtype = delta.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
let Some(state) = self.blocks.get_mut(&index) else {
|
||||
return Vec::new();
|
||||
};
|
||||
match (state.kind.clone(), dtype) {
|
||||
(BlockKind::Text, "text_delta") => {
|
||||
let text = delta
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
state.text_accum.push_str(&text);
|
||||
self.final_text.push_str(&text);
|
||||
vec![ProviderDelta::TextDelta { delta: text }]
|
||||
}
|
||||
(BlockKind::Thinking, "thinking_delta") => {
|
||||
let text = delta
|
||||
.get("thinking")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| delta.get("text").and_then(Value::as_str))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
state.text_accum.push_str(&text);
|
||||
vec![ProviderDelta::ThinkingDelta { delta: text }]
|
||||
}
|
||||
(BlockKind::Tool, "input_json_delta") => {
|
||||
let partial = delta
|
||||
.get("partial_json")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
state.input_accum.push_str(&partial);
|
||||
let call_id = state.call_id.clone().unwrap_or_default();
|
||||
vec![ProviderDelta::ToolCallArgsDelta {
|
||||
call_id,
|
||||
delta: partial,
|
||||
}]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_block_stop(&mut self, index: u64) -> Vec<ProviderDelta> {
|
||||
let Some(state) = self.blocks.remove(&index) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if state.kind == BlockKind::Tool {
|
||||
let call_id = state.call_id.unwrap_or_default();
|
||||
let name = state.tool_name.unwrap_or_default();
|
||||
let arguments = if state.input_accum.trim().is_empty() {
|
||||
"{}".to_string()
|
||||
} else {
|
||||
state.input_accum.clone()
|
||||
};
|
||||
self.tool_calls.push(ToolCall {
|
||||
id: call_id,
|
||||
name,
|
||||
arguments,
|
||||
});
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Build the final aggregated `ChatResponse` once the stream is done.
|
||||
pub fn into_response(self) -> ChatResponse {
|
||||
ChatResponse {
|
||||
text: if self.final_text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.final_text)
|
||||
},
|
||||
tool_calls: self.tool_calls,
|
||||
usage: self.usage,
|
||||
reasoning_content: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_usage(v: &Value) -> UsageInfo {
|
||||
let n = |k: &str| v.get(k).and_then(Value::as_u64).unwrap_or(0);
|
||||
UsageInfo {
|
||||
input_tokens: n("input_tokens"),
|
||||
output_tokens: n("output_tokens"),
|
||||
context_window: 0,
|
||||
cached_input_tokens: n("cache_read_input_tokens"),
|
||||
charged_amount_usd: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn text_block_start(idx: u64) -> Value {
|
||||
json!({"type":"content_block_start","index":idx,"content_block":{"type":"text"}})
|
||||
}
|
||||
fn text_delta(idx: u64, t: &str) -> Value {
|
||||
json!({"type":"content_block_delta","index":idx,"delta":{"type":"text_delta","text":t}})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_streams_through() {
|
||||
let mut m = EventMapper::new();
|
||||
m.handle(ClaudeCodeEvent::StreamEvent {
|
||||
event: text_block_start(0),
|
||||
});
|
||||
let d1 = m.handle(ClaudeCodeEvent::StreamEvent {
|
||||
event: text_delta(0, "hel"),
|
||||
});
|
||||
let d2 = m.handle(ClaudeCodeEvent::StreamEvent {
|
||||
event: text_delta(0, "lo"),
|
||||
});
|
||||
assert!(matches!(&d1[0], ProviderDelta::TextDelta { delta } if delta == "hel"));
|
||||
assert!(matches!(&d2[0], ProviderDelta::TextDelta { delta } if delta == "lo"));
|
||||
assert_eq!(m.final_text, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_assembles_input() {
|
||||
let mut m = EventMapper::new();
|
||||
let start = json!({"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_1","name":"memory_search"}});
|
||||
let d_args = json!({"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"q\":\"foo\"}"}});
|
||||
let stop = json!({"type":"content_block_stop","index":1});
|
||||
let starts = m.handle(ClaudeCodeEvent::StreamEvent { event: start });
|
||||
assert!(
|
||||
matches!(&starts[0], ProviderDelta::ToolCallStart { tool_name, .. } if tool_name == "memory_search")
|
||||
);
|
||||
let args = m.handle(ClaudeCodeEvent::StreamEvent { event: d_args });
|
||||
assert!(matches!(&args[0], ProviderDelta::ToolCallArgsDelta { .. }));
|
||||
m.handle(ClaudeCodeEvent::StreamEvent { event: stop });
|
||||
assert_eq!(m.tool_calls.len(), 1);
|
||||
assert_eq!(m.tool_calls[0].name, "memory_search");
|
||||
assert_eq!(m.tool_calls[0].arguments, r#"{"q":"foo"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_event_captures_usage() {
|
||||
let mut m = EventMapper::new();
|
||||
m.handle(ClaudeCodeEvent::Result {
|
||||
subtype: Some("success".into()),
|
||||
usage: Some(json!({
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"cache_read_input_tokens": 25
|
||||
})),
|
||||
total_cost_usd: Some(0.001),
|
||||
raw: Value::Null,
|
||||
});
|
||||
assert!(m.finished);
|
||||
let u = m.usage.as_ref().unwrap();
|
||||
assert_eq!(u.input_tokens, 100);
|
||||
assert_eq!(u.output_tokens, 50);
|
||||
assert_eq!(u.cached_input_tokens, 25);
|
||||
// cost wired through from total_cost_usd
|
||||
assert!((u.charged_amount_usd - 0.001).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_surfaced_even_without_usage_object() {
|
||||
let mut m = EventMapper::new();
|
||||
m.handle(ClaudeCodeEvent::Result {
|
||||
subtype: Some("success".into()),
|
||||
usage: None,
|
||||
total_cost_usd: Some(0.05),
|
||||
raw: Value::Null,
|
||||
});
|
||||
let u = m
|
||||
.usage
|
||||
.as_ref()
|
||||
.expect("usage synthesized for cost-only result");
|
||||
assert_eq!(u.input_tokens, 0);
|
||||
assert!((u.charged_amount_usd - 0.05).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_assistant_message_is_skipped() {
|
||||
let mut m = EventMapper::new();
|
||||
let deltas = m.handle(ClaudeCodeEvent::Assistant {
|
||||
message: json!({"type":"message","role":"assistant","content":[]}),
|
||||
});
|
||||
assert!(deltas.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Build the stream-json stdin payload fed to `claude --input-format stream-json`.
|
||||
//!
|
||||
//! The CLI consumes one JSON object per line on stdin. Each line looks
|
||||
//! like:
|
||||
//! { "type":"user", "message":{"role":"user","content":[{"type":"text","text":"..."}]} }
|
||||
//!
|
||||
//! v1 piping policy:
|
||||
//! - On a *new* CC session: send every history `ChatMessage` so claude
|
||||
//! has full context (system message is conveyed via
|
||||
//! `--append-system-prompt`, not stdin).
|
||||
//! - On a `--resume` of an existing CC session: claude already has prior
|
||||
//! turns server-side; we only send the last user turn.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::openhuman::inference::provider::traits::ChatMessage;
|
||||
|
||||
/// Build the bytes to write to claude's stdin. Returns an empty `Vec`
|
||||
/// when there is nothing to send (caller should abort).
|
||||
pub fn build_stdin(messages: &[ChatMessage], is_new_session: bool) -> Vec<u8> {
|
||||
let mut out = String::new();
|
||||
let to_emit: Vec<&ChatMessage> = if is_new_session {
|
||||
messages.iter().filter(|m| m.role != "system").collect()
|
||||
} else {
|
||||
// Resume: only the trailing user turn matters.
|
||||
messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.role == "user")
|
||||
.into_iter()
|
||||
.collect()
|
||||
};
|
||||
|
||||
for msg in to_emit {
|
||||
let role = match msg.role.as_str() {
|
||||
"user" => "user",
|
||||
"assistant" => "assistant",
|
||||
// CC stdin doesn't accept `system` or `tool` rows. The system
|
||||
// prompt is plumbed via `--append-system-prompt`; tool roles
|
||||
// belong to the harness, not the CLI's input format.
|
||||
_ => continue,
|
||||
};
|
||||
let line = json!({
|
||||
"type": "user",
|
||||
"message": {
|
||||
"role": role,
|
||||
"content": [{"type": "text", "text": msg.content}],
|
||||
},
|
||||
});
|
||||
push_json_line(&mut out, &line);
|
||||
}
|
||||
|
||||
out.into_bytes()
|
||||
}
|
||||
|
||||
fn push_json_line(buf: &mut String, v: &Value) {
|
||||
buf.push_str(&serde_json::to_string(v).unwrap_or_default());
|
||||
buf.push('\n');
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn msg(role: &str, content: &str) -> ChatMessage {
|
||||
match role {
|
||||
"system" => ChatMessage::system(content),
|
||||
"user" => ChatMessage::user(content),
|
||||
"assistant" => ChatMessage::assistant(content),
|
||||
_ => ChatMessage::tool(content),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_session_pipes_full_user_history() {
|
||||
let history = vec![
|
||||
msg("system", "you are helpful"),
|
||||
msg("user", "hi"),
|
||||
msg("assistant", "hello"),
|
||||
msg("user", "how are you?"),
|
||||
];
|
||||
let bytes = build_stdin(&history, true);
|
||||
let s = String::from_utf8(bytes).unwrap();
|
||||
let lines: Vec<_> = s.lines().collect();
|
||||
assert_eq!(lines.len(), 3); // system filtered out
|
||||
assert!(lines[0].contains("\"hi\""));
|
||||
assert!(lines[1].contains("\"hello\""));
|
||||
assert!(lines[2].contains("how are you"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_pipes_only_last_user_turn() {
|
||||
let history = vec![
|
||||
msg("user", "earlier turn"),
|
||||
msg("assistant", "earlier reply"),
|
||||
msg("user", "follow-up"),
|
||||
];
|
||||
let bytes = build_stdin(&history, false);
|
||||
let s = String::from_utf8(bytes).unwrap();
|
||||
let lines: Vec<_> = s.lines().collect();
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(lines[0].contains("\"follow-up\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_history_yields_empty_bytes() {
|
||||
let bytes = build_stdin(&[], true);
|
||||
assert!(bytes.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Claude Code CLI provider.
|
||||
//!
|
||||
//! Drives Anthropic's `claude` CLI (`-p --output-format stream-json
|
||||
//! --verbose --include-partial-messages --resume <uuid>`) instead of
|
||||
//! calling the HTTP API directly. v2 will expose OpenHuman's native
|
||||
//! Rust tools back into the CLI over MCP; this Phase 2 cut runs the
|
||||
//! driver end-to-end with native CC built-ins disabled at the caller
|
||||
//! (no `--allowedTools` set means CC's own tools simply don't fire
|
||||
//! during a non-interactive `-p` turn).
|
||||
|
||||
pub mod auth;
|
||||
pub mod auth_status;
|
||||
pub mod driver;
|
||||
pub mod event_mapper;
|
||||
pub mod input_builder;
|
||||
pub mod session_store;
|
||||
pub mod stream_parser;
|
||||
pub mod types;
|
||||
pub mod version_check;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use super::traits::{ChatMessage, ChatRequest, ChatResponse, Provider, ProviderCapabilities};
|
||||
|
||||
/// Provider string prefix used in the factory grammar: `claude-code:<model>`.
|
||||
pub const PROVIDER_PREFIX: &str = "claude-code:";
|
||||
|
||||
/// Max concurrent `claude` child processes per provider instance.
|
||||
/// Picked to match the v1 design doc (PLAN §11).
|
||||
pub const MAX_CONCURRENT_TURNS: usize = 4;
|
||||
|
||||
/// CC-CLI-backed `Provider`. Owns a `Semaphore` that caps concurrent
|
||||
/// child processes and an `Arc<SessionStore>` for per-thread UUIDs.
|
||||
pub struct ClaudeCodeProvider {
|
||||
pub model: String,
|
||||
bin_path: PathBuf,
|
||||
workspace_dir: PathBuf,
|
||||
anthropic_api_key: Option<String>,
|
||||
semaphore: Arc<Semaphore>,
|
||||
session_store: Arc<session_store::SessionStore>,
|
||||
}
|
||||
|
||||
impl ClaudeCodeProvider {
|
||||
/// Construct with the CLI path resolved up-front (via `version_check`).
|
||||
pub fn new(
|
||||
model: impl Into<String>,
|
||||
bin_path: PathBuf,
|
||||
workspace_dir: PathBuf,
|
||||
anthropic_api_key: Option<String>,
|
||||
) -> Self {
|
||||
let session_store = Arc::new(session_store::SessionStore::open(&workspace_dir));
|
||||
Self {
|
||||
model: model.into(),
|
||||
bin_path,
|
||||
workspace_dir,
|
||||
anthropic_api_key,
|
||||
semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_TURNS)),
|
||||
session_store,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the provider from environment + workspace. Errors when the
|
||||
/// CLI is not installed or below `MIN_CLI_VERSION`.
|
||||
pub fn from_env(model: impl Into<String>, workspace_dir: PathBuf) -> anyhow::Result<Self> {
|
||||
match version_check::probe() {
|
||||
types::CliStatus::Ok { path, .. } => {
|
||||
let (_, key) = auth::resolve();
|
||||
Ok(Self::new(model, PathBuf::from(path), workspace_dir, key))
|
||||
}
|
||||
types::CliStatus::NotInstalled => {
|
||||
anyhow::bail!(
|
||||
"[claude-code] `claude` CLI not installed. Install Claude Code CLI \
|
||||
({}) >= {} and retry.",
|
||||
"https://docs.anthropic.com/en/docs/claude-code",
|
||||
types::MIN_CLI_VERSION
|
||||
)
|
||||
}
|
||||
types::CliStatus::Outdated {
|
||||
version,
|
||||
min_required,
|
||||
path,
|
||||
} => anyhow::bail!(
|
||||
"[claude-code] `claude` CLI at {} is version {}; require >= {}",
|
||||
path,
|
||||
version,
|
||||
min_required
|
||||
),
|
||||
types::CliStatus::Unusable { path, reason } => anyhow::bail!(
|
||||
"[claude-code] `claude` CLI at {} unusable: {}",
|
||||
path,
|
||||
reason
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
model_override: Option<&str>,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
// Cap concurrent CC processes.
|
||||
let _permit = self
|
||||
.semaphore
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("claude-code semaphore closed: {e}"))?;
|
||||
|
||||
// Extract system prompt + thread_id from the request.
|
||||
let append_system_prompt = request
|
||||
.messages
|
||||
.iter()
|
||||
.find(|m| m.role == "system")
|
||||
.map(|m| m.content.clone());
|
||||
|
||||
// OpenHuman doesn't pass thread_id directly through ChatRequest yet
|
||||
// (Phase 4 will). For Phase 2 we key sessions on a stable hash of
|
||||
// the conversation so /resume kicks in across consecutive turns.
|
||||
let thread_id = thread_key_from_messages(request.messages);
|
||||
|
||||
let model = model_override.unwrap_or(&self.model).to_string();
|
||||
|
||||
let openhuman_core_bin = std::env::current_exe().ok();
|
||||
let turn = driver::TurnContext {
|
||||
bin_path: self.bin_path.clone(),
|
||||
workspace_dir: self.workspace_dir.clone(),
|
||||
thread_id,
|
||||
model,
|
||||
append_system_prompt,
|
||||
messages: request.messages,
|
||||
session_store: self.session_store.clone(),
|
||||
stream: request.stream,
|
||||
anthropic_api_key: self.anthropic_api_key.clone(),
|
||||
openhuman_core_bin,
|
||||
};
|
||||
driver::run_turn(turn).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable session key derived from the conversation's first user message.
|
||||
/// Best-effort — Phase 4 will plumb the real OpenHuman thread id through
|
||||
/// `ChatRequest`.
|
||||
///
|
||||
/// Uses SHA-256 (truncated) so the key is stable across Rust compiler
|
||||
/// versions (unlike `DefaultHasher` which may change between rustc
|
||||
/// releases, breaking persisted session lookups).
|
||||
fn thread_key_from_messages(messages: &[ChatMessage]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let first = messages
|
||||
.iter()
|
||||
.find(|m| m.role == "user")
|
||||
.map(|m| m.content.as_str())
|
||||
.unwrap_or("");
|
||||
let digest = Sha256::digest(first.as_bytes());
|
||||
format!(
|
||||
"hash_{:032x}",
|
||||
u128::from_be_bytes(digest[..16].try_into().unwrap())
|
||||
)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for ClaudeCodeProvider {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
native_tool_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
let mut messages = Vec::new();
|
||||
if let Some(sp) = system_prompt {
|
||||
messages.push(ChatMessage::system(sp));
|
||||
}
|
||||
messages.push(ChatMessage::user(message));
|
||||
let request = ChatRequest {
|
||||
messages: &messages,
|
||||
tools: None,
|
||||
stream: None,
|
||||
};
|
||||
let resp = self.run_chat(request, Some(model)).await?;
|
||||
Ok(resp.text.unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
let request = ChatRequest {
|
||||
messages,
|
||||
tools: None,
|
||||
stream: None,
|
||||
};
|
||||
let resp = self.run_chat(request, Some(model)).await?;
|
||||
Ok(resp.text.unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
self.run_chat(request, Some(model)).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thread_key_is_stable_for_same_conversation() {
|
||||
let a = vec![ChatMessage::user("hello world")];
|
||||
let b = vec![
|
||||
ChatMessage::user("hello world"),
|
||||
ChatMessage::assistant("hi"),
|
||||
];
|
||||
assert_eq!(thread_key_from_messages(&a), thread_key_from_messages(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_key_diverges_for_different_first_user() {
|
||||
let a = vec![ChatMessage::user("alpha")];
|
||||
let b = vec![ChatMessage::user("beta")];
|
||||
assert_ne!(thread_key_from_messages(&a), thread_key_from_messages(&b));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Per-thread CC session UUID persistence.
|
||||
//!
|
||||
//! The `claude` CLI's `--resume <uuid>` only reuses a server-side session
|
||||
//! if we pass it the same UUIDv4 we used the first time. We map an
|
||||
//! OpenHuman thread id → CC session UUID in a JSON file under the
|
||||
//! workspace.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct StoreFile {
|
||||
/// thread_id → CC session uuid (v4)
|
||||
sessions: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Disk-backed session store. Cheap to clone — it's `Arc`-shareable via
|
||||
/// the holding `ClaudeCodeProvider`.
|
||||
#[derive(Debug)]
|
||||
pub struct SessionStore {
|
||||
path: PathBuf,
|
||||
inner: Mutex<StoreFile>,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
/// Open (or initialize) the session store at `workspace/claude-code-sessions.json`.
|
||||
pub fn open(workspace_dir: &Path) -> Self {
|
||||
let path = workspace_dir.join("claude-code-sessions.json");
|
||||
let inner = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<StoreFile>(&s).ok())
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
path,
|
||||
inner: Mutex::new(inner),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lookup an existing CC session UUID for `thread_id`.
|
||||
pub fn get(&self, thread_id: &str) -> Option<String> {
|
||||
let guard = self.inner.lock().expect("session store mutex poisoned");
|
||||
guard.sessions.get(thread_id).cloned()
|
||||
}
|
||||
|
||||
/// Persist a thread → UUID mapping.
|
||||
pub fn set(&self, thread_id: &str, uuid: &str) -> std::io::Result<()> {
|
||||
let mut guard = self.inner.lock().expect("session store mutex poisoned");
|
||||
guard
|
||||
.sessions
|
||||
.insert(thread_id.to_string(), uuid.to_string());
|
||||
let serialized = serde_json::to_string_pretty(&*guard).map_err(std::io::Error::other)?;
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&self.path, serialized)
|
||||
}
|
||||
}
|
||||
|
||||
/// Random RFC-4122 v4 UUID, formatted lower-case with hyphens.
|
||||
pub fn generate_uuid_v4() -> String {
|
||||
use rand::RngExt as _;
|
||||
let mut bytes = [0u8; 16];
|
||||
rand::rng().fill(&mut bytes);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10
|
||||
format!(
|
||||
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
|
||||
bytes[0], bytes[1], bytes[2], bytes[3],
|
||||
bytes[4], bytes[5],
|
||||
bytes[6], bytes[7],
|
||||
bytes[8], bytes[9],
|
||||
bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
|
||||
)
|
||||
}
|
||||
|
||||
/// CC accepts only RFC-4122 v4. Older stores might carry pre-v4 strings;
|
||||
/// we treat those as missing and regenerate.
|
||||
pub fn is_uuid_v4(s: &str) -> bool {
|
||||
let s = s.as_bytes();
|
||||
if s.len() != 36 {
|
||||
return false;
|
||||
}
|
||||
let hyphens = [8, 13, 18, 23];
|
||||
for (i, b) in s.iter().enumerate() {
|
||||
let is_hyphen = hyphens.contains(&i);
|
||||
if is_hyphen {
|
||||
if *b != b'-' {
|
||||
return false;
|
||||
}
|
||||
} else if !b.is_ascii_hexdigit() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// version nibble (index 14) must be '4'; variant nibble (index 19)
|
||||
// must be one of 8/9/a/b
|
||||
s[14] == b'4' && matches!(s[19], b'8' | b'9' | b'a' | b'b' | b'A' | b'B')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn uuid_v4_format() {
|
||||
let id = generate_uuid_v4();
|
||||
assert!(is_uuid_v4(&id), "generated id should be v4: {id}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_v4() {
|
||||
assert!(!is_uuid_v4("not-a-uuid"));
|
||||
assert!(!is_uuid_v4("cc_abc123"));
|
||||
// version 1 uuid (nibble at 14 is '1')
|
||||
assert!(!is_uuid_v4("00000000-0000-1000-8000-000000000000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_set_and_get() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = SessionStore::open(dir.path());
|
||||
assert!(store.get("thread_a").is_none());
|
||||
store.set("thread_a", "abc").unwrap();
|
||||
let reopened = SessionStore::open(dir.path());
|
||||
assert_eq!(reopened.get("thread_a").as_deref(), Some("abc"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Line-buffered JSONL parser for `claude --output-format stream-json`.
|
||||
//!
|
||||
//! The CLI writes one JSON object per line on stdout. Each object has a
|
||||
//! `type` discriminator (`system`, `user`, `assistant`, `stream_event`,
|
||||
//! `result`, `error`, `rate_limit_event`). We keep variants permissive
|
||||
//! (everything is `serde_json::Value`) so a minor CLI schema bump does
|
||||
//! not break the parser — the event mapper interprets what it knows.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// One decoded event from the `claude` CLI stdout stream.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ClaudeCodeEvent {
|
||||
System {
|
||||
session_id: Option<String>,
|
||||
schema_version: Option<String>,
|
||||
raw: Value,
|
||||
},
|
||||
User {
|
||||
message: Value,
|
||||
},
|
||||
Assistant {
|
||||
message: Value,
|
||||
},
|
||||
StreamEvent {
|
||||
event: Value,
|
||||
},
|
||||
RateLimit {
|
||||
raw: Value,
|
||||
},
|
||||
Result {
|
||||
subtype: Option<String>,
|
||||
usage: Option<Value>,
|
||||
total_cost_usd: Option<f64>,
|
||||
raw: Value,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
/// JSONL line that failed to parse. Kept so the driver can log without
|
||||
/// dropping silently. Not surfaced as a `ProviderDelta`.
|
||||
ParseError {
|
||||
line: String,
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Stateful parser that takes byte chunks from `proc.stdout` and emits
|
||||
/// fully-formed events on each newline.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct StreamJsonParser {
|
||||
buffer: String,
|
||||
/// First-seen `schema_version` from a `system` event, if any.
|
||||
pub schema_version: Option<String>,
|
||||
}
|
||||
|
||||
impl StreamJsonParser {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Append a UTF-8 byte chunk and return any events whose terminating
|
||||
/// newline arrived in this chunk.
|
||||
pub fn feed_bytes(&mut self, chunk: &[u8]) -> Vec<ClaudeCodeEvent> {
|
||||
self.buffer.push_str(&String::from_utf8_lossy(chunk));
|
||||
self.flush()
|
||||
}
|
||||
|
||||
/// Append a string chunk.
|
||||
pub fn feed(&mut self, chunk: &str) -> Vec<ClaudeCodeEvent> {
|
||||
self.buffer.push_str(chunk);
|
||||
self.flush()
|
||||
}
|
||||
|
||||
/// Drain any remaining buffered content. Call on EOF.
|
||||
pub fn end(&mut self) -> Vec<ClaudeCodeEvent> {
|
||||
if !self.buffer.is_empty() && !self.buffer.ends_with('\n') {
|
||||
self.buffer.push('\n');
|
||||
}
|
||||
self.flush()
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<ClaudeCodeEvent> {
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
let Some(nl) = self.buffer.find('\n') else {
|
||||
break;
|
||||
};
|
||||
let line = self.buffer[..nl].trim().to_string();
|
||||
self.buffer.drain(..=nl);
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match serde_json::from_str::<Value>(&line) {
|
||||
Ok(v) => out.push(self.decode(v)),
|
||||
Err(e) => out.push(ClaudeCodeEvent::ParseError {
|
||||
line,
|
||||
reason: e.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decode(&mut self, v: Value) -> ClaudeCodeEvent {
|
||||
let ty = v.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
match ty {
|
||||
"system" => {
|
||||
let session_id = v
|
||||
.get("session_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
let schema_version = v
|
||||
.get("schema_version")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
if let Some(sv) = &schema_version {
|
||||
if self.schema_version.is_none() {
|
||||
self.schema_version = Some(sv.clone());
|
||||
}
|
||||
}
|
||||
ClaudeCodeEvent::System {
|
||||
session_id,
|
||||
schema_version,
|
||||
raw: v,
|
||||
}
|
||||
}
|
||||
"user" => ClaudeCodeEvent::User {
|
||||
message: v.get("message").cloned().unwrap_or(Value::Null),
|
||||
},
|
||||
"assistant" => ClaudeCodeEvent::Assistant {
|
||||
message: v.get("message").cloned().unwrap_or(Value::Null),
|
||||
},
|
||||
"stream_event" => ClaudeCodeEvent::StreamEvent {
|
||||
event: v.get("event").cloned().unwrap_or(Value::Null),
|
||||
},
|
||||
"rate_limit_event" => ClaudeCodeEvent::RateLimit { raw: v },
|
||||
"result" => {
|
||||
let subtype = v.get("subtype").and_then(Value::as_str).map(str::to_string);
|
||||
let usage = v.get("usage").cloned();
|
||||
let total_cost_usd = v.get("total_cost_usd").and_then(Value::as_f64);
|
||||
ClaudeCodeEvent::Result {
|
||||
subtype,
|
||||
usage,
|
||||
total_cost_usd,
|
||||
raw: v,
|
||||
}
|
||||
}
|
||||
"error" => ClaudeCodeEvent::Error {
|
||||
message: v
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("claude-code error")
|
||||
.to_string(),
|
||||
},
|
||||
other => ClaudeCodeEvent::ParseError {
|
||||
line: v.to_string(),
|
||||
reason: format!("unknown event type `{other}`"),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_multiline_chunk() {
|
||||
let mut p = StreamJsonParser::new();
|
||||
let chunk = r#"{"type":"system","session_id":"s1","schema_version":"2.0"}
|
||||
{"type":"assistant","message":{"type":"content_block_start","index":0,"content_block":{"type":"text"}}}
|
||||
"#;
|
||||
let events = p.feed(chunk);
|
||||
assert_eq!(events.len(), 2);
|
||||
assert_eq!(p.schema_version.as_deref(), Some("2.0"));
|
||||
assert!(matches!(events[0], ClaudeCodeEvent::System { .. }));
|
||||
assert!(matches!(events[1], ClaudeCodeEvent::Assistant { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_split_lines_across_chunks() {
|
||||
let mut p = StreamJsonParser::new();
|
||||
assert!(p.feed("{\"type\":\"system\"").is_empty());
|
||||
assert!(p.feed(",\"session_id\":\"s1\"}").is_empty());
|
||||
let events = p.feed("\n");
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(events[0], ClaudeCodeEvent::System { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flushes_trailing_line_on_end() {
|
||||
let mut p = StreamJsonParser::new();
|
||||
assert!(p
|
||||
.feed(r#"{"type":"result","subtype":"success"}"#)
|
||||
.is_empty());
|
||||
let events = p.end();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(events[0], ClaudeCodeEvent::Result { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_type_becomes_parse_error() {
|
||||
let mut p = StreamJsonParser::new();
|
||||
let events = p.feed("{\"type\":\"weird\"}\n");
|
||||
assert!(matches!(events[0], ClaudeCodeEvent::ParseError { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_json_becomes_parse_error() {
|
||||
let mut p = StreamJsonParser::new();
|
||||
let events = p.feed("not json\n");
|
||||
assert!(matches!(events[0], ClaudeCodeEvent::ParseError { .. }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Shared types for the Claude Code CLI provider.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Minimum supported `claude` CLI version. Below this, the provider refuses
|
||||
/// to start so we never feed an unsupported stream-json schema into the
|
||||
/// parser.
|
||||
pub const MIN_CLI_VERSION: &str = "2.0.0";
|
||||
|
||||
/// Outcome of probing the `claude` CLI binary on disk.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum CliStatus {
|
||||
Ok {
|
||||
version: String,
|
||||
path: String,
|
||||
},
|
||||
NotInstalled,
|
||||
Outdated {
|
||||
version: String,
|
||||
min_required: String,
|
||||
path: String,
|
||||
},
|
||||
Unusable {
|
||||
path: String,
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Branding string used in user-facing copy. Locked decision (PLAN §13.4).
|
||||
pub const BRAND_LABEL: &str = "Claude Code CLI";
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Locate the `claude` CLI binary and verify it meets `MIN_CLI_VERSION`.
|
||||
//!
|
||||
//! We rely on `claude --version`, which prints a line of the form:
|
||||
//! `2.0.4 (Claude Code)`
|
||||
//! The first whitespace-delimited token is the semver string we compare
|
||||
//! against [`MIN_CLI_VERSION`].
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use super::types::{CliStatus, MIN_CLI_VERSION};
|
||||
|
||||
/// Locate the `claude` CLI binary on `PATH`.
|
||||
///
|
||||
/// Honors `OPENHUMAN_CLAUDE_CLI` env override so tests and power users can
|
||||
/// point at a specific binary.
|
||||
pub fn resolve_binary() -> Option<PathBuf> {
|
||||
if let Ok(explicit) = std::env::var("OPENHUMAN_CLAUDE_CLI") {
|
||||
let p = PathBuf::from(explicit);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
which_on_path("claude")
|
||||
}
|
||||
|
||||
fn which_on_path(name: &str) -> Option<PathBuf> {
|
||||
let path_var = std::env::var_os("PATH")?;
|
||||
let exts: Vec<String> = if cfg!(windows) {
|
||||
std::env::var("PATHEXT")
|
||||
.unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".into())
|
||||
.split(';')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.collect()
|
||||
} else {
|
||||
vec![String::new()]
|
||||
};
|
||||
for dir in std::env::split_paths(&path_var) {
|
||||
if cfg!(windows) {
|
||||
for ext in &exts {
|
||||
let candidate = dir.join(format!("{name}{ext}"));
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let candidate = dir.join(name);
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Probe the `claude` CLI and return its status.
|
||||
pub fn probe() -> CliStatus {
|
||||
let Some(path) = resolve_binary() else {
|
||||
log::debug!("[claude-code][version] no `claude` binary on PATH");
|
||||
return CliStatus::NotInstalled;
|
||||
};
|
||||
let path_str = path.display().to_string();
|
||||
|
||||
let output = match Command::new(&path).arg("--version").output() {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
log::warn!("[claude-code][version] spawn failed path={path_str} err={e}");
|
||||
return CliStatus::Unusable {
|
||||
path: path_str,
|
||||
reason: format!("spawn failed: {e}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
return CliStatus::Unusable {
|
||||
path: path_str,
|
||||
reason: format!(
|
||||
"non-zero exit {}: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let version = match parse_version(&stdout) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return CliStatus::Unusable {
|
||||
path: path_str,
|
||||
reason: format!("could not parse version from: {stdout:?}"),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if version_lt(&version, MIN_CLI_VERSION) {
|
||||
CliStatus::Outdated {
|
||||
version,
|
||||
min_required: MIN_CLI_VERSION.to_string(),
|
||||
path: path_str,
|
||||
}
|
||||
} else {
|
||||
CliStatus::Ok {
|
||||
version,
|
||||
path: path_str,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_version(stdout: &str) -> Option<String> {
|
||||
stdout
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.filter(|tok| tok.chars().next().is_some_and(|c| c.is_ascii_digit()))
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Numeric semver compare. Returns true when `a < b`.
|
||||
/// Pre-release suffixes (`-rc.1`) are stripped before comparison.
|
||||
fn version_lt(a: &str, b: &str) -> bool {
|
||||
let pa = parts(a);
|
||||
let pb = parts(b);
|
||||
pa < pb
|
||||
}
|
||||
|
||||
fn parts(v: &str) -> (u32, u32, u32) {
|
||||
let core = v.split('-').next().unwrap_or(v);
|
||||
let mut it = core.split('.').map(|s| s.parse::<u32>().unwrap_or(0));
|
||||
(
|
||||
it.next().unwrap_or(0),
|
||||
it.next().unwrap_or(0),
|
||||
it.next().unwrap_or(0),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_typical_output() {
|
||||
assert_eq!(
|
||||
parse_version("2.0.4 (Claude Code)\n").as_deref(),
|
||||
Some("2.0.4")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_numeric_prefix() {
|
||||
assert_eq!(parse_version("claude version 2.0.4"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_compare() {
|
||||
assert!(version_lt("1.9.9", "2.0.0"));
|
||||
assert!(version_lt("2.0.0", "2.0.1"));
|
||||
assert!(!version_lt("2.0.0", "2.0.0"));
|
||||
assert!(!version_lt("2.1.0", "2.0.9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_compare_strips_prerelease() {
|
||||
assert!(!version_lt("2.0.0-rc.1", "2.0.0"));
|
||||
}
|
||||
}
|
||||
@@ -274,6 +274,47 @@ pub fn create_chat_provider_from_string(
|
||||
verify_session_active(config)?;
|
||||
}
|
||||
|
||||
if let Some(model_with_temp) =
|
||||
p.strip_prefix(crate::openhuman::inference::provider::claude_code::PROVIDER_PREFIX)
|
||||
{
|
||||
let (model, temperature_override) = split_model_and_temperature(model_with_temp);
|
||||
if temperature_override.is_some() {
|
||||
log::warn!(
|
||||
"[providers][chat-factory] claude-code provider: per-model temperature override \
|
||||
is accepted but not yet wired through to the CLI — the @<temp> suffix is ignored"
|
||||
);
|
||||
}
|
||||
if model.is_empty() {
|
||||
anyhow::bail!(
|
||||
"[chat-factory] provider string '{}' for role '{}' has an empty model — \
|
||||
use 'claude-code:<model-id>'",
|
||||
p,
|
||||
role
|
||||
);
|
||||
}
|
||||
let workspace = config
|
||||
.config_path
|
||||
.parent()
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
directories::UserDirs::new()
|
||||
.map(|d| d.home_dir().join(".openhuman"))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from(".openhuman"))
|
||||
});
|
||||
log::debug!(
|
||||
"[providers][chat-factory] building claude-code CLI provider model={} workspace={}",
|
||||
model,
|
||||
workspace.display()
|
||||
);
|
||||
let provider =
|
||||
crate::openhuman::inference::provider::claude_code::ClaudeCodeProvider::from_env(
|
||||
model.clone(),
|
||||
workspace,
|
||||
)?;
|
||||
let p_box: Box<dyn Provider> = Box::new(provider);
|
||||
return Ok((p_box, model));
|
||||
}
|
||||
|
||||
if let Some(model_with_temp) = p.strip_prefix(OLLAMA_PROVIDER_PREFIX) {
|
||||
let (model, temperature_override) = split_model_and_temperature(model_with_temp);
|
||||
if model.is_empty() {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
pub mod billing_error;
|
||||
pub mod claude_agent_sdk;
|
||||
pub mod claude_code;
|
||||
pub mod compatible;
|
||||
pub mod compatible_dump;
|
||||
pub mod compatible_parse;
|
||||
|
||||
@@ -140,6 +140,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas("test_provider_model"),
|
||||
schemas("should_react"),
|
||||
schemas("analyze_sentiment"),
|
||||
schemas("claude_code_status"),
|
||||
schemas("claude_code_auth_status"),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -225,6 +227,14 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("analyze_sentiment"),
|
||||
handler: handle_inference_analyze_sentiment,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("claude_code_status"),
|
||||
handler: handle_inference_claude_code_status,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("claude_code_auth_status"),
|
||||
handler: handle_inference_claude_code_auth_status,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -436,6 +446,26 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
inputs: vec![required_string("message", "User message content to classify.")],
|
||||
outputs: vec![json_output("sentiment", "Sentiment analysis payload.")],
|
||||
},
|
||||
"claude_code_status" => ControllerSchema {
|
||||
namespace: "inference",
|
||||
function: "claude_code_status",
|
||||
description: "Probe the local `claude` CLI binary (Claude Code CLI provider) and return install + version status.",
|
||||
inputs: vec![],
|
||||
outputs: vec![json_output(
|
||||
"status",
|
||||
"CliStatus payload: ok | not_installed | outdated | unusable, with version + path when present.",
|
||||
)],
|
||||
},
|
||||
"claude_code_auth_status" => ControllerSchema {
|
||||
namespace: "inference",
|
||||
function: "claude_code_auth_status",
|
||||
description: "Detect Claude Code CLI auth state (Pro/Max subscription via credentials.json, API key env, or none). No CLI spawn, no token round-trip.",
|
||||
inputs: vec![],
|
||||
outputs: vec![json_output(
|
||||
"auth",
|
||||
"AuthStatus payload: source = subscription | api_key_env | none, plus optional account_email + expires_at + last_checked.",
|
||||
)],
|
||||
},
|
||||
other => panic!("unknown inference schema: {other}"),
|
||||
}
|
||||
}
|
||||
@@ -818,6 +848,28 @@ fn handle_inference_analyze_sentiment(params: Map<String, Value>) -> ControllerF
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_inference_claude_code_status(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let status = tokio::task::spawn_blocking(
|
||||
crate::openhuman::inference::provider::claude_code::version_check::probe,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("claude_code_status join error: {e}"))?;
|
||||
to_json(RpcOutcome::new(status, vec![]))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_inference_claude_code_auth_status(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let auth = tokio::task::spawn_blocking(
|
||||
crate::openhuman::inference::provider::claude_code::auth_status::probe,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("claude_code_auth_status join error: {e}"))?;
|
||||
to_json(RpcOutcome::new(auth, vec![]))
|
||||
})
|
||||
}
|
||||
|
||||
fn deserialize_params<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
|
||||
serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! End-to-end test of the Claude Code stream-json pipeline.
|
||||
//!
|
||||
//! Feeds a captured representative CC 2.x stream-json transcript through
|
||||
//! `StreamJsonParser` → `EventMapper` and asserts that:
|
||||
//! - text deltas arrive in order and aggregate into the final response
|
||||
//! - tool-use blocks emit ToolCallStart + ToolCallArgsDelta + a final
|
||||
//! ToolCall with parsed JSON arguments
|
||||
//! - the `result` event finalizes usage tokens (incl. cache_read)
|
||||
//! - session_id is captured from the first `system` event
|
||||
//!
|
||||
//! This is a parser-level E2E; the real driver / process spawn is mocked
|
||||
//! in `tests/claude_code_driver_smoke.rs`.
|
||||
|
||||
use openhuman_core::openhuman::inference::provider::claude_code::{
|
||||
event_mapper::EventMapper, stream_parser::StreamJsonParser,
|
||||
};
|
||||
use openhuman_core::openhuman::inference::provider::traits::ProviderDelta;
|
||||
|
||||
const TRANSCRIPT: &str = r#"{"type":"system","subtype":"init","session_id":"f47ac10b-58cc-4372-a567-0e02b2c3d479","schema_version":"2.0"}
|
||||
{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text"}}}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" world"}}}
|
||||
{"type":"stream_event","event":{"type":"content_block_stop","index":0}}
|
||||
{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_42","name":"memory_search"}}}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"que"}}}
|
||||
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"ry\":\"foo\"}"}}}
|
||||
{"type":"stream_event","event":{"type":"content_block_stop","index":1}}
|
||||
{"type":"assistant","message":{"type":"message","role":"assistant","content":[]}}
|
||||
{"type":"result","subtype":"success","usage":{"input_tokens":120,"output_tokens":42,"cache_read_input_tokens":80,"cache_creation_input_tokens":0},"total_cost_usd":0.0012}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn captures_text_tool_call_and_usage() {
|
||||
let mut parser = StreamJsonParser::new();
|
||||
let mut mapper = EventMapper::new();
|
||||
let mut deltas: Vec<ProviderDelta> = Vec::new();
|
||||
|
||||
// Feed in chunks to exercise the chunk-boundary buffering as well.
|
||||
let mid = TRANSCRIPT.len() / 2;
|
||||
for chunk in [&TRANSCRIPT[..mid], &TRANSCRIPT[mid..]] {
|
||||
for evt in parser.feed(chunk) {
|
||||
for d in mapper.handle(evt) {
|
||||
deltas.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
for evt in parser.end() {
|
||||
for d in mapper.handle(evt) {
|
||||
deltas.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
// Schema version was captured by the parser.
|
||||
assert_eq!(parser.schema_version.as_deref(), Some("2.0"));
|
||||
|
||||
// Session id was captured by the mapper from the first system event.
|
||||
assert_eq!(
|
||||
mapper.session_id.as_deref(),
|
||||
Some("f47ac10b-58cc-4372-a567-0e02b2c3d479")
|
||||
);
|
||||
|
||||
// Text deltas arrived in order.
|
||||
let text_chunks: Vec<&str> = deltas
|
||||
.iter()
|
||||
.filter_map(|d| match d {
|
||||
ProviderDelta::TextDelta { delta } => Some(delta.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(text_chunks, vec!["Hello", " world"]);
|
||||
|
||||
// Tool call lifecycle.
|
||||
assert!(deltas.iter().any(|d| matches!(
|
||||
d,
|
||||
ProviderDelta::ToolCallStart { tool_name, call_id }
|
||||
if tool_name == "memory_search" && call_id == "call_42"
|
||||
)));
|
||||
let args_concat: String = deltas
|
||||
.iter()
|
||||
.filter_map(|d| match d {
|
||||
ProviderDelta::ToolCallArgsDelta { call_id, delta } if call_id == "call_42" => {
|
||||
Some(delta.as_str())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
assert_eq!(args_concat, r#"{"query":"foo"}"#);
|
||||
|
||||
// Aggregated response.
|
||||
assert_eq!(mapper.final_text, "Hello world");
|
||||
assert_eq!(mapper.tool_calls.len(), 1);
|
||||
assert_eq!(mapper.tool_calls[0].name, "memory_search");
|
||||
assert_eq!(mapper.tool_calls[0].id, "call_42");
|
||||
assert_eq!(mapper.tool_calls[0].arguments, r#"{"query":"foo"}"#);
|
||||
|
||||
// Usage from the `result` event.
|
||||
assert!(mapper.finished);
|
||||
let u = mapper.usage.as_ref().expect("usage should be populated");
|
||||
assert_eq!(u.input_tokens, 120);
|
||||
assert_eq!(u.output_tokens, 42);
|
||||
assert_eq!(u.cached_input_tokens, 80);
|
||||
}
|
||||
Reference in New Issue
Block a user