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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ title: Changelog
description: Release notes for claude-code-proxy.
---

## Unreleased

- Grok handles images in user messages and tool results without failing requests.
Images are omitted by default, with opt-in vision through
`CCP_GROK_TOOL_IMAGE`. Traffic captures redact image payloads.

## v0.1.25 (2026-07-24)

- Kimi users can select Kimi K3 with the `kimi-k3` or `k3` model name, including
Expand Down
18 changes: 17 additions & 1 deletion docs/src/content/docs/providers/grok.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,28 @@ Claude Code hosted search tools map to Grok-native tools:

## Multimodal support

Grok support in claude-code-proxy focuses on text, reasoning, function tools, and hosted search. Treat image and other media behavior as unsupported unless the current source and tests explicitly cover it.
`CCP_GROK_TOOL_IMAGE` controls image blocks in user messages and tool results:

- `omit`, the default, replaces each image with an `[image omitted: ...]`
placeholder. The model does not receive the pixels.
- `reattach` keeps the placeholder in each tool result and sends accepted images
in a following user message.
- `inline` sends accepted tool-result images alongside text as `input_image`
parts. Text-only outputs retain their string shape.
- `reject` returns the image validation error used by older versions.

Vision modes accept base64 PNG, JPEG, and GIF images with a minimum side of 8
pixels, a minimum area of 512 square pixels, and a decoded size up to 5 MB. At
most the last four accepted images in a request are sent. Images that fail a
gate, WebP images, and remote URL sources degrade to placeholders with a reason.

Traffic captures redact Anthropic image data and upstream image data URLs.

## Configuration

- `CCP_GROK_BASE_URL` or `grok.baseUrl` changes the API base URL.
- `CCP_GROK_CLIENT_VERSION` or `grok.clientVersion` changes the client version header.
- `CCP_GROK_TOOL_IMAGE` selects `omit`, `reattach`, `inline`, or `reject`.

See [Configuration](/reference/configuration/) for defaults.

Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ All keys are optional. An unreadable file, malformed JSON, or incompatible field
| --- | --- | --- | --- |
| `CCP_GROK_BASE_URL` | `grok.baseUrl` | `https://cli-chat-proxy.grok.com/v1` | Changes the Responses API base URL. |
| `CCP_GROK_CLIENT_VERSION` | `grok.clientVersion` | `0.2.93` | Changes the Grok client version header. |
| `CCP_GROK_TOOL_IMAGE` | none | `omit` | Selects `omit`, `reattach`, `inline`, or `reject` image handling. |

## Cursor Agent

Expand Down
56 changes: 56 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,62 @@ pub fn grok_client_version() -> String {
"0.2.93".to_string()
}

// ---------------------------------------------------------------------------
// Grok tool-image policy (CCP_GROK_TOOL_IMAGE)
// ---------------------------------------------------------------------------

/// How the Grok translator treats Anthropic `image` blocks (tool results and
/// top-level user messages). `omit` is the safe default: degrade to the L1
/// placeholder string. `reattach` keeps the placeholder in the tool output and
/// additionally appends a user message carrying the images as `input_image`
/// data URLs. `inline` sends the tool output itself as an array of
/// `input_text` + `input_image` parts (string-only outputs still serialize as
/// plain strings). `reject` restores the pre-L1 hard error.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GrokToolImageMode {
Omit,
Reattach,
Inline,
Reject,
}

pub fn parse_grok_tool_image_mode(raw: Option<&str>) -> GrokToolImageMode {
match raw.map(str::trim) {
Some("reattach") => GrokToolImageMode::Reattach,
Some("inline") => GrokToolImageMode::Inline,
Some("reject") => GrokToolImageMode::Reject,
// Any unknown/empty value degrades to the safe default.
_ => GrokToolImageMode::Omit,
}
}

pub fn grok_tool_image_mode() -> GrokToolImageMode {
parse_grok_tool_image_mode(std::env::var("CCP_GROK_TOOL_IMAGE").ok().as_deref())
}

/// Warn once at startup when an unknown mode was requested. Called from the
/// Grok provider constructor rather than per request.
pub fn warn_grok_tool_image_mode_once(log: &crate::logging::Logger) {
match std::env::var("CCP_GROK_TOOL_IMAGE")
.ok()
.as_deref()
.map(str::trim)
{
Some(other) if !matches!(other, "" | "omit" | "reattach" | "inline" | "reject") => {
let mut fields = serde_json::Map::new();
fields.insert(
"value".to_string(),
serde_json::Value::String(other.to_string()),
);
log.warn(
"unrecognized CCP_GROK_TOOL_IMAGE value; falling back to omit",
Some(fields),
);
}
_ => {}
}
}

pub fn is_verbose() -> bool {
log_verbose()
}
Expand Down
34 changes: 24 additions & 10 deletions src/providers/grok/count_tokens.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use super::translate::request::{GrokContentPart, GrokInputItem, GrokResponsesRequest};
use super::translate::request::{
GrokContentPart, GrokInputItem, GrokResponsesRequest, GrokToolOutput,
};

const MESSAGE_OVERHEAD_TOKENS: u64 = 4;
const TOOL_OVERHEAD_TOKENS: u64 = 4;
Expand Down Expand Up @@ -37,21 +39,33 @@ pub fn count_tokens(request: &GrokResponsesRequest) -> u64 {

fn count_input_item(item: &GrokInputItem) -> u64 {
match item {
GrokInputItem::Message { content, .. } => content
.iter()
.map(|part| match part {
GrokContentPart::InputText { text } | GrokContentPart::OutputText { text } => {
approx_token_count(text)
}
})
.sum(),
GrokInputItem::Message { content, .. } => count_parts(content),
GrokInputItem::FunctionCall {
name, arguments, ..
} => approx_token_count(name) + approx_token_count(arguments),
GrokInputItem::FunctionCallOutput { output, .. } => approx_token_count(output),
GrokInputItem::FunctionCallOutput { output, .. } => match output {
GrokToolOutput::Text(text) => approx_token_count(text),
GrokToolOutput::Parts(parts) => count_parts(parts),
},
}
}

fn count_parts(parts: &[GrokContentPart]) -> u64 {
parts
.iter()
.map(|part| match part {
GrokContentPart::InputText { text } | GrokContentPart::OutputText { text } => {
approx_token_count(text)
}
// Images count toward tokens; charge a flat per-image estimate
// instead of counting base64 payload characters.
GrokContentPart::InputImage { .. } => IMAGE_TOKEN_ESTIMATE,
})
.sum()
}

const IMAGE_TOKEN_ESTIMATE: u64 = 1_024;

fn approx_token_count(text: &str) -> u64 {
if text.is_empty() {
return 0;
Expand Down
1 change: 1 addition & 0 deletions src/providers/grok/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub struct GrokProvider {
}
impl GrokProvider {
pub fn new() -> Self {
crate::config::warn_grok_tool_image_mode_once(&crate::logging::create_logger("grok"));
Self {
client: Arc::new(
client::GrokClient::new(
Expand Down
Loading