Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion rust/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = ["lithe-core", "lithe-core-rules", "lithe-git-host", "lithe-ide-host", "lithe-remote"]
members = ["lithe-ai", "lithe-core", "lithe-core-rules", "lithe-git-host", "lithe-ide-host", "lithe-remote"]
resolver = "2"

[profile.release]
Expand Down
3 changes: 3 additions & 0 deletions rust/contracts/ai-commit.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

Version: 1. Behavioral fixture: `rust/fixtures/ai/commit-generation-v1.json`.

Every `ai.*` command is implemented in the `lithe-ai` crate (`rust/lithe-ai`), which
`lithe-core` routes to and re-exports as `lithe_core::ai`.

Windows consumes the typed Rust API in `lithe_core::ai`; macOS uses the same planner
and decoder through the Core JSON commands `ai.planCommit` and
`ai.decodeCommitMessage`. The API has no filesystem, HTTP, operating-system
Expand Down
2 changes: 2 additions & 0 deletions rust/contracts/ai-generation-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Version: 1. Behavioral fixtures: `rust/fixtures/ai/pull-request-description-v1.json`
and `rust/fixtures/ai/inline-edit-v1.json`.

Both are implemented in the `lithe-ai` crate (`rust/lithe-ai`), like every `ai.*` command.

Both features reuse the credential-free provider request model of
[AI commit generation](ai-commit.md): Core validates the endpoint, appends the
protocol path exactly once, encodes a non-streaming body, and extracts text from
Expand Down
2 changes: 1 addition & 1 deletion rust/contracts/application-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ no runtime JSON loading, package download or write to installed resources occurs
The locale registry preserves each platform's persisted identifiers and default
language. Explicit fallback chains resolve shared entries during generation;
all existing platform catalogs and presentation-owned bilingual copy use this source. Translation coverage is not a
capability claim. [Format fixtures](../fixtures/i18n/format-v1.json) bind generation
capability claim. [Format fixtures](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/i18n/fixtures/format-v1.json) bind generation
to native positional formatting, literal percent signs and unmodified argument data.

## Feature Contracts
Expand Down
410 changes: 409 additions & 1 deletion rust/contracts/rust-core-api.md

Large diffs are not rendered by default.

23 changes: 0 additions & 23 deletions rust/fixtures/i18n/format-v1.json

This file was deleted.

11 changes: 0 additions & 11 deletions rust/fixtures/i18n/native-abi-v1.json

This file was deleted.

3 changes: 0 additions & 3 deletions rust/fixtures/phpunit-project/.gitignore

This file was deleted.

5 changes: 0 additions & 5 deletions rust/fixtures/phpunit-project/composer.json

This file was deleted.

1 change: 0 additions & 1 deletion rust/fixtures/phpunit-project/phpunit.xml

This file was deleted.

6 changes: 0 additions & 6 deletions rust/fixtures/phpunit-project/src/Store.php

This file was deleted.

12 changes: 0 additions & 12 deletions rust/fixtures/phpunit-project/tests/StoreTest.php

This file was deleted.

14 changes: 14 additions & 0 deletions rust/lithe-ai/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[package]
name = "lithe-ai"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
publish = false
description = "Credential-free AI request planning and response decoding for Lithe Core"

[dependencies]
serde = { version = "1.0", features = ["derive"] }
# preserve_order matches lithe-core: planned request bodies keep their field order.
serde_json = { version = "1.0", features = ["preserve_order"] }
toml = { version = "1.1.4", default-features = false, features = ["parse", "serde"] }
url = "2.5"
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use super::configuration::{
self, codex_model, configuration_sources, ConfigurationFiles, DetectedConfiguration,
INVALID_AUTHENTICATION, INVALID_CONFIG,
};
use crate::protocol::{CoreError, ErrorCode};
use crate::{AiError, AiErrorCode};
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::BTreeMap;
Expand All @@ -24,11 +24,11 @@ const MAXIMUM_ENVIRONMENT_VALUE_BYTES: usize = 32 * 1024;
const TOO_LARGE: &str = "AI_CONFIGURATION_TOO_LARGE";

/// Fixed messages only: parser diagnostics may quote a pasted credential.
fn invalid(code: &'static str) -> CoreError {
CoreError::new(ErrorCode::InvalidRequest, code)
fn invalid(code: &'static str) -> AiError {
AiError::new(AiErrorCode::InvalidRequest, code)
}

fn bounded_environment(environment: &BTreeMap<String, String>) -> Result<(), CoreError> {
fn bounded_environment(environment: &BTreeMap<String, String>) -> Result<(), AiError> {
if environment.len() > MAXIMUM_ENVIRONMENT_ENTRIES
|| environment
.iter()
Expand All @@ -40,7 +40,7 @@ fn bounded_environment(environment: &BTreeMap<String, String>) -> Result<(), Cor
}

/// Implements `ai.configuration.sources`: the native files of one source.
pub fn configuration_sources_command(payload: Value) -> Result<Value, CoreError> {
pub fn configuration_sources_command(payload: Value) -> Result<Value, AiError> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Request {
Expand Down Expand Up @@ -68,7 +68,7 @@ pub fn configuration_sources_command(payload: Value) -> Result<Value, CoreError>
/// with `issue` when the configuration parses but is not a usable provider.
/// Draft mode (`allowIncomplete`) parses editor text only: it ignores the
/// environment and keeps missing endpoint and model fields empty.
pub fn parse_configuration_command(payload: Value) -> Result<Value, CoreError> {
pub fn parse_configuration_command(payload: Value) -> Result<Value, AiError> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Request {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use super::{
decode_message, decode_pull_request, plan_commit_bounded, plan_pull_request, CommitFile,
CommitOptions, Provider, PullRequestComparison, PullRequestOptions,
};
use crate::protocol::{CoreError, ErrorCode};
use crate::{AiError, AiErrorCode};
use serde::Deserialize;
use serde_json::{json, Value};

Expand All @@ -32,11 +32,11 @@ struct DecodeRequest {
}

// Avoid echoing untrusted payloads, including diffs or provider responses, in diagnostics.
fn invalid_payload() -> CoreError {
CoreError::new(ErrorCode::InvalidRequest, "AI_COMMIT_INVALID_OPTIONS")
fn invalid_payload() -> AiError {
AiError::new(AiErrorCode::InvalidRequest, "AI_COMMIT_INVALID_OPTIONS")
}

pub(crate) fn plan_commit_command(payload: Value) -> Result<Value, CoreError> {
pub(crate) fn plan_commit_command(payload: Value) -> Result<Value, AiError> {
let request: PlanRequest = serde_json::from_value(payload).map_err(|_| invalid_payload())?;
let lengths: Vec<usize> = request
.files
Expand All @@ -46,21 +46,21 @@ pub(crate) fn plan_commit_command(payload: Value) -> Result<Value, CoreError> {
.ok_or_else(invalid_payload)?;
let files = request.files;
let plan = plan_commit_bounded(&request.provider, &request.options, &files, &lengths)
.map_err(|code| CoreError::new(ErrorCode::InvalidRequest, code))?;
.map_err(|code| AiError::new(AiErrorCode::InvalidRequest, code))?;
Ok(json!({"url": plan.url, "body": plan.body}))
}

pub(crate) fn decode_commit_command(payload: Value) -> Result<Value, CoreError> {
pub(crate) fn decode_commit_command(payload: Value) -> Result<Value, AiError> {
let request: DecodeRequest = serde_json::from_value(payload).map_err(|_| invalid_payload())?;
let message = decode_message(&request.protocol, &request.response, request.include_body)
.map_err(|code| CoreError::new(ErrorCode::InvalidRequest, code))?;
.map_err(|code| AiError::new(AiErrorCode::InvalidRequest, code))?;
Ok(json!({"message": message}))
}

pub(crate) fn commit_input_budget_command(payload: Value) -> Result<Value, CoreError> {
pub(crate) fn commit_input_budget_command(payload: Value) -> Result<Value, AiError> {
let request: BudgetRequest = serde_json::from_value(payload).map_err(|_| invalid_payload())?;
let files = commit_input_budget(request.maximum_diff_characters, &request.file_lengths)
.map_err(|code| CoreError::new(ErrorCode::InvalidRequest, code))?;
.map_err(|code| AiError::new(AiErrorCode::InvalidRequest, code))?;
Ok(json!({"files": files}))
}

Expand All @@ -78,19 +78,19 @@ struct PullRequestDecodeRequest {
}

/// `ai.pullRequest.plan`: credential-free request for a pull request title and description.
pub(crate) fn plan_pull_request_command(payload: Value) -> Result<Value, CoreError> {
pub(crate) fn plan_pull_request_command(payload: Value) -> Result<Value, AiError> {
let request: PullRequestPlanRequest =
serde_json::from_value(payload).map_err(|_| invalid_payload())?;
let plan = plan_pull_request(&request.provider, &request.options, &request.comparison)
.map_err(|code| CoreError::new(ErrorCode::InvalidRequest, code))?;
.map_err(|code| AiError::new(AiErrorCode::InvalidRequest, code))?;
Ok(json!({"url": plan.url, "body": plan.body}))
}

/// `ai.pullRequest.decode`: strict title and description from a provider response.
pub(crate) fn decode_pull_request_command(payload: Value) -> Result<Value, CoreError> {
pub(crate) fn decode_pull_request_command(payload: Value) -> Result<Value, AiError> {
let request: PullRequestDecodeRequest =
serde_json::from_value(payload).map_err(|_| invalid_payload())?;
let description = decode_pull_request(&request.protocol, &request.response)
.map_err(|code| CoreError::new(ErrorCode::InvalidRequest, code))?;
.map_err(|code| AiError::new(AiErrorCode::InvalidRequest, code))?;
serde_json::to_value(description).map_err(|_| invalid_payload())
}
89 changes: 89 additions & 0 deletions rust/lithe-ai/src/dispatch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//! Command routing and the JSON envelope shared with `lithe-core`.

use crate::{agent_configuration, commands, inline_edit, AiError, AiErrorCode};
use serde::Deserialize;
use serde_json::{json, Value};

/// Every command name this crate owns. `lithe-core` routes exactly these names here.
pub const COMMANDS: &[&str] = &[
"ai.commitInputBudget",
"ai.planCommit",
"ai.decodeCommitMessage",
"ai.configuration.sources",
"ai.configuration.parse",
"ai.pullRequest.plan",
"ai.pullRequest.decode",
"ai.inlineEdit.plan",
"ai.inlineEdit.decode",
];

/// Reports whether `command` is owned by this crate.
pub fn is_ai_command(command: &str) -> bool {
COMMANDS.contains(&command)
}

/// Runs one AI command on an already decoded payload.
pub fn execute(command: &str, payload: Value) -> Result<Value, AiError> {
match command {
"ai.commitInputBudget" => commands::commit_input_budget_command(payload),
"ai.planCommit" => commands::plan_commit_command(payload),
"ai.decodeCommitMessage" => commands::decode_commit_command(payload),
"ai.configuration.sources" => agent_configuration::configuration_sources_command(payload),
"ai.configuration.parse" => agent_configuration::parse_configuration_command(payload),
"ai.pullRequest.plan" => commands::plan_pull_request_command(payload),
"ai.pullRequest.decode" => commands::decode_pull_request_command(payload),
"ai.inlineEdit.plan" => inline_edit::plan_command(payload),
"ai.inlineEdit.decode" => inline_edit::decode_command(payload),
_ => Err(AiError::new(
AiErrorCode::NotSupported,
"Unknown AI command",
)),
}
}

#[derive(Deserialize)]
struct Request {
id: Option<String>,
command: String,
#[serde(default)]
payload: Value,
}

/// Executes one Core-style JSON request and returns the JSON response envelope.
///
/// Unknown envelope fields (`operationId`, `origin`, ...) are ignored so callers can send the
/// same request object they send to `lithe-core`.
pub fn execute_json(request: &str) -> String {
let parsed: Request = match serde_json::from_str(request) {
Ok(parsed) => parsed,
Err(_) => {
let error = AiError::new(AiErrorCode::InvalidRequest, "Invalid JSON request");
return failure(None, &error);
}
};
match execute(&parsed.command, parsed.payload) {
Ok(data) => json!({"id": parsed.id, "ok": true, "data": data}).to_string(),
Err(error) => failure(parsed.id, &error),
}
}

fn failure(id: Option<String>, error: &AiError) -> String {
json!({"id": id, "ok": false, "error": error}).to_string()
}

#[cfg(test)]
mod tests {
use super::{execute, COMMANDS};
use crate::AiErrorCode;
use serde_json::json;

#[test]
fn every_listed_command_is_routed() {
for command in COMMANDS {
let error = execute(command, json!(null)).expect_err(command);
assert_eq!(error.code, AiErrorCode::InvalidRequest, "{command}");
}
let unknown = execute("ai.unknown", json!({})).unwrap_err();
assert_eq!(unknown.code, AiErrorCode::NotSupported);
}
}
37 changes: 37 additions & 0 deletions rust/lithe-ai/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//! Failure type whose JSON shape matches the Core error envelope.

use serde::Serialize;

/// Stable failure categories. Names serialize exactly like the matching `lithe-core` codes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AiErrorCode {
/// The payload does not match the command contract, or the planner refused it. The
/// message is a stable `AI_*` code that platforms map to their own wording.
InvalidRequest,
/// The command name is not owned by this crate.
NotSupported,
}

/// An AI planning failure with a stable category and a message that is safe to display.
///
/// Messages are fixed codes: diffs, pasted configuration and provider responses can carry
/// credentials or private source, so no failure echoes its input.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AiError {
/// Machine-readable category.
pub code: AiErrorCode,
/// Stable failure code such as `AI_COMMIT_INVALID_OPTIONS`.
pub message: String,
}

impl AiError {
/// Creates an error from a category and a stable failure code.
pub fn new(code: AiErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
}
Loading
Loading