Skip to content

Commit 18c8bae

Browse files
authored
feat: Add Anthropic Support (#395)
* manual * add json5 - fallback * modify docs and example * refactor: error handling and parsing
1 parent 1218420 commit 18c8bae

File tree

6 files changed

+172
-1
lines changed

6 files changed

+172
-1
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,4 @@ bytes = "1.10.1"
106106
rand = "0.9.0"
107107
indoc = "2.0.6"
108108
owo-colors = "4.2.0"
109+
json5 = "0.4.1"

docs/docs/ai/llm.mdx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,27 @@ cocoindex.LlmSpec(
9797
</TabItem>
9898
</Tabs>
9999

100-
You can find the full list of models supported by Gemini [here](https://ai.google.dev/gemini-api/docs/models).
100+
You can find the full list of models supported by Gemini [here](https://ai.google.dev/gemini-api/docs/models).
101+
102+
### Anthropic
103+
104+
To use the Anthropic LLM API, you need to set the environment variable `ANTHROPIC_API_KEY`.
105+
You can generate the API key from [Anthropic API](https://console.anthropic.com/settings/keys).
106+
107+
A spec for Anthropic looks like this:
108+
109+
<Tabs>
110+
<TabItem value="python" label="Python" default>
111+
112+
```python
113+
cocoindex.LlmSpec(
114+
api_type=cocoindex.LlmApiType.ANTHROPIC,
115+
model="claude-3-5-sonnet-latest",
116+
)
117+
```
118+
119+
</TabItem>
120+
</Tabs>
121+
122+
You can find the full list of models supported by Anthropic [here](https://docs.anthropic.com/en/docs/about-claude/models/all-models).
123+

examples/manuals_llm_extraction/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ def manual_extraction_flow(flow_builder: cocoindex.FlowBuilder, data_scope: coco
9898
# Replace by this spec below, to use Gemini API model
9999
# llm_spec=cocoindex.LlmSpec(
100100
# api_type=cocoindex.LlmApiType.GEMINI, model="gemini-2.0-flash"),
101+
102+
# Replace by this spec below, to use Anthropic API model
103+
# llm_spec=cocoindex.LlmSpec(
104+
# api_type=cocoindex.LlmApiType.ANTHROPIC, model="claude-3-5-sonnet-latest"),
101105
output_type=ModuleInfo,
102106
instruction="Please extract Python module information from the manual."))
103107
doc["module_summary"] = doc["module_info"].transform(summarize_module)

python/cocoindex/llm.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ class LlmApiType(Enum):
66
OPENAI = "OpenAi"
77
OLLAMA = "Ollama"
88
GEMINI = "Gemini"
9+
ANTHROPIC = "Anthropic"
910

1011
@dataclass
1112
class LlmSpec:

src/llm/anthropic.rs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
use async_trait::async_trait;
2+
use crate::llm::{LlmGenerationClient, LlmSpec, LlmGenerateRequest, LlmGenerateResponse, ToJsonSchemaOptions, OutputFormat};
3+
use anyhow::{Result, bail, Context};
4+
use serde_json::Value;
5+
use json5;
6+
7+
use crate::api_bail;
8+
use urlencoding::encode;
9+
10+
pub struct Client {
11+
model: String,
12+
api_key: String,
13+
client: reqwest::Client,
14+
}
15+
16+
impl Client {
17+
pub async fn new(spec: LlmSpec) -> Result<Self> {
18+
let api_key = match std::env::var("ANTHROPIC_API_KEY") {
19+
Ok(val) => val,
20+
Err(_) => api_bail!("ANTHROPIC_API_KEY environment variable must be set"),
21+
};
22+
Ok(Self {
23+
model: spec.model,
24+
api_key,
25+
client: reqwest::Client::new(),
26+
})
27+
}
28+
}
29+
30+
#[async_trait]
31+
impl LlmGenerationClient for Client {
32+
async fn generate<'req>(
33+
&self,
34+
request: LlmGenerateRequest<'req>,
35+
) -> Result<LlmGenerateResponse> {
36+
let messages = vec![serde_json::json!({
37+
"role": "user",
38+
"content": request.user_prompt
39+
})];
40+
41+
let mut payload = serde_json::json!({
42+
"model": self.model,
43+
"messages": messages,
44+
"max_tokens": 4096
45+
});
46+
47+
// Add system prompt as top-level field if present (required)
48+
if let Some(system) = request.system_prompt {
49+
payload["system"] = serde_json::json!(system);
50+
}
51+
52+
// Extract schema from output_format, error if not JsonSchema
53+
let schema = match request.output_format.as_ref() {
54+
Some(OutputFormat::JsonSchema { schema, .. }) => schema,
55+
_ => api_bail!("Anthropic client expects OutputFormat::JsonSchema for all requests"),
56+
};
57+
58+
let schema_json = serde_json::to_value(schema)?;
59+
payload["tools"] = serde_json::json!([
60+
{ "type": "custom", "name": "report_result", "input_schema": schema_json }
61+
]);
62+
63+
let url = "https://api.anthropic.com/v1/messages";
64+
65+
let encoded_api_key = encode(&self.api_key);
66+
67+
let resp = self.client
68+
.post(url)
69+
.header("x-api-key", encoded_api_key.as_ref())
70+
.header("anthropic-version", "2023-06-01")
71+
.json(&payload)
72+
.send()
73+
.await
74+
.context("HTTP error")?;
75+
let mut resp_json: Value = resp.json().await.context("Invalid JSON")?;
76+
if let Some(error) = resp_json.get("error") {
77+
bail!("Anthropic API error: {:?}", error);
78+
}
79+
80+
// Debug print full response
81+
// println!("Anthropic API full response: {resp_json:?}");
82+
83+
let resp_content = &resp_json["content"];
84+
let tool_name = "report_result";
85+
let mut extracted_json: Option<Value> = None;
86+
if let Some(array) = resp_content.as_array() {
87+
for item in array {
88+
if item.get("type") == Some(&Value::String("tool_use".to_string()))
89+
&& item.get("name") == Some(&Value::String(tool_name.to_string()))
90+
{
91+
if let Some(input) = item.get("input") {
92+
extracted_json = Some(input.clone());
93+
break;
94+
}
95+
}
96+
}
97+
}
98+
let text = if let Some(json) = extracted_json {
99+
// Try strict JSON serialization first
100+
serde_json::to_string(&json)?
101+
} else {
102+
// Fallback: try text if no tool output found
103+
match &mut resp_json["content"][0]["text"] {
104+
Value::String(s) => {
105+
// Try strict JSON parsing first
106+
match serde_json::from_str::<serde_json::Value>(s) {
107+
Ok(_) => std::mem::take(s),
108+
Err(e) => {
109+
// Try permissive json5 parsing as fallback
110+
match json5::from_str::<serde_json::Value>(s) {
111+
Ok(value) => {
112+
println!("[Anthropic] Used permissive JSON5 parser for output");
113+
serde_json::to_string(&value)?
114+
},
115+
Err(e2) => return Err(anyhow::anyhow!(format!("No structured tool output or text found in response, and permissive JSON5 parsing also failed: {e}; {e2}")))
116+
}
117+
}
118+
}
119+
},
120+
_ => return Err(anyhow::anyhow!("No structured tool output or text found in response")),
121+
}
122+
};
123+
124+
Ok(LlmGenerateResponse {
125+
text,
126+
})
127+
}
128+
129+
fn json_schema_options(&self) -> ToJsonSchemaOptions {
130+
ToJsonSchemaOptions {
131+
fields_always_required: false,
132+
supports_format: false,
133+
extract_descriptions: false,
134+
top_level_must_be_object: true,
135+
}
136+
}
137+
}

src/llm/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub enum LlmApiType {
1212
Ollama,
1313
OpenAi,
1414
Gemini,
15+
Anthropic,
1516
}
1617

1718
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -54,6 +55,7 @@ pub trait LlmGenerationClient: Send + Sync {
5455
mod ollama;
5556
mod openai;
5657
mod gemini;
58+
mod anthropic;
5759

5860
pub async fn new_llm_generation_client(spec: LlmSpec) -> Result<Box<dyn LlmGenerationClient>> {
5961
let client = match spec.api_type {
@@ -66,6 +68,9 @@ pub async fn new_llm_generation_client(spec: LlmSpec) -> Result<Box<dyn LlmGener
6668
LlmApiType::Gemini => {
6769
Box::new(gemini::Client::new(spec).await?) as Box<dyn LlmGenerationClient>
6870
}
71+
LlmApiType::Anthropic => {
72+
Box::new(anthropic::Client::new(spec).await?) as Box<dyn LlmGenerationClient>
73+
}
6974
};
7075
Ok(client)
7176
}

0 commit comments

Comments
 (0)