refactor(llm): extract codec/openai_responses behind the Codec trait (#487)

## Summary

Next dialect extraction in the gateway refactor series (after #481 /
#485, sibling of the anthropic extraction): the OpenAI Responses API
wire translation moves out of `providers/openai.rs` into
`codec/openai_responses/`, behind the `Codec` / `StreamDecoder` traits.
The adapter becomes a thin transport shell (2,784 → 692 lines) owning
auth (bearer + org/project headers), base URL, the streaming byte loop,
and route config; all translation is in the codec.

Two commits, each independently green:
1. **Add the codec** (`wire`/`encode`/`decode`/`stream`/`mod`) —
compiling but unused behind a scoped `dead_code` allow.
2. **Rewire the adapter** to it and migrate the ~54 unit tests into the
codec submodules they now cover.

Key moves:
- **Codex mode splits along the codec seam**: encode-side param omission
(`temperature`/`top_p`/`max_output_tokens` omitted, `instructions`
always sent) rides on a new `CodecParams::openai_codex` flag; the
transport-side half (blocking requests served via streaming) is route
config on the adapter. No provider-name branching — codex is OpenAI's
only route split.
- **`translate_input` goes sync**: its only async-ness was file-path
image loading, now handled by the shared `attachments::resolve` (#485)
in the adapter before encode (images only; audio/documents render as
text placeholders in the codec without I/O).
- The invariant-dense pieces move wholesale, already pure: opaque
`openai_reasoning`/`openai_message` item round-trip, the `fc_…`/`call_…`
dual-id preservation via `provider_metadata`, custom-tool (apply_patch)
emission and raw-input accumulation, `store: false` + `include:
["reasoning.encrypted_content"]`.
- The SSE state machine becomes `SseAccumulator` behind `StreamDecoder`:
the transport owns byte reading + framing; the decoder is fed framed
`RawEvent`s, resolves the event type from the SSE `event:` line or the
JSON `type` field, and `finish()` synthesizes nothing
(`response.completed`/`incomplete` are the finishers — matching the old
EOF behavior exactly).

Coordination note: this PR makes the same unit→fielded `CodecParams`
change as the sibling anthropic extraction (each adds only its own
fields) — whichever lands second resolves a trivial field-union conflict
in `codec/mod.rs`.

## Behavior preservation

No behavior change. The 33 openai_responses wire snapshots from #471
(codex mode, dual-id round-trip, opaque items, attachment drop-on-error,
response_format, streaming happy path / tool deltas / reasoning deltas /
failure events) pass unmodified, and the full fabro-llm suite is back to
count (516: all 54 migrated tests plus one new test pinning the
count-tokens endpoint + filtered body on the codec).

## Testing

- `cargo nextest run -p fabro-llm` — 516 passed (126 wire snapshots
included)
- `cargo check --workspace`
- `cargo +nightly-2026-04-14 clippy -p fabro-llm --all-targets -- -D
warnings`
- `cargo +nightly-2026-04-14 fmt --check`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Scott Werner 2026-06-10 17:11:18 -04:00 committed by GitHub
parent 269eca719f
commit 45f564cbfe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 2499 additions and 2314 deletions

View file

@ -610,7 +610,8 @@ reasoning = true
fn direct_params() -> CodecParams {
CodecParams {
anthropic_version: AnthropicVersion::Header("2023-06-01"),
anthropic_beta: true,
anthropic_beta: true,
..CodecParams::default()
}
}

View file

@ -12,6 +12,7 @@
pub(crate) mod anthropic_messages;
pub(crate) mod openai_compatible;
pub(crate) mod openai_responses;
use fabro_model::Model;
@ -42,7 +43,8 @@ pub(crate) struct CodecCtx<'a> {
/// Per-route dialect knobs, expressed as data so one codec can serve several
/// routes. The default is inert ("nothing special"); a route that needs a
/// dialect quirk sets the relevant field. Grows as codecs need it — #459 adds
/// `ModelPlacement` for Bedrock.
/// `ModelPlacement` for Bedrock. Inert for codecs that don't read a given
/// field.
#[derive(Debug, Default, Clone)]
pub(crate) struct CodecParams {
/// Where/whether to place the Anthropic API version. Direct Anthropic uses
@ -52,6 +54,12 @@ pub(crate) struct CodecParams {
/// Whether to emit Anthropic beta headers (prompt-caching / fast-mode /
/// 1M-context). True on the direct route, false for Kimi-over-anthropic.
pub anthropic_beta: bool,
/// Codex-endpoint dialect for the openai_responses codec: omit the
/// sampling params (`temperature`/`top_p`/`max_output_tokens`) the Codex
/// endpoint rejects and always send `instructions` (empty string when the
/// request has none). The transport-side half of codex mode (forced
/// streaming) is route config, not codec data.
pub openai_codex: bool,
}
/// Placement of the Anthropic API version on the wire.
@ -83,8 +91,9 @@ pub(crate) struct EncodedRequest {
/// One framed item off the byte stream, handed to a [`StreamDecoder`].
pub(crate) struct RawEvent<'a> {
/// SSE `event:` type — `Some` for anthropic; `None` for the data-only
/// framing openai/gemini use.
/// SSE `event:` type — `Some` when the framing carries one (anthropic,
/// openai responses); `None` for the data-only framing
/// openai_compatible/gemini use.
pub event: Option<&'a str>,
/// The `data:` payload, or a bare JSON line. The sentinel `[DONE]` is
/// passed through verbatim for the decoder to recognize.

View file

@ -0,0 +1,360 @@
//! Response decoding: OpenAI Responses API body → canonical `Response`.
use serde::Deserialize;
use super::wire::{ApiResponse, ApiUsage, InputTokensResponse};
use crate::codec::CodecCtx;
use crate::error::Error;
use crate::types::{
ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, TokenCounts, ToolCall,
};
pub(super) fn token_counts_from_api_usage(usage: Option<&ApiUsage>) -> TokenCounts {
usage.map_or_else(TokenCounts::default, |u| {
let cached_tokens = u
.input_tokens_details
.as_ref()
.and_then(|d| d.cached_tokens)
.unwrap_or(0);
let reasoning_tokens = u
.output_tokens_details
.as_ref()
.and_then(|d| d.reasoning_tokens)
.unwrap_or(0);
TokenCounts {
input_tokens: u.input_tokens.saturating_sub(cached_tokens),
output_tokens: u.output_tokens.saturating_sub(reasoning_tokens),
reasoning_tokens,
cache_read_tokens: cached_tokens,
..TokenCounts::default()
}
})
}
/// Map the Responses API status to a `FinishReason`.
pub(super) fn map_finish_reason(status: Option<&str>, has_tool_calls: bool) -> FinishReason {
if has_tool_calls {
return FinishReason::ToolCalls;
}
match status {
Some("completed") | None => FinishReason::Stop,
Some("incomplete") => FinishReason::Length,
Some("failed") => FinishReason::Error,
Some(other) => FinishReason::Other(other.to_string()),
}
}
/// Build a `ToolCall` from a `function_call` / `custom_tool_call` output item.
/// The call-id/item-id round-trip rules live here, shared by the blocking and
/// streaming decode paths.
pub(super) fn tool_call_from_item(item: &serde_json::Value, custom: bool) -> ToolCall {
let item_id = item
.get("id")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let call_id = item
.get("call_id")
.and_then(serde_json::Value::as_str)
.unwrap_or(item_id);
let name = item
.get("name")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let mut tc = if custom {
let raw_input = item
.get("input")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let mut tc = ToolCall::new(call_id, name, serde_json::json!(raw_input));
tc.tool_type = "custom".to_string();
tc.raw_arguments = Some(raw_input.to_string());
tc
} else {
let args_str = item
.get("arguments")
.and_then(serde_json::Value::as_str)
.unwrap_or("{}");
let arguments = serde_json::from_str(args_str).unwrap_or_else(|_| serde_json::json!({}));
let mut tc = ToolCall::new(call_id, name, arguments);
tc.raw_arguments = Some(args_str.to_string());
tc
};
// Preserve item-level ID (fc_xxx) for Responses API round-trip
if !item_id.is_empty() {
tc.provider_metadata = Some(serde_json::json!({"id": item_id}));
}
tc
}
/// Parse output items from the Responses API into content parts.
pub(super) fn parse_output(output: Vec<serde_json::Value>) -> (Vec<ContentPart>, bool) {
let mut parts = Vec::new();
let mut has_tool_calls = false;
for item in output {
let item_type = item
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
match item_type.as_str() {
"message" => {
// Preserve the full message item for Responses API round-tripping.
// The item's `id` and `status` fields are required so that reasoning
// items preceding it can find their "required following item."
let mut texts = Vec::new();
if let Some(content) = item.get("content").and_then(|c| c.as_array()) {
for block in content {
if block.get("type").and_then(serde_json::Value::as_str)
== Some("output_text")
{
if let Some(text) =
block.get("text").and_then(serde_json::Value::as_str)
{
texts.push(ContentPart::text(text));
}
}
}
}
parts.push(ContentPart::Other {
kind: ContentPart::OPENAI_MESSAGE.to_string(),
data: item,
});
parts.extend(texts);
}
"reasoning" => {
parts.push(ContentPart::Other {
kind: ContentPart::OPENAI_REASONING.to_string(),
data: item,
});
}
"function_call" | "custom_tool_call" => {
let tc = tool_call_from_item(&item, item_type == "custom_tool_call");
// Skip tool calls with empty names (e.g. model-internal items)
if tc.name.is_empty() {
continue;
}
has_tool_calls = true;
parts.push(ContentPart::ToolCall(tc));
}
_ => {}
}
}
(parts, has_tool_calls)
}
pub(super) fn decode_response(
body: &str,
ctx: &CodecCtx<'_>,
rate_limit: Option<RateLimitInfo>,
) -> Result<Response, Error> {
let raw: serde_json::Value = serde_json::from_str(body)
.map_err(|e| Error::network(format!("failed to parse OpenAI response: {e}"), e))?;
let api_resp = ApiResponse::deserialize(&raw)
.map_err(|e| Error::network(format!("failed to parse OpenAI response: {e}"), e))?;
let (content_parts, has_tool_calls) = parse_output(api_resp.output);
let finish_reason = map_finish_reason(api_resp.status.as_deref(), has_tool_calls);
let usage = token_counts_from_api_usage(api_resp.usage.as_ref());
Ok(Response {
id: api_resp.id,
model: api_resp.model.unwrap_or_else(|| ctx.request.model.clone()),
provider: ctx.provider_name.to_string(),
message: Message {
role: Role::Assistant,
content: content_parts,
name: None,
tool_call_id: None,
},
finish_reason,
usage,
raw: Some(raw),
warnings: vec![],
rate_limit,
})
}
pub(super) fn decode_count_tokens(body: &str) -> Result<i64, Error> {
let response: InputTokensResponse =
serde_json::from_str(body).map_err(|e| Error::Configuration {
message: format!("failed to parse OpenAI input token response: {e}"),
source: None,
})?;
if response.object != "response.input_tokens" {
return Err(Error::Configuration {
message: format!(
"failed to parse OpenAI input token response: unexpected object '{}'",
response.object
),
source: None,
});
}
Ok(response.input_tokens)
}
#[cfg(test)]
mod tests {
use super::super::encode;
use super::*;
#[test]
fn parse_output_preserves_both_ids_on_function_call() {
let output = vec![serde_json::json!({
"type": "function_call",
"id": "fc_abc123",
"call_id": "call_xyz789",
"name": "get_weather",
"arguments": "{\"location\":\"NYC\"}"
})];
let (parts, has_tool_calls) = parse_output(output);
assert!(has_tool_calls);
assert_eq!(parts.len(), 1);
match &parts[0] {
ContentPart::ToolCall(tc) => {
// call_id is used as the ToolCall.id (links to tool results)
assert_eq!(tc.id, "call_xyz789");
// item-level id (fc_xxx) is preserved in provider_metadata
let meta = tc
.provider_metadata
.as_ref()
.expect("provider_metadata should be set");
assert_eq!(meta["id"], "fc_abc123");
}
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn parse_output_preserves_custom_tool_call_raw_input() {
let patch = "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n";
let output = vec![serde_json::json!({
"type": "custom_tool_call",
"id": "ctc_abc123",
"call_id": "call_xyz789",
"name": "apply_patch",
"input": patch,
})];
let (parts, has_tool_calls) = parse_output(output);
assert!(has_tool_calls);
assert_eq!(parts.len(), 1);
match &parts[0] {
ContentPart::ToolCall(tc) => {
assert_eq!(tc.id, "call_xyz789");
assert_eq!(tc.name, "apply_patch");
assert_eq!(tc.tool_type, "custom");
assert_eq!(tc.arguments, serde_json::json!(patch));
assert_eq!(tc.raw_arguments.as_deref(), Some(patch));
let meta = tc
.provider_metadata
.as_ref()
.expect("provider metadata should preserve item id");
assert_eq!(meta["id"], "ctc_abc123");
}
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn parse_output_preserves_reasoning_items() {
let output = vec![
serde_json::json!({
"type": "reasoning",
"id": "rs_abc123",
"summary": [{"type": "summary_text", "text": "Thinking..."}]
}),
serde_json::json!({
"type": "function_call",
"id": "fc_def456",
"call_id": "call_789",
"name": "search",
"arguments": "{}"
}),
];
let (parts, has_tool_calls) = parse_output(output);
assert!(has_tool_calls);
assert_eq!(parts.len(), 2);
// First part is the reasoning item
match &parts[0] {
ContentPart::Other { kind, data } => {
assert_eq!(kind, ContentPart::OPENAI_REASONING);
assert_eq!(data["type"], "reasoning");
assert_eq!(data["id"], "rs_abc123");
}
other => panic!("expected Other, got {other:?}"),
}
// Second part is the function call
assert!(matches!(&parts[1], ContentPart::ToolCall(_)));
}
#[test]
fn parse_output_preserves_message_items() {
let output = vec![
serde_json::json!({
"type": "reasoning",
"id": "rs_abc",
"summary": []
}),
serde_json::json!({
"type": "message",
"id": "msg_xyz",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "Hello"}]
}),
serde_json::json!({
"type": "function_call",
"id": "fc_123",
"call_id": "call_456",
"name": "search",
"arguments": "{}"
}),
];
let (parts, has_tool_calls) = parse_output(output);
assert!(has_tool_calls);
// reasoning + openai_message + text + function_call
assert_eq!(parts.len(), 4);
assert!(
matches!(&parts[0], ContentPart::Other { kind, .. } if kind == ContentPart::OPENAI_REASONING)
);
assert!(
matches!(&parts[1], ContentPart::Other { kind, data } if kind == ContentPart::OPENAI_MESSAGE && data["id"] == "msg_xyz")
);
assert!(matches!(&parts[2], ContentPart::Text(t) if t == "Hello"));
assert!(matches!(&parts[3], ContentPart::ToolCall(_)));
}
#[test]
fn parse_output_round_trips_function_call_ids() {
// Simulate a response from the Responses API
let output = vec![serde_json::json!({
"type": "function_call",
"id": "fc_item1",
"call_id": "call_001",
"name": "search",
"arguments": "{\"q\":\"test\"}"
})];
let (parts, _) = parse_output(output);
// Now translate back to input format
let msg = Message {
role: Role::Assistant,
content: parts,
name: None,
tool_call_id: None,
};
let (_, input) = encode::translate_input(&[msg]);
let fc = &input[0];
// The round-tripped function call should have correct IDs
assert_eq!(fc["id"], "fc_item1");
assert_eq!(fc["call_id"], "call_001");
}
}

View file

@ -0,0 +1,949 @@
//! Request encoding: canonical request → OpenAI Responses API body.
//!
//! Pure and sync. File-backed image attachments are resolved to inline data by
//! `attachments::resolve` in the adapter *before* encode runs, so the content
//! translation here never touches the filesystem.
use std::collections::HashSet;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use super::wire::ApiRequest;
use crate::codec::{CodecCtx, EncodedRequest};
use crate::types::{
ContentPart, Message, ResponseFormat, ResponseFormatType, Role, ToolChoice, ToolDefinition,
};
// --- Public entry points -----------------------------------------------------
pub(super) fn encode(ctx: &CodecCtx<'_>, stream: bool) -> EncodedRequest {
EncodedRequest {
body: build_body(ctx, stream),
endpoint: "/responses".to_string(),
headers: Vec::new(),
}
}
pub(super) fn encode_count_tokens(ctx: &CodecCtx<'_>) -> EncodedRequest {
EncodedRequest {
body: filter_input_tokens_request_body(build_body(ctx, false)),
endpoint: "/responses/input_tokens".to_string(),
headers: Vec::new(),
}
}
/// Serialize the API request and merge any `provider_options.openai` keys into
/// the body (overrides win, matching the long-standing contract).
fn build_body(ctx: &CodecCtx<'_>, stream: bool) -> serde_json::Value {
let api_request = build_api_request(ctx, stream);
let mut body = serde_json::to_value(&api_request).unwrap_or_else(|_| serde_json::json!({}));
if let Some(openai_opts) = ctx
.request
.provider_options
.as_ref()
.and_then(|opts| opts.get("openai"))
{
if let (Some(base), Some(overrides)) = (body.as_object_mut(), openai_opts.as_object()) {
for (key, value) in overrides {
base.insert(key.clone(), value.clone());
}
}
}
body
}
/// Build an `ApiRequest` from the canonical request.
///
/// When the route is in codex mode (`ctx.params.openai_codex`), unsupported
/// fields (`temperature`, `max_output_tokens`, `top_p`) are omitted and empty
/// instructions are sent as `""` (required by the Codex endpoint).
fn build_api_request(ctx: &CodecCtx<'_>, stream: bool) -> ApiRequest {
let request = ctx.request;
let codex_mode = ctx.params.openai_codex;
let (instructions, input) = translate_input(&request.messages);
let api_tools = request.tools.as_ref().map(|t| translate_tools(t));
let tool_choice = request.tool_choice.as_ref().map(translate_tool_choice);
let reasoning = request
.reasoning_effort
.as_ref()
.map(|effort| serde_json::json!({"effort": <&'static str>::from(*effort)}));
let text = request
.response_format
.as_ref()
.and_then(translate_response_format);
let include = vec!["reasoning.encrypted_content".to_string()];
let instructions = if codex_mode {
Some(instructions.unwrap_or_default())
} else {
instructions
};
ApiRequest {
model: ctx.deployment_id.to_string(),
input,
instructions,
temperature: if codex_mode {
None
} else {
request.temperature
},
max_output_tokens: if codex_mode { None } else { request.max_tokens },
top_p: if codex_mode { None } else { request.top_p },
tools: api_tools,
tool_choice,
reasoning,
text,
stop: request.stop_sequences.clone(),
metadata: request.metadata.clone(),
// store: false means output items are not persisted server-side.
// Request encrypted reasoning content on every turn so reasoning items
// from models that emit them by default can round-trip statelessly.
store: false,
include,
stream,
}
}
/// Project a full request body down to the fields the
/// `/responses/input_tokens` endpoint accepts.
fn filter_input_tokens_request_body(mut body: serde_json::Value) -> serde_json::Value {
const ALLOWED_FIELDS: &[&str] = &[
"conversation",
"input",
"instructions",
"model",
"parallel_tool_calls",
"previous_response_id",
"reasoning",
"text",
"tool_choice",
"tools",
"truncation",
];
let Some(obj) = body.as_object_mut() else {
return serde_json::json!({});
};
obj.retain(|key, _| ALLOWED_FIELDS.contains(&key.as_str()));
body
}
// --- Content / message / tool translation ------------------------------------
/// Translate unified messages to Responses API `input` array format. Sync:
/// file-backed image attachments are already resolved to inline data upstream.
pub(super) fn translate_input(messages: &[Message]) -> (Option<String>, Vec<serde_json::Value>) {
let mut instructions_parts: Vec<String> = Vec::new();
let mut input: Vec<serde_json::Value> = Vec::new();
let mut custom_call_ids: HashSet<String> = HashSet::new();
for msg in messages {
match msg.role {
Role::System | Role::Developer => {
instructions_parts.push(msg.text());
}
Role::User => {
let mut content = Vec::new();
for part in &msg.content {
let maybe_content = match part {
ContentPart::Text(text) => {
Some(serde_json::json!({"type": "input_text", "text": text}))
}
ContentPart::Image(img) => match &img.url {
Some(url) => {
Some(serde_json::json!({"type": "input_image", "image_url": url}))
}
None => img.data.as_ref().map(|data| {
let mime = img.media_type.as_deref().unwrap_or("image/png");
let b64 = BASE64_STANDARD.encode(data);
serde_json::json!({
"type": "input_image",
"image_url": format!("data:{mime};base64,{b64}"),
})
}),
},
ContentPart::Audio(_) => Some(
serde_json::json!({"type": "input_text", "text": "[Audio content not supported by this provider]"}),
),
ContentPart::Document(doc) => {
let desc = doc.file_name.as_ref().map_or_else(
|| "[Document content not supported by this provider]".to_string(),
|name| format!("[Document '{name}': content type not supported by this provider]"),
);
Some(serde_json::json!({"type": "input_text", "text": desc}))
}
_ => None,
};
if let Some(content_part) = maybe_content {
content.push(content_part);
}
}
if !content.is_empty() {
input.push(serde_json::json!({
"type": "message",
"role": "user",
"content": content,
}));
}
}
Role::Assistant => {
// If we have a preserved opaque message item (with id/status), use
// it instead of constructing a new message from Text parts. This is
// required so that reasoning items can find their "required following
// item" during Responses API round-tripping.
let has_opaque_message = msg.content.iter().any(|p| {
matches!(p, ContentPart::Other { kind, .. } if kind == ContentPart::OPENAI_MESSAGE)
});
for part in &msg.content {
match part {
ContentPart::Text(text) if !has_opaque_message => {
input.push(serde_json::json!({
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": text}],
}));
}
ContentPart::ToolCall(tc) if !tc.name.is_empty() => {
// Use the item-level ID (fc_xxx) for the `id` field;
// fall back to tc.id if no provider_metadata was stored.
let item_id = tc
.provider_metadata
.as_ref()
.and_then(|m| m.get("id"))
.and_then(serde_json::Value::as_str)
.unwrap_or(&tc.id);
if tc.tool_type == "custom" {
custom_call_ids.insert(tc.id.clone());
let raw_input = tc.raw_arguments.as_ref().map_or_else(
|| {
tc.arguments.as_str().map_or_else(
|| tc.arguments.to_string(),
str::to_string,
)
},
Clone::clone,
);
input.push(serde_json::json!({
"type": "custom_tool_call",
"id": item_id,
"call_id": tc.id,
"name": tc.name,
"input": raw_input,
}));
} else {
let args = tc
.raw_arguments
.as_ref()
.map_or_else(|| tc.arguments.to_string(), Clone::clone);
input.push(serde_json::json!({
"type": "function_call",
"id": item_id,
"call_id": tc.id,
"name": tc.name,
"arguments": args,
}));
}
}
ContentPart::Other { data, .. } if part.is_opaque_openai() => {
input.push(data.clone());
}
_ => {}
}
}
}
Role::Tool => {
for part in &msg.content {
if let ContentPart::ToolResult(tr) = part {
let output = tr
.content
.as_str()
.map_or_else(|| tr.content.to_string(), str::to_string);
let is_custom = custom_call_ids.contains(&tr.tool_call_id)
|| msg.name.as_deref() == Some("apply_patch");
let mut item = if is_custom {
serde_json::json!({
"type": "custom_tool_call_output",
"call_id": tr.tool_call_id,
"output": output,
})
} else {
serde_json::json!({
"type": "function_call_output",
"call_id": tr.tool_call_id,
"output": output,
})
};
if tr.is_error && !is_custom {
item["status"] = serde_json::json!("incomplete");
}
input.push(item);
}
}
}
}
}
let instructions = if instructions_parts.is_empty() {
None
} else {
Some(instructions_parts.join("\n"))
};
(instructions, input)
}
/// Translate unified tool definitions to Responses API tool format.
pub(super) fn translate_tools(tools: &[ToolDefinition]) -> Vec<serde_json::Value> {
tools
.iter()
.map(|t| {
if t.is_custom() {
serde_json::json!({
"type": "custom",
"name": t.name,
"description": t.description,
"format": t.custom_format().cloned().unwrap_or_else(|| serde_json::json!({})),
})
} else {
serde_json::json!({
"type": "function",
"name": t.name,
"description": t.description,
"parameters": t.parameters,
})
}
})
.collect()
}
/// Translate unified `ToolChoice` to Responses API format.
fn translate_tool_choice(choice: &ToolChoice) -> serde_json::Value {
match choice {
ToolChoice::Auto => serde_json::json!("auto"),
ToolChoice::None => serde_json::json!("none"),
ToolChoice::Required => serde_json::json!("required"),
ToolChoice::Named { tool_name } => {
serde_json::json!({"type": "function", "name": tool_name})
}
}
}
/// Translate unified `ResponseFormat` to Responses API `text` field.
///
/// The Responses API uses `"text": {"format": {...}}` for structured output.
fn translate_response_format(format: &ResponseFormat) -> Option<serde_json::Value> {
match format.kind {
ResponseFormatType::Text => None,
ResponseFormatType::JsonObject => {
Some(serde_json::json!({"format": {"type": "json_object"}}))
}
ResponseFormatType::JsonSchema => {
let mut schema_obj = serde_json::json!({
"type": "json_schema",
"name": "response",
"strict": format.strict,
});
if let Some(schema) = &format.json_schema {
schema_obj["schema"] = schema.clone();
}
Some(serde_json::json!({"format": schema_obj}))
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::codec::CodecParams;
use crate::types::{AudioData, DocumentData, ReasoningEffort, Request, ToolCall, ToolResult};
fn minimal_request() -> Request {
Request {
model: "gpt-4o".to_string(),
messages: vec![Message::user("Hello")],
provider: None,
tools: None,
tool_choice: None,
response_format: None,
temperature: None,
top_p: None,
max_tokens: None,
stop_sequences: None,
reasoning_effort: None,
speed: None,
metadata: None,
provider_options: None,
}
}
/// Encode `request` (no catalog: the wire model id is the request model)
/// and return the merged body, mirroring the adapter's encode path.
fn encode_body(request: &Request, stream: bool, codex: bool) -> serde_json::Value {
let params = CodecParams {
openai_codex: codex,
..CodecParams::default()
};
let ctx = CodecCtx {
request,
provider_name: "openai",
deployment_id: &request.model,
model: None,
params: &params,
};
encode(&ctx, stream).body
}
#[test]
fn build_request_body_includes_metadata() {
let mut metadata = HashMap::new();
metadata.insert("user_id".to_string(), "u123".to_string());
metadata.insert("session".to_string(), "s456".to_string());
let mut request = minimal_request();
request.metadata = Some(metadata);
let body = encode_body(&request, false, false);
let meta = body.get("metadata").expect("metadata should be present");
assert_eq!(meta["user_id"], "u123");
assert_eq!(meta["session"], "s456");
}
#[test]
fn build_request_body_omits_metadata_when_none() {
let request = minimal_request();
let body = encode_body(&request, false, false);
assert!(body.get("metadata").is_none());
}
#[test]
fn build_request_body_merges_provider_options_openai() {
let mut request = minimal_request();
request.provider_options = Some(serde_json::json!({
"openai": {
"store": true,
"previous_response_id": "resp_abc123"
}
}));
let body = encode_body(&request, false, false);
assert_eq!(body["store"], true);
assert_eq!(body["previous_response_id"], "resp_abc123");
}
#[test]
fn build_request_body_provider_options_override_fields() {
let mut request = minimal_request();
request.temperature = Some(0.5);
request.provider_options = Some(serde_json::json!({
"openai": {
"temperature": 0.9
}
}));
let body = encode_body(&request, false, false);
// provider_options should override the base field
assert_eq!(body["temperature"], 0.9);
}
#[test]
fn build_request_body_ignores_non_openai_provider_options() {
let mut request = minimal_request();
request.provider_options = Some(serde_json::json!({
"anthropic": {
"thinking": {"type": "enabled", "budget_tokens": 10000}
}
}));
let body = encode_body(&request, false, false);
// anthropic options should not leak into the OpenAI request
assert!(body.get("thinking").is_none());
}
#[test]
fn build_request_body_no_provider_options() {
let request = minimal_request();
let body = encode_body(&request, false, false);
assert_eq!(body["model"], "gpt-4o");
// stream field is omitted when false (skip_serializing_if)
assert!(body.get("stream").is_none());
}
#[test]
fn filter_input_tokens_request_body_keeps_only_count_fields() {
let mut metadata = HashMap::new();
metadata.insert("trace".to_string(), "abc".to_string());
let mut request = minimal_request();
request.tools = Some(vec![ToolDefinition::function(
"search",
"Search files",
serde_json::json!({"type": "object"}),
)]);
request.reasoning_effort = Some(ReasoningEffort::Low);
request.response_format = Some(ResponseFormat {
kind: ResponseFormatType::JsonSchema,
json_schema: Some(serde_json::json!({"type": "object"})),
strict: true,
});
request.temperature = Some(0.2);
request.top_p = Some(0.9);
request.max_tokens = Some(32);
request.stop_sequences = Some(vec!["END".to_string()]);
request.metadata = Some(metadata);
let body = encode_body(&request, true, false);
let filtered = filter_input_tokens_request_body(body);
assert_eq!(
filtered,
serde_json::json!({
"input": [{"type": "message", "content": [{"text": "Hello", "type": "input_text"}], "role": "user"}],
"model": "gpt-4o",
"reasoning": {"effort": "low"},
"text": {"format": {"name": "response", "schema": {"type": "object"}, "strict": true, "type": "json_schema"}},
"tools": [{"description": "Search files", "name": "search", "parameters": {"type": "object"}, "type": "function"}]
})
);
assert!(filtered.get("store").is_none());
assert!(filtered.get("include").is_none());
assert!(filtered.get("stream").is_none());
assert!(filtered.get("max_output_tokens").is_none());
assert!(filtered.get("metadata").is_none());
assert!(filtered.get("temperature").is_none());
assert!(filtered.get("top_p").is_none());
assert!(filtered.get("stop").is_none());
}
#[test]
fn filter_input_tokens_request_body_preserves_codex_serialization() {
let body = encode_body(&minimal_request(), false, true);
let filtered = filter_input_tokens_request_body(body);
assert_eq!(filtered["instructions"], "");
assert!(filtered.get("input").is_some());
assert!(filtered.get("model").is_some());
assert!(filtered.get("max_output_tokens").is_none());
assert!(filtered.get("include").is_none());
}
#[test]
fn count_tokens_endpoint_carries_filtered_body() {
let request = minimal_request();
let params = CodecParams::default();
let ctx = CodecCtx {
request: &request,
provider_name: "openai",
deployment_id: &request.model,
model: None,
params: &params,
};
let encoded = encode_count_tokens(&ctx);
assert_eq!(encoded.endpoint, "/responses/input_tokens");
assert!(encoded.body.get("store").is_none());
assert!(encoded.body.get("include").is_none());
assert_eq!(encoded.body["model"], "gpt-4o");
}
#[test]
fn build_request_body_includes_encrypted_reasoning_for_stateless_requests() {
let request = minimal_request();
let body = encode_body(&request, false, false);
assert_eq!(
body["include"],
serde_json::json!(["reasoning.encrypted_content"])
);
}
#[test]
fn build_request_body_emits_custom_apply_patch_tool() {
let mut request = minimal_request();
request.tools = Some(vec![
ToolDefinition::custom(
"apply_patch",
"Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.",
serde_json::json!({
"type": "grammar",
"syntax": "lark",
"definition": "start: begin_patch hunk+ end_patch",
}),
),
ToolDefinition::function(
"read_file",
"Read file",
serde_json::json!({
"type": "object",
"properties": {"file_path": {"type": "string"}},
"required": ["file_path"],
}),
),
]);
let body = encode_body(&request, false, false);
let tools = body["tools"].as_array().expect("tools should be present");
let apply_patch = tools
.iter()
.find(|tool| tool["name"] == "apply_patch")
.expect("apply_patch tool should be present");
let read_file = tools
.iter()
.find(|tool| tool["name"] == "read_file")
.expect("read_file tool should be present");
assert_eq!(apply_patch["type"], "custom");
assert_eq!(apply_patch["format"]["type"], "grammar");
assert_eq!(apply_patch["format"]["syntax"], "lark");
assert!(apply_patch.get("parameters").is_none());
assert_eq!(read_file["type"], "function");
assert_eq!(read_file["parameters"]["type"], "object");
}
#[test]
fn build_request_body_stream_flag() {
let request = minimal_request();
let body = encode_body(&request, true, false);
assert!(body["stream"].as_bool().unwrap_or(false));
}
#[test]
fn build_request_body_metadata_and_provider_options_together() {
let mut metadata = HashMap::new();
metadata.insert("trace_id".to_string(), "t789".to_string());
let mut request = minimal_request();
request.metadata = Some(metadata);
request.provider_options = Some(serde_json::json!({
"openai": {
"store": true
}
}));
let body = encode_body(&request, false, false);
assert_eq!(body["metadata"]["trace_id"], "t789");
assert_eq!(body["store"], true);
}
#[test]
fn build_request_body_includes_stop_sequences() {
let mut request = minimal_request();
request.stop_sequences = Some(vec!["END".to_string(), "STOP".to_string()]);
let body = encode_body(&request, false, false);
let stop = body.get("stop").expect("stop should be present");
let arr = stop.as_array().expect("stop should be an array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0], "END");
assert_eq!(arr[1], "STOP");
}
#[test]
fn build_request_body_omits_stop_when_none() {
let request = minimal_request();
let body = encode_body(&request, false, false);
assert!(body.get("stop").is_none());
}
#[test]
fn audio_content_produces_text_fallback() {
let msg = Message {
role: Role::User,
content: vec![ContentPart::Audio(AudioData {
url: Some("https://example.com/audio.wav".to_string()),
data: None,
media_type: None,
})],
name: None,
tool_call_id: None,
};
let (_, input) = translate_input(&[msg]);
let content = input[0]["content"]
.as_array()
.expect("content should be array");
assert_eq!(content[0]["type"], "input_text");
assert_eq!(
content[0]["text"],
"[Audio content not supported by this provider]"
);
}
#[test]
fn document_content_produces_text_fallback_with_filename() {
let msg = Message {
role: Role::User,
content: vec![ContentPart::Document(DocumentData {
url: Some("https://example.com/doc.pdf".to_string()),
data: None,
media_type: None,
file_name: Some("report.pdf".to_string()),
})],
name: None,
tool_call_id: None,
};
let (_, input) = translate_input(&[msg]);
let content = input[0]["content"]
.as_array()
.expect("content should be array");
assert_eq!(content[0]["type"], "input_text");
assert_eq!(
content[0]["text"],
"[Document 'report.pdf': content type not supported by this provider]"
);
}
#[test]
fn document_content_produces_text_fallback_without_filename() {
let msg = Message {
role: Role::User,
content: vec![ContentPart::Document(DocumentData {
url: None,
data: Some(vec![1, 2, 3]),
media_type: None,
file_name: None,
})],
name: None,
tool_call_id: None,
};
let (_, input) = translate_input(&[msg]);
let content = input[0]["content"]
.as_array()
.expect("content should be array");
assert_eq!(content[0]["type"], "input_text");
assert_eq!(
content[0]["text"],
"[Document content not supported by this provider]"
);
}
#[test]
fn translate_input_uses_item_id_for_id_field() {
let mut tc = ToolCall::new(
"call_xyz789",
"get_weather",
serde_json::json!({"location": "NYC"}),
);
tc.provider_metadata = Some(serde_json::json!({"id": "fc_abc123"}));
let msg = Message {
role: Role::Assistant,
content: vec![ContentPart::ToolCall(tc)],
name: None,
tool_call_id: None,
};
let (_, input) = translate_input(&[msg]);
let fc = &input[0];
assert_eq!(fc["type"], "function_call");
// id field uses the fc_ prefixed item ID
assert_eq!(fc["id"], "fc_abc123");
// call_id field uses the call_ prefixed call ID
assert_eq!(fc["call_id"], "call_xyz789");
}
#[test]
fn translate_input_falls_back_to_tc_id_without_metadata() {
let tc = ToolCall::new("call_xyz789", "get_weather", serde_json::json!({}));
let msg = Message {
role: Role::Assistant,
content: vec![ContentPart::ToolCall(tc)],
name: None,
tool_call_id: None,
};
let (_, input) = translate_input(&[msg]);
let fc = &input[0];
// Without provider_metadata, both fields use tc.id
assert_eq!(fc["id"], "call_xyz789");
assert_eq!(fc["call_id"], "call_xyz789");
}
#[test]
fn reasoning_items_round_trip_through_translate_input() {
let reasoning = serde_json::json!({
"type": "reasoning",
"id": "rs_abc123",
"summary": [{"type": "summary_text", "text": "Thinking..."}]
});
let mut tc = ToolCall::new("call_789", "search", serde_json::json!({}));
tc.provider_metadata = Some(serde_json::json!({"id": "fc_def456"}));
let msg = Message {
role: Role::Assistant,
content: vec![
ContentPart::Other {
kind: ContentPart::OPENAI_REASONING.to_string(),
data: reasoning,
},
ContentPart::ToolCall(tc),
],
name: None,
tool_call_id: None,
};
let (_, input) = translate_input(&[msg]);
assert_eq!(input.len(), 2);
// Reasoning item is emitted first
assert_eq!(input[0]["type"], "reasoning");
assert_eq!(input[0]["id"], "rs_abc123");
// Function call follows
assert_eq!(input[1]["type"], "function_call");
assert_eq!(input[1]["id"], "fc_def456");
assert_eq!(input[1]["call_id"], "call_789");
}
#[test]
fn reasoning_message_function_call_round_trip() {
// Simulates an assistant turn with reasoning + text + tool call.
// The opaque message item (with id/status) must be used instead of
// constructing a new one from Text, so the reasoning item can find
// its "required following item."
let reasoning = serde_json::json!({
"type": "reasoning",
"id": "rs_xyz789",
"summary": [{"type": "summary_text", "text": "Let me check..."}]
});
let opaque_message = serde_json::json!({
"type": "message",
"id": "msg_abc123",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "Checking now."}]
});
let mut tc = ToolCall::new("call_001", "shell", serde_json::json!({"cmd": "ls"}));
tc.provider_metadata = Some(serde_json::json!({"id": "fc_def456"}));
let msg = Message {
role: Role::Assistant,
content: vec![
ContentPart::Other {
kind: ContentPart::OPENAI_REASONING.to_string(),
data: reasoning,
},
ContentPart::Other {
kind: ContentPart::OPENAI_MESSAGE.to_string(),
data: opaque_message,
},
ContentPart::text("Checking now."),
ContentPart::ToolCall(tc),
],
name: None,
tool_call_id: None,
};
let (_, input) = translate_input(&[msg]);
assert_eq!(input.len(), 3);
// Reasoning first
assert_eq!(input[0]["type"], "reasoning");
assert_eq!(input[0]["id"], "rs_xyz789");
// Opaque message with id/status (not a reconstructed one)
assert_eq!(input[1]["type"], "message");
assert_eq!(input[1]["id"], "msg_abc123");
assert_eq!(input[1]["status"], "completed");
// Function call last
assert_eq!(input[2]["type"], "function_call");
assert_eq!(input[2]["id"], "fc_def456");
}
#[test]
fn text_without_opaque_message_still_constructs_message() {
// For non-OpenAI turns or turns without preserved message items,
// Text parts should still produce a constructed message.
let msg = Message {
role: Role::Assistant,
content: vec![ContentPart::text("Hello")],
name: None,
tool_call_id: None,
};
let (_, input) = translate_input(&[msg]);
assert_eq!(input.len(), 1);
assert_eq!(input[0]["type"], "message");
assert_eq!(input[0]["role"], "assistant");
// No id field on constructed messages
assert!(input[0].get("id").is_none());
}
#[test]
fn custom_tool_call_history_round_trips_through_translate_input() {
let patch = "*** Begin Patch\n*** Delete File: stale.txt\n*** End Patch\n";
let mut tc = ToolCall::new("call_001", "apply_patch", serde_json::json!(patch));
tc.tool_type = "custom".to_string();
tc.raw_arguments = Some(patch.to_string());
tc.provider_metadata = Some(serde_json::json!({"id": "ctc_def456"}));
let msg = Message {
role: Role::Assistant,
content: vec![ContentPart::ToolCall(tc)],
name: None,
tool_call_id: None,
};
let (_, input) = translate_input(&[msg]);
assert_eq!(input.len(), 1);
assert_eq!(input[0]["type"], "custom_tool_call");
assert_eq!(input[0]["id"], "ctc_def456");
assert_eq!(input[0]["call_id"], "call_001");
assert_eq!(input[0]["name"], "apply_patch");
assert_eq!(input[0]["input"], patch);
}
#[test]
fn custom_tool_result_history_round_trips_through_translate_input() {
let msg = Message {
role: Role::Tool,
content: vec![ContentPart::ToolResult(ToolResult::success(
"call_001",
serde_json::json!("Success. Updated the following files:\nA hello.txt\n"),
))],
name: Some("apply_patch".to_string()),
tool_call_id: Some("call_001".to_string()),
};
let (_, input) = translate_input(&[msg]);
assert_eq!(input.len(), 1);
assert_eq!(input[0]["type"], "custom_tool_call_output");
assert_eq!(input[0]["call_id"], "call_001");
assert_eq!(
input[0]["output"],
"Success. Updated the following files:\nA hello.txt\n"
);
}
#[test]
fn custom_tool_result_history_uses_prior_custom_call_without_tool_message_name() {
let patch = "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n";
let mut tc = ToolCall::new("call_001", "apply_patch", serde_json::json!(patch));
tc.tool_type = "custom".to_string();
tc.raw_arguments = Some(patch.to_string());
tc.provider_metadata = Some(serde_json::json!({"id": "ctc_def456"}));
let assistant_msg = Message {
role: Role::Assistant,
content: vec![ContentPart::ToolCall(tc)],
name: None,
tool_call_id: None,
};
let tool_msg = Message::tool_result(
"call_001",
serde_json::json!("Success. Updated the following files:\nA hello.txt\n"),
false,
);
let (_, input) = translate_input(&[assistant_msg, tool_msg]);
assert_eq!(input.len(), 2);
assert_eq!(input[1]["type"], "custom_tool_call_output");
assert_eq!(input[1]["call_id"], "call_001");
assert_eq!(
input[1]["output"],
"Success. Updated the following files:\nA hello.txt\n"
);
}
}

View file

@ -0,0 +1,55 @@
//! The OpenAI Responses (`/responses`) codec.
//!
//! Serves OpenAI direct today, in two route flavors that share this codec:
//! the standard route and the Codex route (`CodecParams::openai_codex`, which
//! omits sampling params encode-side; its forced streaming lives in the
//! adapter's route config). Pure translation: no HTTP, auth, or base URL —
//! the adapter shell owns those.
//!
//! HTTP error bodies use the shared `decode_error` default (openai uses the
//! standard `error_from_status_code` + `parse_error_body` path); streaming
//! `error` / `response.failed` events are mapped inside the decoder
//! (`on_event` → `Err`).
mod decode;
mod encode;
mod stream;
mod wire;
use crate::codec::{Codec, CodecCtx, EncodedRequest, StreamDecoder};
use crate::error::Error;
use crate::types::{RateLimitInfo, Response};
/// Codec for the OpenAI Responses wire dialect.
pub(crate) struct OpenAiResponses;
impl Codec for OpenAiResponses {
fn encode(&self, ctx: &CodecCtx<'_>, stream: bool) -> Result<EncodedRequest, Error> {
Ok(encode::encode(ctx, stream))
}
fn decode_response(
&self,
body: &str,
ctx: &CodecCtx<'_>,
rate_limit: Option<RateLimitInfo>,
) -> Result<Response, Error> {
decode::decode_response(body, ctx, rate_limit)
}
fn stream_decoder(
&self,
ctx: &CodecCtx<'_>,
rate_limit: Option<RateLimitInfo>,
) -> Box<dyn StreamDecoder> {
Box::new(stream::SseAccumulator::new(ctx, rate_limit))
}
fn encode_count_tokens(&self, ctx: &CodecCtx<'_>) -> Option<Result<EncodedRequest, Error>> {
Some(Ok(encode::encode_count_tokens(ctx)))
}
fn decode_count_tokens(&self, body: &str) -> Result<i64, Error> {
decode::decode_count_tokens(body)
}
}

View file

@ -0,0 +1,868 @@
//! Streaming decoder: OpenAI Responses SSE events → canonical `StreamEvent`s.
//!
//! Byte reading and SSE block framing live in the transport; this decoder is
//! fed framed `RawEvent`s. The event type is resolved from the SSE `event:`
//! line or the JSON `type` field. The Responses API finishes via
//! `response.completed` / `response.incomplete`; byte-stream end synthesizes
//! nothing, so `finish()` returns an empty list.
use serde::Deserialize;
use super::decode::{map_finish_reason, token_counts_from_api_usage, tool_call_from_item};
use super::wire::ApiUsage;
use crate::codec::{CodecCtx, RawEvent, StreamDecoder};
use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind};
use crate::types::{
ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, StreamEvent, TokenCounts,
ToolCall,
};
/// Map an OpenAI stream `error` / `response.failed` payload to a provider
/// error, classifying on `code` falling back to `type`.
fn provider_error_from_openai_error_json(error: &serde_json::Value, provider: &str) -> Error {
let classifier = error
.get("code")
.and_then(serde_json::Value::as_str)
.filter(|code| !code.is_empty())
.or_else(|| {
error
.get("type")
.and_then(serde_json::Value::as_str)
.filter(|error_type| !error_type.is_empty())
});
let message = error
.get("message")
.and_then(serde_json::Value::as_str)
.filter(|message| !message.is_empty())
.map_or_else(|| "OpenAI stream error".to_string(), str::to_string);
let kind = match classifier {
Some("insufficient_quota" | "billing_hard_limit_reached") => {
ProviderErrorKind::QuotaExceeded
}
Some("rate_limit_error" | "rate_limit_exceeded" | "too_many_requests") => {
ProviderErrorKind::RateLimit
}
Some("authentication_error" | "invalid_api_key" | "invalid_authentication") => {
ProviderErrorKind::Authentication
}
Some(
"access_denied" | "account_deactivated" | "permission_denied" | "permission_error",
) => ProviderErrorKind::AccessDenied,
Some("content_filter" | "content_policy_violation") => ProviderErrorKind::ContentFilter,
Some("context_length_exceeded") => ProviderErrorKind::ContextLength,
Some("server_error" | "internal_error" | "service_unavailable" | "engine_overloaded") => {
ProviderErrorKind::Server
}
Some(code) if code.ends_with("_not_found") => ProviderErrorKind::NotFound,
Some(code)
if code.starts_with("invalid_")
|| code.starts_with("unsupported_")
|| code.ends_with("_too_large")
|| code.ends_with("_too_long") =>
{
ProviderErrorKind::InvalidRequest
}
Some(_) | None => ProviderErrorKind::Server,
};
Error::Provider {
kind,
detail: Box::new(ProviderErrorDetail {
message,
provider: provider.to_string(),
status_code: None,
error_code: classifier.map(str::to_string),
retry_after: None,
raw: Some(error.clone()),
}),
}
}
/// Accumulated state across SSE events during streaming.
pub(super) struct SseAccumulator {
/// Requested model, used as the fallback when the response omits one.
model: String,
/// Configured provider name stamped into responses and error details.
provider: String,
response_id: String,
response_model: String,
accumulated_text: String,
tool_calls: Vec<ToolCall>,
/// Raw reasoning output items to preserve for round-tripping.
reasoning_items: Vec<serde_json::Value>,
/// Raw message output items to preserve for round-tripping.
message_items: Vec<serde_json::Value>,
usage: TokenCounts,
finish_reason: FinishReason,
emitted_start: bool,
emitted_text_start: bool,
emitted_reasoning_start: bool,
rate_limit: Option<RateLimitInfo>,
}
impl SseAccumulator {
pub(super) fn new(ctx: &CodecCtx<'_>, rate_limit: Option<RateLimitInfo>) -> Self {
Self {
model: ctx.request.model.clone(),
provider: ctx.provider_name.to_string(),
response_id: String::new(),
response_model: String::new(),
accumulated_text: String::new(),
tool_calls: Vec::new(),
reasoning_items: Vec::new(),
message_items: Vec::new(),
usage: TokenCounts::default(),
finish_reason: FinishReason::Stop,
emitted_start: false,
emitted_text_start: false,
emitted_reasoning_start: false,
rate_limit,
}
}
/// Process a single SSE event and return the corresponding
/// `StreamEvent`(s).
fn process_sse_event(
&mut self,
event_type: Option<&str>,
data: &str,
) -> Result<Vec<StreamEvent>, Error> {
let mut events = Vec::new();
if !self.emitted_start {
self.emitted_start = true;
events.push(StreamEvent::StreamStart);
}
let json: serde_json::Value = match serde_json::from_str(data) {
Ok(v) => v,
Err(_) => return Ok(events),
};
// Resolve event type from the `event:` SSE line or from the JSON `type`
// field.
let resolved_type = event_type
.or_else(|| json.get("type").and_then(serde_json::Value::as_str))
.unwrap_or_default();
match resolved_type {
"error" => {
let error = json.get("error").unwrap_or(&json);
return Err(provider_error_from_openai_error_json(error, &self.provider));
}
"response.created" => self.handle_response_created(&json),
"response.output_text.delta" => self.handle_text_delta(&json, &mut events),
"response.function_call_arguments.delta" => {
self.handle_tool_call_delta(&json, &mut events, "function");
}
"response.custom_tool_call_input.delta" => {
self.handle_tool_call_delta(&json, &mut events, "custom");
}
"response.output_item.done" => self.handle_output_item_done(&json, &mut events),
"response.completed" | "response.incomplete" => {
self.handle_response_completed(&json, &mut events);
}
"response.failed" => {
let error = json
.get("response")
.and_then(|response| response.get("error"))
.unwrap_or(&json);
return Err(provider_error_from_openai_error_json(error, &self.provider));
}
"response.reasoning_summary_text.delta" | "response.reasoning_text.delta" => {
if let Some(delta) = json.get("delta").and_then(serde_json::Value::as_str) {
if !self.emitted_reasoning_start {
self.emitted_reasoning_start = true;
events.push(StreamEvent::ReasoningStart);
}
events.push(StreamEvent::ReasoningDelta {
delta: delta.to_string(),
});
}
}
// response.reasoning_summary_part.added and other unrecognized
// events are no-ops
_ => {}
}
Ok(events)
}
/// Handle `response.created` by extracting the response ID and model.
fn handle_response_created(&mut self, json: &serde_json::Value) {
if let Some(id) = json
.get("response")
.and_then(|r| r.get("id"))
.and_then(serde_json::Value::as_str)
{
self.response_id = id.to_string();
}
if let Some(model) = json
.get("response")
.and_then(|r| r.get("model"))
.and_then(serde_json::Value::as_str)
{
self.response_model = model.to_string();
}
}
/// Handle `response.output_text.delta` by accumulating text and emitting
/// events.
fn handle_text_delta(&mut self, json: &serde_json::Value, events: &mut Vec<StreamEvent>) {
if let Some(delta) = json.get("delta").and_then(serde_json::Value::as_str) {
if !self.emitted_text_start {
self.emitted_text_start = true;
events.push(StreamEvent::TextStart { text_id: None });
}
self.accumulated_text.push_str(delta);
events.push(StreamEvent::text_delta(delta, None));
}
}
/// Handle `response.function_call_arguments.delta` /
/// `response.custom_tool_call_input.delta` by accumulating args and
/// emitting events.
fn handle_tool_call_delta(
&mut self,
json: &serde_json::Value,
events: &mut Vec<StreamEvent>,
tool_type: &str,
) {
let Some(delta) = json.get("delta").and_then(serde_json::Value::as_str) else {
return;
};
let call_id = json
.get("call_id")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let item_id = json
.get("item_id")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let lookup_id = if call_id.is_empty() { item_id } else { call_id };
let idx = if let Some(idx) = self.tool_calls.iter().position(|tc| tc.id == lookup_id) {
let tc = &mut self.tool_calls[idx];
if let Some(raw) = &mut tc.raw_arguments {
raw.push_str(delta);
}
// Custom tool input is its raw string; keep `arguments` in sync as
// it accumulates.
if tool_type == "custom" {
if let serde_json::Value::String(args) = &mut tc.arguments {
args.push_str(delta);
}
}
idx
} else {
let name = json
.get("name")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let mut tc = ToolCall::new(
lookup_id,
name,
if tool_type == "custom" {
serde_json::json!(delta)
} else {
serde_json::json!({})
},
);
tc.tool_type = tool_type.to_string();
tc.raw_arguments = Some(delta.to_string());
// Preserve item-level ID (fc_xxx) for Responses API round-trip
if !item_id.is_empty() && item_id != lookup_id {
tc.provider_metadata = Some(serde_json::json!({"id": item_id}));
}
events.push(StreamEvent::ToolCallStart {
tool_call: tc.clone(),
});
self.tool_calls.push(tc);
self.tool_calls.len() - 1
};
// The delta event carries the call identity, the arguments
// accumulated so far, and this chunk in `raw_arguments`.
let current = &self.tool_calls[idx];
let mut tool_call = ToolCall::new(&*current.id, &*current.name, current.arguments.clone());
tool_call.tool_type = tool_type.to_string();
tool_call.raw_arguments = Some(delta.to_string());
tool_call
.provider_metadata
.clone_from(&current.provider_metadata);
events.push(StreamEvent::ToolCallDelta { tool_call });
}
/// Handle `response.output_item.done` for text and function call items.
fn handle_output_item_done(&mut self, json: &serde_json::Value, events: &mut Vec<StreamEvent>) {
let item = json.get("item").unwrap_or(json);
let item_type = item.get("type").and_then(serde_json::Value::as_str);
match item_type {
Some("reasoning") => {
if self.emitted_reasoning_start {
self.emitted_reasoning_start = false;
events.push(StreamEvent::ReasoningEnd);
}
self.reasoning_items.push(item.clone());
}
Some("message") => {
if self.emitted_text_start {
events.push(StreamEvent::TextEnd { text_id: None });
self.emitted_text_start = false;
}
self.message_items.push(item.clone());
}
Some(t @ ("function_call" | "custom_tool_call")) => {
let tc = tool_call_from_item(item, t == "custom_tool_call");
if let Some(existing) = self.tool_calls.iter_mut().find(|c| c.id == tc.id) {
existing.name.clone_from(&tc.name);
existing.tool_type.clone_from(&tc.tool_type);
existing.arguments = tc.arguments.clone();
existing.raw_arguments.clone_from(&tc.raw_arguments);
existing.provider_metadata.clone_from(&tc.provider_metadata);
} else {
self.tool_calls.push(tc.clone());
}
events.push(StreamEvent::ToolCallEnd { tool_call: tc });
}
_ => {}
}
}
/// Handle `response.completed` / `response.incomplete` by extracting usage
/// and building the final response.
fn handle_response_completed(
&mut self,
json: &serde_json::Value,
events: &mut Vec<StreamEvent>,
) {
let response_data = json.get("response").unwrap_or(json);
if let Some(usage_data) = response_data.get("usage") {
if let Ok(u) = ApiUsage::deserialize(usage_data) {
self.usage = token_counts_from_api_usage(Some(&u));
}
}
if let Some(id) = response_data.get("id").and_then(serde_json::Value::as_str) {
self.response_id = id.to_string();
}
if let Some(model) = response_data
.get("model")
.and_then(serde_json::Value::as_str)
{
self.response_model = model.to_string();
}
let status = response_data
.get("status")
.and_then(serde_json::Value::as_str);
let has_tool_calls = !self.tool_calls.is_empty();
self.finish_reason = map_finish_reason(status, has_tool_calls);
let mut content_parts = Vec::new();
// Reasoning items must precede function calls for Responses API
// round-trip
for item in std::mem::take(&mut self.reasoning_items) {
content_parts.push(ContentPart::Other {
kind: ContentPart::OPENAI_REASONING.to_string(),
data: item,
});
}
// Preserve full message output items for Responses API round-tripping
for item in std::mem::take(&mut self.message_items) {
content_parts.push(ContentPart::Other {
kind: ContentPart::OPENAI_MESSAGE.to_string(),
data: item,
});
}
if !self.accumulated_text.is_empty() {
content_parts.push(ContentPart::text(std::mem::take(
&mut self.accumulated_text,
)));
}
for tc in std::mem::take(&mut self.tool_calls) {
// Skip tool calls with empty names (e.g. model-internal items)
if tc.name.is_empty() {
continue;
}
content_parts.push(ContentPart::ToolCall(tc));
}
let model = if self.response_model.is_empty() {
self.model.clone()
} else {
self.response_model.clone()
};
let response = Response {
id: self.response_id.clone(),
model,
provider: self.provider.clone(),
message: Message {
role: Role::Assistant,
content: content_parts,
name: None,
tool_call_id: None,
},
finish_reason: self.finish_reason.clone(),
usage: self.usage.clone(),
raw: Some(response_data.clone()),
warnings: vec![],
rate_limit: self.rate_limit.clone(),
};
events.push(StreamEvent::finish(
self.finish_reason.clone(),
self.usage.clone(),
response,
));
}
}
impl StreamDecoder for SseAccumulator {
fn on_event(&mut self, ev: RawEvent<'_>) -> Result<Vec<StreamEvent>, Error> {
self.process_sse_event(ev.event, ev.data)
}
fn finish(&mut self) -> Vec<StreamEvent> {
// The Responses API finishes via `response.completed`/`.incomplete`;
// nothing is synthesized at byte-stream end.
Vec::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Build an accumulator without threading a `CodecCtx`/`Request`: the test
/// module sees the private fields, so the few that matter are set
/// directly. `emitted_start` is true so event assertions don't see the
/// initial `StreamStart`.
fn empty_accumulator() -> SseAccumulator {
SseAccumulator {
model: String::new(),
provider: "openai".to_string(),
response_id: String::new(),
response_model: String::new(),
accumulated_text: String::new(),
tool_calls: Vec::new(),
reasoning_items: Vec::new(),
message_items: Vec::new(),
usage: TokenCounts::default(),
finish_reason: FinishReason::Stop,
emitted_start: true,
emitted_text_start: false,
emitted_reasoning_start: false,
rate_limit: None,
}
}
fn on_event(
acc: &mut SseAccumulator,
event: Option<&str>,
data: &str,
) -> Result<Vec<StreamEvent>, Error> {
acc.on_event(RawEvent { event, data })
}
#[test]
fn token_counts_disjoint_with_cache_and_reasoning() {
let mut acc = empty_accumulator();
let body = serde_json::json!({
"response": {
"id": "resp_test",
"model": "gpt-5",
"output": [],
"status": "completed",
"usage": {
"input_tokens": 200,
"input_tokens_details": { "cached_tokens": 180 },
"output_tokens": 500,
"output_tokens_details": { "reasoning_tokens": 300 },
"total_tokens": 700
}
}
});
let mut events = Vec::new();
acc.handle_response_completed(&body, &mut events);
assert_eq!(acc.usage.input_tokens, 20);
assert_eq!(acc.usage.cache_read_tokens, 180);
assert_eq!(acc.usage.output_tokens, 200);
assert_eq!(acc.usage.reasoning_tokens, 300);
assert_eq!(acc.usage.cache_write_tokens, 0);
assert_eq!(acc.usage.total_tokens(), 700);
}
#[test]
fn custom_tool_call_streaming_delta_accumulates_raw_input() {
let mut acc = empty_accumulator();
let first = r#"{
"type": "response.custom_tool_call_input.delta",
"item_id": "ctc_abc",
"call_id": "call_001",
"delta": "*** Begin"
}"#;
let second = r#"{
"type": "response.custom_tool_call_input.delta",
"item_id": "ctc_abc",
"call_id": "call_001",
"delta": " Patch\n"
}"#;
let first_events = on_event(
&mut acc,
Some("response.custom_tool_call_input.delta"),
first,
)
.expect("first custom delta should parse");
let second_events = on_event(
&mut acc,
Some("response.custom_tool_call_input.delta"),
second,
)
.expect("second custom delta should parse");
assert!(matches!(
first_events.iter().find(|event| matches!(event, StreamEvent::ToolCallStart { .. })),
Some(StreamEvent::ToolCallStart { tool_call })
if tool_call.id == "call_001" && tool_call.tool_type == "custom"
));
assert!(matches!(
second_events.last(),
Some(StreamEvent::ToolCallDelta { tool_call })
if tool_call.raw_arguments.as_deref() == Some(" Patch\n")
&& tool_call.tool_type == "custom"
));
assert_eq!(
acc.tool_calls[0].raw_arguments.as_deref(),
Some("*** Begin Patch\n")
);
}
#[test]
fn custom_tool_call_output_item_done_emits_tool_call_end() {
let mut acc = empty_accumulator();
let patch = "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n";
let data = serde_json::json!({
"type": "response.output_item.done",
"item": {
"type": "custom_tool_call",
"id": "ctc_abc",
"call_id": "call_001",
"name": "apply_patch",
"input": patch,
}
});
let events = on_event(
&mut acc,
Some("response.output_item.done"),
&data.to_string(),
)
.expect("custom output item should parse");
assert!(matches!(
events.last(),
Some(StreamEvent::ToolCallEnd { tool_call })
if tool_call.id == "call_001"
&& tool_call.name == "apply_patch"
&& tool_call.tool_type == "custom"
&& tool_call.raw_arguments.as_deref() == Some(patch)
));
}
#[test]
fn error_event_with_insufficient_quota_returns_provider_error() {
let mut acc = empty_accumulator();
let data = r#"{
"type": "error",
"error": {
"type": "insufficient_quota",
"code": "insufficient_quota",
"message": "You exceeded your current quota.",
"param": null
}
}"#;
let err = on_event(&mut acc, Some("error"), data)
.expect_err("error event should fail the stream");
match err {
Error::Provider { kind, detail } => {
assert_eq!(kind, ProviderErrorKind::QuotaExceeded);
assert!(detail.message.contains("exceeded your current quota"));
assert_eq!(detail.error_code.as_deref(), Some("insufficient_quota"));
assert!(detail.raw.is_some());
}
other => panic!("expected provider error, got {other:?}"),
}
}
#[test]
fn error_event_classifies_on_type_when_code_absent() {
let mut acc = empty_accumulator();
let data = r#"{
"type": "error",
"error": {
"type": "insufficient_quota",
"message": "You exceeded your current quota."
}
}"#;
let err = on_event(&mut acc, Some("error"), data)
.expect_err("error event should fail the stream");
match err {
Error::Provider { kind, detail } => {
assert_eq!(kind, ProviderErrorKind::QuotaExceeded);
assert_eq!(detail.error_code.as_deref(), Some("insufficient_quota"));
}
other => panic!("expected provider error, got {other:?}"),
}
}
#[test]
fn response_failed_event_with_server_error_returns_provider_error() {
let mut acc = empty_accumulator();
let data = r#"{
"type": "response.failed",
"response": {
"status": "failed",
"error": {
"type": "server_error",
"code": "server_error",
"message": "The server had an error while processing your request."
}
}
}"#;
let err = on_event(&mut acc, Some("response.failed"), data)
.expect_err("response.failed should fail the stream");
match err {
Error::Provider { kind, detail } => {
assert_eq!(kind, ProviderErrorKind::Server);
assert!(detail.message.contains("server had an error"));
assert_eq!(detail.error_code.as_deref(), Some("server_error"));
}
other => panic!("expected provider error, got {other:?}"),
}
}
#[test]
fn response_incomplete_preserves_partial_text() {
let mut acc = empty_accumulator();
on_event(
&mut acc,
Some("response.created"),
r#"{"type":"response.created","response":{"id":"resp_123","model":"gpt-5.4"}}"#,
)
.expect("created event should parse");
on_event(
&mut acc,
Some("response.output_text.delta"),
r#"{"type":"response.output_text.delta","delta":"Hel"}"#,
)
.expect("first delta should parse");
on_event(
&mut acc,
Some("response.output_text.delta"),
r#"{"type":"response.output_text.delta","delta":"lo"}"#,
)
.expect("second delta should parse");
let events = on_event(
&mut acc,
Some("response.incomplete"),
r#"{
"type": "response.incomplete",
"response": {
"id": "resp_123",
"model": "gpt-5.4",
"status": "incomplete"
}
}"#,
)
.expect("incomplete response should finish normally");
let finish = events
.last()
.expect("incomplete response should emit finish");
match finish {
StreamEvent::Finish {
finish_reason,
response,
..
} => {
assert_eq!(finish_reason.clone(), FinishReason::Length);
assert_eq!(response.text(), "Hello");
}
other => panic!("expected finish event, got {other:?}"),
}
}
#[test]
fn error_event_with_invalid_api_key_returns_authentication_error() {
let mut acc = empty_accumulator();
let data = r#"{
"type": "error",
"error": {
"type": "invalid_api_key",
"code": "invalid_api_key",
"message": "Incorrect API key provided."
}
}"#;
let err = on_event(&mut acc, Some("error"), data)
.expect_err("error event should fail the stream");
match err {
Error::Provider { kind, detail } => {
assert_eq!(kind, ProviderErrorKind::Authentication);
assert_eq!(detail.error_code.as_deref(), Some("invalid_api_key"));
}
other => panic!("expected provider error, got {other:?}"),
}
}
#[test]
fn error_event_with_rate_limit_error_returns_rate_limit() {
let mut acc = empty_accumulator();
let data = r#"{
"type": "error",
"error": {
"type": "rate_limit_error",
"message": "Too many requests."
}
}"#;
let err = on_event(&mut acc, Some("error"), data)
.expect_err("error event should fail the stream");
match err {
Error::Provider { kind, detail } => {
assert_eq!(kind, ProviderErrorKind::RateLimit);
assert_eq!(detail.error_code.as_deref(), Some("rate_limit_error"));
}
other => panic!("expected provider error, got {other:?}"),
}
}
#[test]
fn error_event_with_unknown_invalid_prefix_returns_invalid_request() {
let mut acc = empty_accumulator();
let data = r#"{
"type": "error",
"error": {
"type": "invalid_prompt",
"code": "invalid_prompt",
"message": "Prompt is invalid."
}
}"#;
let err = on_event(&mut acc, Some("error"), data)
.expect_err("error event should fail the stream");
match err {
Error::Provider { kind, detail } => {
assert_eq!(kind, ProviderErrorKind::InvalidRequest);
assert_eq!(detail.error_code.as_deref(), Some("invalid_prompt"));
}
other => panic!("expected provider error, got {other:?}"),
}
}
#[test]
fn error_event_with_unknown_code_falls_back_to_server_with_message() {
let mut acc = empty_accumulator();
let data = r#"{
"type": "error",
"error": {
"type": "unexpected_stream_failure",
"code": "unexpected_stream_failure",
"message": "Unexpected stream failure."
}
}"#;
let err = on_event(&mut acc, Some("error"), data)
.expect_err("error event should fail the stream");
match err {
Error::Provider { kind, detail } => {
assert_eq!(kind, ProviderErrorKind::Server);
assert_eq!(detail.message, "Unexpected stream failure.");
assert_eq!(
detail.error_code.as_deref(),
Some("unexpected_stream_failure")
);
}
other => panic!("expected provider error, got {other:?}"),
}
}
#[test]
fn reasoning_summary_delta_emits_reasoning_events() {
let mut acc = empty_accumulator();
let data = r#"{"type":"response.reasoning_summary_text.delta","delta":"Let me think"}"#;
let events = on_event(
&mut acc,
Some("response.reasoning_summary_text.delta"),
data,
)
.expect("reasoning summary delta should parse");
assert_eq!(events.len(), 2);
assert!(matches!(events[0], StreamEvent::ReasoningStart));
assert!(
matches!(events[1], StreamEvent::ReasoningDelta { ref delta } if delta == "Let me think")
);
}
#[test]
fn reasoning_text_delta_emits_reasoning_events() {
let mut acc = empty_accumulator();
// First delta: should emit ReasoningStart + ReasoningDelta
let data1 = r#"{"type":"response.reasoning_text.delta","delta":"Step 1"}"#;
let events1 = on_event(&mut acc, Some("response.reasoning_text.delta"), data1)
.expect("first reasoning delta should parse");
assert_eq!(events1.len(), 2);
assert!(matches!(events1[0], StreamEvent::ReasoningStart));
assert!(
matches!(events1[1], StreamEvent::ReasoningDelta { ref delta } if delta == "Step 1")
);
// Second delta: should NOT emit duplicate ReasoningStart
let data2 = r#"{"type":"response.reasoning_text.delta","delta":"Step 2"}"#;
let events2 = on_event(&mut acc, Some("response.reasoning_text.delta"), data2)
.expect("second reasoning delta should parse");
assert_eq!(events2.len(), 1);
assert!(
matches!(events2[0], StreamEvent::ReasoningDelta { ref delta } if delta == "Step 2")
);
}
#[test]
fn reasoning_end_emitted_on_item_done() {
let mut acc = empty_accumulator();
acc.emitted_reasoning_start = true;
let data = r#"{"item":{"type":"reasoning","id":"rs_abc","summary":[]}}"#;
let events = on_event(&mut acc, Some("response.output_item.done"), data)
.expect("output item done should parse");
assert_eq!(events.len(), 1);
assert!(matches!(events[0], StreamEvent::ReasoningEnd));
assert!(!acc.emitted_reasoning_start);
assert_eq!(acc.reasoning_items.len(), 1);
}
}

View file

@ -0,0 +1,67 @@
//! Serde types mirroring the OpenAI Responses API wire shapes.
#[derive(serde::Serialize)]
pub(super) struct ApiRequest {
pub model: String,
pub input: Vec<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<std::collections::HashMap<String, String>>,
pub store: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub include: Vec<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub stream: bool,
}
// --- Response types ---
#[derive(serde::Deserialize)]
pub(super) struct ApiResponse {
pub id: String,
pub model: Option<String>,
pub output: Vec<serde_json::Value>,
pub status: Option<String>,
pub usage: Option<ApiUsage>,
}
#[derive(serde::Deserialize)]
pub(super) struct InputTokensResponse {
pub input_tokens: i64,
pub object: String,
}
#[derive(serde::Deserialize)]
pub(super) struct ApiUsage {
pub input_tokens: i64,
pub output_tokens: i64,
pub output_tokens_details: Option<OutputTokenDetails>,
pub input_tokens_details: Option<InputTokenDetails>,
}
#[derive(serde::Deserialize)]
pub(super) struct OutputTokenDetails {
pub reasoning_tokens: Option<i64>,
}
#[derive(serde::Deserialize)]
pub(super) struct InputTokenDetails {
pub cached_tokens: Option<i64>,
}

View file

@ -94,7 +94,8 @@ impl Adapter {
auth: AuthScheme::ApiKey,
codec_params: CodecParams {
anthropic_version: AnthropicVersion::Header("2023-06-01"),
anthropic_beta: true,
anthropic_beta: true,
..CodecParams::default()
},
supports_count_tokens: true,
force_streaming: false,

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,7 @@ expression: rendered
]
],
"body": {
"model": "gpt-test",
"input": [
{
"type": "message",
@ -41,7 +42,6 @@ expression: rendered
}
],
"instructions": "Be concise",
"model": "gpt-test",
"tools": [
{
"type": "function",